Correct registry validation and contain metadata reads (#1500)

This commit is contained in:
2026-09-10 16:40:16 -05:00
parent b3fa221060
commit 6335342873
12 changed files with 470 additions and 289 deletions
+135 -103
View File
@@ -1,106 +1,138 @@
// Registry tree loading and cross-record reference validation.
// Reads a supplied registry root; never writes, never touches credentials.
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { validateProvider, validateAccount, validateSettingsProfile, validateSeatSelection, validateHarnessManifest } from "./records.mjs";
// 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 = { providers: {}, accounts: {}, profiles: {}, selections: {}, harnesses: {} };
const errors = [];
const providersDir = join(root, "auth", "providers");
for (const file of await safeList(providersDir, errors)) {
if (!file.endsWith(".json")) continue;
const id = file.slice(0, -5);
const record = await readJson(join(providersDir, file), errors);
if (record === undefined) continue;
errors.push(...validateProvider(record));
if (record.id !== id) errors.push(new (await import("./records.mjs")).ValidationError(`providers/${file}`, "id-path-mismatch", `${record.id} vs ${id}`));
if (entries.providers[id]) errors.push(new (await import("./records.mjs")).ValidationError(`providers/${file}`, "duplicate-id", id));
entries.providers[id] = record;
}
const accountsDir = join(root, "auth", "accounts");
for (const provider of await safeList(accountsDir, errors)) {
for (const accountDir of await safeList(join(accountsDir, provider), errors)) {
// account.json sits in a per-account directory; credential.json is never read.
const record = await readJson(join(accountsDir, provider, accountDir, "account.json"), errors);
if (record === undefined) continue;
errors.push(...validateAccount(record, provider));
const accountRef = `${provider}/${accountDir}`;
if (record.id !== accountDir) errors.push(new (await import("./records.mjs")).ValidationError(`accounts/${provider}/${accountDir}`, "id-path-mismatch", `${record.id} vs ${accountDir}`));
if (entries.accounts[accountRef]) errors.push(new (await import("./records.mjs")).ValidationError(`accounts/${provider}/${accountDir}`, "duplicate-id", accountRef));
entries.accounts[accountRef] = record;
}
}
const settingsDir = join(root, "auth", "settings");
for (const file of await safeList(settingsDir, errors)) {
if (!file.endsWith(".json")) continue;
const record = await readJson(join(settingsDir, file), errors);
if (record === undefined) continue;
errors.push(...validateSettingsProfile(record));
if (record.id !== file.slice(0, -5)) errors.push(new (await import("./records.mjs")).ValidationError(`settings/${file}`, "id-path-mismatch"));
entries.profiles[file.slice(0, -5)] = record;
}
const harnessesDir = join(root, "harnesses");
for (const file of await safeList(harnessesDir, errors)) {
if (!file.endsWith(".json")) continue;
const record = await readJson(join(harnessesDir, file), errors);
if (record === undefined) continue;
errors.push(...validateHarnessManifest(record));
entries.harnesses[file.slice(0, -5)] = record;
}
// Cross-record reference integrity.
for (const [ref, account] of Object.entries(entries.accounts)) {
if (!entries.providers[account.provider]) {
errors.push(new (await import("./records.mjs")).ValidationError(`accounts/${ref}`, "missing-provider", account.provider));
}
}
for (const [pid, profile] of Object.entries(entries.profiles)) {
for (const ref of profile.allowedAccounts ?? []) {
if (!entries.accounts[ref]) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "missing-account", ref));
}
for (const provider of profile.providers ?? []) {
if (!entries.providers[provider]) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "missing-provider", provider));
}
for (const [provider, ref] of Object.entries(profile.defaultAccounts ?? {})) {
if (!entries.accounts[ref]) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "missing-default-account", ref));
else if (!ref.startsWith(`${provider}/`)) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "default-account-provider-mismatch", ref));
}
}
return { entries, errors };
}
async function safeList(dir, errors) {
const entries = emptyEntries();
let handle;
try {
const s = await stat(dir);
if (!s.isDirectory()) throw new Error("not-a-directory");
return await readdir(dir);
} catch {
return [];
}
}
async function readJson(path, errors) {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch (err) {
errors.push(new ValidationErrorCompat(path, "invalid-json", err.message));
return undefined;
}
}
class ValidationErrorCompat extends Error {
constructor(path, code, detail) {
super(`${path}: ${code}: ${detail}`);
this.name = "ValidationError";
this.path = path;
this.code = code;
this.detail = detail;
}
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(); }
}