Row 148 round 3, Researcher's candidate (agents/researcher/work/queue-148/, row148.patch 1b63164b, manifest 95ae4736, 27 paths, base18aee8fa1). Dewey approved round 3 (comment 28832) and Filbert approved it with no findings (28835). Mos gave the decision 123 go on public diff 8360122b (mos-0636): no vendored code, no innerHTML or other HTML sinks. The Docs page #/docs lists each project's spec/north-star.md and the Markdown files directly under docs/. Save writes the typed text as one local commit authored by business.human, through a new human-only bus verb project.file.save that carries the opened commit as base and refuses stale-base if the file moved. The preview is a textarea plus a renderer built with createElement and textContent only; raw HTML shows as source text and images never load. CSP is unchanged. Round 3 replaced the backreference HR regex, which overflowed the regexp stack, with an isRule scan. Round 4 added a textContent fallback when paintDoc throws, and the submit handler resets saving in a finally. Mos's rule (ceiling 1 s at 1 MiB, growth x3 per doubling over 1 to 4 MiB): 130/130 and 138/138 cells pass, worst 513 ms at 1 MiB, worst ratio x2.50, control x2.01; the 756-cell grid has 0 timeouts. Mutants 43/44, v21 equivalent. Follow-ups: Vikunja P19 tasks 318 (Save stays off after a non-render paint throw), 319 (dead NUL check in bus.js) and 320 (the 1 MiB + 64 KiB buffer before the human proof, Gitea #1650). Sage's landing gate ran ona4779d2a5plus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), 22:19:24Z to 22:24:09Z, network sage-r148g_default removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
94 lines
5.3 KiB
JavaScript
94 lines
5.3 KiB
JavaScript
import { projectRepo, documentPath, ProjectError } from './paths.mjs';
|
|
import { git, gitBytes, inspectGit } from './git.mjs';
|
|
import { validateRecord } from './validate.mjs';
|
|
const MAX_BYTES = 1024 * 1024;
|
|
const SHA = /^[0-9a-f]{40}$/;
|
|
const fail = (code, message) => { throw new ProjectError(code, message); };
|
|
function argumentsFor(args, allowed) {
|
|
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some(k => !allowed.includes(k)))
|
|
fail('project-read-path', 'unknown read arguments');
|
|
}
|
|
function text(bytes) {
|
|
try {
|
|
const value = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
if (value.includes('\0')) fail('project-read-content', 'NUL in blob');
|
|
return value;
|
|
} catch { fail('project-read-content', 'expected bounded UTF-8 without NUL'); }
|
|
}
|
|
async function blob(repo, revision, path) {
|
|
const entry = await git(repo, ['ls-tree', '-z', revision, '--', path]);
|
|
if (!entry) fail('project-read-missing', 'file absent at revision');
|
|
const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry);
|
|
if (!match || match[3] !== path) fail('project-read-content', 'expected regular Git blob');
|
|
const size = Number(await git(repo, ['cat-file', '-s', match[2]]));
|
|
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES)
|
|
fail('project-read-content', 'blob exceeds size bound');
|
|
return text(await gitBytes(repo, ['cat-file', 'blob', match[2]], MAX_BYTES));
|
|
}
|
|
// The repository, its HEAD and the revision to read, with the committed project identity checked
|
|
// against the caller's business and project. Shared by the file read and the file list.
|
|
async function openRevision({ business, project, dataRoot, commit }) {
|
|
const repo = projectRepo({ business, project, dataRoot });
|
|
await inspectGit(repo);
|
|
let head;
|
|
try { head = await git(repo, ['rev-parse', '--verify', 'HEAD^{commit}']); }
|
|
catch { fail('project-read-commit', 'repository has no readable HEAD commit'); }
|
|
const revision = commit === undefined ? head : commit;
|
|
if (typeof revision !== 'string' || !SHA.test(revision)) fail('project-read-commit', 'expected full commit ID');
|
|
try {
|
|
if (await git(repo, ['cat-file', '-t', revision]) !== 'commit') fail('project-read-commit', 'not a commit');
|
|
await git(repo, ['merge-base', '--is-ancestor', revision, head]);
|
|
} catch { fail('project-read-commit', 'commit is not reachable from HEAD'); }
|
|
let metadata;
|
|
try { metadata = JSON.parse(await blob(repo, revision, 'project.json')); }
|
|
catch { fail('project-invalid', 'missing or invalid committed project metadata'); }
|
|
if (validateRecord('project', metadata).length || metadata.business !== business.id || metadata.project !== project)
|
|
fail('project-invalid', 'committed project identity does not match context');
|
|
return { repo, head, revision };
|
|
}
|
|
// The documents a person can open in the Console (row 148): the spec and the Markdown directly
|
|
// under docs/, as committed at HEAD. Names only; a read opens one.
|
|
export async function listProjectFiles(args) {
|
|
argumentsFor(args, ['business', 'project', 'dataRoot']);
|
|
const { head, repo } = await openRevision(args);
|
|
const listing = await gitBytes(repo, ['ls-tree', '-z', head, '--', 'spec/north-star.md', 'docs/'], 256 * 1024);
|
|
let entries;
|
|
try { entries = new TextDecoder('utf-8', { fatal: true }).decode(listing).split('\0'); }
|
|
catch { fail('project-git', 'invalid UTF-8 Git listing'); }
|
|
if (entries.pop() !== '') fail('project-git', 'invalid Git listing');
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const match = /^(100644|100755) blob [0-9a-f]{40}\t([^\0]+)$/.exec(entry);
|
|
if (match && documentPath(match[2])) files.push({ path: match[2] });
|
|
}
|
|
files.sort((a, b) => a.path < b.path ? -1 : 1);
|
|
return { head, files };
|
|
}
|
|
export async function readProjectFile(args) {
|
|
argumentsFor(args, ['business', 'project', 'dataRoot', 'path', 'commit']);
|
|
const { business, project, dataRoot, path, commit } = args;
|
|
if (!documentPath(path)) fail('project-read-path', 'expected spec/north-star.md or a named Markdown doc');
|
|
const { repo, head, revision } = await openRevision({ business, project, dataRoot, commit });
|
|
const content = await blob(repo, revision, path);
|
|
const bytes = await gitBytes(repo, ['log', '-z', '--max-count=101',
|
|
'--format=%H%x00%an%x00%ae%x00%aI%x00%s', revision, '--', path], 256 * 1024);
|
|
let fields;
|
|
try { fields = new TextDecoder('utf-8', { fatal: true }).decode(bytes).split('\0'); }
|
|
catch { fail('project-git', 'invalid UTF-8 Git history'); }
|
|
if (fields.pop() !== '' || !fields.length || fields.length % 5 !== 0)
|
|
fail('project-git', 'invalid Git history');
|
|
const log = [];
|
|
for (let i = 0; i < fields.length; i += 5) {
|
|
const [commit, name, email, at, subject] = fields.slice(i, i + 5);
|
|
if (!SHA.test(commit) || !Number.isFinite(Date.parse(at))) fail('project-git', 'invalid Git history');
|
|
log.push({ commit, author: { name, email }, at: new Date(at).toISOString(), subject });
|
|
}
|
|
return { repo, path, head, revision, commit: log[0].commit, content, author: log[0].author,
|
|
log: log.slice(0, 100), truncated: log.length > 100 };
|
|
}
|
|
export async function readSpec(args) {
|
|
argumentsFor(args, ['business', 'project', 'dataRoot', 'commit']);
|
|
const result = await readProjectFile({ ...args, path: 'spec/north-star.md' });
|
|
return { ...result, confirmed: result.author.name === args.business.human };
|
|
}
|