180 lines
6.8 KiB
TypeScript
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 })
|
|
});
|
|
}
|