Files
electric-cherry/src/lib/server/backends/files.ts
T

180 lines
6.8 KiB
TypeScript

import { isPrivateConfigPath } from '../fleet/private-config.js';
import * as fs from 'node:fs/promises';
import { join, posix, relative, resolve, sep } from 'node:path';
import { config } from '../config.js';
import { nativeInstance, nativeRequest } from './native.js';
import { getHost } from '../hosts/index.js';
import { getFileBlob } from '../db/file-blobs.js';
function remote(path: string) {
for (const root of [config.serversRoot, config.t6ServersRoot]) {
if (!root) continue;
const rel = relative(resolve(root), resolve(path));
if (!rel || rel.startsWith('..') || rel.startsWith(sep)) continue;
const [name, ...parts] = rel.split(sep);
const instance = nativeInstance(name);
if (instance) return { instance, path: parts.join('/') || '.' };
}
return null;
}
function missing(path: string): never {
throw Object.assign(new Error(`File does not exist: ${path}`), { code: 'ENOENT' });
}
export interface FileFingerprint {
size: number;
sha256: string;
}
/** One scoped agent scan; null preserves compatibility with older agents and local files. */
export async function nativeFileManifest(
root: string,
directories: string[],
ignoreNames: string[],
managedPaths: string[]
): Promise<Map<string, FileFingerprint> | null> {
const target = remote(root);
if (
!target ||
target.path !== '.' ||
!getHost(target.instance.host_id)?.inventory.capabilities?.includes('files.manifest')
)
return null;
const roots = [
...new Set(directories.map((directory) => relative(root, directory).split(sep).join('/')))
];
// Preserve legacy configurations that scan the entire instance root.
if (roots.some((directory) => !directory)) return null;
const paths = [
...new Set(managedPaths.map((path) => relative(root, path).split(sep).join('/')))
];
const result = await nativeRequest<{ files: Array<FileFingerprint & { path: string }> }>(
target.instance,
'files.manifest',
{
roots,
paths,
ignoreNames: ignoreNames.filter(
(name) => !paths.some((path) => path.split('/').includes(name))
)
}
);
if (!result || !Array.isArray(result.files) || result.files.length > 10_000)
throw new Error('Agent returned an invalid file manifest.');
const manifest = new Map<string, FileFingerprint>();
for (const entry of result.files) {
if (
!entry ||
typeof entry.path !== 'string' ||
!entry.path ||
entry.path.includes('\\') ||
entry.path.includes('\0') ||
posix.normalize(entry.path) !== entry.path ||
entry.path.split('/').some((part) => part === '..' || part === '.') ||
!roots.some((directory) => entry.path.startsWith(`${directory}/`)) ||
!Number.isSafeInteger(entry.size) ||
entry.size < 0 ||
!/^[a-f0-9]{64}$/.test(entry.sha256)
) {
throw new Error('Agent returned an invalid file manifest entry.');
}
const path = join(root, entry.path);
if (manifest.has(path)) throw new Error('Agent returned duplicate file manifest entries.');
manifest.set(path, { size: entry.size, sha256: entry.sha256 });
}
return manifest;
}
export async function readFile(path: string): Promise<Buffer> {
for (const root of [config.serversRoot, config.t6ServersRoot]) {
const [, ...parts] = relative(resolve(root), resolve(path)).split(sep);
if (isPrivateConfigPath(parts.join('/')))
throw new Error(
'Private bank configuration is not available through shared file reads.'
);
}
const target = remote(path);
if (!target) return fs.readFile(path);
const result = await nativeRequest<{
contentSha256?: string;
contentBase64?: string | null;
} | null>(target.instance, 'files.read', { path: target.path });
// Stored receipts reference shared bytes; read them directly without a base64 round trip.
if (typeof result?.contentSha256 === 'string') return getFileBlob(result.contentSha256);
if (result?.contentBase64 === null) return missing(path);
if (typeof result?.contentBase64 === 'string')
return Buffer.from(result.contentBase64, 'base64');
throw new Error(`Agent returned an invalid response for files.read: ${target.path}`);
}
export async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}
export async function stat(
path: string
): Promise<{ size: number; mtime: Date; isDirectory(): boolean; isFile(): boolean }> {
const target = remote(path);
if (!target) return fs.stat(path);
const result = await nativeRequest<{
exists?: boolean;
size: number;
mtime: string | number;
isDirectory: boolean;
isFile: boolean;
} | null>(target.instance, 'files.stat', { path: target.path });
if (result === null || result.exists === false) return missing(path);
return {
size: result.size,
mtime: new Date(typeof result.mtime === 'number' ? result.mtime * 1000 : result.mtime),
isDirectory: () => result.isDirectory,
isFile: () => result.isFile
};
}
export async function readdir(
path: string,
options: { withFileTypes: true }
): Promise<{ name: string; isDirectory(): boolean; isFile(): boolean }[]> {
const target = remote(path);
if (!target) return fs.readdir(path, options);
const result = await nativeRequest<{
entries: { name: string; isDirectory: boolean; isFile?: boolean }[];
}>(target.instance, 'files.list', { path: target.path });
return result.entries.map((entry) => ({
name: entry.name,
isDirectory: () => entry.isDirectory,
isFile: () => entry.isFile ?? !entry.isDirectory
}));
}
export async function nativeTail(path: string, lines: number) {
const target = remote(path);
if (!target) return undefined;
return nativeRequest<import('../fleet/logs.js').TailResult | null>(
target.instance,
'logs.read',
{ path: target.path, lines }
);
}
export async function nativeLogChunk(path: string, cursor?: import('../fleet/logs.js').LogCursor) {
const target = remote(path);
if (!target) return undefined;
const incremental = getHost(target.instance.host_id)?.inventory.capabilities?.includes(
'logs.read-offset'
);
return nativeRequest<
import('../fleet/logs.js').LogChunk | import('../fleet/logs.js').TailResult | null
>(target.instance, 'logs.read', {
path: target.path,
...(incremental ? { mode: 'incremental', cursor } : { lines: 2000 })
});
}