ai-foreman 1.7.11 → 1.7.13
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/README.md +15 -0
- package/dist/activity.d.ts +5 -1
- package/dist/activity.js +1 -1
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +133 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +121 -11
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +11 -1
- package/dist/adapters/types.d.ts +47 -1
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +8 -2
- package/dist/branch/resume.js +11 -1
- package/dist/branch/runner.d.ts +12 -3
- package/dist/branch/runner.js +131 -67
- package/dist/buildRuns.d.ts +6 -2
- package/dist/buildRuns.js +76 -32
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +84 -1
- package/dist/cli/manager.d.ts +17 -0
- package/dist/cli/manager.js +135 -0
- package/dist/cli/recovery.d.ts +4 -0
- package/dist/cli/recovery.js +80 -0
- package/dist/cli/start.js +446 -142
- package/dist/cli/status.js +13 -2
- package/dist/cli/tickets.js +27 -9
- package/dist/config.d.ts +4 -0
- package/dist/config.js +28 -1
- package/dist/continuity.js +14 -9
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +428 -0
- package/dist/foreman.d.ts +26 -6
- package/dist/foreman.js +147 -104
- package/dist/handoffs.d.ts +35 -3
- package/dist/handoffs.js +155 -17
- package/dist/index.js +6 -0
- package/dist/log.d.ts +3 -3
- package/dist/log.js +4 -1
- package/dist/managerPacket.d.ts +25 -0
- package/dist/managerPacket.js +83 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +4 -1
- package/dist/notify.d.ts +4 -0
- package/dist/notify.js +15 -0
- package/dist/observability.d.ts +248 -0
- package/dist/observability.js +1018 -0
- package/dist/planChanges.d.ts +4 -0
- package/dist/planChanges.js +16 -0
- package/dist/projectDiagnostics.d.ts +27 -0
- package/dist/projectDiagnostics.js +407 -0
- package/dist/qaRecovery.d.ts +140 -0
- package/dist/qaRecovery.js +595 -0
- package/dist/qaReview.d.ts +133 -14
- package/dist/qaReview.js +984 -59
- package/dist/qaSnapshot.d.ts +31 -5
- package/dist/qaSnapshot.js +209 -134
- package/dist/recoveryPolicy.d.ts +37 -0
- package/dist/recoveryPolicy.js +251 -0
- package/dist/roles.js +1 -0
- package/dist/supervisor.d.ts +40 -0
- package/dist/supervisor.js +102 -0
- package/dist/ticketHistoryReader.d.ts +19 -0
- package/dist/ticketHistoryReader.js +58 -0
- package/dist/tickets/details.d.ts +8 -0
- package/dist/tickets/details.js +59 -11
- package/dist/tickets/output.d.ts +5 -0
- package/dist/tickets/output.js +73 -0
- package/dist/tickets/stateDb.d.ts +1 -0
- package/dist/tickets/stateDb.js +5 -0
- package/dist/workflowDb.d.ts +31 -2
- package/dist/workflowDb.js +192 -23
- package/dist/workflowReader.d.ts +42 -0
- package/dist/workflowReader.js +171 -0
- package/foreman.yaml +13 -0
- package/package.json +51 -3
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { parseQaFailureReport } from "rafi-spec";
|
|
6
|
+
import { calculateFrozenQaStateDigest, captureFrozenQaSource } from "./qaSnapshot.js";
|
|
7
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
8
|
+
export const QA_RECOVERY_ROOT = ".foreman/qa-report-recovery";
|
|
9
|
+
export const QA_CONTEXT_ROOT = ".foreman/qa-recovery-context";
|
|
10
|
+
export class LegacyQaRecoveryPacketError extends Error {
|
|
11
|
+
directory;
|
|
12
|
+
manifest;
|
|
13
|
+
constructor(directory, manifest) {
|
|
14
|
+
super("legacy QA recovery V1 packet requires a clean protected review or non-authoritative historical-context review");
|
|
15
|
+
this.directory = directory;
|
|
16
|
+
this.manifest = manifest;
|
|
17
|
+
this.name = "LegacyQaRecoveryPacketError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function loadQaRecoveryPacket(directory) {
|
|
21
|
+
const root = resolve(directory);
|
|
22
|
+
if (!existsSync(root) || realpathSync(root) !== root)
|
|
23
|
+
throw new Error(`symlink is not allowed in recovery path: ${root}`);
|
|
24
|
+
assertNoSymlinkComponents(root, root);
|
|
25
|
+
let raw = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
|
|
26
|
+
if (raw.version === 1)
|
|
27
|
+
throw new LegacyQaRecoveryPacketError(root, raw);
|
|
28
|
+
const publishedRevision = raw.revision;
|
|
29
|
+
raw = recoverInterruptedPublication(root, raw);
|
|
30
|
+
assertManifestShape(raw);
|
|
31
|
+
for (const resource of raw.resources)
|
|
32
|
+
validateResource(root, resource);
|
|
33
|
+
validateReviewedStates(root, raw);
|
|
34
|
+
const actualDigest = calculatePacketDigest(root, raw);
|
|
35
|
+
if (actualDigest !== raw.packetDigest)
|
|
36
|
+
throw new Error("QA recovery packet digest mismatch");
|
|
37
|
+
const revisionPath = join(root, "manifests", revisionName(raw.revision));
|
|
38
|
+
if (!existsSync(revisionPath) || readFileSync(revisionPath, "utf8") !== readFileSync(join(root, "manifest.json"), "utf8")) {
|
|
39
|
+
throw new Error(`QA recovery revision ${raw.revision} is missing or inconsistent`);
|
|
40
|
+
}
|
|
41
|
+
let parent;
|
|
42
|
+
for (let revision = 1; revision <= raw.revision; revision++) {
|
|
43
|
+
const path = join(root, "manifests", revisionName(revision));
|
|
44
|
+
if (!existsSync(path))
|
|
45
|
+
throw new Error(`QA recovery revision lineage is missing revision ${revision}`);
|
|
46
|
+
const item = JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
assertManifestShape(item);
|
|
48
|
+
if (item.revision !== revision || item.packetId !== raw.packetId)
|
|
49
|
+
throw new Error(`QA recovery revision lineage mismatch at revision ${revision}`);
|
|
50
|
+
if (revision === 1 ? item.parentPacketDigest !== undefined : item.parentPacketDigest !== parent?.packetDigest)
|
|
51
|
+
throw new Error(`QA recovery parent digest mismatch at revision ${revision}`);
|
|
52
|
+
if (calculatePacketDigest(root, item) !== item.packetDigest)
|
|
53
|
+
throw new Error(`QA recovery historical revision digest mismatch at revision ${revision}`);
|
|
54
|
+
parent = item;
|
|
55
|
+
}
|
|
56
|
+
const projectDir = recoveryProjectRoot(root);
|
|
57
|
+
const packet = { directory: root, projectDir, manifest: raw };
|
|
58
|
+
if (raw.revision !== publishedRevision)
|
|
59
|
+
persistPendingState(packet);
|
|
60
|
+
return packet;
|
|
61
|
+
}
|
|
62
|
+
function recoverInterruptedPublication(root, initial) {
|
|
63
|
+
let current = initial;
|
|
64
|
+
while (existsSync(join(root, "manifests", revisionName(current.revision + 1)))) {
|
|
65
|
+
const path = join(root, "manifests", revisionName(current.revision + 1));
|
|
66
|
+
const candidate = JSON.parse(readFileSync(path, "utf8"));
|
|
67
|
+
assertManifestShape(candidate);
|
|
68
|
+
if (candidate.packetId !== current.packetId || candidate.revision !== current.revision + 1 || candidate.parentPacketDigest !== current.packetDigest) {
|
|
69
|
+
throw new Error(`invalid interrupted QA recovery publication at revision ${current.revision + 1}`);
|
|
70
|
+
}
|
|
71
|
+
for (const resource of candidate.resources)
|
|
72
|
+
validateResource(root, resource);
|
|
73
|
+
validateReviewedStates(root, candidate);
|
|
74
|
+
if (calculatePacketDigest(root, candidate) !== candidate.packetDigest)
|
|
75
|
+
throw new Error(`invalid interrupted QA recovery digest at revision ${candidate.revision}`);
|
|
76
|
+
// All referenced objects and projections are durable. Completing the one
|
|
77
|
+
// missing atomic pointer replacement is safe and preserves append-only
|
|
78
|
+
// lineage after a process interruption.
|
|
79
|
+
atomicWrite(join(root, "manifest.json"), readFileSync(path));
|
|
80
|
+
current = candidate;
|
|
81
|
+
}
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
export function inspectLegacyQaRecoveryPacket(directory) {
|
|
85
|
+
const root = resolve(directory);
|
|
86
|
+
if (!existsSync(join(root, "manifest.json")))
|
|
87
|
+
return undefined;
|
|
88
|
+
assertNoSymlinkComponents(root, root);
|
|
89
|
+
const raw = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
|
|
90
|
+
return raw.version === 1 ? { directory: root, manifest: raw } : undefined;
|
|
91
|
+
}
|
|
92
|
+
export function materializeLegacyQaHistoricalContext(projectDir, legacy) {
|
|
93
|
+
const source = realpathSync(resolve(legacy.directory));
|
|
94
|
+
assertNoSymlinkComponents(source, source);
|
|
95
|
+
if (typeof process.getuid === "function" && statOwner(source) !== process.getuid())
|
|
96
|
+
throw new Error("legacy QA recovery packet is not owned by the current operator");
|
|
97
|
+
const target = join(realpathSync(resolve(projectDir)), ".foreman", "qa-legacy-history", `${safeSlug(legacy.manifest.packetId ?? "legacy")}-${randomUUID()}`);
|
|
98
|
+
if (existsSync(target))
|
|
99
|
+
throw new Error(`legacy QA historical context already exists: ${target}`);
|
|
100
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
101
|
+
const readable = [];
|
|
102
|
+
const rejected = [];
|
|
103
|
+
for (const item of legacy.manifest.resources ?? []) {
|
|
104
|
+
const name = typeof item.path === "string" ? item.path : "";
|
|
105
|
+
try {
|
|
106
|
+
const relativePath = safeRelative(name);
|
|
107
|
+
const input = resolve(source, relativePath);
|
|
108
|
+
if (!input.startsWith(`${source}${sep}`) || !existsSync(input))
|
|
109
|
+
throw new Error("missing or outside packet containment");
|
|
110
|
+
assertNoSymlinkComponents(source, input);
|
|
111
|
+
if (typeof process.getuid === "function" && statOwner(input) !== process.getuid())
|
|
112
|
+
throw new Error("not owned by the current operator");
|
|
113
|
+
const bytes = readFileSync(input);
|
|
114
|
+
if (typeof item.bytes === "number" && bytes.length !== item.bytes)
|
|
115
|
+
throw new Error("byte count mismatch");
|
|
116
|
+
if (typeof item.digest === "string" && hash(bytes) !== item.digest)
|
|
117
|
+
throw new Error("digest mismatch");
|
|
118
|
+
const output = join(target, "resources", relativePath);
|
|
119
|
+
mkdirSync(dirname(output), { recursive: true, mode: 0o700 });
|
|
120
|
+
atomicWrite(output, bytes);
|
|
121
|
+
readable.push(relativePath);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
rejected.push({ path: name || "(missing path)", reason: error instanceof Error ? error.message : String(error) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
atomicWrite(join(target, "historical-context.json"), Buffer.from(`${stableJson({ authoritative: false, incomplete: true, legacyVersion: 1, source, readable, rejected })}\n`));
|
|
128
|
+
chmodTreeOwnerOnly(target);
|
|
129
|
+
return { directory: target, readable, rejected };
|
|
130
|
+
}
|
|
131
|
+
export function compareQaRecoveryReviewedState(packet, source) {
|
|
132
|
+
const current = typeof source === "string" ? captureFrozenQaSource(source) : source;
|
|
133
|
+
const originalInventory = sourceInventory(packet, "reviewed-state/original");
|
|
134
|
+
const currentInventory = inventoryFromFrozen(current);
|
|
135
|
+
const drift = deterministicDrift(originalInventory, currentInventory);
|
|
136
|
+
return {
|
|
137
|
+
matches: current.digest === packet.manifest.currentReviewedStateDigest,
|
|
138
|
+
originalDigest: packet.manifest.currentReviewedStateDigest,
|
|
139
|
+
currentDigest: current.digest,
|
|
140
|
+
drift,
|
|
141
|
+
frozenState: current,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Create a unique owner-only V2 packet from the immutable state QA reviewed. */
|
|
145
|
+
export function createQaRecoveryPacket(input) {
|
|
146
|
+
ensureQaRecoveryExcluded(input.projectDir);
|
|
147
|
+
const frozen = input.frozenState ?? captureFrozenQaSource(input.reviewedWorktree ?? input.projectDir);
|
|
148
|
+
const reviewAttemptId = input.reviewAttemptId ?? randomUUID();
|
|
149
|
+
const root = containedRecoveryDirectory(input.projectDir, input.runId, input.ticketId, reviewAttemptId);
|
|
150
|
+
if (existsSync(root))
|
|
151
|
+
throw new Error(`QA recovery packet directory already exists: ${root}`);
|
|
152
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
153
|
+
chmodRecoveryDirectoryChain(input.projectDir, root);
|
|
154
|
+
mkdirSync(join(root, "objects"), { mode: 0o700 });
|
|
155
|
+
mkdirSync(join(root, "manifests"), { mode: 0o700 });
|
|
156
|
+
const resources = [];
|
|
157
|
+
for (const [name, resource] of Object.entries(input.resources)) {
|
|
158
|
+
const path = join("context", `${safeSlug(name)}.${resource.exactText ? "txt" : "json"}`).replaceAll("\\", "/");
|
|
159
|
+
const bytes = encodeValue(resource.value, Boolean(resource.exactText));
|
|
160
|
+
resources.push(storeSealed(root, path, bytes, resource.purpose, resource.mediaType ?? (resource.exactText ? "text/plain" : "application/json"), resource.requiredForRecovery ?? true));
|
|
161
|
+
}
|
|
162
|
+
resources.push(...storeFrozenState(root, frozen, "reviewed-state/original"));
|
|
163
|
+
if (input.reportJson !== undefined)
|
|
164
|
+
resources.push(writeEditableReport(root, Buffer.from(input.reportJson)));
|
|
165
|
+
const now = new Date().toISOString();
|
|
166
|
+
const draft = {
|
|
167
|
+
version: 2,
|
|
168
|
+
packetId: randomUUID(),
|
|
169
|
+
revision: 1,
|
|
170
|
+
runId: input.runId,
|
|
171
|
+
ticketId: input.ticketId,
|
|
172
|
+
cycle: input.cycle,
|
|
173
|
+
reviewAttempt: input.reviewAttempt,
|
|
174
|
+
reviewAttemptId,
|
|
175
|
+
attempt: input.attempt ?? input.reviewAttempt,
|
|
176
|
+
recoveryStage: input.recoveryStage,
|
|
177
|
+
correctionTurns: input.correctionTurns ?? 0,
|
|
178
|
+
pendingAction: input.pendingAction ?? "automatic-recovery",
|
|
179
|
+
originalReviewedStateDigest: frozen.digest,
|
|
180
|
+
currentReviewedStateDigest: frozen.digest,
|
|
181
|
+
reviewedStateDigest: frozen.digest,
|
|
182
|
+
createdAt: now,
|
|
183
|
+
updatedAt: now,
|
|
184
|
+
resources: resources.sort(resourceOrder),
|
|
185
|
+
};
|
|
186
|
+
return publish(root, resolve(input.projectDir), draft);
|
|
187
|
+
}
|
|
188
|
+
export function appendQaRecoveryResource(packet, relativePath, value, options) {
|
|
189
|
+
const requested = safeRelative(relativePath);
|
|
190
|
+
const path = uniqueResourcePath(packet.manifest.resources, requested);
|
|
191
|
+
const bytes = Buffer.isBuffer(value) ? value : encodeValue(value, Boolean(options.exact));
|
|
192
|
+
const resource = storeSealed(packet.directory, path, bytes, options.purpose, options.mediaType ?? (options.exact ? "text/plain" : "application/json"), options.requiredForRecovery ?? true);
|
|
193
|
+
return revise(packet, { resources: [...packet.manifest.resources, resource].sort(resourceOrder) });
|
|
194
|
+
}
|
|
195
|
+
export function appendQaRecoveryReviewedState(packet, source, prefix = "current-state") {
|
|
196
|
+
const frozen = typeof source === "string" ? captureFrozenQaSource(source) : source;
|
|
197
|
+
const safePrefix = safeRelative(prefix);
|
|
198
|
+
const resources = [...packet.manifest.resources, ...storeFrozenState(packet.directory, frozen, safePrefix)].sort(resourceOrder);
|
|
199
|
+
return revise(packet, {
|
|
200
|
+
resources,
|
|
201
|
+
currentReviewedStateDigest: frozen.digest,
|
|
202
|
+
reviewedStateDigest: frozen.digest,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
export function updateQaRecoveryPosition(packet, stage, correctionTurns, pendingAction = "automatic-recovery") {
|
|
206
|
+
return revise(packet, { recoveryStage: stage, correctionTurns, pendingAction });
|
|
207
|
+
}
|
|
208
|
+
export function readManualQaReport(packetDirectory) { return JSON.parse(readFileSync(join(resolve(packetDirectory), "report.json"), "utf8")); }
|
|
209
|
+
export function validateManualQaReport(packet) {
|
|
210
|
+
const path = join(packet.directory, "report.json");
|
|
211
|
+
const raw = existsSync(path) ? readFileSync(path) : Buffer.alloc(0);
|
|
212
|
+
const parsed = parseQaFailureReport(raw.toString());
|
|
213
|
+
let next = appendQaRecoveryResource(packet, `validation/manual-repair-r${packet.manifest.revision + 1}.json`, parsed.validation, { purpose: "Validation result for the latest operator-edited report.json" });
|
|
214
|
+
const editable = writeEditableReport(packet.directory, raw);
|
|
215
|
+
next = revise(next, { resources: next.manifest.resources.filter((resource) => resource.path !== "report.json").concat(editable).sort(resourceOrder) });
|
|
216
|
+
if (parsed.report) {
|
|
217
|
+
next = appendQaRecoveryResource(next, `accepted-reports/report-r${next.manifest.revision + 1}.json`, raw, {
|
|
218
|
+
purpose: "Exact operator-edited report accepted by schema validation", mediaType: "application/json", exact: true,
|
|
219
|
+
});
|
|
220
|
+
next = updateQaRecoveryPosition(next, "manual-report-accepted", next.manifest.correctionTurns, "validated-report");
|
|
221
|
+
}
|
|
222
|
+
return { packet: next, report: parsed.report, errors: parsed.validation.errors };
|
|
223
|
+
}
|
|
224
|
+
/** Copy one exact sealed revision into a fresh snapshot and return a mutation seal. */
|
|
225
|
+
export function materializeQaRecoveryContext(packet, qaWorktree) {
|
|
226
|
+
const loaded = loadQaRecoveryPacket(packet.directory);
|
|
227
|
+
if (loaded.manifest.packetDigest !== packet.manifest.packetDigest)
|
|
228
|
+
throw new Error("cannot materialize a stale QA recovery packet revision");
|
|
229
|
+
ensureQaRecoveryExcluded(qaWorktree);
|
|
230
|
+
const relativePath = join(QA_CONTEXT_ROOT, `${packet.manifest.packetId}-r${packet.manifest.revision}`);
|
|
231
|
+
const target = join(resolve(qaWorktree), relativePath);
|
|
232
|
+
assertNoSymlinkComponents(resolve(qaWorktree), dirname(target));
|
|
233
|
+
if (existsSync(target))
|
|
234
|
+
throw new Error(`QA recovery context already exists: ${target}`);
|
|
235
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
236
|
+
cpSync(packet.directory, target, { recursive: true, dereference: false, preserveTimestamps: true });
|
|
237
|
+
chmodTreeOwnerOnly(target);
|
|
238
|
+
const digest = digestTree(target);
|
|
239
|
+
return { path: target, relativePath, digest, verify: () => {
|
|
240
|
+
const after = digestTree(target);
|
|
241
|
+
if (after !== digest)
|
|
242
|
+
throw new Error(`QA recovery context mutation detected: expected ${digest}, got ${after}`);
|
|
243
|
+
} };
|
|
244
|
+
}
|
|
245
|
+
export function renderQaRecoveryAcknowledgementInstruction(packet, relativePath) {
|
|
246
|
+
const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
|
|
247
|
+
return [
|
|
248
|
+
"Read and verify every required recovery resource in this exact sealed packet revision before replying.",
|
|
249
|
+
`Recovery context directory: ${relativePath}`,
|
|
250
|
+
`Packet ID: ${packet.manifest.packetId}`,
|
|
251
|
+
`Packet revision: ${packet.manifest.revision}`,
|
|
252
|
+
`Packet digest: ${packet.manifest.packetDigest}`,
|
|
253
|
+
`Reviewed-state digest: ${packet.manifest.reviewedStateDigest}`,
|
|
254
|
+
"Required resources:", required,
|
|
255
|
+
"Reply with exactly the following acknowledgement line, followed by the required RAFI_CONTINUITY_DELTA record:",
|
|
256
|
+
`RAFI_QA_RECOVERY_ACK packet="${packet.manifest.packetDigest}" reviewed_state="${packet.manifest.reviewedStateDigest}" required_resources_read="all"`,
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
export function validateQaRecoveryAcknowledgement(text, packet, options = {}) {
|
|
260
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
261
|
+
const expected = `RAFI_QA_RECOVERY_ACK packet="${packet.manifest.packetDigest}" reviewed_state="${packet.manifest.reviewedStateDigest}" required_resources_read="all"`;
|
|
262
|
+
const errors = [];
|
|
263
|
+
if (lines[0] !== expected)
|
|
264
|
+
errors.push("missing or malformed exact recovery-packet acknowledgement");
|
|
265
|
+
if (options.continuityAlreadyValidated) {
|
|
266
|
+
if (lines.length !== 1)
|
|
267
|
+
errors.push("recovery acknowledgement must not contain additional text");
|
|
268
|
+
}
|
|
269
|
+
else if (lines.length !== 2 || !/^RAFI_CONTINUITY_DELTA(?:\s|$)/.test(lines[1] ?? ""))
|
|
270
|
+
errors.push("exactly one continuity record must follow the recovery acknowledgement");
|
|
271
|
+
return errors;
|
|
272
|
+
}
|
|
273
|
+
export function qaRecoveryInventory(packet) {
|
|
274
|
+
return packet.manifest.resources.map((resource) => `${resource.path}\t${resource.purpose}\t${resource.mediaType}\t${resource.bytes}\t${resource.digest}\t${resource.integrityPolicy}`).join("\n");
|
|
275
|
+
}
|
|
276
|
+
export function qaReportDigest(report) { return hash(Buffer.from(JSON.stringify(report))); }
|
|
277
|
+
export function ensureQaRecoveryExcluded(projectDir) {
|
|
278
|
+
let exclude;
|
|
279
|
+
try {
|
|
280
|
+
exclude = execFileSync("git", ["-C", resolve(projectDir), "rev-parse", "--git-path", "info/exclude"], { encoding: "utf8" }).trim();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (!resolve(exclude).startsWith(sep))
|
|
286
|
+
exclude = resolve(projectDir, exclude);
|
|
287
|
+
const entries = [`/${QA_RECOVERY_ROOT}/`, `/${QA_CONTEXT_ROOT}/`];
|
|
288
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
289
|
+
const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
|
|
290
|
+
if (!missing.length)
|
|
291
|
+
return;
|
|
292
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
293
|
+
atomicWrite(exclude, Buffer.from(`${current}${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`), 0o600);
|
|
294
|
+
}
|
|
295
|
+
function storeFrozenState(root, state, prefix) {
|
|
296
|
+
const inventory = state.untracked.map(({ path, kind, mode, digest }, index) => ({ path, kind, mode, digest, stored: `${prefix}/untracked/${String(index).padStart(6, "0")}.bin` }));
|
|
297
|
+
const values = [
|
|
298
|
+
[`${prefix}/head.txt`, Buffer.from(`${state.head}\n`), "text/plain", "Base HEAD for the immutable reviewed state"],
|
|
299
|
+
[`${prefix}/status.bin`, state.status, "application/octet-stream", "Exact porcelain-v2 source status"],
|
|
300
|
+
[`${prefix}/tracked-head.diff`, state.combinedDiff, "application/octet-stream", "Exact combined tracked binary diff against HEAD"],
|
|
301
|
+
[`${prefix}/staged.diff`, state.stagedDiff, "application/octet-stream", "Exact staged tracked binary diff"],
|
|
302
|
+
[`${prefix}/unstaged.diff`, state.unstagedDiff, "application/octet-stream", "Exact unstaged tracked binary diff"],
|
|
303
|
+
[`${prefix}/change-summary.txt`, Buffer.from(`${state.changeSummary}\n`), "text/plain", "Deterministic path-level source summary"],
|
|
304
|
+
[`${prefix}/integrity.json`, Buffer.from(`${stableJson({ digest: state.digest, capturedAt: state.capturedAt })}\n`), "application/json", "Reviewed-state integrity manifest"],
|
|
305
|
+
[`${prefix}/untracked-manifest.json`, Buffer.from(`${stableJson(inventory)}\n`), "application/json", "Untracked path, type, mode, digest, and object mapping"],
|
|
306
|
+
];
|
|
307
|
+
state.untracked.forEach((item, index) => values.push([`${prefix}/untracked/${String(index).padStart(6, "0")}.bin`, item.bytes, "application/octet-stream", `Exact untracked ${item.kind} bytes for ${item.path}`]));
|
|
308
|
+
return values.map(([path, bytes, mediaType, purpose]) => storeSealed(root, safeRelative(path), bytes, purpose, mediaType, true));
|
|
309
|
+
}
|
|
310
|
+
function sourceInventory(packet, prefix) {
|
|
311
|
+
const resource = packet.manifest.resources.find((candidate) => candidate.path === `${prefix}/untracked-manifest.json`);
|
|
312
|
+
const rows = new Map();
|
|
313
|
+
for (const candidate of packet.manifest.resources.filter((item) => item.path.startsWith(`${prefix}/`) && !item.path.includes("/untracked/")))
|
|
314
|
+
rows.set(candidate.path.slice(prefix.length + 1), candidate.digest);
|
|
315
|
+
if (resource?.objectPath) {
|
|
316
|
+
const inventory = JSON.parse(readFileSync(join(packet.directory, resource.objectPath), "utf8"));
|
|
317
|
+
for (const item of inventory)
|
|
318
|
+
rows.set(`untracked:${item.path}`, `${item.kind}:${item.mode}:${item.digest}`);
|
|
319
|
+
}
|
|
320
|
+
return rows;
|
|
321
|
+
}
|
|
322
|
+
function inventoryFromFrozen(state) {
|
|
323
|
+
const rows = new Map([
|
|
324
|
+
["head.txt", hash(Buffer.from(`${state.head}\n`))], ["status.bin", hash(state.status)], ["tracked-head.diff", hash(state.combinedDiff)],
|
|
325
|
+
["staged.diff", hash(state.stagedDiff)], ["unstaged.diff", hash(state.unstagedDiff)], ["change-summary.txt", hash(Buffer.from(`${state.changeSummary}\n`))],
|
|
326
|
+
]);
|
|
327
|
+
for (const item of state.untracked)
|
|
328
|
+
rows.set(`untracked:${item.path}`, `${item.kind}:${item.mode}:${item.digest}`);
|
|
329
|
+
return rows;
|
|
330
|
+
}
|
|
331
|
+
function deterministicDrift(before, after) {
|
|
332
|
+
return [...new Set([...before.keys(), ...after.keys()])].sort().filter((key) => before.get(key) !== after.get(key));
|
|
333
|
+
}
|
|
334
|
+
function storeSealed(root, path, bytes, purpose, mediaType, requiredForRecovery) {
|
|
335
|
+
const digest = hash(bytes);
|
|
336
|
+
const objectPath = `objects/${digest}`;
|
|
337
|
+
const absolute = join(root, objectPath);
|
|
338
|
+
if (!existsSync(absolute))
|
|
339
|
+
atomicWrite(absolute, bytes);
|
|
340
|
+
else if (hash(readFileSync(absolute)) !== digest)
|
|
341
|
+
throw new Error(`content-addressed QA recovery object collision: ${digest}`);
|
|
342
|
+
const projection = join(root, path);
|
|
343
|
+
if (existsSync(projection))
|
|
344
|
+
throw new Error(`QA recovery resource path already exists: ${path}`);
|
|
345
|
+
mkdirSync(dirname(projection), { recursive: true, mode: 0o700 });
|
|
346
|
+
copyFileSync(absolute, projection);
|
|
347
|
+
chmodSync(projection, 0o600);
|
|
348
|
+
return { path, purpose, mediaType, bytes: bytes.length, digest, requiredForRecovery, integrityPolicy: "sealed", objectPath };
|
|
349
|
+
}
|
|
350
|
+
function writeEditableReport(root, bytes) {
|
|
351
|
+
atomicWrite(join(root, "report.json"), bytes);
|
|
352
|
+
return { path: "report.json", purpose: "Operator-editable QA failure report", mediaType: "application/json", bytes: bytes.length, digest: hash(bytes), requiredForRecovery: false, integrityPolicy: "operator_editable" };
|
|
353
|
+
}
|
|
354
|
+
function validateResource(root, resource) {
|
|
355
|
+
safeRelative(resource.path);
|
|
356
|
+
if (!Number.isSafeInteger(resource.bytes) || resource.bytes < 0 || !/^[a-f0-9]{64}$/.test(resource.digest))
|
|
357
|
+
throw new Error(`invalid QA recovery resource metadata: ${resource.path}`);
|
|
358
|
+
if (resource.integrityPolicy === "operator_editable") {
|
|
359
|
+
if (resource.path !== "report.json")
|
|
360
|
+
throw new Error(`only report.json may be operator editable: ${resource.path}`);
|
|
361
|
+
const path = join(root, "report.json");
|
|
362
|
+
if (!existsSync(path) || lstatSync(path).isSymbolicLink())
|
|
363
|
+
throw new Error("missing or unsafe operator-editable report.json");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (resource.integrityPolicy !== "sealed" || !resource.objectPath)
|
|
367
|
+
throw new Error(`invalid integrity policy for ${resource.path}`);
|
|
368
|
+
const objectPath = resolve(root, safeRelative(resource.objectPath));
|
|
369
|
+
if (!objectPath.startsWith(`${root}${sep}`) || !existsSync(objectPath))
|
|
370
|
+
throw new Error(`missing or unsafe QA recovery object: ${resource.path}`);
|
|
371
|
+
assertNoSymlinkComponents(root, objectPath);
|
|
372
|
+
const bytes = readFileSync(objectPath);
|
|
373
|
+
if (bytes.length !== resource.bytes || hash(bytes) !== resource.digest)
|
|
374
|
+
throw new Error(`QA recovery resource digest mismatch: ${resource.path}`);
|
|
375
|
+
const projection = resolve(root, resource.path);
|
|
376
|
+
if (!projection.startsWith(`${root}${sep}`) || !existsSync(projection))
|
|
377
|
+
throw new Error(`missing QA recovery resource projection: ${resource.path}`);
|
|
378
|
+
assertNoSymlinkComponents(root, projection);
|
|
379
|
+
const projected = readFileSync(projection);
|
|
380
|
+
if (projected.length !== resource.bytes || hash(projected) !== resource.digest)
|
|
381
|
+
throw new Error(`QA recovery resource projection mismatch: ${resource.path}`);
|
|
382
|
+
}
|
|
383
|
+
function validateReviewedStates(root, manifest) {
|
|
384
|
+
const prefixes = manifest.resources
|
|
385
|
+
.map((resource) => resource.path.endsWith("/integrity.json") ? resource.path.slice(0, -"/integrity.json".length) : undefined)
|
|
386
|
+
.filter((prefix) => Boolean(prefix?.startsWith("reviewed-state/")));
|
|
387
|
+
if (!prefixes.includes("reviewed-state/original"))
|
|
388
|
+
throw new Error("QA recovery packet is missing its original reviewed-state core resources");
|
|
389
|
+
const digests = new Map(prefixes.map((prefix) => [prefix, validateReviewedState(root, manifest, prefix)]));
|
|
390
|
+
if (digests.get("reviewed-state/original") !== manifest.originalReviewedStateDigest)
|
|
391
|
+
throw new Error("QA recovery original reviewed-state digest mismatch");
|
|
392
|
+
if (![...digests.values()].includes(manifest.currentReviewedStateDigest) || manifest.reviewedStateDigest !== manifest.currentReviewedStateDigest) {
|
|
393
|
+
throw new Error("QA recovery current reviewed-state digest mismatch");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function validateReviewedState(root, manifest, prefix) {
|
|
397
|
+
const required = ["head.txt", "status.bin", "tracked-head.diff", "staged.diff", "unstaged.diff", "change-summary.txt", "integrity.json", "untracked-manifest.json"];
|
|
398
|
+
const byPath = new Map(manifest.resources.map((resource) => [resource.path, resource]));
|
|
399
|
+
const bytes = (suffix) => {
|
|
400
|
+
const resource = byPath.get(`${prefix}/${suffix}`);
|
|
401
|
+
if (!resource || resource.integrityPolicy !== "sealed" || !resource.objectPath)
|
|
402
|
+
throw new Error(`QA recovery packet is missing reviewed-state core resource: ${prefix}/${suffix}`);
|
|
403
|
+
return readFileSync(join(root, resource.objectPath));
|
|
404
|
+
};
|
|
405
|
+
for (const suffix of required)
|
|
406
|
+
bytes(suffix);
|
|
407
|
+
const inventory = JSON.parse(bytes("untracked-manifest.json").toString());
|
|
408
|
+
if (!Array.isArray(inventory))
|
|
409
|
+
throw new Error(`invalid reviewed-state untracked inventory: ${prefix}`);
|
|
410
|
+
const untracked = inventory.map((item) => {
|
|
411
|
+
const mode = Number(item.mode);
|
|
412
|
+
if (typeof item.path !== "string" || (item.kind !== "file" && item.kind !== "symlink") || !Number.isInteger(mode) || typeof item.digest !== "string" || typeof item.stored !== "string")
|
|
413
|
+
throw new Error(`invalid reviewed-state untracked entry: ${prefix}`);
|
|
414
|
+
const resource = byPath.get(item.stored);
|
|
415
|
+
if (!resource || resource.integrityPolicy !== "sealed" || !resource.objectPath)
|
|
416
|
+
throw new Error(`missing reviewed-state untracked bytes: ${item.path}`);
|
|
417
|
+
const value = readFileSync(join(root, resource.objectPath));
|
|
418
|
+
if (hash(value) !== item.digest)
|
|
419
|
+
throw new Error(`reviewed-state untracked digest mismatch: ${item.path}`);
|
|
420
|
+
return { path: item.path, kind: item.kind, mode, digest: item.digest, bytes: value };
|
|
421
|
+
});
|
|
422
|
+
const declared = JSON.parse(bytes("integrity.json").toString());
|
|
423
|
+
const state = {
|
|
424
|
+
head: bytes("head.txt").toString().trimEnd(),
|
|
425
|
+
status: bytes("status.bin"),
|
|
426
|
+
combinedDiff: bytes("tracked-head.diff"),
|
|
427
|
+
stagedDiff: bytes("staged.diff"),
|
|
428
|
+
unstagedDiff: bytes("unstaged.diff"),
|
|
429
|
+
changeSummary: bytes("change-summary.txt").toString().replace(/\n$/, ""),
|
|
430
|
+
untracked,
|
|
431
|
+
};
|
|
432
|
+
const digest = calculateFrozenQaStateDigest(state);
|
|
433
|
+
if (declared.digest !== digest)
|
|
434
|
+
throw new Error(`reviewed-state integrity digest mismatch: ${prefix}`);
|
|
435
|
+
return digest;
|
|
436
|
+
}
|
|
437
|
+
function revise(packet, changes) {
|
|
438
|
+
const current = loadQaRecoveryPacket(packet.directory);
|
|
439
|
+
if (current.manifest.packetDigest !== packet.manifest.packetDigest)
|
|
440
|
+
throw new Error("stale QA recovery packet revision cannot be appended");
|
|
441
|
+
const { packetDigest: _digest, ...base } = current.manifest;
|
|
442
|
+
const draft = {
|
|
443
|
+
...base, ...changes,
|
|
444
|
+
revision: current.manifest.revision + 1,
|
|
445
|
+
parentPacketDigest: current.manifest.packetDigest,
|
|
446
|
+
updatedAt: new Date().toISOString(),
|
|
447
|
+
};
|
|
448
|
+
return publish(packet.directory, packet.projectDir, draft);
|
|
449
|
+
}
|
|
450
|
+
function publish(root, projectDir, draft) {
|
|
451
|
+
const manifest = { ...draft, resources: draft.resources.slice().sort(resourceOrder), packetDigest: "" };
|
|
452
|
+
manifest.packetDigest = calculatePacketDigest(root, manifest);
|
|
453
|
+
const bytes = Buffer.from(`${stableJson(manifest)}\n`);
|
|
454
|
+
const revisionPath = join(root, "manifests", revisionName(manifest.revision));
|
|
455
|
+
if (existsSync(revisionPath))
|
|
456
|
+
throw new Error(`QA recovery revision already exists: ${manifest.revision}`);
|
|
457
|
+
atomicWrite(revisionPath, bytes);
|
|
458
|
+
atomicWrite(join(root, "manifest.json"), bytes);
|
|
459
|
+
chmodTreeOwnerOnly(root);
|
|
460
|
+
const packet = { directory: root, projectDir, manifest };
|
|
461
|
+
persistPendingState(packet);
|
|
462
|
+
return packet;
|
|
463
|
+
}
|
|
464
|
+
function calculatePacketDigest(root, manifest) {
|
|
465
|
+
const canonical = { ...manifest, packetDigest: undefined };
|
|
466
|
+
delete canonical.packetDigest;
|
|
467
|
+
const h = createHash("sha256").update(stableJson(canonical));
|
|
468
|
+
for (const resource of manifest.resources.filter((item) => item.integrityPolicy === "sealed").sort(resourceOrder)) {
|
|
469
|
+
h.update("\0").update(resource.path).update("\0").update(readFileSync(join(root, resource.objectPath)));
|
|
470
|
+
}
|
|
471
|
+
return h.digest("hex");
|
|
472
|
+
}
|
|
473
|
+
function persistPendingState(packet) {
|
|
474
|
+
let db;
|
|
475
|
+
try {
|
|
476
|
+
db = new WorkflowDb(packet.projectDir);
|
|
477
|
+
const run = db.getRun(packet.manifest.runId);
|
|
478
|
+
if (!run)
|
|
479
|
+
return;
|
|
480
|
+
const qaReportRecovery = {
|
|
481
|
+
packetPath: packet.directory,
|
|
482
|
+
packetDigest: packet.manifest.packetDigest,
|
|
483
|
+
reviewedStateDigest: packet.manifest.reviewedStateDigest,
|
|
484
|
+
revision: packet.manifest.revision,
|
|
485
|
+
ladderPosition: packet.manifest.correctionTurns,
|
|
486
|
+
pendingAction: packet.manifest.pendingAction,
|
|
487
|
+
ticketId: packet.manifest.ticketId,
|
|
488
|
+
packetId: packet.manifest.packetId,
|
|
489
|
+
retentionProtected: true,
|
|
490
|
+
};
|
|
491
|
+
db.transition(packet.manifest.runId, {
|
|
492
|
+
status: run.status, checkpoint: run.checkpoint, remainingWork: run.remainingWork,
|
|
493
|
+
state: { ...run.state, qaReportRecovery }, event: "qa_recovery_packet_revision",
|
|
494
|
+
payload: { packetDigest: packet.manifest.packetDigest, revision: packet.manifest.revision, pendingAction: packet.manifest.pendingAction, ticketId: packet.manifest.ticketId },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
db?.close();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function assertManifestShape(value) {
|
|
502
|
+
if (value.version !== 2
|
|
503
|
+
|| !Array.isArray(value.resources)
|
|
504
|
+
|| !Number.isSafeInteger(value.revision) || value.revision < 1
|
|
505
|
+
|| !Number.isSafeInteger(value.cycle) || value.cycle < 0
|
|
506
|
+
|| !Number.isSafeInteger(value.reviewAttempt) || value.reviewAttempt < 0
|
|
507
|
+
|| !Number.isSafeInteger(value.correctionTurns) || value.correctionTurns < 0
|
|
508
|
+
|| !/^[a-f0-9]{64}$/.test(value.packetDigest)
|
|
509
|
+
|| !value.packetId || !value.reviewAttemptId || !value.runId || !value.ticketId
|
|
510
|
+
|| !/^[a-f0-9]{64}$/.test(value.originalReviewedStateDigest)
|
|
511
|
+
|| !/^[a-f0-9]{64}$/.test(value.currentReviewedStateDigest)
|
|
512
|
+
|| !/^[a-f0-9]{64}$/.test(value.reviewedStateDigest))
|
|
513
|
+
throw new Error("invalid QA recovery V2 manifest");
|
|
514
|
+
}
|
|
515
|
+
function containedRecoveryDirectory(projectDir, run, ticket, attemptId) {
|
|
516
|
+
const project = realpathSync(resolve(projectDir));
|
|
517
|
+
const base = resolve(project, QA_RECOVERY_ROOT);
|
|
518
|
+
const result = resolve(base, slugWithHash(run), slugWithHash(ticket), slugWithHash(attemptId));
|
|
519
|
+
if (!base.startsWith(`${project}${sep}`) || !result.startsWith(`${base}${sep}`))
|
|
520
|
+
throw new Error("unsafe QA recovery packet path");
|
|
521
|
+
assertNoSymlinkComponents(project, result);
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
function recoveryProjectRoot(packetRoot) {
|
|
525
|
+
const marker = `${sep}${QA_RECOVERY_ROOT.split("/").join(sep)}${sep}`;
|
|
526
|
+
const index = packetRoot.indexOf(marker);
|
|
527
|
+
return index >= 0 ? packetRoot.slice(0, index) : dirname(packetRoot);
|
|
528
|
+
}
|
|
529
|
+
function chmodRecoveryDirectoryChain(projectDir, target) {
|
|
530
|
+
const project = realpathSync(resolve(projectDir));
|
|
531
|
+
const base = resolve(project, QA_RECOVERY_ROOT);
|
|
532
|
+
let cursor = base;
|
|
533
|
+
for (const part of ["", ...relative(base, target).split(sep).filter(Boolean)]) {
|
|
534
|
+
if (part)
|
|
535
|
+
cursor = join(cursor, part);
|
|
536
|
+
if (existsSync(cursor))
|
|
537
|
+
chmodSync(cursor, 0o700);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function uniqueResourcePath(resources, requested) {
|
|
541
|
+
if (!resources.some((resource) => resource.path === requested))
|
|
542
|
+
return requested;
|
|
543
|
+
const dot = requested.lastIndexOf(".");
|
|
544
|
+
const stem = dot > requested.lastIndexOf("/") ? requested.slice(0, dot) : requested;
|
|
545
|
+
const extension = dot > requested.lastIndexOf("/") ? requested.slice(dot) : "";
|
|
546
|
+
let sequence = 2;
|
|
547
|
+
while (resources.some((resource) => resource.path === `${stem}.seq-${String(sequence).padStart(4, "0")}${extension}`))
|
|
548
|
+
sequence++;
|
|
549
|
+
return `${stem}.seq-${String(sequence).padStart(4, "0")}${extension}`;
|
|
550
|
+
}
|
|
551
|
+
function safeRelative(path) {
|
|
552
|
+
const clean = path.replace(/\\/g, "/");
|
|
553
|
+
if (!clean || clean.startsWith("/") || clean.split("/").includes(".."))
|
|
554
|
+
throw new Error(`unsafe recovery resource path: ${path}`);
|
|
555
|
+
return clean;
|
|
556
|
+
}
|
|
557
|
+
function safeSlug(value) { return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[.-]+|[.-]+$/g, "").slice(0, 64) || "unknown"; }
|
|
558
|
+
function slugWithHash(value) { return `${safeSlug(value)}-${hash(Buffer.from(value)).slice(0, 12)}`; }
|
|
559
|
+
function revisionName(revision) { return `revision-${String(revision).padStart(8, "0")}.json`; }
|
|
560
|
+
function resourceOrder(a, b) { return a.path.localeCompare(b.path); }
|
|
561
|
+
function encodeValue(value, exact) { return exact ? Buffer.from(typeof value === "string" ? value : String(value)) : Buffer.from(`${stableJson(value)}\n`); }
|
|
562
|
+
function atomicWrite(path, bytes, mode = 0o600) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); writeFileSync(temp, bytes, { mode }); chmodSync(temp, mode); renameSync(temp, path); chmodSync(path, mode); }
|
|
563
|
+
function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
564
|
+
function statOwner(path) { return lstatSync(path).uid; }
|
|
565
|
+
function stableJson(value) { if (Array.isArray(value))
|
|
566
|
+
return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object")
|
|
567
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; return JSON.stringify(value) ?? "null"; }
|
|
568
|
+
function digestTree(root) { const rows = []; const visit = (directory) => { for (const name of readdirSync(directory).sort()) {
|
|
569
|
+
const path = join(directory, name);
|
|
570
|
+
const rel = relative(root, path);
|
|
571
|
+
const stat = lstatSync(path);
|
|
572
|
+
if (stat.isDirectory())
|
|
573
|
+
visit(path);
|
|
574
|
+
else {
|
|
575
|
+
const bytes = stat.isSymbolicLink() ? Buffer.from(readlinkSync(path)) : readFileSync(path);
|
|
576
|
+
rows.push(`${rel}\0${stat.mode & 0o7777}\0${hash(bytes)}`);
|
|
577
|
+
}
|
|
578
|
+
} }; visit(root); return hash(Buffer.from(rows.join("\n"))); }
|
|
579
|
+
function chmodTreeOwnerOnly(root) { for (const name of readdirSync(root)) {
|
|
580
|
+
const path = join(root, name);
|
|
581
|
+
const stat = lstatSync(path);
|
|
582
|
+
if (stat.isDirectory()) {
|
|
583
|
+
chmodSync(path, 0o700);
|
|
584
|
+
chmodTreeOwnerOnly(path);
|
|
585
|
+
}
|
|
586
|
+
else if (!stat.isSymbolicLink())
|
|
587
|
+
chmodSync(path, 0o600);
|
|
588
|
+
} chmodSync(root, 0o700); }
|
|
589
|
+
function assertNoSymlinkComponents(base, target) { const root = resolve(base); const absolute = resolve(target); if (absolute !== root && !absolute.startsWith(`${root}${sep}`))
|
|
590
|
+
throw new Error(`path escapes recovery containment: ${target}`); let cursor = root; if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink())
|
|
591
|
+
throw new Error(`symlink is not allowed in recovery path: ${cursor}`); for (const part of relative(root, absolute).split(sep).filter(Boolean)) {
|
|
592
|
+
cursor = join(cursor, part);
|
|
593
|
+
if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink())
|
|
594
|
+
throw new Error(`symlink is not allowed in recovery path: ${cursor}`);
|
|
595
|
+
} }
|