Files
sencho/backend/src/services/FileSystemService.ts
T
Anso 72b6cdd0a3 fix: suppress ERROR logging for missing .env files in image update scan (#936)
* fix: suppress ERROR logging for missing .env files in image update scan

The ImageUpdateService logged a full ERROR stack trace for every stack
without a .env file, which is a normal and expected configuration.
Also added a 5-minute check timeout, developer_mode diagnostic logging,
and proper startup timeout cleanup.

* fix: add missing Node fields in test mock to satisfy tsc strict checking

* fix: remove unused variables to satisfy ESLint no-unused-vars
2026-05-06 11:21:37 -04:00

628 lines
22 KiB
TypeScript

import path from 'path';
import { promises as fsPromises, createReadStream } from 'fs';
import type { Readable } from 'stream';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
import { sanitizeForLog } from '../utils/safeLog';
export interface FileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: number;
isProtected: boolean;
}
/**
* Resolves the writable Sencho data directory (same one DatabaseService /
* CryptoService use). Recomputed lazily so test harnesses that override
* `process.env.DATA_DIR` after module load still take effect.
*/
function getBackupBaseDir(): string {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
return path.join(dataDir, 'backups');
}
import { isDebugEnabled } from '../utils/debug';
const PROTECTED_STACK_FILES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
'.env',
]);
const MIME_MAP: Record<string, string> = {
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.json': 'application/json',
'.sh': 'text/x-sh',
'.env': 'text/plain',
};
/**
* FileSystemService - local-only file I/O for compose stack management.
*
* In the Distributed API model, remote node file operations are handled
* by the remote Sencho instance itself. This service only operates on
* the local filesystem.
*/
export class FileSystemService {
private baseDir: string;
constructor(nodeId?: number) {
this.baseDir = NodeRegistry.getInstance().getComposeDir(
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
);
}
public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId);
}
private assertWithinBase(filePath: string): void {
if (!isPathWithinBase(filePath, this.baseDir)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
}
private resolveStackDir(stackName: string): string {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const stackDir = path.join(this.baseDir, stackName);
this.assertWithinBase(stackDir);
return stackDir;
}
async hasComposeFile(dir: string): Promise<boolean> {
this.assertWithinBase(dir);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
await fsPromises.access(path.join(dir, file));
return true;
} catch {
// continue
}
}
return false;
}
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = this.resolveStackDir(stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
try {
await fsPromises.access(filePath);
if (isDebugEnabled()) console.debug('[FileSystemService:debug] Resolved compose file', { stackName, file });
return filePath;
} catch {
// continue
}
}
throw new Error(`No compose file found for stack: ${stackName}`);
}
async getComposeFilename(stackName: string): Promise<string> {
return path.basename(await this.getComposeFilePath(stackName));
}
async getStacks(): Promise<string[]> {
try {
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = [];
for (const item of items) {
if (!item.isDirectory()) continue;
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
if (await this.hasComposeFile(stackDir)) {
stackNames.push(item.name);
}
}
return stackNames;
} catch (error: any) {
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
return [];
}
}
async getStackContent(stackName: string): Promise<string> {
try {
const filePath = await this.getComposeFilePath(stackName);
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', sanitizeForLog((error as Error)?.message ?? String(error)));
throw new Error(`Failed to read stack: ${stackName}`);
}
}
async saveStackContent(stackName: string, content: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
const filePath = path.join(stackDir, 'compose.yaml');
try {
await fsPromises.writeFile(filePath, content, 'utf-8');
} catch (error) {
console.error('Error writing file:', error);
throw new Error(`Failed to save stack: ${stackName}`);
}
}
async envExists(stackName: string): Promise<boolean> {
const stackDir = this.resolveStackDir(stackName);
try {
await fsPromises.access(path.join(stackDir, '.env'));
return true;
} catch {
return false;
}
}
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
this.assertWithinBase(filePath);
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
this.assertWithinBase(filePath);
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
this.assertWithinBase(filePath);
return fsPromises.access(filePath);
}
async getEnvContent(stackName: string): Promise<string> {
const stackDir = this.resolveStackDir(stackName);
const envPath = path.join(stackDir, '.env');
try {
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error reading env file:', error);
}
throw error;
}
}
async saveEnvContent(stackName: string, content: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
const envPath = path.join(stackDir, '.env');
try {
await fsPromises.writeFile(envPath, content, 'utf-8');
} catch (error) {
console.error('Error writing env file:', error);
throw new Error(`Failed to save env file for stack: ${stackName}`);
}
}
async createStack(stackName: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
try {
await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) {
if (error.message.includes('already exists')) throw error;
}
await fsPromises.mkdir(stackDir, { recursive: true });
const boilerplate = `services:
app:
image: nginx:latest
ports:
- "8080:80"
restart: always
`;
try {
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
} catch (error) {
console.error('Error creating stack:', error);
throw new Error(`Failed to create stack: ${stackName}`);
}
}
public async deleteStack(stackName: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
try {
await fsPromises.rm(stackDir, { recursive: true, force: true });
} catch (error: unknown) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') return;
console.error('Error deleting stack directory:', fsError.message);
throw new Error(`Failed to delete stack directory: ${fsError.message}`);
}
}
getBaseDir(): string {
return this.baseDir;
}
async migrateFlatToDirectory(): Promise<void> {
try {
try {
await fsPromises.access(this.baseDir);
} catch {
await fsPromises.mkdir(this.baseDir, { recursive: true });
return;
}
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
for (const item of items) {
if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName);
try {
await fsPromises.access(stackDir);
continue;
} catch {
// Directory doesn't exist, proceed
}
await fsPromises.mkdir(stackDir, { recursive: true });
const oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml');
await fsPromises.rename(oldComposePath, newComposePath);
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env');
try {
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not migrate env file for ${stackName}:`, (e as Error).message);
}
}
}
} catch (error) {
console.error('Migration error:', error);
}
}
/**
* Backup stack files (compose.yaml + .env) into Sencho's data dir.
*
* Backups live at <DATA_DIR>/backups/<stackName>/ (NOT inside the user's
* compose folder) so the operation always succeeds even when the stack
* folder is owned by another UID (e.g., a container running as root has
* chowned its bind mount). DATA_DIR is the same writable location that
* holds sencho.db and encryption.key.
*/
async backupStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = this.resolveStackDir(stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
await fsPromises.mkdir(backupDir, { recursive: true });
// Copy compose file
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const src = path.join(stackDir, file);
try {
await fsPromises.access(src);
await fsPromises.copyFile(src, path.join(backupDir, file));
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not back up ${file}:`, (e as Error).message);
}
}
}
// Copy .env if it exists
const envSrc = path.join(stackDir, '.env');
try {
await fsPromises.access(envSrc);
await fsPromises.copyFile(envSrc, path.join(backupDir, '.env'));
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn('[FileSystemService] Could not back up .env:', (e as Error).message);
}
}
// Write timestamp marker
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
if (debug) console.debug(`[FileSystemService:debug] Backup completed in ${Date.now() - t0}ms`, { stackName });
}
async restoreStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = this.resolveStackDir(stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
const items = await fsPromises.readdir(backupDir);
for (const item of items) {
if (item === '.timestamp') continue;
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
}
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, files: items.filter(i => i !== '.timestamp') });
}
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
const backupDir = path.join(getBackupBaseDir(), stackName);
try {
await fsPromises.access(backupDir);
const tsFile = path.join(backupDir, '.timestamp');
try {
const ts = await fsPromises.readFile(tsFile, 'utf-8');
return { exists: true, timestamp: parseInt(ts, 10) || null };
} catch (e) {
console.warn('[FileSystemService] Backup timestamp file unreadable:', (e as Error).message);
return { exists: true, timestamp: null };
}
} catch {
return { exists: false, timestamp: null };
}
}
// ---------------------------------------------------------------------------
// Stack-scoped file explorer methods
// ---------------------------------------------------------------------------
private guessMime(filePath: string): string {
if (path.basename(filePath) === '.env') return 'text/plain';
const ext = path.extname(filePath).toLowerCase();
return MIME_MAP[ext] ?? 'text/plain';
}
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath);
if (!isPathWithinBase(target, stackDir)) {
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
}
let realTarget: string;
try {
realTarget = await fsPromises.realpath(target);
} catch (err: unknown) {
const fsErr = err as NodeJS.ErrnoException;
if (fsErr.code !== 'ENOENT') throw err;
// Walk up to the deepest existing ancestor, then reattach the suffix.
let existing = target;
const suffix: string[] = [];
while (true) {
const parent = path.dirname(existing);
if (parent === existing) {
// Reached filesystem root without finding an existing path.
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
}
suffix.unshift(path.basename(existing));
existing = parent;
try {
const realExisting = await fsPromises.realpath(existing);
if (!isPathWithinBase(realExisting, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
}
realTarget = path.join(realExisting, ...suffix);
break;
} catch (innerErr: unknown) {
const innerFsErr = innerErr as NodeJS.ErrnoException;
if (innerFsErr.code !== 'ENOENT') throw innerErr;
// Continue walking up.
}
}
}
if (!isPathWithinBase(realTarget, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
}
return realTarget;
}
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
const entries = await Promise.all(
dirents.map(async (dirent): Promise<FileEntry> => {
const entryPath = path.join(safePath, dirent.name);
let size = 0;
let mtime = 0;
try {
const st = await fsPromises.stat(entryPath);
size = dirent.isDirectory() ? 0 : st.size;
mtime = st.mtimeMs;
} catch {
// stat can fail for broken symlinks; use defaults.
}
const type: FileEntry['type'] = dirent.isDirectory()
? 'directory'
: dirent.isSymbolicLink()
? 'symlink'
: 'file';
return {
name: dirent.name,
type,
size,
mtime,
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
};
})
);
return entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
async readStackFile(
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
const mime = this.guessMime(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
if (stat.size > maxBytes) {
const fd = await fsPromises.open(safePath, 'r');
const probe = Buffer.allocUnsafe(8192);
const { bytesRead } = await fd.read(probe, 0, 8192, 0);
await fd.close();
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
return { binary, oversized: true, size: stat.size, mime };
}
const buf = await fsPromises.readFile(safePath);
if (isBinaryBuffer(buf)) {
return { binary: true, oversized: false, size: stat.size, mime };
}
return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') };
}
async streamStackFile(
stackName: string,
relPath: string
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
return {
stream: createReadStream(safePath),
size: stat.size,
filename: path.basename(safePath),
mime: this.guessMime(safePath),
};
}
async writeStackFile(stackName: string, relPath: string, content: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
await fsPromises.writeFile(safePath, content, 'utf-8');
}
async writeStackFileBuffer(stackName: string, relPath: string, buffer: Buffer): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
await fsPromises.writeFile(safePath, buffer);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
if (recursive) {
await fsPromises.rm(safePath, { recursive: true, force: true });
return;
}
try {
await fsPromises.unlink(safePath);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EISDIR') {
try {
await fsPromises.rmdir(safePath);
} catch (inner: unknown) {
const ie = inner as NodeJS.ErrnoException;
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
}
throw inner;
}
} else {
throw err;
}
}
}
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(safePath, { recursive: true });
}
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
const fromPath = await this.resolveSafeStackPath(stackName, fromRel);
// toRel must resolve to the same parent directory (rename only, no cross-dir move).
const toPath = await this.resolveSafeStackPath(stackName, toRel);
if (path.dirname(fromPath) !== path.dirname(toPath)) {
throw Object.assign(new Error('Cross-directory rename is not supported'), { code: 'INVALID_PATH' });
}
const toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
}
// Prevent overwriting an existing path.
try {
await fsPromises.access(toPath);
throw Object.assign(new Error('A file or folder with that name already exists'), { code: 'EEXIST' });
} catch (e: unknown) {
const fe = e as NodeJS.ErrnoException;
if (fe.code !== 'ENOENT') throw e;
}
await fsPromises.rename(fromPath, toPath);
}
async getStackEntryMode(stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
const mode = stat.mode & 0o777;
return { mode, octal: mode.toString(8).padStart(3, '0') };
}
async chmodStackPath(stackName: string, relPath: string, mode: number): Promise<void> {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.chmod(safePath, mode);
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
const stat = await fsPromises.lstat(safePath);
const name = path.basename(safePath);
const type: FileEntry['type'] = stat.isDirectory()
? 'directory'
: stat.isSymbolicLink()
? 'symlink'
: 'file';
return {
name,
type,
size: stat.isDirectory() ? 0 : stat.size,
mtime: stat.mtimeMs,
isProtected: PROTECTED_STACK_FILES.has(name),
};
}
}