Correct registry validation and contain metadata reads (#1500)
This commit is contained in:
@@ -19,12 +19,11 @@ async function main(argv) {
|
||||
return 2;
|
||||
}
|
||||
const { entries, errors } = await loadRegistry(root);
|
||||
if (errors.length > 0) {
|
||||
for (const err of errors) process.stderr.write(`${err.path}: ${err.code}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (command === "validate") {
|
||||
if (errors.length > 0) {
|
||||
for (const err of errors) process.stderr.write(`${err.path}: ${err.code}${err.detail ? `: ${err.detail}` : ""}\n`);
|
||||
process.stderr.write(`${errors.length} validation error(s)\n`);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write("valid\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
+91
-157
@@ -1,184 +1,118 @@
|
||||
// Registry record validation for the Mosaic auth/provider/harness domain.
|
||||
// Increment 1 per docs/plans/2026-09-10_m20-increment1-charter.md: pure
|
||||
// schema/reference validation, no I/O, no secrets, no materialization.
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
// Pure metadata validation. Diagnostics never include input values or unknown keys.
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const REF = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
export const validId = value => typeof value === 'string' && ID.test(value);
|
||||
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(v));
|
||||
const text = v => typeof v === 'string' && v.length > 0;
|
||||
const list = predicate => v => Array.isArray(v) && v.length > 0 &&
|
||||
v.every(predicate) && new Set(v).size === v.length;
|
||||
const types = {
|
||||
version: v => v === 1,
|
||||
id: validId,
|
||||
string: text,
|
||||
ids: list(validId),
|
||||
strings: list(text),
|
||||
refs: list(v => typeof v === 'string' && REF.test(v)),
|
||||
object,
|
||||
boolean: v => typeof v === 'boolean',
|
||||
timestamp: v => typeof v === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(v) && Number.isFinite(Date.parse(v)),
|
||||
};
|
||||
const codes = { version: 'unsupported-version', id: 'invalid-id', string: 'invalid-string',
|
||||
ids: 'invalid-id-list', strings: 'invalid-model-list', refs: 'invalid-account-ref-list', object: 'not-an-object',
|
||||
boolean: 'invalid-boolean', timestamp: 'invalid-timestamp' };
|
||||
export class ValidationError extends Error {
|
||||
constructor(path, code, detail) {
|
||||
super(`${path}: ${code}${detail ? `: ${detail}` : ""}`);
|
||||
this.name = "ValidationError";
|
||||
this.path = path;
|
||||
this.code = code;
|
||||
this.detail = detail ?? "";
|
||||
constructor(path, code) {
|
||||
super(`${path}: ${code}`);
|
||||
this.name = 'ValidationError'; this.path = path; this.code = code; this.detail = '';
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (errors, path, code, detail) => errors.push(new ValidationError(path, code, detail));
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const fail = (errors, path, code) => errors.push(new ValidationError(path, code));
|
||||
function shape(errors, path, value, fields, required) {
|
||||
if (!object(value)) { fail(errors, path, 'not-an-object'); return false; }
|
||||
if (Object.keys(value).some(k => !Object.hasOwn(fields, k))) fail(errors, path, 'unknown-field');
|
||||
for (const [key, type] of Object.entries(fields)) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
if (required.includes(key)) fail(errors, `${path}.${key}`, 'missing-field');
|
||||
} else if (!types[type](value[key])) fail(errors, `${path}.${key}`, codes[type]);
|
||||
}
|
||||
return errors.length === 0;
|
||||
}
|
||||
|
||||
// Rejects unknown fields: every record lists its allowed keys and a type map.
|
||||
function checkShape(errors, path, value, allowed, requireAll) {
|
||||
if (!isPlainObject(value)) {
|
||||
fail(errors, path, "not-an-object");
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!(key in allowed)) fail(errors, `${path}.${key}`, "unknown-field");
|
||||
}
|
||||
for (const [key, type] of Object.entries(allowed)) {
|
||||
if (!(key in value)) {
|
||||
if (requireAll.includes(key)) fail(errors, `${path}.${key}`, "missing-field");
|
||||
continue;
|
||||
}
|
||||
const v = value[key];
|
||||
if (type === "id") {
|
||||
if (typeof v !== "string" || !ID_PATTERN.test(v)) fail(errors, `${path}.${key}`, "invalid-id");
|
||||
} else if (type === "string") {
|
||||
if (typeof v !== "string" || v.length === 0) fail(errors, `${path}.${key}`, "invalid-string");
|
||||
} else if (type === "accountRef[]") {
|
||||
if (!Array.isArray(v) || v.length === 0 || v.some((e) => typeof e !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(e))) {
|
||||
fail(errors, `${path}.${key}`, "invalid-account-ref-list");
|
||||
}
|
||||
} else if (type === "string[]") {
|
||||
if (!Array.isArray(v) || v.length === 0 || v.some((e) => typeof e !== "string" || !ID_PATTERN.test(e))) {
|
||||
fail(errors, `${path}.${key}`, "invalid-id-list");
|
||||
}
|
||||
} else if (type === "record") {
|
||||
if (!isPlainObject(v)) fail(errors, `${path}.${key}`, "not-an-object");
|
||||
} else if (type === "timestamp") {
|
||||
if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(v)) {
|
||||
fail(errors, `${path}.${key}`, "invalid-timestamp");
|
||||
}
|
||||
} else if (type === "boolean") {
|
||||
if (typeof v !== "boolean") fail(errors, `${path}.${key}`, "invalid-boolean");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateProvider(record) {
|
||||
const errors = [];
|
||||
checkShape(errors, "provider", record, {
|
||||
providerVersion: "id",
|
||||
id: "id",
|
||||
kind: "string",
|
||||
harnesses: "record",
|
||||
credentialTypes: "string[]",
|
||||
allowInsecureTransport: "boolean",
|
||||
}, ["providerVersion", "id", "kind", "harnesses", "credentialTypes"]);
|
||||
if (errors.length === 0) {
|
||||
if (record.kind !== "native" && record.kind !== "custom-endpoint") {
|
||||
fail(errors, "provider.kind", "unsupported-kind", record.kind);
|
||||
}
|
||||
for (const [harness, cfg] of Object.entries(record.harnesses)) {
|
||||
if (!ID_PATTERN.test(harness)) fail(errors, `provider.harnesses.${harness}`, "invalid-id");
|
||||
if (!isPlainObject(cfg)) fail(errors, `provider.harnesses.${harness}`, "not-an-object");
|
||||
}
|
||||
const harnessCfg = record.harnesses.pi;
|
||||
if (record.kind === "custom-endpoint" && isPlainObject(harnessCfg)) {
|
||||
const url = harnessCfg.baseUrl;
|
||||
if (typeof url !== "string" || !/^https?:\/\//.test(url)) {
|
||||
fail(errors, "provider.harnesses.pi.baseUrl", "invalid-url");
|
||||
} else if (url.startsWith("http://") && record.allowInsecureTransport !== true) {
|
||||
fail(errors, "provider.allowInsecureTransport", "insecure-transport-not-allowed");
|
||||
if (!shape(errors, 'provider', record, { providerVersion: 'version', id: 'id', kind: 'string',
|
||||
harnesses: 'object', credentialTypes: 'ids', allowInsecureTransport: 'boolean' },
|
||||
['providerVersion', 'id', 'kind', 'harnesses', 'credentialTypes'])) return errors;
|
||||
if (!['native', 'custom-endpoint'].includes(record.kind)) fail(errors, 'provider.kind', 'unsupported-kind');
|
||||
if (record.credentialTypes.some(t => !['oauth', 'api_key', 'none'].includes(t)))
|
||||
fail(errors, 'provider.credentialTypes', 'unsupported-credential-type');
|
||||
if (!Object.keys(record.harnesses).length) fail(errors, 'provider.harnesses', 'empty-harnesses');
|
||||
if (record.kind === 'native' && record.allowInsecureTransport === true)
|
||||
fail(errors, 'provider.allowInsecureTransport', 'insecure-transport-unsupported-for-native');
|
||||
for (const [id, cfg] of Object.entries(record.harnesses)) {
|
||||
const p = 'provider.harnesses.entry';
|
||||
if (!validId(id)) fail(errors, p, 'invalid-id');
|
||||
const local = [];
|
||||
if (record.kind === 'native') {
|
||||
shape(local, p, cfg, { providerId: 'id' }, ['providerId']);
|
||||
} else if (record.kind === 'custom-endpoint') {
|
||||
if (shape(local, p, cfg, { api: 'string', baseUrl: 'string', models: 'strings' }, ['api', 'baseUrl', 'models'])) {
|
||||
checkUrl(local, p, cfg.baseUrl, record.allowInsecureTransport);
|
||||
}
|
||||
if (!Array.isArray(harnessCfg.models) || harnessCfg.models.length === 0) {
|
||||
fail(errors, "provider.harnesses.pi.models", "invalid-model-list");
|
||||
}
|
||||
} else if (record.kind === "native" && isPlainObject(harnessCfg) && harnessCfg.baseUrl === undefined && Array.isArray(harnessCfg.models) === false) {
|
||||
// native: pi entry is providerId only; models/bases come from provider catalog
|
||||
}
|
||||
if (record.kind === "native" && record.allowInsecureTransport === true) {
|
||||
fail(errors, "provider.allowInsecureTransport", "insecure-transport-unsupported-for-native");
|
||||
}
|
||||
errors.push(...local);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function checkUrl(errors, path, value, allowHttp) {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
if (!['http:', 'https:'].includes(u.protocol) || !u.hostname || u.hash || /[\s\\]/.test(value))
|
||||
fail(errors, path, 'invalid-url');
|
||||
if (u.username || u.password) fail(errors, path, 'url-credentials-forbidden');
|
||||
if (u.protocol === 'http:' && allowHttp !== true) fail(errors, path, 'insecure-transport-not-allowed');
|
||||
} catch { fail(errors, path, 'invalid-url'); }
|
||||
}
|
||||
export function validateAccount(record, providerId) {
|
||||
const errors = [];
|
||||
checkShape(errors, "account", record, {
|
||||
accountVersion: "id",
|
||||
id: "id",
|
||||
name: "string",
|
||||
provider: "id",
|
||||
type: "string",
|
||||
createdAt: "timestamp",
|
||||
}, ["accountVersion", "id", "name", "provider", "type", "createdAt"]);
|
||||
if (errors.length === 0) {
|
||||
if (providerId !== undefined && record.provider !== providerId) {
|
||||
fail(errors, "account.provider", "provider-path-mismatch", `${record.provider} vs ${providerId}`);
|
||||
}
|
||||
if (record.type !== "oauth" && record.type !== "api_key" && record.type !== "none") {
|
||||
fail(errors, "account.type", "unsupported-credential-type", record.type);
|
||||
}
|
||||
}
|
||||
if (!shape(errors, 'account', record, { accountVersion: 'version', id: 'id', name: 'string',
|
||||
provider: 'id', type: 'string', createdAt: 'timestamp' },
|
||||
['accountVersion', 'id', 'name', 'provider', 'type', 'createdAt'])) return errors;
|
||||
if (providerId !== undefined && record.provider !== providerId) fail(errors, 'account.provider', 'provider-path-mismatch');
|
||||
if (!['oauth', 'api_key', 'none'].includes(record.type)) fail(errors, 'account.type', 'unsupported-credential-type');
|
||||
return errors;
|
||||
}
|
||||
|
||||
function accountMap(errors, path, map) {
|
||||
for (const [provider, ref] of Object.entries(map ?? {})) {
|
||||
if (!validId(provider) || typeof ref !== 'string' || !REF.test(ref)) fail(errors, path, 'invalid-account-ref');
|
||||
else if (ref.split('/')[0] !== provider) fail(errors, path, 'account-provider-mismatch');
|
||||
}
|
||||
}
|
||||
export function validateSettingsProfile(record) {
|
||||
const errors = [];
|
||||
checkShape(errors, "profile", record, {
|
||||
settingsVersion: "id",
|
||||
id: "id",
|
||||
allowedAccounts: "accountRef[]",
|
||||
providers: "string[]",
|
||||
defaultAccounts: "record",
|
||||
models: "record",
|
||||
}, ["settingsVersion", "id", "allowedAccounts"]);
|
||||
if (errors.length === 0) {
|
||||
for (const [provider, account] of Object.entries(record.defaultAccounts ?? {})) {
|
||||
if (!ID_PATTERN.test(provider)) fail(errors, `profile.defaultAccounts.${provider}`, "invalid-id");
|
||||
if (typeof account !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(account)) {
|
||||
fail(errors, `profile.defaultAccounts.${provider}`, "invalid-account-ref");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!shape(errors, 'profile', record, { settingsVersion: 'version', id: 'id', allowedAccounts: 'refs',
|
||||
providers: 'ids', defaultAccounts: 'object', models: 'object' }, ['settingsVersion', 'id', 'allowedAccounts'])) return errors;
|
||||
accountMap(errors, 'profile.defaultAccounts', record.defaultAccounts);
|
||||
for (const ref of Object.values(record.defaultAccounts ?? {}))
|
||||
if (!record.allowedAccounts.includes(ref)) fail(errors, 'profile.defaultAccounts', 'default-account-not-enrolled');
|
||||
for (const [id, models] of Object.entries(record.models ?? {}))
|
||||
if (!validId(id) || !list(text)(models)) fail(errors, 'profile.models', 'invalid-model-list');
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSeatSelection(record) {
|
||||
const errors = [];
|
||||
checkShape(errors, "selection", record, {
|
||||
selectionVersion: "id",
|
||||
profile: "id",
|
||||
accounts: "record",
|
||||
updatedAt: "timestamp",
|
||||
// Fork pin (gate 6): present only when this selection was pinned at fork time.
|
||||
pinnedFromSession: "string",
|
||||
}, ["selectionVersion", "profile"]);
|
||||
if (errors.length === 0) {
|
||||
for (const [provider, account] of Object.entries(record.accounts ?? {})) {
|
||||
if (!ID_PATTERN.test(provider)) fail(errors, `selection.accounts.${provider}`, "invalid-id");
|
||||
if (typeof account !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(account)) {
|
||||
fail(errors, `selection.accounts.${provider}`, "invalid-account-ref");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shape(errors, 'selection', record, { selectionVersion: 'version', profile: 'id', accounts: 'object',
|
||||
updatedAt: 'timestamp', pinnedFromSession: 'string' }, ['selectionVersion', 'profile']))
|
||||
accountMap(errors, 'selection.accounts', record.accounts);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateHarnessManifest(record) {
|
||||
const errors = [];
|
||||
checkShape(errors, "harness", record, {
|
||||
harnessVersion: "id",
|
||||
id: "id",
|
||||
executable: "string",
|
||||
adapter: "string",
|
||||
compatibleRange: "string",
|
||||
executionMode: "string",
|
||||
materializers: "string[]",
|
||||
}, ["harnessVersion", "id", "executable", "adapter", "compatibleRange", "executionMode", "materializers"]);
|
||||
if (errors.length === 0) {
|
||||
if (record.id !== record.executable) {
|
||||
fail(errors, "harness.executable", "id-executable-mismatch", "gate 1: canonical IDs are executable names");
|
||||
}
|
||||
if (record.executionMode !== "container" && record.executionMode !== "host") {
|
||||
fail(errors, "harness.executionMode", "unsupported-mode", record.executionMode);
|
||||
}
|
||||
}
|
||||
if (!shape(errors, 'harness', record, { harnessVersion: 'version', id: 'id', executable: 'id',
|
||||
adapter: 'id', compatibleRange: 'string', executionMode: 'string', materializers: 'ids' },
|
||||
['harnessVersion', 'id', 'executable', 'adapter', 'compatibleRange', 'executionMode', 'materializers'])) return errors;
|
||||
if (record.id !== record.executable) fail(errors, 'harness.executable', 'id-executable-mismatch');
|
||||
if (!['container', 'host'].includes(record.executionMode)) fail(errors, 'harness.executionMode', 'unsupported-mode');
|
||||
return errors;
|
||||
}
|
||||
|
||||
+135
-103
@@ -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(); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user