ailock
Version:
AI-Proof File Guard - Protect sensitive files from accidental AI modifications
419 lines • 15.5 kB
JavaScript
import { randomBytes, createHash } from 'crypto';
import { mkdir, readFile, unlink, stat } from 'fs/promises';
import { lock, unlock, check as isLocked } from 'proper-lockfile';
import writeFileAtomic from 'write-file-atomic';
import path from 'path';
/**
* Atomic file manager that prevents race conditions and ensures data integrity
* during file operations
*/
export class AtomicFileManager {
workingDir;
lockDir = '.ailock-locks';
defaultTimeout = 5000; // 5 seconds (shorter for better test performance)
activeLocks = new Map(); // filePath -> lockId
lockCleanupTimers = new Map();
constructor(workingDir = process.cwd()) {
this.workingDir = workingDir;
// Lock directory will be created on first use
}
/**
* Acquires an exclusive lock on a file with automatic cleanup
*/
async acquireLock(filePath, options = {}) {
const resolvedPath = path.resolve(this.workingDir, filePath);
const lockId = randomBytes(16).toString('hex');
const timeout = options.timeout || this.defaultTimeout;
// Ensure lock directory exists
await this.ensureLockDirectory();
try {
// Check if file is already locked by this process
if (this.activeLocks.has(resolvedPath)) {
throw new Error(`File already locked by this process: ${filePath}`);
}
// Acquire process-level lock using proper-lockfile
await lock(resolvedPath, {
lockfilePath: path.join(this.getLockDir(), `${path.basename(filePath)}.lock`),
retries: {
retries: options.retries || 5,
factor: 2,
minTimeout: 1000,
maxTimeout: 5000,
randomize: true,
},
realpath: false, // Don't resolve symlinks for security
});
// Store lock in memory first
this.activeLocks.set(resolvedPath, lockId);
// Create lock metadata
const lockInfo = {
lockId,
pid: process.pid,
timestamp: Date.now(),
filePath: resolvedPath,
userId: this.getCurrentUserId(),
// Don't calculate checksum at lock time - will be set after write operations
checksum: undefined,
};
// Store lock information (non-critical - don't fail if this fails)
await this.storeLockInfo(resolvedPath, lockInfo).catch(() => { });
// Set automatic cleanup timer
const cleanupTimer = setTimeout(() => {
this.releaseLock(filePath, lockId).catch(() => {
// Silent cleanup - log but don't throw
console.warn(`Failed to auto-cleanup lock for ${filePath}`);
});
}, timeout);
this.lockCleanupTimers.set(resolvedPath, cleanupTimer);
return lockId;
}
catch (error) {
// Clean up on failure
try {
await this.forceReleaseLock(resolvedPath);
}
catch {
// Ignore cleanup errors
}
if (error instanceof Error && error.message.includes('already being held')) {
throw new Error(`File is locked by another process: ${filePath}`);
}
throw new Error(`Failed to acquire lock: ${error}`);
}
}
/**
* Releases a file lock with validation
*/
async releaseLock(filePath, lockId) {
const resolvedPath = path.resolve(this.workingDir, filePath);
try {
// Validate lock ownership - be more lenient to handle edge cases
const currentLockId = this.activeLocks.get(resolvedPath);
if (currentLockId && currentLockId !== lockId) {
throw new Error(`Invalid lock ID for ${filePath}`);
}
// Clear cleanup timer
const timer = this.lockCleanupTimers.get(resolvedPath);
if (timer) {
clearTimeout(timer);
this.lockCleanupTimers.delete(resolvedPath);
}
// Release process-level lock
const lockfilePath = path.join(this.getLockDir(), `${path.basename(filePath)}.lock`);
try {
await unlock(resolvedPath, { lockfilePath });
}
catch (unlockError) {
// Check if lock file exists and try direct removal
try {
await unlink(lockfilePath);
}
catch {
// Lock may already be released, continue with cleanup
}
}
// Clean up lock metadata and internal state
try {
await this.removeLockInfo(resolvedPath);
}
catch {
// Metadata file may not exist, continue
}
this.activeLocks.delete(resolvedPath);
}
catch (error) {
// Still clean up internal state even if release fails
this.activeLocks.delete(resolvedPath);
const timer = this.lockCleanupTimers.get(resolvedPath);
if (timer) {
clearTimeout(timer);
this.lockCleanupTimers.delete(resolvedPath);
}
throw error;
}
}
/**
* Performs an atomic write operation with lock protection
*/
async atomicWrite(filePath, data, options = {}) {
const resolvedPath = path.resolve(this.workingDir, filePath);
let lockId = null;
try {
// Acquire exclusive lock
lockId = await this.acquireLock(filePath, options);
// Create backup if requested
if (options.backup) {
await this.createBackup(resolvedPath);
}
// Perform atomic write
const writeOptions = {
mode: 0o644,
};
if (typeof data === 'string') {
writeOptions.encoding = 'utf8';
}
await writeFileAtomic(resolvedPath, data, writeOptions);
// Update checksum after write if integrity checking is enabled
if (options.checkIntegrity) {
await this.updateChecksumAfterWrite(resolvedPath, lockId);
}
}
catch (error) {
// Restore backup on failure
if (options.backup) {
await this.restoreBackup(resolvedPath).catch(() => {
// Log but don't throw - original error is more important
console.warn(`Failed to restore backup for ${filePath}`);
});
}
throw new Error(`Atomic write failed: ${error}`);
}
finally {
// Always release lock
if (lockId) {
await this.releaseLock(filePath, lockId).catch(() => {
console.warn(`Failed to release lock after write: ${filePath}`);
});
}
}
}
/**
* Performs an atomic read operation with lock protection
*/
async atomicRead(filePath, options = {}) {
const resolvedPath = path.resolve(this.workingDir, filePath);
let lockId = null;
try {
// Check if file exists before attempting to lock
try {
await stat(resolvedPath);
}
catch (error) {
throw new Error(`File does not exist: ${filePath}`);
}
// Acquire shared lock (for read operations, we still use exclusive for simplicity)
lockId = await this.acquireLock(filePath, {
...options,
timeout: (options.timeout || this.defaultTimeout) / 2 // Shorter timeout for reads
});
// Read file content
const content = await readFile(resolvedPath, 'utf8');
// Verify integrity if requested
if (options.checkIntegrity) {
await this.verifyIntegrity(resolvedPath, lockId);
}
return content;
}
catch (error) {
throw new Error(`Atomic read failed: ${error}`);
}
finally {
// Always release lock
if (lockId) {
await this.releaseLock(filePath, lockId).catch(() => {
console.warn(`Failed to release lock after read: ${filePath}`);
});
}
}
}
/**
* Checks if a file is currently locked
*/
async isFileLocked(filePath) {
const resolvedPath = path.resolve(this.workingDir, filePath);
const lockfilePath = path.join(this.getLockDir(), `${path.basename(filePath)}.lock`);
try {
// Simple check - just ask the external lock system
// Don't try to "fix" state mismatches as this causes race conditions
return await isLocked(resolvedPath, { lockfilePath });
}
catch {
// If external check fails, assume unlocked (fail-safe behavior)
return false;
}
}
/**
* Gets information about the current lock on a file
*/
async getLockInfo(filePath) {
const resolvedPath = path.resolve(this.workingDir, filePath);
try {
return await this.readLockInfo(resolvedPath);
}
catch {
return null;
}
}
/**
* Forces release of a lock (emergency cleanup)
*/
async forceReleaseLock(filePath) {
const resolvedPath = path.resolve(this.workingDir, filePath);
const lockfilePath = path.join(this.getLockDir(), `${path.basename(filePath)}.lock`);
try {
// Force unlock
await unlock(resolvedPath, { lockfilePath }).catch(() => {
// Try to remove lock file directly
return unlink(lockfilePath).catch(() => { });
});
// Clean up metadata
await this.removeLockInfo(resolvedPath).catch(() => { });
this.activeLocks.delete(resolvedPath);
// Clear timer
const timer = this.lockCleanupTimers.get(resolvedPath);
if (timer) {
clearTimeout(timer);
this.lockCleanupTimers.delete(resolvedPath);
}
}
catch (error) {
console.warn(`Force release failed for ${filePath}: ${error}`);
}
}
/**
* Cleans up all locks held by this process
*/
async cleanup() {
const lockPromises = Array.from(this.activeLocks.entries()).map(([filePath, lockId]) => this.releaseLock(filePath, lockId).catch(() => { }));
await Promise.all(lockPromises);
// Clear all timers
for (const timer of this.lockCleanupTimers.values()) {
clearTimeout(timer);
}
this.lockCleanupTimers.clear();
}
/**
* Ensure lock directory exists
*/
async ensureLockDirectory() {
try {
await mkdir(this.getLockDir(), { recursive: true, mode: 0o755 });
}
catch (error) {
// Directory may already exist, which is fine
if (error.code !== 'EEXIST') {
console.warn(`Failed to create lock directory: ${error}`);
}
}
}
/**
* Get lock directory path
*/
getLockDir() {
return path.join(this.workingDir, this.lockDir);
}
/**
* Store lock information in metadata file
*/
async storeLockInfo(filePath, lockInfo) {
const metadataPath = `${filePath}.ailock-meta`;
try {
await writeFileAtomic(metadataPath, JSON.stringify(lockInfo, null, 2));
}
catch (error) {
// Non-critical - metadata is optional
console.debug(`Failed to store lock metadata: ${error}`);
}
}
/**
* Read lock information from metadata file
*/
async readLockInfo(filePath) {
const metadataPath = `${filePath}.ailock-meta`;
const content = await readFile(metadataPath, 'utf8');
return JSON.parse(content);
}
/**
* Remove lock information metadata file
*/
async removeLockInfo(filePath) {
const metadataPath = `${filePath}.ailock-meta`;
await unlink(metadataPath).catch(() => { }); // Ignore if doesn't exist
}
/**
* Calculate file checksum for integrity verification
*/
async calculateChecksum(filePath) {
try {
const content = await readFile(filePath);
return createHash('sha256').update(content).digest('hex');
}
catch {
return 'file-not-accessible';
}
}
/**
* Update checksum in lock metadata after write operation
*/
async updateChecksumAfterWrite(filePath, lockId) {
try {
const lockInfo = await this.readLockInfo(filePath);
if (lockInfo.lockId !== lockId) {
throw new Error('Lock ID mismatch during checksum update');
}
// Calculate new checksum after write
const newChecksum = await this.calculateChecksum(filePath);
lockInfo.checksum = newChecksum;
// Update lock metadata with new checksum
await this.storeLockInfo(filePath, lockInfo);
}
catch (error) {
throw new Error(`Failed to update checksum: ${error}`);
}
}
/**
* Verify file integrity against stored checksum
*/
async verifyIntegrity(filePath, lockId) {
try {
const lockInfo = await this.readLockInfo(filePath);
if (lockInfo.lockId !== lockId) {
throw new Error('Lock ID mismatch during integrity check');
}
if (lockInfo.checksum) {
const currentChecksum = await this.calculateChecksum(filePath);
if (currentChecksum !== lockInfo.checksum) {
throw new Error('File integrity verification failed');
}
}
}
catch (error) {
throw new Error(`Integrity verification failed: ${error}`);
}
}
/**
* Create backup of file
*/
async createBackup(filePath) {
try {
const backupPath = `${filePath}.ailock-backup`;
const content = await readFile(filePath);
await writeFileAtomic(backupPath, content);
}
catch (error) {
// Only warn for backup failures - don't fail the operation
console.warn(`Failed to create backup for ${filePath}: ${error}`);
}
}
/**
* Restore file from backup
*/
async restoreBackup(filePath) {
const backupPath = `${filePath}.ailock-backup`;
try {
const content = await readFile(backupPath);
await writeFileAtomic(filePath, content);
await unlink(backupPath); // Clean up backup
}
catch (error) {
throw new Error(`Failed to restore backup: ${error}`);
}
}
/**
* Get current user ID for lock tracking
*/
getCurrentUserId() {
return process.env.USER || process.env.USERNAME || `pid-${process.pid}`;
}
}
//# sourceMappingURL=AtomicFileManager.js.map