@nimiplatform/nimi-coding 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +3 -3
- package/README.md +285 -41
- package/README.zh-CN.md +294 -31
- package/bin/nimicoding.mjs +1 -1
- package/cli/commands/authority.mjs +849 -0
- package/cli/commands/clear.mjs +27 -7
- package/cli/commands/doctor.mjs +10 -1
- package/cli/commands/start.mjs +23 -27
- package/cli/commands/sync.mjs +21 -101
- package/cli/constants.mjs +7 -31
- package/cli/dev/check-authority-surface.mjs +173 -0
- package/cli/dev/check-product-conformance.mjs +917 -0
- package/cli/help.mjs +10 -17
- package/cli/index.mjs +6 -47
- package/cli/lib/authority/anchors.mjs +491 -0
- package/cli/lib/authority/audit.mjs +490 -0
- package/cli/lib/authority/closed-sets.mjs +646 -0
- package/cli/lib/authority/compile.mjs +110 -0
- package/cli/lib/authority/consumers.mjs +470 -0
- package/cli/lib/authority/diagnostics.mjs +87 -0
- package/cli/lib/authority/diff.mjs +287 -0
- package/cli/lib/authority/discover.mjs +447 -0
- package/cli/lib/authority/evidence-snapshot.mjs +1 -0
- package/cli/lib/authority/evidence.mjs +1432 -0
- package/cli/lib/authority/format.mjs +197 -0
- package/cli/lib/authority/git-snapshot.mjs +1065 -0
- package/cli/lib/authority/graph.mjs +351 -0
- package/cli/lib/authority/impact.mjs +252 -0
- package/cli/lib/authority/lexicon.mjs +55 -0
- package/cli/lib/authority/query.mjs +162 -0
- package/cli/lib/authority/repository-corpus.mjs +37 -0
- package/cli/lib/authority/repository-inventory.mjs +34 -0
- package/cli/lib/authority/review.mjs +222 -0
- package/cli/lib/authority/sarif.mjs +181 -0
- package/cli/lib/authority/scope-bindings.mjs +225 -0
- package/cli/lib/authority/source-map.mjs +105 -0
- package/cli/lib/authority/source-markdown.mjs +167 -0
- package/cli/lib/authority/source-yaml.mjs +312 -0
- package/cli/lib/authority/surface.mjs +79 -0
- package/cli/lib/authority/terms.mjs +421 -0
- package/cli/lib/authority/tracked-text.mjs +70 -0
- package/cli/lib/authority/validate.mjs +308 -0
- package/cli/lib/bootstrap.mjs +55 -71
- package/cli/lib/doctor.mjs +40 -123
- package/cli/lib/entrypoints.mjs +103 -105
- package/cli/lib/fs-helpers.mjs +139 -15
- package/cli/lib/internal/governance/config.mjs +0 -36
- package/cli/lib/shared.mjs +8 -21
- package/cli/lib/sync.mjs +81 -92
- package/cli/lib/value-helpers.mjs +0 -26
- package/cli/lib/yaml-helpers.mjs +1 -124
- package/cli/seeds/bootstrap.mjs +31 -87
- package/cli/seeds/seed-policy.yaml +9 -63
- package/contracts/authority-evidence-bindings.schema.yaml +53 -0
- package/contracts/authority-evidence-probe-results.schema.yaml +38 -0
- package/contracts/authority-impact-dispositions.schema.yaml +13 -0
- package/contracts/authority-scope-bindings.schema.yaml +15 -0
- package/contracts/authority-source.schema.yaml +64 -0
- package/contracts/authority-verifier-bindings.schema.yaml +20 -0
- package/methodology/authority-authoring.yaml +144 -0
- package/methodology/core.yaml +31 -26
- package/package.json +10 -6
- package/spec/authority-anchors.yaml +99 -0
- package/spec/authority-audit.yaml +85 -0
- package/spec/authority-authoring.yaml +150 -0
- package/spec/authority-closed-sets.yaml +65 -0
- package/spec/authority-consumers.yaml +70 -0
- package/spec/authority-context.yaml +50 -0
- package/spec/authority-discovery.yaml +119 -0
- package/spec/authority-evidence.yaml +200 -0
- package/spec/authority-graph.yaml +59 -0
- package/spec/authority-impact.yaml +84 -0
- package/spec/authority-review.yaml +89 -0
- package/spec/authority-terms.yaml +59 -0
- package/spec/product-scope.yaml +36 -23
- package/cli/commands/blueprint-audit.mjs +0 -91
- package/cli/commands/classify-spec-tree.mjs +0 -5
- package/cli/commands/generate-spec-derived-docs.mjs +0 -151
- package/cli/commands/generate-spec-migration-plan.mjs +0 -30
- package/cli/commands/surface-validator-command.mjs +0 -49
- package/cli/commands/validate-domain-admission.mjs +0 -5
- package/cli/commands/validate-guidance-bodies.mjs +0 -5
- package/cli/commands/validate-placement.mjs +0 -5
- package/cli/commands/validate-projection-edges.mjs +0 -5
- package/cli/commands/validate-spec-audit.mjs +0 -27
- package/cli/commands/validate-spec-governance.mjs +0 -124
- package/cli/commands/validate-spec-tree.mjs +0 -27
- package/cli/commands/validate-table-family.mjs +0 -5
- package/cli/commands/validate-tracked-output-admission.mjs +0 -5
- package/cli/lib/blueprint-audit.mjs +0 -363
- package/cli/lib/contracts.mjs +0 -21
- package/cli/lib/internal/contracts-loaders.mjs +0 -47
- package/cli/lib/internal/contracts-parse.mjs +0 -192
- package/cli/lib/internal/governance/runner.mjs +0 -35
- package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
- package/cli/lib/internal/validators-shared.mjs +0 -12
- package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
- package/cli/lib/internal/validators-spec.mjs +0 -380
- package/cli/lib/validators.mjs +0 -30
- package/config/bootstrap.yaml +0 -6
- package/config/spec-generation-inputs.yaml +0 -41
- package/contracts/domain-admission.schema.yaml +0 -56
- package/contracts/migration-inventory.schema.yaml +0 -15
- package/contracts/negative-fixtures.yaml +0 -26
- package/contracts/placement-contract.schema.yaml +0 -127
- package/contracts/projection-edge.schema.yaml +0 -46
- package/contracts/shared-enums.yaml +0 -63
- package/contracts/spec-generation-audit.schema.yaml +0 -46
- package/contracts/spec-generation-inputs.schema.yaml +0 -43
- package/contracts/spec-layout.schema.yaml +0 -32
- package/contracts/surface-taxonomy.schema.yaml +0 -139
- package/contracts/table-family.schema.yaml +0 -133
- package/contracts/tracked-output-admission.schema.yaml +0 -17
- package/methodology/four-closure-policy.yaml +0 -20
- package/methodology/role-separation-policy.yaml +0 -22
- package/methodology/spec-reconstruction.yaml +0 -69
- package/spec/_meta/spec-tree-model.yaml +0 -44
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { constants as fsConstants } from "node:fs";
|
|
5
|
+
import { lstat, mkdir, mkdtemp, open, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { compareText, makeDiagnostic } from "./diagnostics.mjs";
|
|
10
|
+
|
|
11
|
+
const SNAPSHOT_IDENTITY_FORMAT = "nimicoding.authority-snapshot-content/v1";
|
|
12
|
+
const SPEC_ROOT = ".nimi/spec";
|
|
13
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
14
|
+
|
|
15
|
+
export class AuthorityReviewRefusal extends Error {
|
|
16
|
+
constructor(code, reason, file = ".") {
|
|
17
|
+
super(reason);
|
|
18
|
+
this.name = "AuthorityReviewRefusal";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.file = file;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function authorityReviewRefusalDiagnostic(error) {
|
|
25
|
+
return makeDiagnostic({
|
|
26
|
+
code: error.code,
|
|
27
|
+
file: error.file,
|
|
28
|
+
range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
|
|
29
|
+
pointer: "",
|
|
30
|
+
reason: error.message,
|
|
31
|
+
repair: "provide one complete stable Git repository snapshot; no partial authority review is admitted",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function refusal(code, reason, file = ".") {
|
|
36
|
+
return new AuthorityReviewRefusal(code, reason, file);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decodeUtf8(bytes, description, code) {
|
|
40
|
+
try {
|
|
41
|
+
return UTF8.decode(bytes);
|
|
42
|
+
} catch {
|
|
43
|
+
throw refusal(code, `${description} is not valid UTF-8`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function childEnvironment() {
|
|
48
|
+
const env = { ...process.env };
|
|
49
|
+
const exact = new Set([
|
|
50
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
51
|
+
"GIT_COMMON_DIR",
|
|
52
|
+
"GIT_CONFIG",
|
|
53
|
+
"GIT_CONFIG_COUNT",
|
|
54
|
+
"GIT_CONFIG_PARAMETERS",
|
|
55
|
+
"GIT_DIR",
|
|
56
|
+
"GIT_EXEC_PATH",
|
|
57
|
+
"GIT_INDEX_FILE",
|
|
58
|
+
"GIT_NAMESPACE",
|
|
59
|
+
"GIT_OBJECT_DIRECTORY",
|
|
60
|
+
"GIT_PREFIX",
|
|
61
|
+
"GIT_QUARANTINE_PATH",
|
|
62
|
+
"GIT_REPLACE_REF_BASE",
|
|
63
|
+
"GIT_SHALLOW_FILE",
|
|
64
|
+
"GIT_WORK_TREE",
|
|
65
|
+
]);
|
|
66
|
+
for (const key of Object.keys(env)) {
|
|
67
|
+
if (exact.has(key) || key.startsWith("GIT_CONFIG_KEY_") || key.startsWith("GIT_CONFIG_VALUE_") || key.startsWith("GIT_TRACE")) delete env[key];
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
...env,
|
|
71
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
72
|
+
GIT_LITERAL_PATHSPECS: "1",
|
|
73
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
74
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
75
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
76
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
77
|
+
LC_ALL: "C",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runGit(repository, args) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const child = spawn("git", ["--no-replace-objects", ...args], {
|
|
84
|
+
cwd: repository,
|
|
85
|
+
env: childEnvironment(),
|
|
86
|
+
shell: false,
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
+
});
|
|
89
|
+
const stdout = [];
|
|
90
|
+
const stderr = [];
|
|
91
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
92
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
93
|
+
child.once("error", reject);
|
|
94
|
+
child.once("close", (status, signal) => resolve({
|
|
95
|
+
status,
|
|
96
|
+
signal,
|
|
97
|
+
stdout: Buffer.concat(stdout),
|
|
98
|
+
stderr: Buffer.concat(stderr),
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function checkedGit(repository, args, code, reason) {
|
|
104
|
+
const result = await runGit(repository, args);
|
|
105
|
+
if (result.signal !== null) throw new Error(`Git process terminated by signal ${result.signal}`);
|
|
106
|
+
if (result.status !== 0) throw refusal(code, reason);
|
|
107
|
+
return result.stdout;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function oneLine(bytes, description, code) {
|
|
111
|
+
const text = decodeUtf8(bytes, description, code);
|
|
112
|
+
const value = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
113
|
+
if (value.length === 0 || value.includes("\n") || value.includes("\r")) throw refusal(code, `${description} is not one exact line`);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function resolveRepository(repositoryPath) {
|
|
118
|
+
const requested = path.resolve(process.cwd(), repositoryPath);
|
|
119
|
+
let info;
|
|
120
|
+
try {
|
|
121
|
+
info = await lstat(requested);
|
|
122
|
+
} catch {
|
|
123
|
+
throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path is not a readable Git worktree root");
|
|
124
|
+
}
|
|
125
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
126
|
+
throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path must be one regular non-symlink directory");
|
|
127
|
+
}
|
|
128
|
+
const repository = await realpath(requested);
|
|
129
|
+
const inside = await checkedGit(
|
|
130
|
+
repository,
|
|
131
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
132
|
+
"AUTH_REVIEW_GIT_REPOSITORY_INVALID",
|
|
133
|
+
"repository path is not a Git worktree",
|
|
134
|
+
);
|
|
135
|
+
if (oneLine(inside, "Git worktree response", "AUTH_REVIEW_GIT_REPOSITORY_INVALID") !== "true") {
|
|
136
|
+
throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path is not a non-bare Git worktree");
|
|
137
|
+
}
|
|
138
|
+
const top = oneLine(await checkedGit(
|
|
139
|
+
repository,
|
|
140
|
+
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
|
141
|
+
"AUTH_REVIEW_GIT_REPOSITORY_INVALID",
|
|
142
|
+
"repository worktree root cannot be resolved",
|
|
143
|
+
), "Git worktree root", "AUTH_REVIEW_GIT_REPOSITORY_INVALID");
|
|
144
|
+
let resolvedTop;
|
|
145
|
+
try {
|
|
146
|
+
resolvedTop = await realpath(top);
|
|
147
|
+
} catch {
|
|
148
|
+
throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "resolved Git worktree root is not readable");
|
|
149
|
+
}
|
|
150
|
+
if (resolvedTop !== repository) {
|
|
151
|
+
throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path must name the exact Git worktree root, not a subdirectory");
|
|
152
|
+
}
|
|
153
|
+
const protectedRoots = [repository];
|
|
154
|
+
for (const [argument, description] of [
|
|
155
|
+
["--git-dir", "Git worktree administration directory"],
|
|
156
|
+
["--git-common-dir", "Git common administration directory"],
|
|
157
|
+
]) {
|
|
158
|
+
const resolved = oneLine(await checkedGit(
|
|
159
|
+
repository,
|
|
160
|
+
["rev-parse", "--path-format=absolute", argument],
|
|
161
|
+
"AUTH_REVIEW_GIT_REPOSITORY_INVALID",
|
|
162
|
+
`${description} cannot be resolved`,
|
|
163
|
+
), description, "AUTH_REVIEW_GIT_REPOSITORY_INVALID");
|
|
164
|
+
try {
|
|
165
|
+
protectedRoots.push(await realpath(resolved));
|
|
166
|
+
} catch {
|
|
167
|
+
throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", `${description} is not readable`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { repository, protectedRoots: [...new Set(protectedRoots)] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validBaseRef(baseRef) {
|
|
174
|
+
return typeof baseRef === "string"
|
|
175
|
+
&& baseRef.length > 0
|
|
176
|
+
&& baseRef.trim() === baseRef
|
|
177
|
+
&& !baseRef.startsWith("-")
|
|
178
|
+
&& !baseRef.includes("\0")
|
|
179
|
+
&& !baseRef.includes("\n")
|
|
180
|
+
&& !baseRef.includes("\r");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function resolveBaseOid(repository, baseRef) {
|
|
184
|
+
if (!validBaseRef(baseRef)) throw refusal("AUTH_REVIEW_BASE_INVALID", "base ref must be one non-option Git commit expression");
|
|
185
|
+
const bytes = await checkedGit(
|
|
186
|
+
repository,
|
|
187
|
+
["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`],
|
|
188
|
+
"AUTH_REVIEW_BASE_INVALID",
|
|
189
|
+
"base ref does not resolve to one commit",
|
|
190
|
+
);
|
|
191
|
+
const oid = oneLine(bytes, "resolved base commit OID", "AUTH_REVIEW_BASE_INVALID");
|
|
192
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) throw refusal("AUTH_REVIEW_BASE_INVALID", "base ref did not resolve to one immutable full commit OID");
|
|
193
|
+
return oid;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function splitNullRecords(bytes) {
|
|
197
|
+
const records = [];
|
|
198
|
+
let offset = 0;
|
|
199
|
+
while (offset < bytes.length) {
|
|
200
|
+
const end = bytes.indexOf(0, offset);
|
|
201
|
+
if (end < 0) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree output is not NUL-terminated");
|
|
202
|
+
if (end === offset) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree output contains an empty entry");
|
|
203
|
+
records.push(bytes.subarray(offset, end));
|
|
204
|
+
offset = end + 1;
|
|
205
|
+
}
|
|
206
|
+
return records;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseTreeRecord(record) {
|
|
210
|
+
const tab = record.indexOf(9);
|
|
211
|
+
if (tab <= 0 || tab === record.length - 1) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree entry has an invalid binary record shape");
|
|
212
|
+
const header = decodeUtf8(record.subarray(0, tab), "Git tree entry metadata", "AUTH_REVIEW_BASE_ENTRY_INVALID");
|
|
213
|
+
const match = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})$/.exec(header);
|
|
214
|
+
if (!match) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree entry metadata is not exact mode, type, and full OID");
|
|
215
|
+
return {
|
|
216
|
+
mode: match[1],
|
|
217
|
+
objectType: match[2],
|
|
218
|
+
oid: match[3],
|
|
219
|
+
rawPath: record.subarray(tab + 1),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateRelativePath(value) {
|
|
224
|
+
if (value.length === 0 || path.posix.isAbsolute(value) || value.includes("\\")) return false;
|
|
225
|
+
const parts = value.split("/");
|
|
226
|
+
return parts.every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function baseTreeEntries(repository, baseOid, hooks) {
|
|
230
|
+
const rootRecords = splitNullRecords(await checkedGit(
|
|
231
|
+
repository,
|
|
232
|
+
["ls-tree", "-z", baseOid, "--", SPEC_ROOT],
|
|
233
|
+
"AUTH_REVIEW_GIT_OBJECT_MISSING",
|
|
234
|
+
"base commit or authority tree object is missing",
|
|
235
|
+
));
|
|
236
|
+
if (rootRecords.length !== 1) throw refusal("AUTH_REVIEW_BASE_SPEC_MISSING", "base commit must contain one complete .nimi/spec tree");
|
|
237
|
+
const root = parseTreeRecord(rootRecords[0]);
|
|
238
|
+
const rootPath = decodeUtf8(root.rawPath, "base authority root path", "AUTH_REVIEW_BASE_ENTRY_INVALID");
|
|
239
|
+
if (root.mode !== "040000" || root.objectType !== "tree" || rootPath !== SPEC_ROOT) {
|
|
240
|
+
throw refusal("AUTH_REVIEW_BASE_SPEC_MISSING", "base .nimi/spec must be one Git tree");
|
|
241
|
+
}
|
|
242
|
+
const records = splitNullRecords(await checkedGit(
|
|
243
|
+
repository,
|
|
244
|
+
["ls-tree", "-r", "-t", "-z", "--full-tree", root.oid],
|
|
245
|
+
"AUTH_REVIEW_GIT_OBJECT_MISSING",
|
|
246
|
+
"base authority tree object is missing or unreadable",
|
|
247
|
+
));
|
|
248
|
+
const entries = [{ path: SPEC_ROOT, type: "directory", bytes: null }];
|
|
249
|
+
const seen = new Set([SPEC_ROOT]);
|
|
250
|
+
for (const record of records) {
|
|
251
|
+
const parsed = parseTreeRecord(record);
|
|
252
|
+
const relative = decodeUtf8(parsed.rawPath, "base authority entry path", "AUTH_REVIEW_BASE_ENTRY_INVALID");
|
|
253
|
+
if (!validateRelativePath(relative)) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "base authority entry path is not one safe relative portable path");
|
|
254
|
+
const entryPath = `${SPEC_ROOT}/${relative}`;
|
|
255
|
+
if (seen.has(entryPath)) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", `base authority tree contains a duplicate path: ${entryPath}`);
|
|
256
|
+
seen.add(entryPath);
|
|
257
|
+
if (parsed.mode === "040000" && parsed.objectType === "tree") {
|
|
258
|
+
entries.push({ path: entryPath, type: "directory", bytes: null });
|
|
259
|
+
} else if (["100644", "100755"].includes(parsed.mode) && parsed.objectType === "blob") {
|
|
260
|
+
entries.push({ path: entryPath, type: "regular-file", bytes: null, oid: parsed.oid });
|
|
261
|
+
} else {
|
|
262
|
+
throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", `base authority entry is not a regular blob or tree: ${entryPath}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
entries.sort((left, right) => compareText(left.path, right.path));
|
|
266
|
+
if (hooks?.afterBaseInventory) await hooks.afterBaseInventory({ repository, baseOid, entries: entries.map(({ path: entryPath, type, oid }) => ({ path: entryPath, type, oid })) });
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
if (entry.type !== "regular-file") continue;
|
|
269
|
+
entry.bytes = await checkedGit(
|
|
270
|
+
repository,
|
|
271
|
+
["cat-file", "blob", entry.oid],
|
|
272
|
+
"AUTH_REVIEW_GIT_OBJECT_MISSING",
|
|
273
|
+
`base authority blob is missing or unreadable: ${entry.path}`,
|
|
274
|
+
);
|
|
275
|
+
delete entry.oid;
|
|
276
|
+
}
|
|
277
|
+
return entries;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function statToken(info) {
|
|
281
|
+
return [info.dev, info.ino, info.mode, info.nlink, info.size, info.mtimeNs, info.ctimeNs].map(String).join(":");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sameObject(left, right) {
|
|
285
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function contained(root, candidate) {
|
|
289
|
+
const relative = path.relative(root, candidate);
|
|
290
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function filesystemFailure(error, initial, entryPath = SPEC_ROOT) {
|
|
294
|
+
if (error instanceof AuthorityReviewRefusal) return error;
|
|
295
|
+
if (!initial && ["ENOENT", "ENOTDIR", "ELOOP"].includes(error?.code)) {
|
|
296
|
+
return refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority inventory changed during capture: ${entryPath}`, entryPath);
|
|
297
|
+
}
|
|
298
|
+
return refusal("AUTH_REVIEW_WORKTREE_INVALID", `worktree authority entry is not safely readable: ${entryPath}`, entryPath);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function securedDirectory(absolute, containmentRoot, entryPath, initial) {
|
|
302
|
+
try {
|
|
303
|
+
const before = await lstat(absolute, { bigint: true });
|
|
304
|
+
if (before.isSymbolicLink() || !before.isDirectory()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority entry is not a regular directory: ${entryPath}`, entryPath);
|
|
305
|
+
const beforeReal = await realpath(absolute);
|
|
306
|
+
if (!contained(containmentRoot, beforeReal)) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority directory escapes .nimi/spec: ${entryPath}`, entryPath);
|
|
307
|
+
const handle = await open(absolute, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
308
|
+
try {
|
|
309
|
+
const held = await handle.stat({ bigint: true });
|
|
310
|
+
const current = await lstat(absolute, { bigint: true });
|
|
311
|
+
const currentReal = await realpath(absolute);
|
|
312
|
+
if (!held.isDirectory() || !sameObject(before, held) || !sameObject(held, current) || !contained(containmentRoot, currentReal)) {
|
|
313
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed during capture: ${entryPath}`, entryPath);
|
|
314
|
+
}
|
|
315
|
+
return { handle, token: statToken(held) };
|
|
316
|
+
} catch (error) {
|
|
317
|
+
await handle.close().catch(() => {});
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw filesystemFailure(error, initial, entryPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function openSecuredFile(absolute, containmentRoot, entryPath, initial) {
|
|
326
|
+
try {
|
|
327
|
+
const before = await lstat(absolute, { bigint: true });
|
|
328
|
+
if (before.isSymbolicLink() || !before.isFile()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority entry is not a regular non-symlink file: ${entryPath}`, entryPath);
|
|
329
|
+
const beforeReal = await realpath(absolute);
|
|
330
|
+
if (containmentRoot !== null && !contained(containmentRoot, beforeReal)) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority file escapes .nimi/spec: ${entryPath}`, entryPath);
|
|
331
|
+
const handle = await open(absolute, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
332
|
+
try {
|
|
333
|
+
const held = await handle.stat({ bigint: true });
|
|
334
|
+
const current = await lstat(absolute, { bigint: true });
|
|
335
|
+
const currentReal = await realpath(absolute);
|
|
336
|
+
if (!held.isFile() || !sameObject(before, held) || !sameObject(held, current) || (containmentRoot !== null && !contained(containmentRoot, currentReal))) {
|
|
337
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before capture: ${entryPath}`, entryPath);
|
|
338
|
+
}
|
|
339
|
+
return { handle, token: statToken(held) };
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await handle.close().catch(() => {});
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw filesystemFailure(error, initial, entryPath);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function readHandleBytes(handle, size, entryPath) {
|
|
350
|
+
if (size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
351
|
+
throw refusal("AUTH_REVIEW_WORKTREE_INVALID", `worktree authority file is too large to capture exactly: ${entryPath}`, entryPath);
|
|
352
|
+
}
|
|
353
|
+
let bytes;
|
|
354
|
+
try {
|
|
355
|
+
bytes = Buffer.alloc(Number(size));
|
|
356
|
+
} catch {
|
|
357
|
+
throw refusal("AUTH_REVIEW_WORKTREE_INVALID", `worktree authority file is too large to capture exactly: ${entryPath}`, entryPath);
|
|
358
|
+
}
|
|
359
|
+
let offset = 0;
|
|
360
|
+
while (offset < bytes.length) {
|
|
361
|
+
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
362
|
+
if (bytesRead === 0) break;
|
|
363
|
+
offset += bytesRead;
|
|
364
|
+
}
|
|
365
|
+
if (offset !== bytes.length) throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed while bytes were captured: ${entryPath}`, entryPath);
|
|
366
|
+
return bytes;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function readSecuredFile(secured, absolute, containmentRoot, entryPath, initial, budget = null) {
|
|
370
|
+
try {
|
|
371
|
+
const heldBefore = await secured.handle.stat({ bigint: true });
|
|
372
|
+
const currentBefore = await lstat(absolute, { bigint: true });
|
|
373
|
+
const currentReal = await realpath(absolute);
|
|
374
|
+
if (!heldBefore.isFile() || statToken(heldBefore) !== secured.token || !sameObject(heldBefore, currentBefore) || (containmentRoot !== null && !contained(containmentRoot, currentReal))) {
|
|
375
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before bytes were captured: ${entryPath}`, entryPath);
|
|
376
|
+
}
|
|
377
|
+
if (budget !== null) budget.reserve(heldBefore.size, entryPath);
|
|
378
|
+
const bytes = await readHandleBytes(secured.handle, heldBefore.size, entryPath);
|
|
379
|
+
const heldAfter = await secured.handle.stat({ bigint: true });
|
|
380
|
+
const currentAfter = await lstat(absolute, { bigint: true });
|
|
381
|
+
if (statToken(heldBefore) !== statToken(heldAfter) || !sameObject(heldAfter, currentAfter) || BigInt(bytes.length) !== heldAfter.size) {
|
|
382
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed while bytes were captured: ${entryPath}`, entryPath);
|
|
383
|
+
}
|
|
384
|
+
return { bytes, token: statToken(heldAfter) };
|
|
385
|
+
} catch (error) {
|
|
386
|
+
throw filesystemFailure(error, initial, entryPath);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function securedFile(absolute, containmentRoot, entryPath, initial, retainHandle = false, budget = null) {
|
|
391
|
+
const secured = await openSecuredFile(absolute, containmentRoot, entryPath, initial);
|
|
392
|
+
let retained = false;
|
|
393
|
+
try {
|
|
394
|
+
const captured = await readSecuredFile(secured, absolute, containmentRoot, entryPath, initial, budget);
|
|
395
|
+
if (retainHandle) {
|
|
396
|
+
retained = true;
|
|
397
|
+
return { ...captured, handle: secured.handle };
|
|
398
|
+
}
|
|
399
|
+
return captured;
|
|
400
|
+
} finally {
|
|
401
|
+
if (!retained) await secured.handle.close();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function decodeFileName(name) {
|
|
406
|
+
const bytes = Buffer.isBuffer(name) ? name : Buffer.from(name);
|
|
407
|
+
const value = decodeUtf8(bytes, "worktree authority entry name", "AUTH_REVIEW_WORKTREE_ENTRY_INVALID");
|
|
408
|
+
if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) {
|
|
409
|
+
throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", "worktree authority entry name is not one safe portable path component");
|
|
410
|
+
}
|
|
411
|
+
return value;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function closeHeldEntries(heldEntries) {
|
|
415
|
+
await Promise.all(heldEntries.map(({ handle }) => handle.close().catch(() => {})));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function scanWorktree(repository, initial, { retainHandles = false, budget = null } = {}) {
|
|
419
|
+
const nimiPath = path.join(repository, ".nimi");
|
|
420
|
+
const specPath = path.join(repository, ".nimi", "spec");
|
|
421
|
+
try {
|
|
422
|
+
const nimi = await lstat(nimiPath, { bigint: true });
|
|
423
|
+
const spec = await lstat(specPath, { bigint: true });
|
|
424
|
+
if (nimi.isSymbolicLink() || !nimi.isDirectory() || spec.isSymbolicLink() || !spec.isDirectory()) {
|
|
425
|
+
throw refusal("AUTH_REVIEW_WORKTREE_INVALID", "worktree .nimi/spec must be one regular non-symlink directory", SPEC_ROOT);
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!initial && !(error instanceof AuthorityReviewRefusal)) throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", "worktree .nimi/spec changed during capture", SPEC_ROOT);
|
|
429
|
+
throw filesystemFailure(error, initial, SPEC_ROOT);
|
|
430
|
+
}
|
|
431
|
+
const specReal = await realpath(specPath);
|
|
432
|
+
const entries = [];
|
|
433
|
+
const heldEntries = [];
|
|
434
|
+
|
|
435
|
+
async function walk(absolute, entryPath) {
|
|
436
|
+
const secured = await securedDirectory(absolute, specReal, entryPath, initial);
|
|
437
|
+
entries.push({ path: entryPath, type: "directory", bytes: null, token: secured.token });
|
|
438
|
+
if (retainHandles) heldEntries.push({ path: entryPath, type: "directory", absolute, containmentRoot: specReal, ...secured });
|
|
439
|
+
try {
|
|
440
|
+
let children;
|
|
441
|
+
try {
|
|
442
|
+
children = await readdir(absolute, { withFileTypes: true, encoding: "buffer" });
|
|
443
|
+
} catch (error) {
|
|
444
|
+
throw filesystemFailure(error, initial, entryPath);
|
|
445
|
+
}
|
|
446
|
+
const named = children.map((entry) => decodeFileName(entry.name)).sort(compareText);
|
|
447
|
+
if (new Set(named).size !== named.length) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority directory contains duplicate decoded paths: ${entryPath}`, entryPath);
|
|
448
|
+
for (const name of named) {
|
|
449
|
+
const childAbsolute = path.join(absolute, name);
|
|
450
|
+
const childPath = `${entryPath}/${name}`;
|
|
451
|
+
let childInfo;
|
|
452
|
+
try {
|
|
453
|
+
childInfo = await lstat(childAbsolute, { bigint: true });
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw filesystemFailure(error, initial, childPath);
|
|
456
|
+
}
|
|
457
|
+
if (childInfo.isSymbolicLink()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority input refuses symbolic link: ${childPath}`, childPath);
|
|
458
|
+
if (childInfo.isDirectory()) await walk(childAbsolute, childPath);
|
|
459
|
+
else if (childInfo.isFile()) {
|
|
460
|
+
const captured = await securedFile(childAbsolute, specReal, childPath, initial, retainHandles, budget);
|
|
461
|
+
entries.push({ path: childPath, type: "regular-file", bytes: captured.bytes, token: captured.token });
|
|
462
|
+
if (retainHandles) heldEntries.push({ path: childPath, type: "regular-file", absolute: childAbsolute, containmentRoot: specReal, ...captured });
|
|
463
|
+
} else throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority input contains a non-regular filesystem entry: ${childPath}`, childPath);
|
|
464
|
+
}
|
|
465
|
+
const current = await lstat(absolute, { bigint: true });
|
|
466
|
+
const currentReal = await realpath(absolute);
|
|
467
|
+
const held = await secured.handle.stat({ bigint: true });
|
|
468
|
+
if (statToken(held) !== secured.token || !sameObject(held, current) || !contained(specReal, currentReal)) {
|
|
469
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed during traversal: ${entryPath}`, entryPath);
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
if (!retainHandles) await secured.handle.close();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await walk(specPath, SPEC_ROOT);
|
|
478
|
+
entries.sort((left, right) => compareText(left.path, right.path));
|
|
479
|
+
heldEntries.sort((left, right) => compareText(left.path, right.path));
|
|
480
|
+
return { entries, heldEntries };
|
|
481
|
+
} catch (error) {
|
|
482
|
+
await closeHeldEntries(heldEntries);
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function verifyHeldEntries(heldEntries) {
|
|
488
|
+
const results = await Promise.allSettled(heldEntries.map(async (entry) => {
|
|
489
|
+
if (entry.type === "regular-file") {
|
|
490
|
+
const captured = await readSecuredFile(entry, entry.absolute, entry.containmentRoot, entry.path, false);
|
|
491
|
+
if (captured.token !== entry.token || !captured.bytes.equals(entry.bytes)) {
|
|
492
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before capture commit: ${entry.path}`, entry.path);
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const held = await entry.handle.stat({ bigint: true });
|
|
498
|
+
const current = await lstat(entry.absolute, { bigint: true });
|
|
499
|
+
const currentReal = await realpath(entry.absolute);
|
|
500
|
+
if (!held.isDirectory() || statToken(held) !== entry.token || !sameObject(held, current) || !contained(entry.containmentRoot, currentReal)) {
|
|
501
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed before capture commit: ${entry.path}`, entry.path);
|
|
502
|
+
}
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw filesystemFailure(error, false, entry.path);
|
|
505
|
+
}
|
|
506
|
+
}));
|
|
507
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
508
|
+
if (failed) throw failed.reason;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function compareCapturedEntries(first, second, includeTokens) {
|
|
512
|
+
if (first.length !== second.length) return false;
|
|
513
|
+
for (let index = 0; index < first.length; index += 1) {
|
|
514
|
+
const left = first[index];
|
|
515
|
+
const right = second[index];
|
|
516
|
+
if (left.path !== right.path || left.type !== right.type) return false;
|
|
517
|
+
if (includeTokens && left.token !== right.token) return false;
|
|
518
|
+
if (left.type === "regular-file" && !left.bytes.equals(right.bytes)) return false;
|
|
519
|
+
}
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function semanticEntries(entries) {
|
|
524
|
+
return entries.map(({ path: entryPath, type, bytes }) => ({ path: entryPath, type, bytes }));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function frame(hash, value) {
|
|
528
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
|
|
529
|
+
const length = Buffer.alloc(8);
|
|
530
|
+
length.writeBigUInt64BE(BigInt(bytes.length));
|
|
531
|
+
hash.update(length);
|
|
532
|
+
hash.update(bytes);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function authoritySnapshotContentIdentity(entries) {
|
|
536
|
+
const ordered = entries
|
|
537
|
+
.filter((entry) => entry.type === "regular-file")
|
|
538
|
+
.sort((left, right) => compareText(left.path, right.path));
|
|
539
|
+
const hash = createHash("sha256");
|
|
540
|
+
frame(hash, SNAPSHOT_IDENTITY_FORMAT);
|
|
541
|
+
frame(hash, String(ordered.length));
|
|
542
|
+
for (const entry of ordered) {
|
|
543
|
+
frame(hash, entry.type);
|
|
544
|
+
frame(hash, entry.path);
|
|
545
|
+
frame(hash, entry.bytes ?? Buffer.alloc(0));
|
|
546
|
+
}
|
|
547
|
+
return `sha256:${hash.digest("hex")}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function snapshotCounts(entries) {
|
|
551
|
+
const files = entries.filter((entry) => entry.type === "regular-file");
|
|
552
|
+
return {
|
|
553
|
+
fileCount: files.length,
|
|
554
|
+
byteCount: files.reduce((total, entry) => total + entry.bytes.length, 0),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function materializeEntries(root, entries, invalidCode) {
|
|
559
|
+
const directories = entries.filter((entry) => entry.type === "directory").sort((left, right) => left.path.split("/").length - right.path.split("/").length || compareText(left.path, right.path));
|
|
560
|
+
const files = entries.filter((entry) => entry.type === "regular-file").sort((left, right) => compareText(left.path, right.path));
|
|
561
|
+
try {
|
|
562
|
+
for (const entry of directories) await mkdir(path.join(root, ...entry.path.split("/")), { recursive: true, mode: 0o700 });
|
|
563
|
+
for (const entry of files) {
|
|
564
|
+
const target = path.join(root, ...entry.path.split("/"));
|
|
565
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
566
|
+
await writeFile(target, entry.bytes, { flag: "wx", mode: 0o600 });
|
|
567
|
+
}
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (["EEXIST", "EINVAL", "ENAMETOOLONG", "ENOTDIR"].includes(error?.code)) {
|
|
570
|
+
throw refusal(invalidCode, "authority snapshot paths cannot be represented without collision or escape");
|
|
571
|
+
}
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
const readback = semanticEntries((await scanWorktree(root, true)).entries);
|
|
575
|
+
if (!compareCapturedEntries(entries, readback, false)) throw refusal(invalidCode, "materialized authority snapshot does not match captured paths, types, and bytes");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function captureWorktree(repository, hooks) {
|
|
579
|
+
const { entries: first, heldEntries } = await scanWorktree(repository, true, { retainHandles: true });
|
|
580
|
+
try {
|
|
581
|
+
if (hooks?.afterWorktreeCapture) await hooks.afterWorktreeCapture({ repository, entries: semanticEntries(first) });
|
|
582
|
+
const { entries: second } = await scanWorktree(repository, false);
|
|
583
|
+
if (!compareCapturedEntries(first, second, true)) throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", "worktree authority paths, types, metadata, or bytes changed during capture", SPEC_ROOT);
|
|
584
|
+
if (hooks?.beforeWorktreeCaptureCommit) await hooks.beforeWorktreeCaptureCommit({ repository, entries: semanticEntries(first) });
|
|
585
|
+
await verifyHeldEntries(heldEntries);
|
|
586
|
+
return semanticEntries(first);
|
|
587
|
+
} finally {
|
|
588
|
+
await closeHeldEntries(heldEntries);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function stableRegularFileBudget(maxBytes, label) {
|
|
593
|
+
if (maxBytes === null) return null;
|
|
594
|
+
return {
|
|
595
|
+
reserve(size) {
|
|
596
|
+
if (size > BigInt(maxBytes)) {
|
|
597
|
+
throw refusal("AUTH_EVIDENCE_INPUT_BUDGET", `evidence input requires ${size} bytes but maxInputBytes is ${maxBytes}: ${label}`, label);
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export async function captureStableRegularFile(file, label, containmentRoot = null, maxBytes = null) {
|
|
604
|
+
const absolute = path.resolve(process.cwd(), file);
|
|
605
|
+
const first = await securedFile(absolute, containmentRoot, label, true, true, stableRegularFileBudget(maxBytes, label));
|
|
606
|
+
try {
|
|
607
|
+
const second = await securedFile(absolute, containmentRoot, label, false, false, stableRegularFileBudget(maxBytes, label));
|
|
608
|
+
const committed = await readSecuredFile(first, absolute, containmentRoot, label, false, stableRegularFileBudget(maxBytes, label));
|
|
609
|
+
if (first.token !== second.token
|
|
610
|
+
|| first.token !== committed.token
|
|
611
|
+
|| !first.bytes.equals(second.bytes)
|
|
612
|
+
|| !first.bytes.equals(committed.bytes)) {
|
|
613
|
+
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `review input changed during capture: ${label}`, label);
|
|
614
|
+
}
|
|
615
|
+
return first.bytes;
|
|
616
|
+
} finally {
|
|
617
|
+
await first.handle.close().catch(() => {});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function evidenceRefusal(code, reason, file = ".") {
|
|
622
|
+
return refusal(code, reason, file);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function mapEvidenceRefusal(error, fallbackCode, fallbackFile = ".") {
|
|
626
|
+
if (!(error instanceof AuthorityReviewRefusal)) return error;
|
|
627
|
+
if (error.code === "AUTH_REVIEW_CAPTURE_CHANGED" || error.code === "AUTH_EVIDENCE_CAPTURE_CHANGED") {
|
|
628
|
+
return evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", error.message, error.file ?? fallbackFile);
|
|
629
|
+
}
|
|
630
|
+
if (error.code.startsWith("AUTH_EVIDENCE_")) return error;
|
|
631
|
+
return evidenceRefusal(fallbackCode, error.message, error.file ?? fallbackFile);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function validEvidencePath(value) {
|
|
635
|
+
return typeof value === "string"
|
|
636
|
+
&& validateRelativePath(value)
|
|
637
|
+
&& value === value.normalize("NFC")
|
|
638
|
+
&& !value.includes("\0")
|
|
639
|
+
&& !value.includes("\n")
|
|
640
|
+
&& !value.includes("\r")
|
|
641
|
+
&& value.split("/").every((part) => part.toLowerCase() !== ".git");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function evidenceInputBudget(maxInputBytes) {
|
|
645
|
+
if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes <= 0) {
|
|
646
|
+
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_BUDGET", "maxInputBytes must be one positive safe integer");
|
|
647
|
+
}
|
|
648
|
+
let captured = 0;
|
|
649
|
+
return {
|
|
650
|
+
reserve(size, entryPath) {
|
|
651
|
+
if (size < 0n || size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
652
|
+
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_BUDGET", `evidence input is too large to capture exactly: ${entryPath}`, entryPath);
|
|
653
|
+
}
|
|
654
|
+
const required = BigInt(captured) + size;
|
|
655
|
+
if (required > BigInt(maxInputBytes)) {
|
|
656
|
+
throw evidenceRefusal(
|
|
657
|
+
"AUTH_EVIDENCE_INPUT_BUDGET",
|
|
658
|
+
`complete evidence snapshot requires at least ${required} input bytes but maxInputBytes is ${maxInputBytes}`,
|
|
659
|
+
entryPath,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
captured = Number(required);
|
|
663
|
+
},
|
|
664
|
+
get captured() {
|
|
665
|
+
return captured;
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function trackedRegularIndexRecord(repository, bindingPath) {
|
|
671
|
+
const result = await runGit(repository, ["ls-files", "--stage", "-z", "--error-unmatch", "--", bindingPath]);
|
|
672
|
+
if (result.signal !== null) throw new Error(`Git process terminated by signal ${result.signal}`);
|
|
673
|
+
if (result.status !== 0) {
|
|
674
|
+
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "evidence binding must be one tracked regular repository path", bindingPath);
|
|
675
|
+
}
|
|
676
|
+
const records = splitNullRecords(result.stdout);
|
|
677
|
+
if (records.length !== 1) {
|
|
678
|
+
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "evidence binding must resolve to exactly one non-conflicted index entry", bindingPath);
|
|
679
|
+
}
|
|
680
|
+
const tab = records[0].indexOf(9);
|
|
681
|
+
if (tab <= 0 || tab === records[0].length - 1) {
|
|
682
|
+
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "tracked evidence binding index entry is malformed", bindingPath);
|
|
683
|
+
}
|
|
684
|
+
const metadata = decodeUtf8(records[0].subarray(0, tab), "tracked evidence binding metadata", "AUTH_EVIDENCE_BINDING_INVALID");
|
|
685
|
+
const indexedPath = decodeUtf8(records[0].subarray(tab + 1), "tracked evidence binding path", "AUTH_EVIDENCE_BINDING_INVALID");
|
|
686
|
+
if (!/^(?:100644|100755) (?:[0-9a-f]{40}|[0-9a-f]{64}) 0$/.test(metadata) || indexedPath !== bindingPath) {
|
|
687
|
+
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "evidence binding must be one exact stage-zero regular Git entry", bindingPath);
|
|
688
|
+
}
|
|
689
|
+
return Buffer.from(records[0]);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function captureEvidenceInput(repository, descriptor, budget, retainHandles) {
|
|
693
|
+
const heldEntries = [];
|
|
694
|
+
const pathTokens = [];
|
|
695
|
+
const parts = descriptor.path.split("/");
|
|
696
|
+
let absolute = repository;
|
|
697
|
+
let retained = false;
|
|
698
|
+
|
|
699
|
+
async function retainDirectory(directory, entryPath) {
|
|
700
|
+
const secured = await securedDirectory(directory, repository, entryPath, true);
|
|
701
|
+
pathTokens.push({ path: entryPath, token: secured.token });
|
|
702
|
+
if (retainHandles) heldEntries.push({ path: entryPath, type: "directory", absolute: directory, containmentRoot: repository, ...secured });
|
|
703
|
+
else await secured.handle.close();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function missing(missingAbsolute, missingPath) {
|
|
707
|
+
if (!descriptor.allowMissing) {
|
|
708
|
+
throw evidenceRefusal(descriptor.invalidCode, `${descriptor.role} input is missing: ${descriptor.path}`, descriptor.path);
|
|
709
|
+
}
|
|
710
|
+
retained = retainHandles;
|
|
711
|
+
return {
|
|
712
|
+
role: descriptor.role,
|
|
713
|
+
path: descriptor.path,
|
|
714
|
+
type: "missing",
|
|
715
|
+
bytes: null,
|
|
716
|
+
token: null,
|
|
717
|
+
pathTokens,
|
|
718
|
+
heldEntries,
|
|
719
|
+
missingAbsolute,
|
|
720
|
+
missingPath,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
await retainDirectory(repository, ".");
|
|
726
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
727
|
+
absolute = path.join(absolute, parts[index]);
|
|
728
|
+
const entryPath = parts.slice(0, index + 1).join("/");
|
|
729
|
+
try {
|
|
730
|
+
const info = await lstat(absolute, { bigint: true });
|
|
731
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
732
|
+
throw evidenceRefusal(descriptor.invalidCode, `${descriptor.role} ancestor is not one regular non-symlink directory: ${entryPath}`, entryPath);
|
|
733
|
+
}
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (error?.code === "ENOENT") return missing(absolute, entryPath);
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
await retainDirectory(absolute, entryPath);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const target = path.join(repository, ...parts);
|
|
742
|
+
try {
|
|
743
|
+
const info = await lstat(target, { bigint: true });
|
|
744
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
745
|
+
throw evidenceRefusal(descriptor.invalidCode, `${descriptor.role} input is not one regular non-symlink file: ${descriptor.path}`, descriptor.path);
|
|
746
|
+
}
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error?.code === "ENOENT") return missing(target, descriptor.path);
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
const captured = await securedFile(target, repository, descriptor.path, true, retainHandles, budget);
|
|
752
|
+
if (retainHandles) heldEntries.push({ path: descriptor.path, type: "regular-file", absolute: target, containmentRoot: repository, ...captured });
|
|
753
|
+
retained = retainHandles;
|
|
754
|
+
return {
|
|
755
|
+
role: descriptor.role,
|
|
756
|
+
path: descriptor.path,
|
|
757
|
+
type: "regular-file",
|
|
758
|
+
bytes: captured.bytes,
|
|
759
|
+
token: captured.token,
|
|
760
|
+
pathTokens,
|
|
761
|
+
heldEntries,
|
|
762
|
+
missingAbsolute: null,
|
|
763
|
+
missingPath: null,
|
|
764
|
+
};
|
|
765
|
+
} catch (error) {
|
|
766
|
+
throw mapEvidenceRefusal(error, descriptor.invalidCode, descriptor.path);
|
|
767
|
+
} finally {
|
|
768
|
+
if (!retained) await closeHeldEntries(heldEntries);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function sameEvidenceInput(left, right) {
|
|
773
|
+
if (left.role !== right.role
|
|
774
|
+
|| left.path !== right.path
|
|
775
|
+
|| left.type !== right.type
|
|
776
|
+
|| left.token !== right.token
|
|
777
|
+
|| left.pathTokens.length !== right.pathTokens.length) return false;
|
|
778
|
+
for (let index = 0; index < left.pathTokens.length; index += 1) {
|
|
779
|
+
if (left.pathTokens[index].path !== right.pathTokens[index].path
|
|
780
|
+
|| left.pathTokens[index].token !== right.pathTokens[index].token) return false;
|
|
781
|
+
}
|
|
782
|
+
return left.type === "missing" || left.bytes.equals(right.bytes);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function verifyEvidenceInput(entry) {
|
|
786
|
+
try {
|
|
787
|
+
await verifyHeldEntries(entry.heldEntries);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `${entry.role} changed before evidence capture commit: ${entry.path}; ${error.message}`, entry.path);
|
|
790
|
+
}
|
|
791
|
+
if (entry.type !== "missing") return;
|
|
792
|
+
try {
|
|
793
|
+
await lstat(entry.missingAbsolute, { bigint: true });
|
|
794
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `missing locator appeared during capture: ${entry.path}`, entry.path);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (error?.code === "ENOENT") return;
|
|
797
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_CAPTURE_CHANGED", entry.path);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function semanticEvidenceInput(entry) {
|
|
802
|
+
return {
|
|
803
|
+
role: entry.role,
|
|
804
|
+
path: entry.path,
|
|
805
|
+
type: entry.type,
|
|
806
|
+
bytes: entry.bytes,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function evidenceInputMetadata(entries) {
|
|
811
|
+
return entries.map((entry) => ({
|
|
812
|
+
role: entry.role,
|
|
813
|
+
path: entry.path,
|
|
814
|
+
type: entry.type,
|
|
815
|
+
bytes: entry.bytes?.length ?? 0,
|
|
816
|
+
}));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function createTemporaryRoot(protectedRoots, prefix = "nimicoding-authority-review-") {
|
|
820
|
+
let base;
|
|
821
|
+
try {
|
|
822
|
+
base = await realpath(os.tmpdir());
|
|
823
|
+
const info = await lstat(base);
|
|
824
|
+
if (!info.isDirectory()) throw new Error("not a directory");
|
|
825
|
+
} catch {
|
|
826
|
+
throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "system temporary directory is not one readable physical directory");
|
|
827
|
+
}
|
|
828
|
+
if (protectedRoots.some((protectedRoot) => contained(protectedRoot, base))) {
|
|
829
|
+
throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "system temporary directory must be outside the reviewed worktree and Git administration directories");
|
|
830
|
+
}
|
|
831
|
+
const created = await mkdtemp(path.join(base, prefix));
|
|
832
|
+
try {
|
|
833
|
+
const temporaryRoot = await realpath(created);
|
|
834
|
+
const info = await lstat(temporaryRoot);
|
|
835
|
+
if (!info.isDirectory() || protectedRoots.some((protectedRoot) => contained(protectedRoot, temporaryRoot) || contained(temporaryRoot, protectedRoot))) {
|
|
836
|
+
throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "authority snapshots require one isolated temporary directory outside the reviewed repository");
|
|
837
|
+
}
|
|
838
|
+
return temporaryRoot;
|
|
839
|
+
} catch (error) {
|
|
840
|
+
await rm(created, { recursive: true, force: true });
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function evidenceDescriptors(bindingPath, probeResultsPath, locatorPaths) {
|
|
846
|
+
if (!validEvidencePath(bindingPath) || !bindingPath.startsWith(".nimi/config/")) {
|
|
847
|
+
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "bindingPath must be one repository-relative path nested under .nimi/config", String(bindingPath ?? "."));
|
|
848
|
+
}
|
|
849
|
+
if (probeResultsPath !== null && probeResultsPath !== undefined && !validEvidencePath(probeResultsPath)) {
|
|
850
|
+
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_INVALID", "probeResultsPath must be one safe repository-relative regular-file path", String(probeResultsPath));
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(locatorPaths) || locatorPaths.some((entry) => !validEvidencePath(entry))) {
|
|
853
|
+
throw evidenceRefusal("AUTH_EVIDENCE_LOCATOR_INVALID", "locatorPaths must be one list of safe repository-relative paths");
|
|
854
|
+
}
|
|
855
|
+
const orderedLocators = [...locatorPaths].sort(compareText);
|
|
856
|
+
if (new Set(orderedLocators.map((entry) => entry.toLowerCase())).size !== orderedLocators.length) {
|
|
857
|
+
throw evidenceRefusal("AUTH_EVIDENCE_LOCATOR_INVALID", "locatorPaths must not contain duplicate or portable-colliding paths");
|
|
858
|
+
}
|
|
859
|
+
const allPaths = [bindingPath, ...(probeResultsPath === null || probeResultsPath === undefined ? [] : [probeResultsPath]), ...orderedLocators];
|
|
860
|
+
if (new Set(allPaths.map((entry) => entry.toLowerCase())).size !== allPaths.length) {
|
|
861
|
+
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_INVALID", "binding, probe results, and locator paths must be logically distinct without portable collisions");
|
|
862
|
+
}
|
|
863
|
+
return [
|
|
864
|
+
{ role: "binding", path: bindingPath, allowMissing: false, invalidCode: "AUTH_EVIDENCE_BINDING_INVALID" },
|
|
865
|
+
...(probeResultsPath === null || probeResultsPath === undefined ? [] : [{ role: "probe-results", path: probeResultsPath, allowMissing: false, invalidCode: "AUTH_EVIDENCE_INPUT_INVALID" }]),
|
|
866
|
+
...orderedLocators.map((entryPath) => ({ role: "locator", path: entryPath, allowMissing: true, invalidCode: "AUTH_EVIDENCE_LOCATOR_INVALID" })),
|
|
867
|
+
];
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export async function withGitEvidenceSnapshot({
|
|
871
|
+
repositoryPath,
|
|
872
|
+
bindingPath,
|
|
873
|
+
probeResultsPath = null,
|
|
874
|
+
locatorPaths,
|
|
875
|
+
maxInputBytes,
|
|
876
|
+
hooks = null,
|
|
877
|
+
}, callback) {
|
|
878
|
+
if (typeof callback !== "function") throw evidenceRefusal("AUTH_EVIDENCE_INPUT_INVALID", "evidence snapshot requires one callback");
|
|
879
|
+
const budget = evidenceInputBudget(maxInputBytes);
|
|
880
|
+
const descriptors = evidenceDescriptors(bindingPath, probeResultsPath, locatorPaths);
|
|
881
|
+
let resolved;
|
|
882
|
+
try {
|
|
883
|
+
resolved = await resolveRepository(repositoryPath);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_REPOSITORY_INVALID");
|
|
886
|
+
}
|
|
887
|
+
const { repository, protectedRoots } = resolved;
|
|
888
|
+
let headOid;
|
|
889
|
+
try {
|
|
890
|
+
headOid = await resolveBaseOid(repository, "HEAD");
|
|
891
|
+
} catch (error) {
|
|
892
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_HEAD_INVALID");
|
|
893
|
+
}
|
|
894
|
+
if (hooks?.afterHeadResolved) await hooks.afterHeadResolved({ repository, headOid });
|
|
895
|
+
|
|
896
|
+
let trackedBinding;
|
|
897
|
+
try {
|
|
898
|
+
trackedBinding = await trackedRegularIndexRecord(repository, bindingPath);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_BINDING_INVALID", bindingPath);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
let authorityEntries;
|
|
904
|
+
let semanticInputs;
|
|
905
|
+
let authorityHeld = [];
|
|
906
|
+
const capturedInputs = [];
|
|
907
|
+
try {
|
|
908
|
+
let firstAuthority;
|
|
909
|
+
try {
|
|
910
|
+
const captured = await scanWorktree(repository, true, { retainHandles: true, budget });
|
|
911
|
+
firstAuthority = captured.entries;
|
|
912
|
+
authorityHeld = captured.heldEntries;
|
|
913
|
+
} catch (error) {
|
|
914
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_AUTHORITY_INVALID", SPEC_ROOT);
|
|
915
|
+
}
|
|
916
|
+
if (hooks?.afterAuthorityCapture) {
|
|
917
|
+
await hooks.afterAuthorityCapture({
|
|
918
|
+
repository,
|
|
919
|
+
headOid,
|
|
920
|
+
authority: semanticEntries(firstAuthority).map((entry) => ({ path: entry.path, type: entry.type, bytes: entry.bytes?.length ?? 0 })),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
for (const descriptor of descriptors) capturedInputs.push(await captureEvidenceInput(repository, descriptor, budget, true));
|
|
924
|
+
if (hooks?.afterInitialCapture) {
|
|
925
|
+
await hooks.afterInitialCapture({ repository, headOid, inputs: evidenceInputMetadata(capturedInputs) });
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const recaptureBudget = evidenceInputBudget(maxInputBytes);
|
|
929
|
+
let secondAuthority;
|
|
930
|
+
try {
|
|
931
|
+
secondAuthority = (await scanWorktree(repository, false, { budget: recaptureBudget })).entries;
|
|
932
|
+
} catch (error) {
|
|
933
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `authority inventory changed during evidence capture: ${error.message}`, SPEC_ROOT);
|
|
934
|
+
}
|
|
935
|
+
if (!compareCapturedEntries(firstAuthority, secondAuthority, true)) {
|
|
936
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", "authority paths, types, metadata, or bytes changed during evidence capture", SPEC_ROOT);
|
|
937
|
+
}
|
|
938
|
+
for (let index = 0; index < descriptors.length; index += 1) {
|
|
939
|
+
let second;
|
|
940
|
+
try {
|
|
941
|
+
second = await captureEvidenceInput(repository, descriptors[index], recaptureBudget, false);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `${descriptors[index].role} input changed during evidence capture: ${descriptors[index].path}; ${error.message}`, descriptors[index].path);
|
|
944
|
+
}
|
|
945
|
+
if (!sameEvidenceInput(capturedInputs[index], second)) {
|
|
946
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `${descriptors[index].role} path, type, metadata, or bytes changed during evidence capture: ${descriptors[index].path}`, descriptors[index].path);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const secondTrackedBinding = await trackedRegularIndexRecord(repository, bindingPath).catch((error) => {
|
|
950
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `evidence binding tracking state changed during capture: ${error.message}`, bindingPath);
|
|
951
|
+
});
|
|
952
|
+
if (!trackedBinding.equals(secondTrackedBinding)) {
|
|
953
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", "evidence binding index identity changed during capture", bindingPath);
|
|
954
|
+
}
|
|
955
|
+
if (hooks?.beforeCaptureCommit) {
|
|
956
|
+
await hooks.beforeCaptureCommit({ repository, headOid, inputs: evidenceInputMetadata(capturedInputs) });
|
|
957
|
+
}
|
|
958
|
+
await verifyHeldEntries(authorityHeld).catch((error) => {
|
|
959
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `authority changed before evidence capture commit: ${error.message}`, SPEC_ROOT);
|
|
960
|
+
});
|
|
961
|
+
for (const entry of capturedInputs) await verifyEvidenceInput(entry);
|
|
962
|
+
const committedTrackedBinding = await trackedRegularIndexRecord(repository, bindingPath).catch((error) => {
|
|
963
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `evidence binding tracking state changed before capture commit: ${error.message}`, bindingPath);
|
|
964
|
+
});
|
|
965
|
+
if (!trackedBinding.equals(committedTrackedBinding)) {
|
|
966
|
+
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", "evidence binding index identity changed before capture commit", bindingPath);
|
|
967
|
+
}
|
|
968
|
+
authorityEntries = semanticEntries(firstAuthority);
|
|
969
|
+
semanticInputs = capturedInputs.map(semanticEvidenceInput);
|
|
970
|
+
} finally {
|
|
971
|
+
await closeHeldEntries(authorityHeld);
|
|
972
|
+
await Promise.all(capturedInputs.map((entry) => closeHeldEntries(entry.heldEntries)));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
let temporaryRoot;
|
|
976
|
+
try {
|
|
977
|
+
temporaryRoot = await createTemporaryRoot(protectedRoots, "nimicoding-authority-evidence-");
|
|
978
|
+
} catch (error) {
|
|
979
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_TEMPORARY_INVALID");
|
|
980
|
+
}
|
|
981
|
+
const authorityRoot = path.join(temporaryRoot, "authority");
|
|
982
|
+
try {
|
|
983
|
+
await mkdir(authorityRoot, { mode: 0o700 });
|
|
984
|
+
try {
|
|
985
|
+
await materializeEntries(authorityRoot, authorityEntries, "AUTH_EVIDENCE_AUTHORITY_INVALID");
|
|
986
|
+
} catch (error) {
|
|
987
|
+
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_AUTHORITY_INVALID", SPEC_ROOT);
|
|
988
|
+
}
|
|
989
|
+
const counts = snapshotCounts(authorityEntries);
|
|
990
|
+
return await callback({
|
|
991
|
+
repository,
|
|
992
|
+
headOid,
|
|
993
|
+
temporaryRoot,
|
|
994
|
+
authority: {
|
|
995
|
+
root: authorityRoot,
|
|
996
|
+
contentIdentity: authoritySnapshotContentIdentity(authorityEntries),
|
|
997
|
+
...counts,
|
|
998
|
+
},
|
|
999
|
+
inputs: semanticInputs,
|
|
1000
|
+
capturedInputBytes: budget.captured,
|
|
1001
|
+
});
|
|
1002
|
+
} finally {
|
|
1003
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export async function listGitTrackedFiles(repositoryPath) {
|
|
1008
|
+
const { repository } = await resolveRepository(repositoryPath);
|
|
1009
|
+
const bytes = await checkedGit(
|
|
1010
|
+
repository,
|
|
1011
|
+
["ls-files", "--cached", "--full-name", "-z"],
|
|
1012
|
+
"AUTH_REVIEW_GIT_INDEX_INVALID",
|
|
1013
|
+
"Git tracked-file inventory cannot be read",
|
|
1014
|
+
);
|
|
1015
|
+
const files = [];
|
|
1016
|
+
let offset = 0;
|
|
1017
|
+
while (offset < bytes.length) {
|
|
1018
|
+
const end = bytes.indexOf(0, offset);
|
|
1019
|
+
if (end < 0) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory is not NUL-terminated");
|
|
1020
|
+
if (end === offset) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an empty path");
|
|
1021
|
+
const file = decodeUtf8(bytes.subarray(offset, end), "Git tracked-file path", "AUTH_REVIEW_GIT_INDEX_INVALID");
|
|
1022
|
+
if (!validateRelativePath(file)) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an unsafe path");
|
|
1023
|
+
files.push(file);
|
|
1024
|
+
offset = end + 1;
|
|
1025
|
+
}
|
|
1026
|
+
return { repository, files: [...new Set(files)].sort(compareText) };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export async function withGitAuthoritySnapshots({ repositoryPath, baseRef, hooks = null }, callback) {
|
|
1030
|
+
const { repository, protectedRoots } = await resolveRepository(repositoryPath);
|
|
1031
|
+
const baseOid = await resolveBaseOid(repository, baseRef);
|
|
1032
|
+
if (hooks?.afterBaseResolved) await hooks.afterBaseResolved({ repository, baseOid });
|
|
1033
|
+
const baseEntries = await baseTreeEntries(repository, baseOid, hooks);
|
|
1034
|
+
const worktreeEntries = await captureWorktree(repository, hooks);
|
|
1035
|
+
const temporaryRoot = await createTemporaryRoot(protectedRoots);
|
|
1036
|
+
const baseRoot = path.join(temporaryRoot, "base");
|
|
1037
|
+
const worktreeRoot = path.join(temporaryRoot, "worktree");
|
|
1038
|
+
try {
|
|
1039
|
+
await mkdir(baseRoot, { mode: 0o700 });
|
|
1040
|
+
await mkdir(worktreeRoot, { mode: 0o700 });
|
|
1041
|
+
await materializeEntries(baseRoot, baseEntries, "AUTH_REVIEW_BASE_ENTRY_INVALID");
|
|
1042
|
+
await materializeEntries(worktreeRoot, worktreeEntries, "AUTH_REVIEW_WORKTREE_ENTRY_INVALID");
|
|
1043
|
+
const baseCounts = snapshotCounts(baseEntries);
|
|
1044
|
+
const worktreeCounts = snapshotCounts(worktreeEntries);
|
|
1045
|
+
return await callback({
|
|
1046
|
+
repository,
|
|
1047
|
+
baseOid,
|
|
1048
|
+
temporaryRoot,
|
|
1049
|
+
base: {
|
|
1050
|
+
root: baseRoot,
|
|
1051
|
+
contentIdentity: authoritySnapshotContentIdentity(baseEntries),
|
|
1052
|
+
...baseCounts,
|
|
1053
|
+
},
|
|
1054
|
+
worktree: {
|
|
1055
|
+
root: worktreeRoot,
|
|
1056
|
+
contentIdentity: authoritySnapshotContentIdentity(worktreeEntries),
|
|
1057
|
+
...worktreeCounts,
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
} finally {
|
|
1061
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export { SNAPSHOT_IDENTITY_FORMAT, SPEC_ROOT };
|