@markstanden/opencode-test-container 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +263 -2
- package/container/Containerfile +24 -0
- package/container/README.md +29 -0
- package/dist/adapters/opencode/events.d.ts +90 -0
- package/dist/adapters/opencode/events.js +213 -0
- package/dist/adapters/opencode/runtime.d.ts +87 -0
- package/dist/adapters/opencode/runtime.js +136 -0
- package/dist/adapters/podman/container.d.ts +62 -0
- package/dist/adapters/podman/container.js +119 -0
- package/dist/adapters/podman/image.d.ts +50 -0
- package/dist/adapters/podman/image.js +124 -0
- package/dist/adapters/process.d.ts +38 -0
- package/dist/adapters/process.js +71 -0
- package/dist/campaign/assess.d.ts +45 -0
- package/dist/campaign/assess.js +208 -0
- package/dist/campaign/close.d.ts +17 -0
- package/dist/campaign/close.js +112 -0
- package/dist/campaign/evidence-guard.d.ts +24 -0
- package/dist/campaign/evidence-guard.js +49 -0
- package/dist/campaign/freeze.d.ts +94 -0
- package/dist/campaign/freeze.js +297 -0
- package/dist/campaign/inventory.d.ts +62 -0
- package/dist/campaign/inventory.js +192 -0
- package/dist/campaign/judge.d.ts +209 -0
- package/dist/campaign/judge.js +1285 -0
- package/dist/campaign/ledger.d.ts +64 -0
- package/dist/campaign/ledger.js +149 -0
- package/dist/campaign/prepare.d.ts +129 -0
- package/dist/campaign/prepare.js +483 -0
- package/dist/campaign/primary.d.ts +96 -0
- package/dist/campaign/primary.js +189 -0
- package/dist/campaign/run.d.ts +151 -0
- package/dist/campaign/run.js +562 -0
- package/dist/campaign/status.d.ts +76 -0
- package/dist/campaign/status.js +237 -0
- package/dist/cli/otc.d.ts +51 -0
- package/dist/cli/otc.js +435 -0
- package/dist/contracts/canonical.d.ts +21 -0
- package/dist/contracts/canonical.js +44 -0
- package/dist/contracts/types.d.ts +261 -0
- package/dist/contracts/types.js +9 -0
- package/dist/contracts/validate.d.ts +72 -0
- package/dist/contracts/validate.js +1021 -0
- package/dist/contracts/version.d.ts +44 -0
- package/dist/contracts/version.js +147 -0
- package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
- package/dist/evaluators/docs/analysis/accounting.js +237 -0
- package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
- package/dist/evaluators/docs/analysis/blinding.js +80 -0
- package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
- package/dist/evaluators/docs/analysis/bounds.js +31 -0
- package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
- package/dist/evaluators/docs/analysis/budget.js +305 -0
- package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
- package/dist/evaluators/docs/analysis/closure.js +251 -0
- package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
- package/dist/evaluators/docs/analysis/decision.js +161 -0
- package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
- package/dist/evaluators/docs/analysis/freeze.js +395 -0
- package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
- package/dist/evaluators/docs/analysis/guardrails.js +194 -0
- package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
- package/dist/evaluators/docs/analysis/journal.js +475 -0
- package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
- package/dist/evaluators/docs/analysis/ledger.js +145 -0
- package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
- package/dist/evaluators/docs/analysis/measures.js +1069 -0
- package/dist/evaluators/docs/analysis/model.d.ts +236 -0
- package/dist/evaluators/docs/analysis/model.js +740 -0
- package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
- package/dist/evaluators/docs/analysis/pairing.js +95 -0
- package/dist/evaluators/docs/analysis/records.d.ts +229 -0
- package/dist/evaluators/docs/analysis/records.js +935 -0
- package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
- package/dist/evaluators/docs/analysis/recovery.js +507 -0
- package/dist/evaluators/docs/analysis/render.d.ts +235 -0
- package/dist/evaluators/docs/analysis/render.js +566 -0
- package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
- package/dist/evaluators/docs/analysis/replay.js +1272 -0
- package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
- package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
- package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
- package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
- package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
- package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
- package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
- package/dist/evaluators/docs/analysis/selector.js +123 -0
- package/dist/evaluators/docs/assess.d.ts +129 -0
- package/dist/evaluators/docs/assess.js +433 -0
- package/dist/evaluators/docs/campaign.d.ts +133 -0
- package/dist/evaluators/docs/campaign.js +222 -0
- package/dist/evaluators/docs/index.d.ts +25 -0
- package/dist/evaluators/docs/index.js +25 -0
- package/dist/evaluators/docs/journal.d.ts +150 -0
- package/dist/evaluators/docs/journal.js +475 -0
- package/dist/evaluators/docs/scoring.d.ts +367 -0
- package/dist/evaluators/docs/scoring.js +662 -0
- package/dist/evaluators/skills/assess.d.ts +94 -0
- package/dist/evaluators/skills/assess.js +207 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +29 -0
- package/dist/legacy/prod014.d.ts +86 -0
- package/dist/legacy/prod014.js +209 -0
- package/dist/reporting/report.d.ts +79 -0
- package/dist/reporting/report.js +242 -0
- package/docs/architecture.md +106 -0
- package/docs/capability-matrix.md +84 -0
- package/docs/offline-rehearsal.md +75 -0
- package/docs/parity-record.md +81 -0
- package/docs/pr-description.md +103 -0
- package/docs/release-0.1.0.md +52 -0
- package/docs/releasing.md +82 -0
- package/docs/schema.md +97 -0
- package/examples/skills/README.md +13 -0
- package/examples/skills/campaign.json +73 -0
- package/examples/skills/skills/otel-helper/SKILL.md +10 -0
- package/examples/skills/suite.json +29 -0
- package/package.json +45 -5
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sha256Bytes, sha256Of } from "../contracts/canonical.js";
|
|
4
|
+
import { isRecord, readJson } from "../contracts/validate.js";
|
|
5
|
+
import { resolveImage } from "../adapters/podman/image.js";
|
|
6
|
+
import { runCommand } from "../adapters/process.js";
|
|
7
|
+
import { loadPreparedManifest, manifestSha256, preparedDifferences, } from "./prepare.js";
|
|
8
|
+
import { toolIdentity, toolIdentityProblems, } from "../contracts/version.js";
|
|
9
|
+
/** The canonical freeze identity, independent of the freeze timestamp. */
|
|
10
|
+
export function freezeIdentity(record) {
|
|
11
|
+
return sha256Of(record);
|
|
12
|
+
}
|
|
13
|
+
/** Path of the freeze receipt. */
|
|
14
|
+
export function frozenPath(root) {
|
|
15
|
+
return join(root, "prepare", "frozen.json");
|
|
16
|
+
}
|
|
17
|
+
/** The required string fields of a freeze record. */
|
|
18
|
+
const FROZEN_TEXT_FIELDS = [
|
|
19
|
+
"campaignId",
|
|
20
|
+
"suiteId",
|
|
21
|
+
"freezeSha256",
|
|
22
|
+
"manifestSha256",
|
|
23
|
+
"campaignSha256",
|
|
24
|
+
"suiteSha256",
|
|
25
|
+
"scheduleSha256",
|
|
26
|
+
"subjectsSha256",
|
|
27
|
+
"workspacesSha256",
|
|
28
|
+
"promptsSha256",
|
|
29
|
+
"primaryChecksSha256",
|
|
30
|
+
"image",
|
|
31
|
+
"imageId",
|
|
32
|
+
"opencodeVersion",
|
|
33
|
+
"frozenAt",
|
|
34
|
+
];
|
|
35
|
+
/** Check the freeze record's tool identity shape. */
|
|
36
|
+
function toolShapeProblems(tool, path) {
|
|
37
|
+
return !isRecord(tool) ||
|
|
38
|
+
typeof tool.name !== "string" ||
|
|
39
|
+
typeof tool.version !== "string" ||
|
|
40
|
+
typeof tool.evaluatorVersion !== "number" ||
|
|
41
|
+
typeof tool.artifactSha256 !== "string"
|
|
42
|
+
? [
|
|
43
|
+
`${path}.tool must name name, version, evaluatorVersion and artifactSha256`,
|
|
44
|
+
]
|
|
45
|
+
: [];
|
|
46
|
+
}
|
|
47
|
+
/** Check a freeze record's shape. */
|
|
48
|
+
function frozenShapeProblems(record, path) {
|
|
49
|
+
const problems = FROZEN_TEXT_FIELDS.filter((field) => typeof record[field] !== "string").map((field) => `${path}.${field} must be a string`);
|
|
50
|
+
if (!isRecord(record.provider) ||
|
|
51
|
+
typeof record.provider.route !== "string" ||
|
|
52
|
+
typeof record.provider.model !== "string") {
|
|
53
|
+
problems.push(`${path}.provider must name route and model`);
|
|
54
|
+
}
|
|
55
|
+
problems.push(...toolShapeProblems(record.tool, path));
|
|
56
|
+
if (record.schemaVersion !== 1 || record.kind !== "otc-freeze") {
|
|
57
|
+
problems.push(`${path} is not a schemaVersion 1 otc-freeze record`);
|
|
58
|
+
}
|
|
59
|
+
return problems;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Read a freeze receipt without trusting its shape.
|
|
63
|
+
* @param root The campaign root.
|
|
64
|
+
* @returns The record, or the reason it is unusable.
|
|
65
|
+
*/
|
|
66
|
+
export function loadFrozen(root) {
|
|
67
|
+
const path = frozenPath(root);
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
return { ok: false, problem: `${path} is missing; run otc freeze` };
|
|
70
|
+
}
|
|
71
|
+
const parsed = readJson(path);
|
|
72
|
+
if (!parsed.ok)
|
|
73
|
+
return { ok: false, problem: parsed.problem };
|
|
74
|
+
if (!isRecord(parsed.value)) {
|
|
75
|
+
return { ok: false, problem: `${path} is not an object` };
|
|
76
|
+
}
|
|
77
|
+
const problems = frozenShapeProblems(parsed.value, path);
|
|
78
|
+
if (problems.length > 0) {
|
|
79
|
+
return { ok: false, problem: problems.join("; ") };
|
|
80
|
+
}
|
|
81
|
+
const typed = parsed.value;
|
|
82
|
+
if (freezeIdentity(identityOf(typed)) !== typed.freezeSha256) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
problem: `${path} freezeSha256 does not match its content`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, record: typed };
|
|
89
|
+
}
|
|
90
|
+
/** Strip volatile and derived fields from a freeze record. */
|
|
91
|
+
export function identityOf(record) {
|
|
92
|
+
return {
|
|
93
|
+
schemaVersion: record.schemaVersion,
|
|
94
|
+
kind: record.kind,
|
|
95
|
+
campaignId: record.campaignId,
|
|
96
|
+
suiteId: record.suiteId,
|
|
97
|
+
manifestSha256: record.manifestSha256,
|
|
98
|
+
campaignSha256: record.campaignSha256,
|
|
99
|
+
suiteSha256: record.suiteSha256,
|
|
100
|
+
scheduleSha256: record.scheduleSha256,
|
|
101
|
+
subjectsSha256: record.subjectsSha256,
|
|
102
|
+
workspacesSha256: record.workspacesSha256,
|
|
103
|
+
promptsSha256: record.promptsSha256,
|
|
104
|
+
primaryChecksSha256: record.primaryChecksSha256,
|
|
105
|
+
tool: record.tool,
|
|
106
|
+
image: record.image,
|
|
107
|
+
imageId: record.imageId,
|
|
108
|
+
opencodeVersion: record.opencodeVersion,
|
|
109
|
+
provider: record.provider,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Compare prepared inputs with a freeze receipt. */
|
|
113
|
+
export function freezeDifferences(manifest, frozen, current = toolIdentity()) {
|
|
114
|
+
const observed = {
|
|
115
|
+
schemaVersion: 1,
|
|
116
|
+
kind: "otc-freeze",
|
|
117
|
+
campaignId: manifest.campaignId,
|
|
118
|
+
suiteId: manifest.suiteId,
|
|
119
|
+
manifestSha256: manifestSha256(manifest),
|
|
120
|
+
campaignSha256: manifest.campaignSha256,
|
|
121
|
+
suiteSha256: manifest.suiteSha256,
|
|
122
|
+
scheduleSha256: manifest.scheduleSha256,
|
|
123
|
+
subjectsSha256: manifest.subjectsSha256,
|
|
124
|
+
workspacesSha256: manifest.workspacesSha256,
|
|
125
|
+
promptsSha256: manifest.promptsSha256,
|
|
126
|
+
primaryChecksSha256: manifest.primaryChecksSha256,
|
|
127
|
+
tool: current,
|
|
128
|
+
image: manifest.campaign.runtime.image,
|
|
129
|
+
imageId: manifest.campaign.runtime.imageId ?? frozen.imageId,
|
|
130
|
+
opencodeVersion: manifest.campaign.runtime.opencodeVersion,
|
|
131
|
+
provider: {
|
|
132
|
+
route: manifest.campaign.runtime.provider.route,
|
|
133
|
+
model: manifest.campaign.runtime.provider.model,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const differences = [];
|
|
137
|
+
for (const field of Object.keys(observed)) {
|
|
138
|
+
const same = field === "imageId"
|
|
139
|
+
? String(observed[field]).replace(/^sha256:/, "") ===
|
|
140
|
+
String(frozen[field]).replace(/^sha256:/, "")
|
|
141
|
+
: typeof observed[field] === "object"
|
|
142
|
+
? sha256Of(observed[field]) === sha256Of(frozen[field])
|
|
143
|
+
: observed[field] === frozen[field];
|
|
144
|
+
if (!same) {
|
|
145
|
+
differences.push(`frozen ${field} no longer holds`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return differences;
|
|
149
|
+
}
|
|
150
|
+
/** Check that prepared files and the stored campaign copy are intact. */
|
|
151
|
+
export function inputDifferences(root, manifest) {
|
|
152
|
+
const problems = preparedDifferences(root, manifest);
|
|
153
|
+
const campaign = join(root, "prepare", "campaign.json");
|
|
154
|
+
if (!existsSync(campaign)) {
|
|
155
|
+
problems.push("missing prepare/campaign.json");
|
|
156
|
+
}
|
|
157
|
+
else if (sha256Bytes(readFileSync(campaign)) !== manifest.campaignSha256) {
|
|
158
|
+
problems.push("changed prepare/campaign.json");
|
|
159
|
+
}
|
|
160
|
+
return problems;
|
|
161
|
+
}
|
|
162
|
+
/** The state of an existing freeze receipt, if one exists. */
|
|
163
|
+
function existingFreeze(root, manifest, current) {
|
|
164
|
+
if (!existsSync(frozenPath(root)))
|
|
165
|
+
return { kind: "none" };
|
|
166
|
+
const existing = loadFrozen(root);
|
|
167
|
+
if (!existing.ok) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "refuse",
|
|
170
|
+
problems: [existing.problem],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const drift = freezeDifferences(manifest, existing.record, current);
|
|
174
|
+
return drift.length === 0
|
|
175
|
+
? { kind: "ok", record: existing.record }
|
|
176
|
+
: {
|
|
177
|
+
kind: "refuse",
|
|
178
|
+
problems: [
|
|
179
|
+
"already frozen with different inputs; use a fresh root",
|
|
180
|
+
...drift,
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Freeze a prepared campaign: resolve the image to its immutable identity,
|
|
186
|
+
* verify the CLI version it contains, and record all input identities.
|
|
187
|
+
* @param root The prepared campaign root.
|
|
188
|
+
* @param run Process adapter, injected for tests.
|
|
189
|
+
* @returns The receipt, or every refusal.
|
|
190
|
+
*/
|
|
191
|
+
export async function freezeCampaign(root, run = runCommand) {
|
|
192
|
+
const loaded = loadPreparedManifest(root);
|
|
193
|
+
if (!loaded.ok)
|
|
194
|
+
return { ok: false, problems: [loaded.problem] };
|
|
195
|
+
const manifest = loaded.manifest;
|
|
196
|
+
const differences = inputDifferences(root, manifest);
|
|
197
|
+
if (differences.length > 0) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
problems: [
|
|
201
|
+
"prepared payload does not match its manifest",
|
|
202
|
+
...differences,
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const current = toolIdentity();
|
|
207
|
+
if (manifest.tool === undefined) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
problems: [
|
|
211
|
+
"prepared manifest has no tool identity; prepare a fresh root",
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const toolDrift = toolIdentityProblems(manifest.tool, current);
|
|
216
|
+
if (toolDrift.length > 0) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
problems: [
|
|
220
|
+
"prepared payload was produced by a different executing tool",
|
|
221
|
+
...toolDrift,
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const existing = existingFreeze(root, manifest, current);
|
|
226
|
+
if (existing.kind === "ok") {
|
|
227
|
+
return { ok: true, record: existing.record, alreadyFrozen: true };
|
|
228
|
+
}
|
|
229
|
+
if (existing.kind === "refuse") {
|
|
230
|
+
return { ok: false, problems: existing.problems };
|
|
231
|
+
}
|
|
232
|
+
const resolved = await resolveImage(manifest.campaign.runtime.imageId ?? manifest.campaign.runtime.image, manifest.campaign.runtime.opencodeVersion, run);
|
|
233
|
+
if (!resolved.ok)
|
|
234
|
+
return { ok: false, problems: [resolved.problem] };
|
|
235
|
+
const image = resolved.image;
|
|
236
|
+
const identity = {
|
|
237
|
+
schemaVersion: 1,
|
|
238
|
+
kind: "otc-freeze",
|
|
239
|
+
campaignId: manifest.campaignId,
|
|
240
|
+
suiteId: manifest.suiteId,
|
|
241
|
+
manifestSha256: manifestSha256(manifest),
|
|
242
|
+
campaignSha256: manifest.campaignSha256,
|
|
243
|
+
suiteSha256: manifest.suiteSha256,
|
|
244
|
+
scheduleSha256: manifest.scheduleSha256,
|
|
245
|
+
subjectsSha256: manifest.subjectsSha256,
|
|
246
|
+
workspacesSha256: manifest.workspacesSha256,
|
|
247
|
+
promptsSha256: manifest.promptsSha256,
|
|
248
|
+
primaryChecksSha256: manifest.primaryChecksSha256,
|
|
249
|
+
tool: current,
|
|
250
|
+
image: manifest.campaign.runtime.image,
|
|
251
|
+
imageId: image.imageId,
|
|
252
|
+
opencodeVersion: image.opencodeVersion,
|
|
253
|
+
provider: {
|
|
254
|
+
route: manifest.campaign.runtime.provider.route,
|
|
255
|
+
model: manifest.campaign.runtime.provider.model,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
const record = {
|
|
259
|
+
...identity,
|
|
260
|
+
freezeSha256: freezeIdentity(identity),
|
|
261
|
+
frozenAt: new Date().toISOString(),
|
|
262
|
+
};
|
|
263
|
+
writeFileSync(frozenPath(root), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
264
|
+
return { ok: true, record, alreadyFrozen: false };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The read-only delivery check: is the frozen payload complete and unchanged?
|
|
268
|
+
* @param root The campaign root.
|
|
269
|
+
* @returns Differences; empty means delivery is intact.
|
|
270
|
+
*/
|
|
271
|
+
export function deliveryCheck(root, current = toolIdentity()) {
|
|
272
|
+
const frozen = loadFrozen(root);
|
|
273
|
+
const prepared = loadPreparedManifest(root);
|
|
274
|
+
if (!frozen.ok)
|
|
275
|
+
return [frozen.problem];
|
|
276
|
+
if (!prepared.ok)
|
|
277
|
+
return [prepared.problem];
|
|
278
|
+
const problems = inputDifferences(root, prepared.manifest);
|
|
279
|
+
if (manifestSha256(prepared.manifest) !== frozen.record.manifestSha256) {
|
|
280
|
+
problems.push("prepared manifest does not match the freeze");
|
|
281
|
+
}
|
|
282
|
+
for (const difference of freezeDifferences(prepared.manifest, frozen.record, current)) {
|
|
283
|
+
problems.push(difference);
|
|
284
|
+
}
|
|
285
|
+
problems.push(...toolIdentityProblems(frozen.record.tool, current));
|
|
286
|
+
for (const arm of prepared.manifest.subjects) {
|
|
287
|
+
if (arm.plugins.length > 0) {
|
|
288
|
+
for (const plugin of arm.plugins) {
|
|
289
|
+
const packageJson = join(root, "prepare", plugin.path, "package.json");
|
|
290
|
+
if (!existsSync(packageJson)) {
|
|
291
|
+
problems.push(`arm ${arm.armId}: plugin ${plugin.id} has no package.json`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return problems;
|
|
297
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** One file identity in an immutable inventory. */
|
|
2
|
+
export interface InventoryEntry {
|
|
3
|
+
/** The path value. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** The sha256 value. */
|
|
6
|
+
sha256: string;
|
|
7
|
+
/** Present only for a contained symlink, recording its relative target. */
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A deterministic inventory of a directory tree. */
|
|
11
|
+
export type Inventory = InventoryEntry[];
|
|
12
|
+
/** Whether a path is inside a root after resolution. */
|
|
13
|
+
export declare function isContained(root: string, path: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve an existing path and prove it stays inside an allowed root.
|
|
16
|
+
* @param root The allowed root; it must exist.
|
|
17
|
+
* @param candidate The path to resolve.
|
|
18
|
+
* @returns The real path, or a refusal.
|
|
19
|
+
*/
|
|
20
|
+
export declare function containedRealPath(root: string, candidate: string): {
|
|
21
|
+
ok: true;
|
|
22
|
+
path: string;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
problem: string;
|
|
26
|
+
};
|
|
27
|
+
/** The SHA-256 of one file's exact bytes. */
|
|
28
|
+
export declare function fileSha256(path: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Inventory a directory deterministically. Symlinks are followed only when
|
|
31
|
+
* their resolved target stays inside the root; anything else is a refusal.
|
|
32
|
+
* @param root The directory to inventory.
|
|
33
|
+
* @returns The sorted inventory or the first unsafe path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function inventory(root: string): {
|
|
36
|
+
ok: true;
|
|
37
|
+
entries: Inventory;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
problem: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Recheck an inventory against a directory.
|
|
44
|
+
* @param root The directory to check.
|
|
45
|
+
* @param expected The inventory that must still hold.
|
|
46
|
+
* @returns The differences; empty means intact.
|
|
47
|
+
*/
|
|
48
|
+
export declare function inventoryDifferences(root: string, expected: Inventory): string[];
|
|
49
|
+
/**
|
|
50
|
+
* Copy a source tree into a destination that must not already exist.
|
|
51
|
+
* Symlinks are checked for containment and preserved as relative links.
|
|
52
|
+
* @param source The source directory.
|
|
53
|
+
* @param destination The destination directory.
|
|
54
|
+
* @param include Optional top-level members to copy; all when omitted.
|
|
55
|
+
* @returns Nothing, or the refusal that stopped the copy.
|
|
56
|
+
*/
|
|
57
|
+
export declare function stageTree(source: string, destination: string, include?: readonly string[]): {
|
|
58
|
+
ok: true;
|
|
59
|
+
} | {
|
|
60
|
+
ok: false;
|
|
61
|
+
problem: string;
|
|
62
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, symlinkSync, copyFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
/** Whether a path is inside a root after resolution. */
|
|
5
|
+
export function isContained(root, path) {
|
|
6
|
+
const rel = relative(root, path);
|
|
7
|
+
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(`..${sep}`));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolve an existing path and prove it stays inside an allowed root.
|
|
11
|
+
* @param root The allowed root; it must exist.
|
|
12
|
+
* @param candidate The path to resolve.
|
|
13
|
+
* @returns The real path, or a refusal.
|
|
14
|
+
*/
|
|
15
|
+
export function containedRealPath(root, candidate) {
|
|
16
|
+
if (!existsSync(candidate)) {
|
|
17
|
+
return { ok: false, problem: `${candidate} does not exist` };
|
|
18
|
+
}
|
|
19
|
+
let realRoot;
|
|
20
|
+
let realCandidate;
|
|
21
|
+
try {
|
|
22
|
+
realRoot = realpathSync(root);
|
|
23
|
+
realCandidate = realpathSync(candidate);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
problem: `${candidate}: ${error instanceof Error ? error.message : String(error)}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!isContained(realRoot, realCandidate)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
problem: `${candidate} resolves outside ${root}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, path: realCandidate };
|
|
38
|
+
}
|
|
39
|
+
/** The SHA-256 of one file's exact bytes. */
|
|
40
|
+
export function fileSha256(path) {
|
|
41
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Inventory a directory deterministically. Symlinks are followed only when
|
|
45
|
+
* their resolved target stays inside the root; anything else is a refusal.
|
|
46
|
+
* @param root The directory to inventory.
|
|
47
|
+
* @returns The sorted inventory or the first unsafe path.
|
|
48
|
+
*/
|
|
49
|
+
export function inventory(root) {
|
|
50
|
+
if (!existsSync(root)) {
|
|
51
|
+
return { ok: false, problem: `${root} does not exist` };
|
|
52
|
+
}
|
|
53
|
+
const realRoot = realpathSync(root);
|
|
54
|
+
const entries = [];
|
|
55
|
+
const walk = (directory) => {
|
|
56
|
+
for (const entry of readdirSync(directory, {
|
|
57
|
+
withFileTypes: true,
|
|
58
|
+
}).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
59
|
+
const path = join(directory, entry.name);
|
|
60
|
+
const stat = lstatSync(path);
|
|
61
|
+
if (stat.isSymbolicLink()) {
|
|
62
|
+
const target = readlinkSync(path);
|
|
63
|
+
const resolved = resolve(dirname(path), target);
|
|
64
|
+
if (!isContained(realRoot, resolved)) {
|
|
65
|
+
return `${path} symlink escapes ${root}`;
|
|
66
|
+
}
|
|
67
|
+
const real = realpathSync(path);
|
|
68
|
+
if (!isContained(realRoot, real)) {
|
|
69
|
+
return `${path} resolves outside ${root}`;
|
|
70
|
+
}
|
|
71
|
+
entries.push({
|
|
72
|
+
path: relative(realRoot, path).split(sep).join("/"),
|
|
73
|
+
sha256: fileSha256(real),
|
|
74
|
+
link: target,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (stat.isDirectory()) {
|
|
79
|
+
const problem = walk(path);
|
|
80
|
+
if (problem !== null)
|
|
81
|
+
return problem;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!stat.isFile()) {
|
|
85
|
+
return `${path} is not a regular file`;
|
|
86
|
+
}
|
|
87
|
+
entries.push({
|
|
88
|
+
path: relative(realRoot, path).split(sep).join("/"),
|
|
89
|
+
sha256: fileSha256(path),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
const problem = walk(realRoot);
|
|
95
|
+
if (problem !== null)
|
|
96
|
+
return { ok: false, problem };
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
entries: entries.sort((left, right) => left.path.localeCompare(right.path)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Recheck an inventory against a directory.
|
|
104
|
+
* @param root The directory to check.
|
|
105
|
+
* @param expected The inventory that must still hold.
|
|
106
|
+
* @returns The differences; empty means intact.
|
|
107
|
+
*/
|
|
108
|
+
export function inventoryDifferences(root, expected) {
|
|
109
|
+
const observed = inventory(root);
|
|
110
|
+
if (!observed.ok)
|
|
111
|
+
return [observed.problem];
|
|
112
|
+
const expectedMap = new Map(expected.map((entry) => [entry.path, entry]));
|
|
113
|
+
const observedMap = new Map(observed.entries.map((entry) => [entry.path, entry]));
|
|
114
|
+
const problems = [];
|
|
115
|
+
for (const [path, entry] of expectedMap) {
|
|
116
|
+
const found = observedMap.get(path);
|
|
117
|
+
if (found === undefined)
|
|
118
|
+
problems.push(`missing ${path}`);
|
|
119
|
+
else if (found.sha256 !== entry.sha256) {
|
|
120
|
+
problems.push(`changed ${path}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const path of observedMap.keys()) {
|
|
124
|
+
if (!expectedMap.has(path))
|
|
125
|
+
problems.push(`added ${path}`);
|
|
126
|
+
}
|
|
127
|
+
return problems;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Copy a source tree into a destination that must not already exist.
|
|
131
|
+
* Symlinks are checked for containment and preserved as relative links.
|
|
132
|
+
* @param source The source directory.
|
|
133
|
+
* @param destination The destination directory.
|
|
134
|
+
* @param include Optional top-level members to copy; all when omitted.
|
|
135
|
+
* @returns Nothing, or the refusal that stopped the copy.
|
|
136
|
+
*/
|
|
137
|
+
export function stageTree(source, destination, include) {
|
|
138
|
+
const contained = containedRealPath(source, source);
|
|
139
|
+
if (!contained.ok)
|
|
140
|
+
return contained;
|
|
141
|
+
const realSource = contained.path;
|
|
142
|
+
if (existsSync(destination)) {
|
|
143
|
+
return { ok: false, problem: `${destination} already exists` };
|
|
144
|
+
}
|
|
145
|
+
mkdirSync(destination, { recursive: true });
|
|
146
|
+
const copied = new Set();
|
|
147
|
+
const copy = (from, to) => {
|
|
148
|
+
const stat = lstatSync(from);
|
|
149
|
+
if (stat.isSymbolicLink()) {
|
|
150
|
+
const target = readlinkSync(from);
|
|
151
|
+
const resolved = resolve(dirname(from), target);
|
|
152
|
+
if (!isContained(realSource, resolved)) {
|
|
153
|
+
return `${from} symlink escapes ${realSource}`;
|
|
154
|
+
}
|
|
155
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
156
|
+
symlinkSync(target, to);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (stat.isDirectory()) {
|
|
160
|
+
mkdirSync(to, { recursive: true });
|
|
161
|
+
for (const entry of readdirSync(from, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
162
|
+
const problem = copy(join(from, entry.name), join(to, entry.name));
|
|
163
|
+
if (problem !== null)
|
|
164
|
+
return problem;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (!stat.isFile())
|
|
169
|
+
return `${from} is not a regular file`;
|
|
170
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
171
|
+
copyFileSync(from, to);
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
const members = include === undefined
|
|
175
|
+
? readdirSync(realSource, { withFileTypes: true })
|
|
176
|
+
.map((entry) => entry.name)
|
|
177
|
+
.sort()
|
|
178
|
+
: [...include].sort();
|
|
179
|
+
for (const member of members) {
|
|
180
|
+
if (!copied.add(member)) {
|
|
181
|
+
return { ok: false, problem: `duplicate include ${member}` };
|
|
182
|
+
}
|
|
183
|
+
const from = join(realSource, member);
|
|
184
|
+
if (!existsSync(from)) {
|
|
185
|
+
return { ok: false, problem: `${from} does not exist` };
|
|
186
|
+
}
|
|
187
|
+
const problem = copy(from, join(destination, member));
|
|
188
|
+
if (problem !== null)
|
|
189
|
+
return { ok: false, problem };
|
|
190
|
+
}
|
|
191
|
+
return { ok: true };
|
|
192
|
+
}
|