import * as plugins from './plugins.js'; import { resolveAGLHomePaths } from './classes.aglhome.js'; import { readControllerProcessIdentity } from './classes.processinspection.js'; import { controllerMaxAttachmentPromptSuffixBytes, controllerMaxDraftAttachmentBytes, controllerMaxDraftAttachmentTotalBytes, controllerMaxDraftAttachments, type IControllerDraftAttachment, } from '../ts_interfaces/interfaces.js'; const maximumActiveUploads = 64; const maximumActiveUploadBytes = 64 * 1024 * 1024; const uploadOwnerFileName = '.controller-upload-owner.json'; const uploadOwnerMaximumBytes = 8 * 1024; const uploadOrphanRetentionMs = 24 * 60 * 60 * 1000; const maximumStaleUploadNodes = 8_192; const maximumStaleUploadDepth = 32; interface IControllerUploadOwner { version: 1; pid: number; fingerprint: string; nonce: string; createdAt: number; } const parseUploadOwner = (valueArg: unknown): IControllerUploadOwner => { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) { throw new Error('Controller upload owner metadata is malformed.'); } const value = valueArg as Record; if ( Object.keys(value).sort().join('\0') !== ['createdAt', 'fingerprint', 'nonce', 'pid', 'version'].sort().join('\0') || value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid as number) < 2 || typeof value.fingerprint !== 'string' || value.fingerprint.length === 0 || value.fingerprint.length > 512 || typeof value.nonce !== 'string' || !/^[a-f0-9]{32}$/.test(value.nonce) || !Number.isSafeInteger(value.createdAt) ) throw new Error('Controller upload owner metadata is invalid.'); return value as unknown as IControllerUploadOwner; }; export interface IControllerUploadHandle { id: string; directory: string; paths: string[]; promptSuffix: string; bytes: number; cleanup(): Promise; } interface IActiveUpload { handle: IControllerUploadHandle; cleanupPromise?: Promise; } export interface IControllerUploadManagerOptions { rootDirectory?: string; readProcessIdentity?: typeof readControllerProcessIdentity; } export class ControllerUploadManager { private readonly readProcessIdentity: typeof readControllerProcessIdentity; private rootPromise?: Promise; private readonly activeUploads = new Map(); private readonly pendingCreates = new Set>(); private activeUploadCount = 0; private activeBytes = 0; private closed = false; private closePromise?: Promise; constructor(private readonly options: IControllerUploadManagerOptions = {}) { this.readProcessIdentity = options.readProcessIdentity ?? readControllerProcessIdentity; } public create( attachmentsArg: readonly IControllerDraftAttachment[], ): Promise { if (attachmentsArg.length === 0) return Promise.resolve(undefined); if (this.closed) throw new Error('The controller upload manager is closed.'); if (attachmentsArg.length > controllerMaxDraftAttachments) { throw new Error('The attachment count exceeds the controller limit.'); } if (this.activeUploadCount >= maximumActiveUploads) { throw new Error('The controller has too many active uploads.'); } const decoded = attachmentsArg.map((attachment, index) => { if ( attachment.name.length === 0 || attachment.name !== plugins.path.basename(attachment.name) || Buffer.byteLength(attachment.name, 'utf8') > 255 // eslint-disable-next-line no-control-regex || /[\x00-\x1f\x7f/\\]/.test(attachment.name) ) { throw new Error(`Attachment ${index + 1} has an unsafe file name.`); } if ( attachment.mediaType.length === 0 || Buffer.byteLength(attachment.mediaType, 'utf8') > 129 // eslint-disable-next-line no-control-regex || /[\x00-\x20\x7f]/.test(attachment.mediaType) ) throw new Error(`Attachment ${index + 1} has an unsafe media type.`); const data = Buffer.from(attachment.dataBase64, 'base64'); if (data.toString('base64') !== attachment.dataBase64) { throw new Error(`Attachment ${index + 1} is not canonical base64.`); } if (data.byteLength !== attachment.size) { throw new Error(`Attachment ${index + 1} size does not match its content.`); } if (data.byteLength > controllerMaxDraftAttachmentBytes) { throw new Error(`Attachment ${index + 1} exceeds the controller byte limit.`); } return { attachment, data }; }); const bytes = decoded.reduce((sum, entry) => sum + entry.data.byteLength, 0); if (bytes > controllerMaxDraftAttachmentTotalBytes) { throw new Error('The attachments exceed the controller total byte limit.'); } if (this.activeBytes + bytes > maximumActiveUploadBytes) { throw new Error('The controller active upload byte limit is exhausted.'); } this.activeUploadCount += 1; this.activeBytes += bytes; const createPromise = this.createReserved(decoded, bytes); this.pendingCreates.add(createPromise); void createPromise.finally(() => this.pendingCreates.delete(createPromise)).catch(() => undefined); return createPromise; } public async close(): Promise { if (this.closePromise) return this.closePromise; this.closed = true; const closePromise = this.closeInternal(); this.closePromise = closePromise; try { await closePromise; } catch (errorArg) { if (this.closePromise === closePromise) this.closePromise = undefined; throw errorArg; } } private async createReserved( decodedArg: Array<{ attachment: IControllerDraftAttachment; data: Buffer; }>, bytesArg: number, ): Promise { const id = plugins.crypto.randomBytes(16).toString('base64url'); let directory: string | undefined; let admitted = false; try { const root = await this.ensureRoot(); directory = await plugins.fs.promises.mkdtemp(plugins.path.join(root, 'message-')); await plugins.fs.promises.chmod(directory, 0o700); const paths: string[] = []; for (const [index, entry] of decodedArg.entries()) { const filePath = plugins.path.join(directory, `${index + 1}-${entry.attachment.name}`); const relative = plugins.path.relative(directory, filePath); if ( relative === '..' || relative.startsWith(`..${plugins.path.sep}`) || plugins.path.isAbsolute(relative) ) throw new Error('An attachment path escaped its upload directory.'); const flags = plugins.fs.constants.O_WRONLY | plugins.fs.constants.O_CREAT | plugins.fs.constants.O_EXCL | (plugins.fs.constants.O_NOFOLLOW ?? 0); const file = await plugins.fs.promises.open(filePath, flags, 0o600); try { await file.writeFile(entry.data); } finally { await file.close(); } paths.push(filePath); } const promptSuffix = `\n\n[Attached files - stored on disk; read them with filesystem tools]\n${decodedArg .map((entry, index) => `- ${paths[index]} (${entry.attachment.mediaType}, ${entry.data.byteLength} bytes)`) .join('\n')}`; if (Buffer.byteLength(promptSuffix, 'utf8') > controllerMaxAttachmentPromptSuffixBytes) { throw new Error('The generated attachment prompt suffix exceeds its byte limit.'); } const handle: IControllerUploadHandle = { id, directory, paths, promptSuffix, bytes: bytesArg, cleanup: () => this.cleanup(id), }; this.activeUploads.set(id, { handle }); admitted = true; return handle; } finally { if (!admitted) { if (directory) { await plugins.fs.promises.rm(directory, { recursive: true, force: true }).catch(() => undefined); } this.activeUploadCount -= 1; this.activeBytes -= bytesArg; } } } private async closeInternal(): Promise { await Promise.allSettled([...this.pendingCreates]); const results = await Promise.allSettled( [...this.activeUploads.values()].map((entry) => entry.handle.cleanup()), ); const errors = results .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map((result) => result.reason); if (this.rootPromise) { try { const root = await this.rootPromise; await plugins.fs.promises.rm(root, { recursive: true, force: true }); this.activeUploads.clear(); this.activeUploadCount = 0; this.activeBytes = 0; errors.length = 0; } catch (errorArg) { errors.push(errorArg); } } if (errors.length > 0) throw new AggregateError(errors, 'Controller upload cleanup failed.'); } private async ensureRoot(): Promise { if (!this.rootPromise) { this.rootPromise = (async () => { let root: string | undefined; try { const baseRoot = this.options.rootDirectory ?? resolveAGLHomePaths().uploads; await plugins.fs.promises.mkdir(baseRoot, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(baseRoot, 0o700); await this.cleanupStaleRoots(baseRoot); root = await plugins.fs.promises.mkdtemp( plugins.path.join(baseRoot, 'controller-'), ); await plugins.fs.promises.chmod(root, 0o700); const identity = await readControllerProcessIdentity(process.pid); if (!identity) throw new Error('Unable to establish controller upload ownership.'); await this.writeOwnerMarker(root, { version: 1, pid: identity.pid, fingerprint: identity.fingerprint, nonce: plugins.crypto.randomBytes(16).toString('hex'), createdAt: Date.now(), }); return await plugins.fs.promises.realpath(root); } catch (errorArg) { if (root) { await plugins.fs.promises.rm(root, { recursive: true, force: true }) .catch(() => undefined); } throw errorArg; } })(); } const rootPromise = this.rootPromise; try { return await rootPromise; } catch (errorArg) { if (this.rootPromise === rootPromise) this.rootPromise = undefined; throw errorArg; } } private async writeOwnerMarker( rootArg: string, ownerArg: IControllerUploadOwner, ): Promise { const markerPath = plugins.path.join(rootArg, uploadOwnerFileName); const temporaryPath = `${markerPath}.tmp-${ownerArg.pid}-${ownerArg.nonce.slice(0, 16)}`; let handle: plugins.fs.promises.FileHandle | undefined; let temporaryCreated = false; try { handle = await plugins.fs.promises.open( temporaryPath, plugins.fs.constants.O_WRONLY | plugins.fs.constants.O_CREAT | plugins.fs.constants.O_EXCL | (plugins.fs.constants.O_NOFOLLOW ?? 0), 0o600, ); temporaryCreated = true; await handle.writeFile(`${JSON.stringify(ownerArg)}\n`, 'utf8'); await handle.chmod(0o600); await handle.sync(); await handle.close(); handle = undefined; await plugins.fs.promises.rename(temporaryPath, markerPath); await plugins.fs.promises.open(rootArg, plugins.fs.constants.O_RDONLY) .then(async (directoryHandle) => { try { await directoryHandle.sync(); } finally { await directoryHandle.close(); } }); temporaryCreated = false; } catch (errorArg) { const cleanupErrors: unknown[] = []; if (handle) { try { await handle.close(); } catch (cleanupErrorArg) { cleanupErrors.push(cleanupErrorArg); } } if (temporaryCreated) { try { await plugins.fs.promises.unlink(temporaryPath); } catch (cleanupErrorArg) { if ((cleanupErrorArg as NodeJS.ErrnoException).code !== 'ENOENT') { cleanupErrors.push(cleanupErrorArg); } } } if (cleanupErrors.length > 0) { throw new AggregateError( [errorArg, ...cleanupErrors], 'Controller upload owner publication failed and cleanup was incomplete.', { cause: errorArg }, ); } throw errorArg; } } private async cleanupStaleRoots(baseRootArg: string): Promise { const entries = await plugins.fs.promises.readdir(baseRootArg, { withFileTypes: true }); if (entries.length > 256) throw new Error('The controller upload root contains too many entries.'); for (const entry of entries) { if (!entry.name.startsWith('controller-')) { throw new Error(`Unexpected controller upload root entry: ${entry.name}`); } const root = plugins.path.join(baseRootArg, entry.name); const stats = await plugins.fs.promises.lstat(root); if ( !stats.isDirectory() || stats.isSymbolicLink() || (typeof process.getuid === 'function' && stats.uid !== process.getuid()) || (stats.mode & 0o077) !== 0 ) throw new Error(`Controller upload root is unsafe: ${root}`); const markerPath = plugins.path.join(root, uploadOwnerFileName); const markerStats = await plugins.fs.promises.lstat(markerPath).catch((errorArg) => { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; }); let stale = false; if (!markerStats) { stale = Date.now() - stats.mtimeMs > uploadOrphanRetentionMs; } else { if ( !markerStats.isFile() || markerStats.isSymbolicLink() || (typeof process.getuid === 'function' && markerStats.uid !== process.getuid()) || markerStats.nlink !== 1 || (markerStats.mode & 0o077) !== 0 || markerStats.size > uploadOwnerMaximumBytes ) throw new Error(`Controller upload owner marker is unsafe: ${markerPath}`); const owner = parseUploadOwner(JSON.parse( await plugins.fs.promises.readFile(markerPath, 'utf8'), ) as unknown); let identity: Awaited>; try { identity = await this.readProcessIdentity(owner.pid); } catch (errorArg) { throw new Error(`Controller upload owner cannot be verified safely: ${root}`, { cause: errorArg, }); } stale = !identity || identity.fingerprint !== owner.fingerprint; } if (!stale) continue; await this.removeStaleRoot(root, stats); } } private async removeStaleRoot( rootArg: string, identityArg: plugins.fs.Stats, ): Promise { const current = await plugins.fs.promises.lstat(rootArg); if ( !current.isDirectory() || current.isSymbolicLink() || current.dev !== identityArg.dev || current.ino !== identityArg.ino ) throw new Error(`Controller upload root changed before stale cleanup: ${rootArg}`); const tombstone = `${rootArg}.stale-${process.pid}-${plugins.crypto.randomBytes(8).toString('hex')}`; await plugins.fs.promises.rename(rootArg, tombstone); try { const moved = await plugins.fs.promises.lstat(tombstone); if (moved.dev !== identityArg.dev || moved.ino !== identityArg.ino) { throw new Error(`Controller upload stale root identity changed: ${rootArg}`); } await this.validateStaleTree(tombstone, identityArg.dev); await plugins.fs.promises.rm(tombstone, { recursive: true, force: true }); } finally { const parentHandle = await plugins.fs.promises.open( plugins.path.dirname(rootArg), plugins.fs.constants.O_RDONLY, ); try { await parentHandle.sync(); } finally { await parentHandle.close(); } } } private async validateStaleTree(rootArg: string, deviceArg: number): Promise { const pending = [{ path: rootArg, depth: 0 }]; let inspected = 0; while (pending.length > 0) { const current = pending.pop()!; const entries = await plugins.fs.promises.readdir(current.path, { withFileTypes: true }); inspected += entries.length; if (inspected > maximumStaleUploadNodes) { throw new Error(`Controller upload stale tree contains too many entries: ${rootArg}`); } for (const entry of entries) { const path = plugins.path.join(current.path, entry.name); const stats = await plugins.fs.promises.lstat(path); if ( stats.dev !== deviceArg || (typeof process.getuid === 'function' && stats.uid !== process.getuid()) ) throw new Error(`Controller upload stale tree contains a foreign node: ${path}`); if (!stats.isDirectory() || stats.isSymbolicLink()) continue; if (current.depth >= maximumStaleUploadDepth) { throw new Error(`Controller upload stale tree is too deep: ${path}`); } pending.push({ path, depth: current.depth + 1 }); } } } private async cleanup(idArg: string): Promise { const active = this.activeUploads.get(idArg); if (!active) return; if (active.cleanupPromise) return active.cleanupPromise; const cleanupPromise = (async () => { await plugins.fs.promises.rm(active.handle.directory, { recursive: true, force: true }); if (this.activeUploads.get(idArg) === active) { this.activeUploads.delete(idArg); this.activeUploadCount -= 1; this.activeBytes -= active.handle.bytes; } })(); active.cleanupPromise = cleanupPromise; try { await cleanupPromise; } finally { if (this.activeUploads.get(idArg) === active && active.cleanupPromise === cleanupPromise) { active.cleanupPromise = undefined; } } } }