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 | 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 }>( 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(); 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 { 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 { 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( 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 }) }); }