// Linux descriptor-anchored metadata reader. Never opens credential.json. // Directory FDs keep traversal inside the checked tree even during rename races. import { open, readdir, lstat } from 'node:fs/promises'; import { constants } from 'node:fs'; import { resolve } from 'node:path'; import { ValidationError, validId, validateProvider, validateAccount, validateSettingsProfile, validateHarnessManifest } from './records.mjs'; const fail = code => { throw new ValidationError('registry', code); }; const fdPath = handle => `/proc/self/fd/${handle.fd}`; const ioCode = e => e instanceof ValidationError ? e : new ValidationError('registry', ({ ENOENT: 'missing-path', EACCES: 'inaccessible-path', EPERM: 'inaccessible-path', ENOTDIR: 'not-a-directory', ELOOP: 'symlink-forbidden' })[e.code] ?? 'read-failed'); function privateMode(s, directory) { if (s.uid !== process.getuid() || (s.mode & 0o777) !== (directory ? 0o700 : 0o600)) fail('insecure-permissions'); } async function directory(path, privateRequired = true) { const before = await lstat(path); if (before.isSymbolicLink()) fail('symlink-forbidden'); if (!before.isDirectory()) fail('not-a-directory'); if (privateRequired) privateMode(before, true); const h = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); try { const after = await h.stat(); if (before.dev !== after.dev || before.ino !== after.ino) fail('path-changed'); if (privateRequired) privateMode(after, true); return h; } catch (e) { await h.close(); throw e; } } async function rootDirectory(root) { if (process.platform !== 'linux' || typeof process.getuid !== 'function') fail('unsupported-platform'); if (typeof root !== 'string' || !root.length || root.split('/').includes('..')) fail('invalid-root'); const parts = resolve(root).split('/').filter(Boolean); if (!parts.length) fail('invalid-root'); let h = await directory('/', false); try { for (let i = 0; i < parts.length; i++) { const next = await directory(`${fdPath(h)}/${parts[i]}`, i === parts.length - 1); await h.close(); h = next; } return h; } catch (e) { await h.close(); throw e; } } async function withDirectory(parent, name, fn) { const h = await directory(`${fdPath(parent)}/${name}`); try { return await fn(h); } finally { await h.close(); } } async function jsonFile(parent, name) { const path = `${fdPath(parent)}/${name}`; const before = await lstat(path); if (before.isSymbolicLink()) fail('symlink-forbidden'); if (!before.isFile()) fail('not-a-regular-file'); privateMode(before, false); if (before.size > 1024 * 1024) fail('record-too-large'); const h = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const after = await h.stat(); if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) fail('path-changed'); privateMode(after, false); // Bounded read even if the writer grows the file after stat. const buffer = Buffer.alloc(1024 * 1024 + 1); let length = 0; while (length < buffer.length) { const { bytesRead } = await h.read(buffer, length, buffer.length - length, null); if (!bytesRead) break; length += bytesRead; } if (length > 1024 * 1024) fail('record-too-large'); try { return JSON.parse(buffer.toString('utf8', 0, length)); } catch { fail('invalid-json'); } } finally { await h.close(); } } async function records(dir, validator, target) { for (const name of (await readdir(fdPath(dir))).sort()) { if (!name.endsWith('.json') || !validId(name.slice(0, -5))) fail('invalid-record-name'); const id = name.slice(0, -5), record = await jsonFile(dir, name); const errors = validator(record); if (errors.length) throw errors[0]; if (record.id !== id) fail('id-path-mismatch'); if (Object.hasOwn(target, id)) fail('duplicate-id'); target[id] = record; } } function emptyEntries() { return Object.fromEntries(['providers', 'accounts', 'profiles', 'selections', 'harnesses'] .map(k => [k, Object.create(null)])); } export async function loadRegistry(root) { const entries = emptyEntries(); let handle; try { handle = await rootDirectory(root); await withDirectory(handle, 'auth', async auth => { await withDirectory(auth, 'providers', d => records(d, validateProvider, entries.providers)); await withDirectory(auth, 'accounts', async accounts => { for (const provider of (await readdir(fdPath(accounts))).sort()) { if (!validId(provider)) fail('invalid-provider-directory'); await withDirectory(accounts, provider, async pd => { for (const id of (await readdir(fdPath(pd))).sort()) { if (!validId(id)) fail('invalid-account-directory'); await withDirectory(pd, id, async ad => { // Do not stat, open or parse the credential sibling. const account = await jsonFile(ad, 'account.json'); const errors = validateAccount(account, provider); if (errors.length) throw errors[0]; if (account.id !== id) fail('id-path-mismatch'); entries.accounts[`${provider}/${id}`] = account; }); } }); } }); await withDirectory(auth, 'settings', d => records(d, validateSettingsProfile, entries.profiles)); }); await withDirectory(handle, 'harnesses', d => records(d, validateHarnessManifest, entries.harnesses)); for (const provider of Object.values(entries.providers)) for (const id of Object.keys(provider.harnesses)) if (!Object.hasOwn(entries.harnesses, id)) fail('missing-harness'); for (const account of Object.values(entries.accounts)) { const provider = entries.providers[account.provider]; if (!provider) fail('missing-provider'); if (!provider.credentialTypes.includes(account.type)) fail('credential-type-not-supported'); } for (const profile of Object.values(entries.profiles)) { for (const ref of profile.allowedAccounts) if (!entries.accounts[ref]) fail('missing-account'); for (const id of [...(profile.providers ?? []), ...Object.keys(profile.models ?? {})]) if (!entries.providers[id]) fail('missing-provider'); for (const ref of Object.values(profile.defaultAccounts ?? {})) if (!entries.accounts[ref]) fail('missing-default-account'); } if (!Object.keys(entries.providers).length || !Object.keys(entries.profiles).length || !Object.keys(entries.harnesses).length) fail('empty-registry'); return { entries, errors: [] }; } catch (e) { // Never return partially trusted data after a refusal. return { entries: emptyEntries(), errors: [ioCode(e)] }; } finally { if (handle) await handle.close(); } }