skill-family-engineering-kit 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 +21 -0
- package/CHANGELOG.zh-CN.md +21 -0
- package/README.md +11 -12
- package/README.zh-CN.md +11 -12
- package/candidate/adoption-cli.mjs +67 -0
- package/candidate/adoption-mechanisms.mjs +69 -0
- package/candidate/index.mjs +3 -0
- package/candidate/profile-bundle.mjs +464 -15
- package/candidate/projection-bundle-cli.mjs +18 -0
- package/docs/agents/capability-catalog.en.json +1746 -1562
- package/docs/agents/capability-catalog.json +1029 -930
- package/docs/agents/capability-catalog.zh-CN.json +1746 -1562
- package/docs/architecture/index.html +10 -3
- package/docs/en/architecture/index.html +12 -3
- package/docs/public/status/index.html +3 -3
- package/docs/reference/api/harness/index.html +396 -1
- package/docs/search/search_index.json +1 -1
- package/package.json +3 -3
- package/release-notes/0.4.0.yaml +23 -0
- package/src/adopt-plan.mjs +26 -1
- package/src/index.mjs +5 -2
- package/src/migration.mjs +121 -1
- package/src/projection.mjs +1152 -266
package/src/projection.mjs
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
lstat,
|
|
4
|
+
open,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
realpath,
|
|
8
|
+
rename,
|
|
9
|
+
rmdir,
|
|
10
|
+
stat,
|
|
11
|
+
unlink,
|
|
12
|
+
} from "node:fs/promises";
|
|
2
13
|
import path from "node:path";
|
|
14
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
15
|
import { ContractsError } from "skill-family-contracts";
|
|
4
16
|
import {
|
|
5
17
|
classifyPathInput,
|
|
@@ -19,71 +31,545 @@ import {
|
|
|
19
31
|
} from "./workspace.mjs";
|
|
20
32
|
|
|
21
33
|
/**
|
|
22
|
-
* projection —
|
|
23
|
-
*
|
|
24
|
-
* The projection manifest is the authorization document. Every entry is
|
|
25
|
-
* checked before ANY write happens (two-phase execution):
|
|
34
|
+
* projection — apply one explicitly owned set of managed writes and deletes.
|
|
26
35
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* lock). A target without any managed declaration authorizes nothing;
|
|
35
|
-
* 5. handwritten protection: a path matching handwritten patterns is never
|
|
36
|
-
* written, even if some declaration also claims it as managed;
|
|
37
|
-
* 6. conflict guard: existing files are overwritten only when the entry
|
|
38
|
-
* declares the exact prior sha256; identical existing bytes are an
|
|
39
|
-
* idempotent no-op; anything else is a conflict.
|
|
40
|
-
*
|
|
41
|
-
* If any entry fails a check, the whole projection is refused and nothing
|
|
42
|
-
* is written. A failure during or after the write phase rolls everything
|
|
43
|
-
* back: overwritten files are restored from their in-memory prior bytes
|
|
44
|
-
* and every file created by this run is removed, so a failed projection
|
|
45
|
-
* never leaves the target half-updated (FC-17).
|
|
36
|
+
* Version 1 remains the target-local, write-only contract. Version 2 accepts
|
|
37
|
+
* an in-memory manifest or an external manifest plus a complete external
|
|
38
|
+
* candidate tree. Version 2 validates the entire candidate and every target
|
|
39
|
+
* expectation before mutation, rechecks each expectation immediately before
|
|
40
|
+
* its mutation, and restores the complete authorized target closure on any
|
|
41
|
+
* failure. A rollback failure is reported as a fatal projection failure and
|
|
42
|
+
* never as a zero-write result.
|
|
46
43
|
*/
|
|
47
44
|
|
|
48
45
|
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
46
|
+
const V2_KIND = "skill-family.projection-manifest";
|
|
47
|
+
const OWNER_KIND = "managed";
|
|
48
|
+
const TARGET_FACTS_CONTROL_PATHS = new Set([
|
|
49
|
+
".foundation/file-registry.json",
|
|
50
|
+
"skill-family.project-manifest.json",
|
|
51
|
+
"skill-family.managed-file-lock.json",
|
|
52
|
+
]);
|
|
49
53
|
|
|
50
54
|
function invalidManifest(message, extraDetails) {
|
|
51
55
|
return kitError(KIT_ERROR_KINDS.INVALID_MANIFEST, message, extraDetails);
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
58
|
+
function canonicalDigest(value) {
|
|
59
|
+
return digestBytes(Buffer.from(JSON.stringify(value), "utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function modeOf(st) {
|
|
63
|
+
return st.mode & 0o7777;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function portableKey(rel) {
|
|
67
|
+
return rel
|
|
68
|
+
.split("/")
|
|
69
|
+
.map((part) => part.normalize("NFKC").replace(/[ .]+$/u, "").toLocaleLowerCase("en-US"))
|
|
70
|
+
.join("/");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertNoPortableCollisions(paths, label) {
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
for (const rel of paths) {
|
|
76
|
+
const key = portableKey(rel);
|
|
77
|
+
if (seen.has(key) && seen.get(key) !== rel) {
|
|
78
|
+
throw invalidManifest(`${label} contains a portable path collision`, {
|
|
79
|
+
first: seen.get(key),
|
|
80
|
+
second: rel,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
seen.set(key, rel);
|
|
66
84
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
? `projection manifest not found: ${manifestPath}`
|
|
73
|
-
: `projection manifest is not valid JSON: ${manifestPath}`,
|
|
74
|
-
{ manifest: manifestPath },
|
|
75
|
-
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateV2Expect(expect, label, { deleteOperation = false } = {}) {
|
|
88
|
+
if (!expect || typeof expect !== "object" || Array.isArray(expect)) {
|
|
89
|
+
throw invalidManifest(`${label}.expect must be an object`);
|
|
76
90
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
throw invalidManifest("projection manifest must be a JSON object");
|
|
91
|
+
if (!new Set(["absent", "sha256"]).has(expect.state)) {
|
|
92
|
+
throw invalidManifest(`${label}.expect.state must be one of: absent, sha256`);
|
|
80
93
|
}
|
|
81
|
-
if (
|
|
82
|
-
throw invalidManifest(
|
|
94
|
+
if (expect.state === "sha256" && !SHA256_HEX_PATTERN.test(expect.value ?? "")) {
|
|
95
|
+
throw invalidManifest(`${label}.expect.value must be a lowercase sha256 hex digest`);
|
|
96
|
+
}
|
|
97
|
+
if (expect.mode !== undefined
|
|
98
|
+
&& (expect.state !== "sha256" || !Number.isInteger(expect.mode) || expect.mode < 0 || expect.mode > 0o7777)) {
|
|
99
|
+
throw invalidManifest(`${label}.expect.mode must be an integer permission mode for sha256 state`);
|
|
100
|
+
}
|
|
101
|
+
if (expect.state === "absent" && expect.value !== undefined) {
|
|
102
|
+
throw invalidManifest(`${label}.expect.value is forbidden for absent state`);
|
|
103
|
+
}
|
|
104
|
+
if (deleteOperation && expect.state !== "sha256") {
|
|
105
|
+
throw invalidManifest(`${label} delete requires an exact expect.sha256`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateOwner(owner, label) {
|
|
110
|
+
if (!owner || typeof owner !== "object" || Array.isArray(owner)) {
|
|
111
|
+
throw invalidManifest(`${label}.owner must explicitly declare managed ownership`);
|
|
112
|
+
}
|
|
113
|
+
if (owner.kind !== OWNER_KIND || typeof owner.id !== "string" || owner.id.trim() === "") {
|
|
114
|
+
throw invalidManifest(`${label}.owner must contain kind=managed and a non-empty id`);
|
|
115
|
+
}
|
|
116
|
+
const keys = Object.keys(owner).sort();
|
|
117
|
+
if (keys.join(",") !== "id,kind") {
|
|
118
|
+
throw invalidManifest(`${label}.owner fields must be exactly id and kind`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function projectionPlanInputError(message, details) {
|
|
123
|
+
throw invalidManifest(`projection plan input invalid: ${message}`, details);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validatePlanPath(rawPath, label) {
|
|
127
|
+
if (typeof rawPath !== "string" || rawPath.length === 0) {
|
|
128
|
+
projectionPlanInputError(`${label} must be a non-empty path`);
|
|
129
|
+
}
|
|
130
|
+
const classification = classifyPathInput(rawPath);
|
|
131
|
+
const normalized = normalizeRelPath(rawPath);
|
|
132
|
+
const segments = normalized.split("/");
|
|
133
|
+
if (!classification.ok || segments.includes("..") || segments.includes(".") || normalized === "") {
|
|
134
|
+
projectionPlanInputError(`${label} must be a contained portable path`, { path: rawPath, kind: classification.kind });
|
|
135
|
+
}
|
|
136
|
+
return normalized;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validatePlanMode(mode, label) {
|
|
140
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) {
|
|
141
|
+
projectionPlanInputError(`${label} must be an integer permission mode`);
|
|
142
|
+
}
|
|
143
|
+
return mode;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeRootBinding(rootBinding) {
|
|
147
|
+
if (typeof rootBinding !== "string" || !path.isAbsolute(rootBinding) || rootBinding.includes("\0")) {
|
|
148
|
+
projectionPlanInputError("rootBinding must be a canonical absolute realpath");
|
|
149
|
+
}
|
|
150
|
+
const normalized = path.normalize(rootBinding);
|
|
151
|
+
if (normalized !== rootBinding) {
|
|
152
|
+
projectionPlanInputError("rootBinding must already be normalized", { rootBinding, normalized });
|
|
153
|
+
}
|
|
154
|
+
return rootBinding;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizePlanClosure(rawClosure, label) {
|
|
158
|
+
if (!rawClosure || typeof rawClosure !== "object" || Array.isArray(rawClosure)) {
|
|
159
|
+
projectionPlanInputError(`${label} must be an object`);
|
|
160
|
+
}
|
|
161
|
+
if (rawClosure.digestAlgorithm !== "sha256" || !SHA256_HEX_PATTERN.test(rawClosure.digest ?? "")) {
|
|
162
|
+
projectionPlanInputError(`${label} must carry a lowercase sha256 closure digest`);
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(rawClosure.resources)) {
|
|
165
|
+
projectionPlanInputError(`${label}.resources must be an array`);
|
|
166
|
+
}
|
|
167
|
+
const resources = rawClosure.resources.map((resource, index) => {
|
|
168
|
+
const resourceLabel = `${label}.resources[${index}]`;
|
|
169
|
+
if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
|
|
170
|
+
projectionPlanInputError(`${resourceLabel} must be an object`);
|
|
171
|
+
}
|
|
172
|
+
const rel = validatePlanPath(resource.path, `${resourceLabel}.path`);
|
|
173
|
+
if (resource.type === "symlink") {
|
|
174
|
+
projectionPlanInputError(`${resourceLabel} may not represent a symlink`, { path: rel });
|
|
175
|
+
}
|
|
176
|
+
if (resource.type !== "file") {
|
|
177
|
+
projectionPlanInputError(`${resourceLabel}.type must be file`, { path: rel, type: resource.type ?? null });
|
|
178
|
+
}
|
|
179
|
+
if (!SHA256_HEX_PATTERN.test(resource.sha256 ?? "")) {
|
|
180
|
+
projectionPlanInputError(`${resourceLabel}.sha256 must be a lowercase sha256 hex digest`, { path: rel });
|
|
181
|
+
}
|
|
182
|
+
return { path: rel, type: "file", sha256: resource.sha256, mode: validatePlanMode(resource.mode, `${resourceLabel}.mode`) };
|
|
183
|
+
}).sort((a, b) => a.path.localeCompare(b.path));
|
|
184
|
+
const paths = resources.map((resource) => resource.path);
|
|
185
|
+
if (new Set(paths).size !== paths.length) projectionPlanInputError(`${label} contains a duplicate path`);
|
|
186
|
+
assertNoPortableCollisions(paths, label);
|
|
187
|
+
const digest = canonicalDigest(resources);
|
|
188
|
+
if (digest !== rawClosure.digest) {
|
|
189
|
+
projectionPlanInputError(`${label} digest does not match its normalized resources`, { expected: rawClosure.digest, actual: digest });
|
|
190
|
+
}
|
|
191
|
+
return { digestAlgorithm: "sha256", digest, resourceCount: resources.length, resources };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeAuthoritySources(rawSources, candidatePaths) {
|
|
195
|
+
if (!Array.isArray(rawSources) || rawSources.length === 0) {
|
|
196
|
+
projectionPlanInputError("authoritySources must be a non-empty array");
|
|
197
|
+
}
|
|
198
|
+
const ids = new Set();
|
|
199
|
+
const paths = new Set();
|
|
200
|
+
const sources = rawSources.map((source, index) => {
|
|
201
|
+
const label = `authoritySources[${index}]`;
|
|
202
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) projectionPlanInputError(`${label} must be an object`);
|
|
203
|
+
if (typeof source.id !== "string" || source.id.trim() === "" || ids.has(source.id)) {
|
|
204
|
+
projectionPlanInputError(`${label}.id must be unique and non-empty`);
|
|
205
|
+
}
|
|
206
|
+
const rel = validatePlanPath(source.path, `${label}.path`);
|
|
207
|
+
if (paths.has(rel)) projectionPlanInputError(`authoritySources contain a duplicate path`, { path: rel });
|
|
208
|
+
if (!SHA256_HEX_PATTERN.test(source.sha256 ?? "")) projectionPlanInputError(`${label}.sha256 must be a lowercase sha256 hex digest`);
|
|
209
|
+
const type = source.type ?? "file";
|
|
210
|
+
if (type !== "file") projectionPlanInputError(`${label}.type must be file`);
|
|
211
|
+
const mode = validatePlanMode(source.mode ?? 0o644, `${label}.mode`);
|
|
212
|
+
if (candidatePaths.has(rel)) {
|
|
213
|
+
projectionPlanInputError("an authority source may not come from the same external candidate closure", { path: rel });
|
|
214
|
+
}
|
|
215
|
+
ids.add(source.id);
|
|
216
|
+
paths.add(rel);
|
|
217
|
+
return { id: source.id, path: rel, type, sha256: source.sha256, mode };
|
|
218
|
+
}).sort((a, b) => a.id.localeCompare(b.id));
|
|
219
|
+
assertNoPortableCollisions([...paths], "authoritySources");
|
|
220
|
+
return { sources, ids, paths, digestAlgorithm: "sha256", digest: canonicalDigest(sources) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Purely compiles frozen consumer authority into target-fact and projection
|
|
225
|
+
* descriptions. It performs no filesystem access. runProjection reloads the
|
|
226
|
+
* target-owned facts by default, or accepts this complete canonical result
|
|
227
|
+
* after independently binding it to the live target and candidate closures.
|
|
228
|
+
*/
|
|
229
|
+
export function compileProjectionPlan({
|
|
230
|
+
rootBinding,
|
|
231
|
+
authoritySources,
|
|
232
|
+
ownership,
|
|
233
|
+
handwrittenPolicy,
|
|
234
|
+
previousOwnedClosure,
|
|
235
|
+
externalCandidateClosure,
|
|
236
|
+
} = {}) {
|
|
237
|
+
const canonicalRootBinding = normalizeRootBinding(rootBinding);
|
|
238
|
+
const previous = normalizePlanClosure(previousOwnedClosure, "previousOwnedClosure");
|
|
239
|
+
const candidate = normalizePlanClosure(externalCandidateClosure, "externalCandidateClosure");
|
|
240
|
+
const previousByPath = new Map(previous.resources.map((resource) => [resource.path, resource]));
|
|
241
|
+
const candidateByPath = new Map(candidate.resources.map((resource) => [resource.path, resource]));
|
|
242
|
+
const authority = normalizeAuthoritySources(authoritySources, new Set(candidateByPath.keys()));
|
|
243
|
+
|
|
244
|
+
if (!handwrittenPolicy || typeof handwrittenPolicy !== "object" || Array.isArray(handwrittenPolicy)) {
|
|
245
|
+
projectionPlanInputError("handwrittenPolicy must be an object");
|
|
246
|
+
}
|
|
247
|
+
if (!authority.ids.has(handwrittenPolicy.authoritySource)) {
|
|
248
|
+
projectionPlanInputError("handwrittenPolicy must bind an existing authority source");
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(handwrittenPolicy.patterns)) projectionPlanInputError("handwrittenPolicy.patterns must be an array");
|
|
251
|
+
const handwrittenPatterns = [...new Set(handwrittenPolicy.patterns.map((pattern, index) => {
|
|
252
|
+
if (typeof pattern !== "string" || pattern.trim() === "") {
|
|
253
|
+
projectionPlanInputError(`handwrittenPolicy.patterns[${index}] must be a non-empty string`);
|
|
254
|
+
}
|
|
255
|
+
return pattern;
|
|
256
|
+
}))].sort();
|
|
257
|
+
|
|
258
|
+
const rawEntries = Array.isArray(ownership) ? ownership : ownership?.entries;
|
|
259
|
+
if (!Array.isArray(rawEntries) || rawEntries.length === 0) projectionPlanInputError("ownership entries must be a non-empty array");
|
|
260
|
+
const targetPaths = new Set();
|
|
261
|
+
const usedSources = new Set();
|
|
262
|
+
const entries = rawEntries.map((entry, index) => {
|
|
263
|
+
const label = `ownership[${index}]`;
|
|
264
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) projectionPlanInputError(`${label} must be an object`);
|
|
265
|
+
const rel = validatePlanPath(entry.path, `${label}.path`);
|
|
266
|
+
if (targetPaths.has(rel)) projectionPlanInputError("ownership contains a duplicate target path", { path: rel });
|
|
267
|
+
if (authority.paths.has(rel) && candidateByPath.has(rel)) {
|
|
268
|
+
projectionPlanInputError("a same-batch authority target cannot authorize itself", { path: rel });
|
|
269
|
+
}
|
|
270
|
+
if (!authority.ids.has(entry.authoritySource)) projectionPlanInputError(`${label}.authoritySource is unknown`, { path: rel });
|
|
271
|
+
try { validateOwner(entry.owner, label); } catch (cause) { projectionPlanInputError(cause.message, { path: rel }); }
|
|
272
|
+
if (matchAnyGlob(handwrittenPatterns, rel)) projectionPlanInputError("ownership conflicts with handwritten policy", { path: rel });
|
|
273
|
+
const source = entry.source === undefined ? rel : validatePlanPath(entry.source, `${label}.source`);
|
|
274
|
+
const candidateResource = candidateByPath.get(source);
|
|
275
|
+
const previousResource = previousByPath.get(rel);
|
|
276
|
+
if (!candidateResource && !previousResource) projectionPlanInputError("ownership names an unknown path", { path: rel, source });
|
|
277
|
+
if (candidateResource) {
|
|
278
|
+
if (usedSources.has(source)) projectionPlanInputError("an external candidate source has conflicting ownership", { source });
|
|
279
|
+
usedSources.add(source);
|
|
280
|
+
} else if (entry.source !== undefined) {
|
|
281
|
+
projectionPlanInputError("a delete-only ownership entry may not declare a candidate source", { path: rel });
|
|
282
|
+
}
|
|
283
|
+
if (entry.expect !== undefined) {
|
|
284
|
+
const expected = entry.expect;
|
|
285
|
+
if (!expected || typeof expected !== "object" || Array.isArray(expected)) {
|
|
286
|
+
projectionPlanInputError(`${label}.expect must be an object`, { path: rel });
|
|
287
|
+
}
|
|
288
|
+
const actual = previousResource
|
|
289
|
+
? { state: "sha256", value: previousResource.sha256, type: previousResource.type, mode: previousResource.mode }
|
|
290
|
+
: { state: "absent" };
|
|
291
|
+
if (expected.state !== actual.state
|
|
292
|
+
|| (actual.state === "sha256" && expected.value !== actual.value)
|
|
293
|
+
|| (expected.type !== undefined && expected.type !== actual.type)
|
|
294
|
+
|| (expected.mode !== undefined && expected.mode !== actual.mode)) {
|
|
295
|
+
projectionPlanInputError("ownership expect, type or mode differs from the previous owned closure", { path: rel, expected, actual });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (entry.candidate !== undefined) {
|
|
299
|
+
const expected = entry.candidate;
|
|
300
|
+
if (!candidateResource
|
|
301
|
+
|| !expected || typeof expected !== "object" || Array.isArray(expected)
|
|
302
|
+
|| (expected.sha256 !== undefined && expected.sha256 !== candidateResource.sha256)
|
|
303
|
+
|| (expected.type !== undefined && expected.type !== candidateResource.type)
|
|
304
|
+
|| (expected.mode !== undefined && expected.mode !== candidateResource.mode)) {
|
|
305
|
+
projectionPlanInputError("ownership candidate digest, type or mode differs from the external candidate closure", {
|
|
306
|
+
path: rel,
|
|
307
|
+
expected,
|
|
308
|
+
actual: candidateResource ?? null,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
targetPaths.add(rel);
|
|
313
|
+
return {
|
|
314
|
+
path: rel,
|
|
315
|
+
source,
|
|
316
|
+
authoritySource: entry.authoritySource,
|
|
317
|
+
owner: { kind: OWNER_KIND, id: entry.owner.id },
|
|
318
|
+
rootOutput: entry.rootOutput === true,
|
|
319
|
+
previousResource,
|
|
320
|
+
candidateResource,
|
|
321
|
+
};
|
|
322
|
+
}).sort((a, b) => a.path.localeCompare(b.path));
|
|
323
|
+
assertNoPortableCollisions([...targetPaths], "ownership targets");
|
|
324
|
+
for (const candidatePath of candidateByPath.keys()) {
|
|
325
|
+
if (!usedSources.has(candidatePath)) projectionPlanInputError("external candidate contains an unowned path", { path: candidatePath });
|
|
326
|
+
}
|
|
327
|
+
for (const previousPath of previousByPath.keys()) {
|
|
328
|
+
if (!targetPaths.has(previousPath)) projectionPlanInputError("previous owned closure contains an unknown path", { path: previousPath });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const operations = entries.map((entry) => {
|
|
332
|
+
const expect = entry.previousResource
|
|
333
|
+
? { state: "sha256", value: entry.previousResource.sha256, mode: entry.previousResource.mode }
|
|
334
|
+
: { state: "absent" };
|
|
335
|
+
if (entry.candidateResource) {
|
|
336
|
+
return {
|
|
337
|
+
operation: "write",
|
|
338
|
+
path: entry.path,
|
|
339
|
+
source: entry.source,
|
|
340
|
+
owner: entry.owner,
|
|
341
|
+
expect,
|
|
342
|
+
type: entry.candidateResource.type,
|
|
343
|
+
mode: entry.candidateResource.mode,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
operation: "delete",
|
|
348
|
+
path: entry.path,
|
|
349
|
+
owner: entry.owner,
|
|
350
|
+
expect,
|
|
351
|
+
type: entry.previousResource.type,
|
|
352
|
+
mode: entry.previousResource.mode,
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
const managedPaths = entries.map((entry) => entry.path);
|
|
356
|
+
const managedLock = {
|
|
357
|
+
entries: entries.map((entry) => ({
|
|
358
|
+
path: entry.path,
|
|
359
|
+
generator: { tool: entry.owner.id },
|
|
360
|
+
authoritySource: entry.authoritySource,
|
|
361
|
+
})),
|
|
362
|
+
};
|
|
363
|
+
const registryProjection = {
|
|
364
|
+
classes: {
|
|
365
|
+
managed: managedPaths,
|
|
366
|
+
handwritten: { entries: handwrittenPatterns },
|
|
367
|
+
},
|
|
368
|
+
authority: { digestAlgorithm: authority.digestAlgorithm, digest: authority.digest },
|
|
369
|
+
};
|
|
370
|
+
const targetFacts = {
|
|
371
|
+
fileRegistry: registryProjection,
|
|
372
|
+
projectManifest: null,
|
|
373
|
+
managedLock,
|
|
374
|
+
managedSet: managedPaths,
|
|
375
|
+
handwrittenPatterns,
|
|
376
|
+
artifactPatterns: [],
|
|
377
|
+
trackedToolLocks: [],
|
|
378
|
+
hasOwnRegistry: true,
|
|
379
|
+
};
|
|
380
|
+
const manifest = validateV2Manifest({
|
|
381
|
+
schemaVersion: 2,
|
|
382
|
+
kind: V2_KIND,
|
|
383
|
+
candidate: { members: candidate.resources.map(({ path: resourcePath, sha256, mode }) => ({ path: resourcePath, sha256, mode })) },
|
|
384
|
+
operations,
|
|
385
|
+
rootOutputs: entries.filter((entry) => entry.rootOutput && entry.candidateResource).map((entry) => entry.path),
|
|
386
|
+
});
|
|
387
|
+
const ownershipBindings = entries.map((entry) => ({
|
|
388
|
+
path: entry.path,
|
|
389
|
+
source: entry.source,
|
|
390
|
+
authoritySource: entry.authoritySource,
|
|
391
|
+
owner: entry.owner,
|
|
392
|
+
rootOutput: entry.rootOutput,
|
|
393
|
+
}));
|
|
394
|
+
const plan = {
|
|
395
|
+
kind: "skill-family.projection-plan",
|
|
396
|
+
schemaVersion: 1,
|
|
397
|
+
rootBinding: canonicalRootBinding,
|
|
398
|
+
authority: {
|
|
399
|
+
sources: authority.sources,
|
|
400
|
+
digestAlgorithm: authority.digestAlgorithm,
|
|
401
|
+
digest: authority.digest,
|
|
402
|
+
handwrittenPolicySource: handwrittenPolicy.authoritySource,
|
|
403
|
+
},
|
|
404
|
+
handwrittenPolicy: {
|
|
405
|
+
authoritySource: handwrittenPolicy.authoritySource,
|
|
406
|
+
patterns: handwrittenPatterns,
|
|
407
|
+
},
|
|
408
|
+
ownership: ownershipBindings,
|
|
409
|
+
targetFacts,
|
|
410
|
+
registryProjection,
|
|
411
|
+
previousOwnedClosure: previous,
|
|
412
|
+
externalCandidateClosure: candidate,
|
|
413
|
+
operations,
|
|
414
|
+
manifest,
|
|
415
|
+
authorizationBoundary: {
|
|
416
|
+
compilerOutputAuthorizesProjection: false,
|
|
417
|
+
runProjectionReloadsTargetFacts: true,
|
|
418
|
+
runProjectionAcceptsCanonicalPreparedProjection: true,
|
|
419
|
+
registryPersistenceAllowed: false,
|
|
420
|
+
sameBatchSelfAuthorizationAllowed: false,
|
|
421
|
+
},
|
|
422
|
+
digestAlgorithm: "sha256",
|
|
423
|
+
};
|
|
424
|
+
return { ...plan, digest: canonicalDigest(plan) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function preparedPlanInput(prepared) {
|
|
428
|
+
if (!prepared || typeof prepared !== "object" || Array.isArray(prepared)) {
|
|
429
|
+
throw invalidManifest("preparedProjection must be a complete compileProjectionPlan result");
|
|
430
|
+
}
|
|
431
|
+
if (prepared.kind !== "skill-family.projection-plan" || prepared.schemaVersion !== 1) {
|
|
432
|
+
throw invalidManifest("preparedProjection kind and schemaVersion must identify a canonical projection plan");
|
|
433
|
+
}
|
|
434
|
+
if (!prepared.authority || !prepared.targetFacts || !prepared.manifest || !Array.isArray(prepared.operations)
|
|
435
|
+
|| !Array.isArray(prepared.ownership) || !prepared.handwrittenPolicy) {
|
|
436
|
+
throw invalidManifest("preparedProjection is incomplete");
|
|
437
|
+
}
|
|
438
|
+
const lockEntries = prepared.targetFacts.managedLock?.entries;
|
|
439
|
+
if (!Array.isArray(lockEntries)) throw invalidManifest("preparedProjection targetFacts.managedLock is incomplete");
|
|
440
|
+
return {
|
|
441
|
+
rootBinding: prepared.rootBinding,
|
|
442
|
+
authoritySources: prepared.authority.sources,
|
|
443
|
+
handwrittenPolicy: prepared.handwrittenPolicy,
|
|
444
|
+
ownership: prepared.ownership.map((binding) => {
|
|
445
|
+
const operation = prepared.operations.find((item) => normalizeRelPath(item.path) === normalizeRelPath(binding.path));
|
|
446
|
+
return {
|
|
447
|
+
path: binding.path,
|
|
448
|
+
...(operation?.operation === "write" ? { source: binding.source } : {}),
|
|
449
|
+
authoritySource: binding.authoritySource,
|
|
450
|
+
owner: binding.owner,
|
|
451
|
+
rootOutput: binding.rootOutput,
|
|
452
|
+
expect: operation?.expect,
|
|
453
|
+
...(operation?.operation === "write" ? { candidate: { type: operation.type, mode: operation.mode } } : {}),
|
|
454
|
+
};
|
|
455
|
+
}),
|
|
456
|
+
previousOwnedClosure: prepared.previousOwnedClosure,
|
|
457
|
+
externalCandidateClosure: prepared.externalCandidateClosure,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function assertCanonicalPreparedProjection(prepared) {
|
|
462
|
+
let canonical;
|
|
463
|
+
try {
|
|
464
|
+
canonical = compileProjectionPlan(preparedPlanInput(prepared));
|
|
465
|
+
} catch (cause) {
|
|
466
|
+
if (cause instanceof ContractsError) throw cause;
|
|
467
|
+
throw invalidManifest(`preparedProjection cannot be canonically reconstructed: ${cause?.message ?? "unknown"}`);
|
|
468
|
+
}
|
|
469
|
+
if (!isDeepStrictEqual(prepared, canonical)) {
|
|
470
|
+
throw invalidManifest("preparedProjection differs from the canonical compileProjectionPlan result");
|
|
471
|
+
}
|
|
472
|
+
return canonical;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function protectedPreparedPaths(prepared) {
|
|
476
|
+
return new Set([
|
|
477
|
+
...TARGET_FACTS_CONTROL_PATHS,
|
|
478
|
+
...prepared.authority.sources.map((source) => normalizeRelPath(source.path)),
|
|
479
|
+
].map(portableKey));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function verifyPreparedAuthority(rootAbs, prepared) {
|
|
483
|
+
for (const source of prepared.authority.sources) {
|
|
484
|
+
const rel = normalizeRelPath(source.path);
|
|
485
|
+
try {
|
|
486
|
+
await resolveContained(rootAbs, rel);
|
|
487
|
+
} catch (cause) {
|
|
488
|
+
throw invalidManifest("preparedProjection authority source escapes the target root", { path: rel, cause: cause?.message ?? "unknown" });
|
|
489
|
+
}
|
|
490
|
+
const state = await readRegularState(rootAbs, rel);
|
|
491
|
+
if (state.state !== source.type || state.sha256 !== source.sha256 || state.mode !== source.mode) {
|
|
492
|
+
throw invalidManifest("preparedProjection authority source differs from the live target", {
|
|
493
|
+
path: rel,
|
|
494
|
+
expectedSha256: source.sha256,
|
|
495
|
+
expectedType: source.type,
|
|
496
|
+
expectedMode: source.mode,
|
|
497
|
+
actual: publicState(state),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function verifyPreparedPreviousClosure(rootAbs, prepared) {
|
|
504
|
+
const resources = [];
|
|
505
|
+
for (const expected of prepared.previousOwnedClosure.resources) {
|
|
506
|
+
const rel = normalizeRelPath(expected.path);
|
|
507
|
+
const state = await readRegularState(rootAbs, rel);
|
|
508
|
+
if (state.state !== "file") {
|
|
509
|
+
throw invalidManifest("preparedProjection previous-owned path is not a live regular file", { path: rel, actual: publicState(state) });
|
|
510
|
+
}
|
|
511
|
+
resources.push({ path: rel, type: "file", sha256: state.sha256, mode: state.mode });
|
|
512
|
+
}
|
|
513
|
+
const live = normalizePlanClosure({
|
|
514
|
+
digestAlgorithm: "sha256",
|
|
515
|
+
digest: canonicalDigest(resources.sort((a, b) => a.path.localeCompare(b.path))),
|
|
516
|
+
resources,
|
|
517
|
+
}, "livePreviousOwnedClosure");
|
|
518
|
+
if (!isDeepStrictEqual(live, prepared.previousOwnedClosure)) {
|
|
519
|
+
throw invalidManifest("preparedProjection previous-owned closure differs from the live target");
|
|
83
520
|
}
|
|
84
|
-
|
|
85
|
-
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function canonicalCandidateClosure(candidate) {
|
|
524
|
+
const resources = [...candidate.byPath.values()]
|
|
525
|
+
.map(({ path: rel, sha256, mode }) => ({ path: rel, type: "file", sha256, mode }))
|
|
526
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
527
|
+
return {
|
|
528
|
+
digestAlgorithm: "sha256",
|
|
529
|
+
digest: canonicalDigest(resources),
|
|
530
|
+
resourceCount: resources.length,
|
|
531
|
+
resources,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function validatePreparedProjection({ prepared, rootAbs, loaded, candidateRoot }) {
|
|
536
|
+
if (loaded.manifest.schemaVersion !== 2) {
|
|
537
|
+
throw invalidManifest("preparedProjection is supported only for schemaVersion 2");
|
|
538
|
+
}
|
|
539
|
+
const canonical = assertCanonicalPreparedProjection(prepared);
|
|
540
|
+
const targetReal = await realpath(rootAbs);
|
|
541
|
+
if (canonical.rootBinding !== targetReal) {
|
|
542
|
+
throw invalidManifest("preparedProjection rootBinding differs from the canonical target realpath", {
|
|
543
|
+
expected: canonical.rootBinding,
|
|
544
|
+
actual: targetReal,
|
|
545
|
+
});
|
|
86
546
|
}
|
|
547
|
+
if (!isDeepStrictEqual(loaded.manifest, canonical.manifest)) {
|
|
548
|
+
throw invalidManifest("runProjection manifest differs from preparedProjection.manifest");
|
|
549
|
+
}
|
|
550
|
+
const protectedPaths = protectedPreparedPaths(canonical);
|
|
551
|
+
for (const operation of canonical.operations) {
|
|
552
|
+
if (protectedPaths.has(portableKey(normalizeRelPath(operation.path)))) {
|
|
553
|
+
throw invalidManifest("preparedProjection may not mutate an authority or target-facts path", { path: operation.path });
|
|
554
|
+
}
|
|
555
|
+
if (operation.operation === "write" && protectedPaths.has(portableKey(normalizeRelPath(operation.source)))) {
|
|
556
|
+
throw invalidManifest("preparedProjection candidate may not supply an authority or target-facts path", { path: operation.source });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
await verifyPreparedAuthority(rootAbs, canonical);
|
|
560
|
+
await verifyPreparedPreviousClosure(rootAbs, canonical);
|
|
561
|
+
const candidate = await verifyCandidateRoot(candidateRoot, rootAbs, loaded.manifest);
|
|
562
|
+
if (!isDeepStrictEqual(canonicalCandidateClosure(candidate), canonical.externalCandidateClosure)) {
|
|
563
|
+
throw invalidManifest("preparedProjection external candidate closure differs from candidateRoot");
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
facts: { ...canonical.targetFacts, managedSet: new Set(canonical.targetFacts.managedSet) },
|
|
567
|
+
candidate,
|
|
568
|
+
prepared: canonical,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function validateV1Manifest(manifest) {
|
|
87
573
|
if (!Array.isArray(manifest.entries) || manifest.entries.length === 0) {
|
|
88
574
|
throw invalidManifest("projection manifest entries must be a non-empty array");
|
|
89
575
|
}
|
|
@@ -96,17 +582,13 @@ export async function loadProjectionManifest(rootAbs, manifestRelPath) {
|
|
|
96
582
|
throw invalidManifest(`entries[${index}].path must be a non-empty string`);
|
|
97
583
|
}
|
|
98
584
|
const normalized = normalizeRelPath(entry.path);
|
|
99
|
-
if (seen.has(normalized)) {
|
|
100
|
-
throw invalidManifest(`duplicate entries[].path: ${normalized}`);
|
|
101
|
-
}
|
|
585
|
+
if (seen.has(normalized)) throw invalidManifest(`duplicate entries[].path: ${normalized}`);
|
|
102
586
|
seen.add(normalized);
|
|
103
587
|
const content = entry.content;
|
|
104
588
|
const hasText = content && typeof content.text === "string";
|
|
105
589
|
const hasBase64 = content && typeof content.base64 === "string";
|
|
106
590
|
if (!content || typeof content !== "object" || hasText === hasBase64) {
|
|
107
|
-
throw invalidManifest(
|
|
108
|
-
`entries[${index}].content must carry exactly one of { text } or { base64 }`,
|
|
109
|
-
);
|
|
591
|
+
throw invalidManifest(`entries[${index}].content must carry exactly one of { text } or { base64 }`);
|
|
110
592
|
}
|
|
111
593
|
if (hasBase64 && !/^[A-Za-z0-9+/]*={0,2}$/.test(content.base64)) {
|
|
112
594
|
throw invalidManifest(`entries[${index}].content.base64 is not valid base64`);
|
|
@@ -115,181 +597,310 @@ export async function loadProjectionManifest(rootAbs, manifestRelPath) {
|
|
|
115
597
|
const expect = entry.expect;
|
|
116
598
|
const states = ["absent", "sha256"];
|
|
117
599
|
if (!expect || typeof expect !== "object" || !states.includes(expect.state)) {
|
|
118
|
-
throw invalidManifest(
|
|
119
|
-
`entries[${index}].expect.state must be one of: ${states.join(", ")}`,
|
|
120
|
-
);
|
|
600
|
+
throw invalidManifest(`entries[${index}].expect.state must be one of: ${states.join(", ")}`);
|
|
121
601
|
}
|
|
122
602
|
if (expect.state === "sha256" && !SHA256_HEX_PATTERN.test(expect.value ?? "")) {
|
|
123
603
|
throw invalidManifest(`entries[${index}].expect.value must be a lowercase sha256 hex digest`);
|
|
124
604
|
}
|
|
125
605
|
}
|
|
126
606
|
}
|
|
127
|
-
return
|
|
607
|
+
return manifest;
|
|
128
608
|
}
|
|
129
609
|
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
610
|
+
function validateV2Manifest(manifest) {
|
|
611
|
+
if (!manifest.candidate || typeof manifest.candidate !== "object" || Array.isArray(manifest.candidate)) {
|
|
612
|
+
throw invalidManifest("projection manifest candidate must be an object");
|
|
613
|
+
}
|
|
614
|
+
if (!Array.isArray(manifest.candidate.members)) {
|
|
615
|
+
throw invalidManifest("projection manifest candidate.members must be an array");
|
|
616
|
+
}
|
|
617
|
+
if (!Array.isArray(manifest.operations) || manifest.operations.length === 0) {
|
|
618
|
+
throw invalidManifest("projection manifest operations must be a non-empty array");
|
|
619
|
+
}
|
|
620
|
+
if (manifest.rootOutputs !== undefined && !Array.isArray(manifest.rootOutputs)) {
|
|
621
|
+
throw invalidManifest("projection manifest rootOutputs must be an array");
|
|
622
|
+
}
|
|
135
623
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
await unlink(path.join(rootAbs, item.path));
|
|
153
|
-
removed.push(item.path);
|
|
154
|
-
}
|
|
155
|
-
} catch {
|
|
156
|
-
// Rollback is best-effort; the original failure is reported.
|
|
624
|
+
const members = new Set();
|
|
625
|
+
for (const [index, member] of manifest.candidate.members.entries()) {
|
|
626
|
+
if (!member || typeof member !== "object" || Array.isArray(member)) {
|
|
627
|
+
throw invalidManifest(`candidate.members[${index}] must be an object`);
|
|
628
|
+
}
|
|
629
|
+
const classification = classifyPathInput(member.path);
|
|
630
|
+
if (typeof member.path !== "string" || !classification.ok) {
|
|
631
|
+
throw invalidManifest(`candidate.members[${index}].path must be a contained portable path`);
|
|
632
|
+
}
|
|
633
|
+
const rel = normalizeRelPath(member.path);
|
|
634
|
+
if (members.has(rel)) throw invalidManifest(`duplicate candidate member: ${rel}`);
|
|
635
|
+
if (!SHA256_HEX_PATTERN.test(member.sha256 ?? "")) {
|
|
636
|
+
throw invalidManifest(`candidate.members[${index}].sha256 must be a lowercase sha256 hex digest`);
|
|
637
|
+
}
|
|
638
|
+
if (member.mode !== undefined && (!Number.isInteger(member.mode) || member.mode < 0 || member.mode > 0o7777)) {
|
|
639
|
+
throw invalidManifest(`candidate.members[${index}].mode must be an integer permission mode`);
|
|
157
640
|
}
|
|
641
|
+
members.add(rel);
|
|
158
642
|
}
|
|
159
|
-
|
|
160
|
-
}
|
|
643
|
+
assertNoPortableCollisions([...members], "candidate.members");
|
|
161
644
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
645
|
+
const targets = new Set();
|
|
646
|
+
const sources = new Set();
|
|
647
|
+
for (const [index, operation] of manifest.operations.entries()) {
|
|
648
|
+
const label = `operations[${index}]`;
|
|
649
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
|
|
650
|
+
throw invalidManifest(`${label} must be an object`);
|
|
651
|
+
}
|
|
652
|
+
if (!new Set(["write", "delete"]).has(operation.operation)) {
|
|
653
|
+
throw invalidManifest(`${label}.operation must be one of: write, delete`);
|
|
654
|
+
}
|
|
655
|
+
const classification = classifyPathInput(operation.path);
|
|
656
|
+
if (typeof operation.path !== "string" || !classification.ok) {
|
|
657
|
+
throw invalidManifest(`${label}.path must be a contained portable path`);
|
|
658
|
+
}
|
|
659
|
+
const rel = normalizeRelPath(operation.path);
|
|
660
|
+
if (targets.has(rel)) throw invalidManifest(`duplicate operation target: ${rel}`);
|
|
661
|
+
targets.add(rel);
|
|
662
|
+
validateOwner(operation.owner, label);
|
|
663
|
+
validateV2Expect(operation.expect, label, { deleteOperation: operation.operation === "delete" });
|
|
664
|
+
if (operation.operation === "write") {
|
|
665
|
+
const source = normalizeRelPath(operation.source ?? operation.path);
|
|
666
|
+
if (!members.has(source)) throw invalidManifest(`${label}.source is not a declared candidate member`);
|
|
667
|
+
sources.add(source);
|
|
668
|
+
} else if (operation.source !== undefined) {
|
|
669
|
+
throw invalidManifest(`${label}.source is forbidden for delete`);
|
|
182
670
|
}
|
|
183
|
-
plan.push({ rel: normalizeRelPath(entry.path), entry, desired: desiredBytes(entry) });
|
|
184
671
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
672
|
+
assertNoPortableCollisions([...targets], "operations");
|
|
673
|
+
if (sources.size !== members.size || [...members].some((member) => !sources.has(member))) {
|
|
674
|
+
throw invalidManifest("candidate.members must exactly equal the write-operation source set", {
|
|
675
|
+
members: [...members].sort(),
|
|
676
|
+
sources: [...sources].sort(),
|
|
677
|
+
});
|
|
191
678
|
}
|
|
192
679
|
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
for (const
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
680
|
+
const rootOutputs = manifest.rootOutputs ?? [];
|
|
681
|
+
const rootSeen = new Set();
|
|
682
|
+
for (const [index, rawPath] of rootOutputs.entries()) {
|
|
683
|
+
const rel = normalizeRelPath(rawPath);
|
|
684
|
+
if (typeof rawPath !== "string" || rootSeen.has(rel)) {
|
|
685
|
+
throw invalidManifest(`rootOutputs[${index}] must be a unique path`);
|
|
686
|
+
}
|
|
687
|
+
const operation = manifest.operations.find((item) => normalizeRelPath(item.path) === rel);
|
|
688
|
+
if (!operation || operation.operation !== "write") {
|
|
689
|
+
throw invalidManifest(`root output must name a write operation: ${rel}`);
|
|
200
690
|
}
|
|
201
|
-
|
|
691
|
+
rootSeen.add(rel);
|
|
202
692
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
693
|
+
return manifest;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function validateManifestObject(manifest) {
|
|
697
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
698
|
+
throw invalidManifest("projection manifest must be a JSON object");
|
|
699
|
+
}
|
|
700
|
+
if (![1, 2].includes(manifest.schemaVersion)) {
|
|
701
|
+
throw invalidManifest("projection manifest schemaVersion must be 1 or 2");
|
|
702
|
+
}
|
|
703
|
+
if (manifest.kind !== V2_KIND) {
|
|
704
|
+
throw invalidManifest(`projection manifest kind must be ${V2_KIND}`);
|
|
705
|
+
}
|
|
706
|
+
return manifest.schemaVersion === 1 ? validateV1Manifest(manifest) : validateV2Manifest(manifest);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function validateLocalV1Manifest(manifest) {
|
|
710
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
711
|
+
throw invalidManifest("projection manifest must be a JSON object");
|
|
712
|
+
}
|
|
713
|
+
if (manifest.schemaVersion !== 1) {
|
|
714
|
+
throw invalidManifest("projection manifest schemaVersion must be 1");
|
|
715
|
+
}
|
|
716
|
+
if (manifest.kind !== V2_KIND) {
|
|
717
|
+
throw invalidManifest(`projection manifest kind must be ${V2_KIND}`);
|
|
718
|
+
}
|
|
719
|
+
return validateV1Manifest(manifest);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function loadExternalManifest(rootAbs, absPath) {
|
|
723
|
+
const st = await lstat(absPath).catch(() => null);
|
|
724
|
+
if (!st || !st.isFile() || st.isSymbolicLink()) {
|
|
725
|
+
throw invalidManifest("external projection manifest must be a regular non-symlink file", { manifest: absPath });
|
|
726
|
+
}
|
|
727
|
+
const [targetReal, manifestReal] = await Promise.all([
|
|
728
|
+
realpath(rootAbs),
|
|
729
|
+
realpath(absPath).catch(() => null),
|
|
730
|
+
]);
|
|
731
|
+
if (!manifestReal) {
|
|
732
|
+
throw invalidManifest("external projection manifest cannot be resolved", { manifest: absPath });
|
|
733
|
+
}
|
|
734
|
+
const relativeToTarget = path.relative(targetReal, manifestReal);
|
|
735
|
+
if (relativeToTarget === "" || (!relativeToTarget.startsWith("..") && !path.isAbsolute(relativeToTarget))) {
|
|
736
|
+
throw invalidManifest("external projection manifest must resolve outside the target", { manifest: absPath });
|
|
737
|
+
}
|
|
738
|
+
let manifest;
|
|
739
|
+
try {
|
|
740
|
+
manifest = JSON.parse(await readFile(absPath, "utf8"));
|
|
741
|
+
} catch {
|
|
742
|
+
throw invalidManifest("external projection manifest is not valid JSON", { manifest: absPath });
|
|
743
|
+
}
|
|
744
|
+
return { manifestPath: manifestReal, manifest: validateManifestObject(manifest), external: true };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Loads and shape-validates a target-local v1 or external v2 manifest. */
|
|
748
|
+
export async function loadProjectionManifest(rootAbs, manifestInput) {
|
|
749
|
+
if (manifestInput && typeof manifestInput === "object" && !Array.isArray(manifestInput)) {
|
|
750
|
+
return { manifestPath: "<in-memory>", manifest: validateManifestObject(manifestInput), external: true };
|
|
751
|
+
}
|
|
752
|
+
if (typeof manifestInput === "string" && path.isAbsolute(manifestInput)) {
|
|
753
|
+
return loadExternalManifest(rootAbs, manifestInput);
|
|
754
|
+
}
|
|
755
|
+
const rawManifestPath = manifestInput ?? PROJECTION_MANIFEST_PATH;
|
|
756
|
+
const classification = classifyPathInput(rawManifestPath);
|
|
757
|
+
if (!classification.ok) {
|
|
758
|
+
throw kitError(classification.kind, `projection manifest path rejected (kind: ${classification.kind})`, {
|
|
759
|
+
input: rawManifestPath,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
const manifestPath = normalizeRelPath(rawManifestPath);
|
|
763
|
+
const loaded = await readOptionalJson(rootAbs, manifestPath);
|
|
764
|
+
if (!loaded.ok) {
|
|
765
|
+
throw invalidManifest(
|
|
766
|
+
loaded.reason === "missing"
|
|
767
|
+
? `projection manifest not found: ${manifestPath}`
|
|
768
|
+
: `projection manifest is not valid JSON: ${manifestPath}`,
|
|
207
769
|
{ manifest: manifestPath },
|
|
208
770
|
);
|
|
209
771
|
}
|
|
772
|
+
return { manifestPath, manifest: validateLocalV1Manifest(loaded.value) };
|
|
773
|
+
}
|
|
210
774
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
775
|
+
function desiredBytes(entry) {
|
|
776
|
+
return entry.content.text !== undefined
|
|
777
|
+
? Buffer.from(entry.content.text, "utf8")
|
|
778
|
+
: Buffer.from(entry.content.base64, "base64");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function readRegularState(rootAbs, rel) {
|
|
782
|
+
const target = path.join(rootAbs, rel);
|
|
783
|
+
let st;
|
|
214
784
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
785
|
+
st = await lstat(target);
|
|
786
|
+
} catch (cause) {
|
|
787
|
+
if (cause && cause.code === "ENOENT") return { state: "absent" };
|
|
788
|
+
if (cause && cause.code === "ENOTDIR") {
|
|
789
|
+
return { state: "blocked-parent", mode: null };
|
|
790
|
+
}
|
|
791
|
+
throw cause;
|
|
792
|
+
}
|
|
793
|
+
if (st.isSymbolicLink()) return { state: "symlink", mode: modeOf(st) };
|
|
794
|
+
if (!st.isFile()) return { state: st.isDirectory() ? "directory" : "special", mode: modeOf(st) };
|
|
795
|
+
const bytes = await readFile(target);
|
|
796
|
+
return { state: "file", sha256: digestBytes(bytes), mode: modeOf(st), bytes };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function publicState(state) {
|
|
800
|
+
return state.state === "file"
|
|
801
|
+
? { state: "sha256", value: state.sha256, mode: state.mode }
|
|
802
|
+
: { state: state.state, value: null, mode: state.mode ?? null };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function stateMatchesExpect(state, expect) {
|
|
806
|
+
if (expect.state === "absent") return state.state === "absent";
|
|
807
|
+
return state.state === "file"
|
|
808
|
+
&& state.sha256 === expect.value
|
|
809
|
+
&& (expect.mode === undefined || state.mode === expect.mode);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function stateEquals(left, right) {
|
|
813
|
+
return left.state === right.state
|
|
814
|
+
&& (left.state !== "file" || (left.sha256 === right.sha256 && left.mode === right.mode));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function closureForStates(states) {
|
|
818
|
+
const resources = [...states.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([rel, state]) => ({
|
|
819
|
+
path: rel,
|
|
820
|
+
...publicState(state),
|
|
821
|
+
}));
|
|
822
|
+
return { digestAlgorithm: "sha256", digest: canonicalDigest(resources), resourceCount: resources.length, resources };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function enumerateCandidate(rootAbs) {
|
|
826
|
+
const records = [];
|
|
827
|
+
async function walk(absDir, relBase) {
|
|
828
|
+
const entries = await readdir(absDir, { withFileTypes: true });
|
|
829
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
830
|
+
for (const entry of entries) {
|
|
831
|
+
const rel = relBase === "" ? entry.name : `${relBase}/${entry.name}`;
|
|
832
|
+
const abs = path.join(absDir, entry.name);
|
|
833
|
+
const st = await lstat(abs);
|
|
834
|
+
if (st.isSymbolicLink()) {
|
|
835
|
+
throw invalidManifest("candidate tree contains a symbolic link", { path: rel });
|
|
219
836
|
}
|
|
220
|
-
if (
|
|
221
|
-
|
|
837
|
+
if (st.isDirectory()) {
|
|
838
|
+
await walk(abs, rel);
|
|
839
|
+
} else if (st.isFile()) {
|
|
840
|
+
const bytes = await readFile(abs);
|
|
841
|
+
records.push({ path: rel, sha256: digestBytes(bytes), mode: modeOf(st), bytes });
|
|
842
|
+
} else {
|
|
843
|
+
throw invalidManifest("candidate tree contains a special file", { path: rel });
|
|
222
844
|
}
|
|
223
|
-
await writeFileAtomic(rootAbs, action.rel, action.desired);
|
|
224
|
-
written.push({ path: action.rel, sha256: action.sha256, mode: action.type });
|
|
225
845
|
}
|
|
226
|
-
} catch (cause) {
|
|
227
|
-
// FC-17: restore overwritten files AND remove every file created by
|
|
228
|
-
// this run — a failed projection leaves no half-updated target.
|
|
229
|
-
const { restored, removed } = await rollbackWrites(rootAbs, actions, written);
|
|
230
|
-
// Coded errors (kit or harness) propagate unchanged so their stable
|
|
231
|
-
// kinds survive; anything else becomes a coded projection failure.
|
|
232
|
-
if (cause instanceof ContractsError) throw cause;
|
|
233
|
-
throw kitError(
|
|
234
|
-
KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED,
|
|
235
|
-
`projection write failed: ${cause && cause.message ? cause.message : "unknown"}`,
|
|
236
|
-
{ restored, removed },
|
|
237
|
-
);
|
|
238
846
|
}
|
|
847
|
+
await walk(rootAbs, "");
|
|
848
|
+
assertNoPortableCollisions(records.map((record) => record.path), "candidate tree");
|
|
849
|
+
return records;
|
|
850
|
+
}
|
|
239
851
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
const closure = await computeResourceClosure({
|
|
255
|
-
root: rootAbs,
|
|
256
|
-
resources: [
|
|
257
|
-
{ path: manifestPath, role: "input" },
|
|
258
|
-
...written.map((item) => ({ path: item.path, role: "output" })),
|
|
259
|
-
...unchanged.map((item) => ({ path: item.path, role: "output" })),
|
|
260
|
-
],
|
|
261
|
-
});
|
|
852
|
+
async function verifyCandidateRoot(candidateRoot, targetRoot, manifest) {
|
|
853
|
+
if (typeof candidateRoot !== "string" || candidateRoot.length === 0) {
|
|
854
|
+
throw invalidManifest("schemaVersion 2 projection requires candidateRoot");
|
|
855
|
+
}
|
|
856
|
+
const candidateAbs = await realpath(path.resolve(candidateRoot)).catch(() => null);
|
|
857
|
+
if (!candidateAbs) throw invalidManifest("candidateRoot does not exist", { candidateRoot });
|
|
858
|
+
const st = await stat(candidateAbs);
|
|
859
|
+
if (!st.isDirectory()) throw invalidManifest("candidateRoot must be a directory", { candidateRoot });
|
|
860
|
+
const targetReal = await realpath(targetRoot);
|
|
861
|
+
const candidateFromTarget = path.relative(targetReal, candidateAbs);
|
|
862
|
+
const targetFromCandidate = path.relative(candidateAbs, targetReal);
|
|
863
|
+
if (candidateFromTarget === "" || !candidateFromTarget.startsWith("..") || !targetFromCandidate.startsWith("..")) {
|
|
864
|
+
throw invalidManifest("candidateRoot must be external to the target", { candidateRoot: candidateAbs });
|
|
865
|
+
}
|
|
262
866
|
|
|
867
|
+
const actual = await enumerateCandidate(candidateAbs);
|
|
868
|
+
const declared = new Map(manifest.candidate.members.map((member) => [normalizeRelPath(member.path), member]));
|
|
869
|
+
if (actual.length !== declared.size) {
|
|
870
|
+
throw invalidManifest("candidate tree member count differs from candidate.members", {
|
|
871
|
+
actual: actual.map((item) => item.path), declared: [...declared.keys()].sort(),
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
for (const record of actual) {
|
|
875
|
+
const member = declared.get(record.path);
|
|
876
|
+
if (!member) throw invalidManifest("candidate tree contains an undeclared extra member", { path: record.path });
|
|
877
|
+
if (member.sha256 !== record.sha256) {
|
|
878
|
+
throw invalidManifest("candidate member digest mismatch", { path: record.path, expected: member.sha256, actual: record.sha256 });
|
|
879
|
+
}
|
|
880
|
+
if (member.mode !== undefined && member.mode !== record.mode) {
|
|
881
|
+
throw invalidManifest("candidate member mode mismatch", { path: record.path, expected: member.mode, actual: record.mode });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
for (const rel of declared.keys()) {
|
|
885
|
+
if (!actual.some((record) => record.path === rel)) {
|
|
886
|
+
throw invalidManifest("candidate tree is missing a declared member", { path: rel });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const byPath = new Map(actual.map((record) => [record.path, record]));
|
|
890
|
+
const resources = actual.map(({ path: rel, sha256, mode }) => ({ path: rel, sha256, mode }));
|
|
263
891
|
return {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
manifest: manifestPath,
|
|
268
|
-
written,
|
|
269
|
-
unchanged,
|
|
270
|
-
closure: { digest: closure.digest, resourceCount: closure.resources.length },
|
|
271
|
-
policy:
|
|
272
|
-
"projection wrote only manifest-authorized managed artifacts; handwritten and unauthorized paths were never touched",
|
|
892
|
+
rootAbs: candidateAbs,
|
|
893
|
+
byPath,
|
|
894
|
+
closure: { digestAlgorithm: "sha256", digest: canonicalDigest(resources), resourceCount: resources.length, resources },
|
|
273
895
|
};
|
|
274
896
|
}
|
|
275
897
|
|
|
276
|
-
async function
|
|
277
|
-
void entry;
|
|
898
|
+
async function validateTargetPath({ rawPath, facts, manifestPath, rootAbs, externalManifest, owner }) {
|
|
278
899
|
const rel = normalizeRelPath(rawPath);
|
|
279
|
-
// Classification runs on the RAW input so ambiguous cross-platform paths
|
|
280
|
-
// (backslashes on POSIX, drive letters, UNC) are refused before any
|
|
281
|
-
// normalization can mask them.
|
|
282
900
|
const classification = classifyPathInput(rawPath);
|
|
283
901
|
if (!classification.ok) {
|
|
284
|
-
return {
|
|
285
|
-
path: rel,
|
|
286
|
-
kind: classification.kind,
|
|
287
|
-
code: "SFC2004",
|
|
288
|
-
detail: `path rejected before resolution (kind: ${classification.kind})`,
|
|
289
|
-
};
|
|
902
|
+
return { path: rel, kind: classification.kind, code: "SFC2004", detail: `path rejected before resolution (kind: ${classification.kind})` };
|
|
290
903
|
}
|
|
291
|
-
// Full containment preflight (traversal, symlink escape, realpath escape)
|
|
292
|
-
// before any authorization decision; escaping paths never reach a write.
|
|
293
904
|
try {
|
|
294
905
|
await resolveContained(rootAbs, rel);
|
|
295
906
|
} catch (cause) {
|
|
@@ -300,98 +911,373 @@ async function validateEntry({ rawPath, entry, facts, manifestPath, rootAbs }) {
|
|
|
300
911
|
detail: `containment preflight rejected the path: ${cause && cause.message ? cause.message : "unknown"}`,
|
|
301
912
|
};
|
|
302
913
|
}
|
|
303
|
-
if (rel === normalizeRelPath(manifestPath)) {
|
|
304
|
-
return {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
detail: "the projection manifest may not list itself",
|
|
309
|
-
};
|
|
914
|
+
if (!externalManifest && rel === normalizeRelPath(manifestPath)) {
|
|
915
|
+
return { path: rel, kind: KIT_ERROR_KINDS.SELF_PROJECTION, code: "SFC2004", detail: "the projection manifest may not list itself" };
|
|
916
|
+
}
|
|
917
|
+
if (rel === ".git" || rel.startsWith(".git/")) {
|
|
918
|
+
return { path: rel, kind: KIT_ERROR_KINDS.HANDWRITTEN_OVERWRITE, code: "SFC2004", detail: "path is protected handwritten material" };
|
|
310
919
|
}
|
|
311
|
-
// Handwritten material wins over any managed declaration.
|
|
312
920
|
if (matchAnyGlob(facts.handwrittenPatterns, rel)) {
|
|
313
|
-
return {
|
|
314
|
-
path: rel,
|
|
315
|
-
kind: KIT_ERROR_KINDS.HANDWRITTEN_OVERWRITE,
|
|
316
|
-
code: "SFC2004",
|
|
317
|
-
detail: "path matches handwritten patterns; the kit never writes handwritten material",
|
|
318
|
-
};
|
|
921
|
+
return { path: rel, kind: KIT_ERROR_KINDS.HANDWRITTEN_OVERWRITE, code: "SFC2004", detail: "path matches handwritten patterns" };
|
|
319
922
|
}
|
|
320
923
|
if (!facts.managedSet.has(rel)) {
|
|
321
|
-
return {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
924
|
+
return { path: rel, kind: KIT_ERROR_KINDS.UNAUTHORIZED_PATH, code: "SFC2004", detail: "path is not declared managed by the target" };
|
|
925
|
+
}
|
|
926
|
+
const lockedEntry = Array.isArray(facts.managedLock?.entries)
|
|
927
|
+
? facts.managedLock.entries.find((entry) => entry && normalizeRelPath(entry.path) === rel)
|
|
928
|
+
: null;
|
|
929
|
+
const lockedOwner = lockedEntry?.generator?.tool;
|
|
930
|
+
if (owner !== undefined && typeof lockedOwner === "string" && lockedOwner.length > 0 && owner.id !== lockedOwner) {
|
|
931
|
+
return { path: rel, kind: KIT_ERROR_KINDS.UNAUTHORIZED_PATH, code: "SFC2004", detail: "manifest owner does not match the target managed-file lock" };
|
|
328
932
|
}
|
|
329
933
|
return null;
|
|
330
934
|
}
|
|
331
935
|
|
|
332
|
-
async function
|
|
333
|
-
|
|
334
|
-
|
|
936
|
+
async function invokeFault(faultInjector, phase, context) {
|
|
937
|
+
if (typeof faultInjector === "function") await faultInjector({ phase, ...context });
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function removeCreatedDirectories(rootAbs, rel, existingDirectories) {
|
|
941
|
+
let current = path.posix.dirname(rel);
|
|
942
|
+
while (current !== "." && current !== "") {
|
|
943
|
+
if (existingDirectories.has(current)) break;
|
|
944
|
+
try {
|
|
945
|
+
await rmdir(path.join(rootAbs, current));
|
|
946
|
+
} catch (cause) {
|
|
947
|
+
if (cause && new Set(["ENOENT", "ENOTEMPTY"]).has(cause.code)) break;
|
|
948
|
+
throw cause;
|
|
949
|
+
}
|
|
950
|
+
current = path.posix.dirname(current);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function restoreState(rootAbs, rel, before, existingDirectories) {
|
|
335
955
|
const target = path.join(rootAbs, rel);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
956
|
+
if (before.state === "absent") {
|
|
957
|
+
try { await unlink(target); } catch (cause) { if (!cause || cause.code !== "ENOENT") throw cause; }
|
|
958
|
+
await removeCreatedDirectories(rootAbs, rel, existingDirectories);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (before.state !== "file") throw new Error(`cannot restore prior ${before.state} state`);
|
|
962
|
+
const current = await readRegularState(rootAbs, rel);
|
|
963
|
+
if (current.state !== "absent" && current.state !== "file") {
|
|
964
|
+
throw new Error(`cannot restore over current ${current.state} state`);
|
|
341
965
|
}
|
|
966
|
+
await writeFileAtomic(rootAbs, rel, before.bytes, { mode: before.mode });
|
|
967
|
+
await chmod(target, before.mode);
|
|
968
|
+
}
|
|
342
969
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
970
|
+
async function rollbackV2({ rootAbs, actions, beforeStates, existingDirectories, completed, faultInjector, cause }) {
|
|
971
|
+
const failures = [];
|
|
972
|
+
const restored = [];
|
|
973
|
+
const completedSet = new Set(completed);
|
|
974
|
+
for (const action of [...actions].reverse()) {
|
|
975
|
+
try {
|
|
976
|
+
await invokeFault(faultInjector, "beforeRollback", { index: action.index, operation: action.publicOperation, cause });
|
|
977
|
+
const current = await readRegularState(rootAbs, action.rel);
|
|
978
|
+
if (completedSet.has(action.rel) || !stateEquals(current, beforeStates.get(action.rel))) {
|
|
979
|
+
await restoreState(rootAbs, action.rel, beforeStates.get(action.rel), existingDirectories);
|
|
980
|
+
restored.push(action.rel);
|
|
981
|
+
}
|
|
982
|
+
await invokeFault(faultInjector, "afterRollback", { index: action.index, operation: action.publicOperation, cause });
|
|
983
|
+
} catch (rollbackCause) {
|
|
984
|
+
failures.push({ path: action.rel, message: rollbackCause && rollbackCause.message ? rollbackCause.message : "unknown" });
|
|
352
985
|
}
|
|
353
|
-
return { type: "create", rel, desired, sha256: desiredSha256 };
|
|
354
986
|
}
|
|
987
|
+
const after = new Map();
|
|
988
|
+
for (const action of actions) after.set(action.rel, await readRegularState(rootAbs, action.rel));
|
|
989
|
+
const differences = actions.filter((action) => !stateEquals(beforeStates.get(action.rel), after.get(action.rel))).map((action) => action.rel);
|
|
990
|
+
return {
|
|
991
|
+
status: failures.length === 0 && differences.length === 0 ? "RESTORED" : "FAILED",
|
|
992
|
+
restored,
|
|
993
|
+
failures,
|
|
994
|
+
differences,
|
|
995
|
+
zeroWriteSuccess: failures.length === 0 && differences.length === 0,
|
|
996
|
+
closure: closureForStates(after),
|
|
997
|
+
};
|
|
998
|
+
}
|
|
355
999
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1000
|
+
async function listExistingDirectories(rootAbs, actions) {
|
|
1001
|
+
const existing = new Set();
|
|
1002
|
+
for (const action of actions) {
|
|
1003
|
+
let current = path.posix.dirname(action.rel);
|
|
1004
|
+
while (current !== "." && current !== "") {
|
|
1005
|
+
try {
|
|
1006
|
+
if ((await lstat(path.join(rootAbs, current))).isDirectory()) existing.add(current);
|
|
1007
|
+
} catch { /* absent before the transaction */ }
|
|
1008
|
+
current = path.posix.dirname(current);
|
|
1009
|
+
}
|
|
364
1010
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1011
|
+
return existing;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function runProjectionV2({ rootAbs, facts, manifestPath, manifest, externalManifest, candidateRoot, faultInjector, verifiedCandidate, preparedProjection }) {
|
|
1015
|
+
const candidate = verifiedCandidate ?? await verifyCandidateRoot(candidateRoot, rootAbs, manifest);
|
|
1016
|
+
const refusals = [];
|
|
1017
|
+
const initial = [];
|
|
1018
|
+
for (const [index, operation] of manifest.operations.entries()) {
|
|
1019
|
+
const rel = normalizeRelPath(operation.path);
|
|
1020
|
+
const rejection = await validateTargetPath({ rawPath: operation.path, facts, manifestPath, rootAbs, externalManifest, owner: operation.owner });
|
|
1021
|
+
if (rejection) { refusals.push(rejection); continue; }
|
|
1022
|
+
const before = await readRegularState(rootAbs, rel);
|
|
1023
|
+
if (new Set(["directory", "symlink", "special"]).has(before.state)) {
|
|
1024
|
+
refusals.push({ path: rel, kind: before.state === "symlink" ? KIT_ERROR_KINDS.SYMLINK_ON_PLANNED_PATH : KIT_ERROR_KINDS.TYPE_CONFLICT, code: "SFC2004", detail: `a ${before.state} occupies the planned path` });
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (!stateMatchesExpect(before, operation.expect)) {
|
|
1028
|
+
refusals.push({ path: rel, kind: KIT_ERROR_KINDS.CONFLICT_DRIFT, code: "SFC2004", detail: "target differs from the exact declared expect state" });
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const source = operation.operation === "write" ? normalizeRelPath(operation.source ?? operation.path) : null;
|
|
1032
|
+
const candidateRecord = source === null ? null : candidate.byPath.get(source);
|
|
1033
|
+
initial.push({ index, rel, operation, before, source, candidateRecord });
|
|
1034
|
+
}
|
|
1035
|
+
if (refusals.length > 0) {
|
|
1036
|
+
throw refusalError(refusals, `projection refused: ${refusals.length} version 2 operation conflict(s); nothing was written`, { manifest: manifestPath });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const rootSet = new Set((manifest.rootOutputs ?? []).map(normalizeRelPath));
|
|
1040
|
+
const actions = initial
|
|
1041
|
+
.map((item) => ({ ...item, publicOperation: { operation: item.operation.operation, path: item.rel, source: item.source, owner: item.operation.owner, expect: item.operation.expect } }))
|
|
1042
|
+
.sort((a, b) => Number(rootSet.has(a.rel)) - Number(rootSet.has(b.rel)) || a.index - b.index);
|
|
1043
|
+
const beforeStates = new Map(actions.map((action) => [action.rel, action.before]));
|
|
1044
|
+
const beforeClosure = closureForStates(beforeStates);
|
|
1045
|
+
const existingDirectories = await listExistingDirectories(rootAbs, actions);
|
|
1046
|
+
const written = [];
|
|
1047
|
+
const deleted = [];
|
|
1048
|
+
const unchanged = [];
|
|
1049
|
+
const completed = [];
|
|
1050
|
+
let operationCause = null;
|
|
1051
|
+
|
|
1052
|
+
try {
|
|
1053
|
+
for (const [executionIndex, action] of actions.entries()) {
|
|
1054
|
+
if (preparedProjection) await verifyPreparedAuthority(rootAbs, preparedProjection);
|
|
1055
|
+
await invokeFault(faultInjector, "beforeMutation", { index: executionIndex, operation: action.publicOperation });
|
|
1056
|
+
if (preparedProjection) await verifyPreparedAuthority(rootAbs, preparedProjection);
|
|
1057
|
+
const current = await readRegularState(rootAbs, action.rel);
|
|
1058
|
+
if (!stateEquals(current, action.before)) {
|
|
1059
|
+
throw kitError(KIT_ERROR_KINDS.CONFLICT_DRIFT, `optimistic concurrency drift before ${action.rel}`, { path: action.rel, executionIndex });
|
|
1060
|
+
}
|
|
1061
|
+
if (action.operation.operation === "write") {
|
|
1062
|
+
const desiredState = { state: "file", sha256: action.candidateRecord.sha256, mode: action.candidateRecord.mode };
|
|
1063
|
+
if (stateEquals(current, desiredState)) {
|
|
1064
|
+
unchanged.push({ path: action.rel, sha256: current.sha256, mode: current.mode });
|
|
1065
|
+
} else {
|
|
1066
|
+
await writeFileAtomic(rootAbs, action.rel, action.candidateRecord.bytes, { mode: action.candidateRecord.mode });
|
|
1067
|
+
await chmod(path.join(rootAbs, action.rel), action.candidateRecord.mode);
|
|
1068
|
+
written.push({ path: action.rel, sha256: action.candidateRecord.sha256, mode: action.candidateRecord.mode, source: action.source });
|
|
1069
|
+
}
|
|
1070
|
+
} else {
|
|
1071
|
+
await unlink(path.join(rootAbs, action.rel));
|
|
1072
|
+
deleted.push({ path: action.rel, priorSha256: action.before.sha256, priorMode: action.before.mode });
|
|
1073
|
+
}
|
|
1074
|
+
completed.push(action.rel);
|
|
1075
|
+
await invokeFault(faultInjector, "afterMutation", { index: executionIndex, operation: action.publicOperation });
|
|
1076
|
+
}
|
|
1077
|
+
} catch (cause) {
|
|
1078
|
+
operationCause = cause;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (!operationCause) {
|
|
1082
|
+
for (const action of actions) {
|
|
1083
|
+
const final = await readRegularState(rootAbs, action.rel);
|
|
1084
|
+
const expected = action.operation.operation === "delete"
|
|
1085
|
+
? { state: "absent" }
|
|
1086
|
+
: { state: "file", sha256: action.candidateRecord.sha256, mode: action.candidateRecord.mode };
|
|
1087
|
+
if (!stateEquals(final, expected)) {
|
|
1088
|
+
operationCause = kitError(KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED, `post-mutation verification failed for ${action.rel}`, { path: action.rel });
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (operationCause) {
|
|
1095
|
+
const rollback = await rollbackV2({ rootAbs, actions, beforeStates, existingDirectories, completed, faultInjector, cause: operationCause });
|
|
1096
|
+
throw kitError(
|
|
1097
|
+
KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED,
|
|
1098
|
+
rollback.status === "RESTORED"
|
|
1099
|
+
? `projection failed and the complete target closure was restored: ${operationCause.message ?? "unknown"}`
|
|
1100
|
+
: `fatal projection failure: rollback did not restore the complete target closure`,
|
|
1101
|
+
{
|
|
1102
|
+
cause: { code: operationCause.code ?? null, kind: operationCause.details?.kind ?? null, message: operationCause.message ?? "unknown" },
|
|
1103
|
+
completed,
|
|
1104
|
+
rollback,
|
|
1105
|
+
zeroWriteSuccess: rollback.zeroWriteSuccess,
|
|
371
1106
|
},
|
|
372
|
-
|
|
1107
|
+
);
|
|
373
1108
|
}
|
|
374
1109
|
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
1110
|
+
const afterStates = new Map();
|
|
1111
|
+
for (const action of actions) afterStates.set(action.rel, await readRegularState(rootAbs, action.rel));
|
|
1112
|
+
const afterClosure = closureForStates(afterStates);
|
|
1113
|
+
const orderedOperations = actions.map((action, executionIndex) => ({
|
|
1114
|
+
executionIndex,
|
|
1115
|
+
declaredIndex: action.index,
|
|
1116
|
+
...action.publicOperation,
|
|
1117
|
+
before: publicState(action.before),
|
|
1118
|
+
after: publicState(afterStates.get(action.rel)),
|
|
1119
|
+
}));
|
|
1120
|
+
const operationsClosure = {
|
|
1121
|
+
digestAlgorithm: "sha256",
|
|
1122
|
+
digest: canonicalDigest(orderedOperations),
|
|
1123
|
+
operationCount: orderedOperations.length,
|
|
1124
|
+
};
|
|
1125
|
+
const rootOutputs = [...rootSet].map((rel) => ({ path: rel, after: publicState(afterStates.get(rel)) }));
|
|
1126
|
+
return {
|
|
1127
|
+
kind: "skill-family.projection-receipt",
|
|
1128
|
+
schemaVersion: 2,
|
|
1129
|
+
generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
|
|
1130
|
+
manifest: {
|
|
1131
|
+
location: manifestPath,
|
|
1132
|
+
sha256: canonicalDigest(manifest),
|
|
1133
|
+
external: externalManifest,
|
|
1134
|
+
},
|
|
1135
|
+
candidate: { root: candidate.rootAbs, closure: candidate.closure },
|
|
1136
|
+
operations: orderedOperations,
|
|
1137
|
+
written,
|
|
1138
|
+
deleted,
|
|
1139
|
+
unchanged,
|
|
1140
|
+
rootOutputs,
|
|
1141
|
+
closures: { before: beforeClosure, candidate: candidate.closure, operations: operationsClosure, after: afterClosure },
|
|
1142
|
+
rollback: { status: "NOT_NEEDED", attempted: false, zeroWriteSuccess: null },
|
|
1143
|
+
policy: "projection mutated only explicitly owned managed paths and committed declared root outputs last",
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function validateV1Entry({ rawPath, facts, manifestPath, rootAbs }) {
|
|
1148
|
+
const rejection = await validateTargetPath({ rawPath, facts, manifestPath, rootAbs, externalManifest: false });
|
|
1149
|
+
if (rejection) return rejection;
|
|
1150
|
+
const rel = normalizeRelPath(rawPath);
|
|
1151
|
+
if (matchAnyGlob(facts.handwrittenPatterns, rel)) {
|
|
1152
|
+
return { path: rel, kind: KIT_ERROR_KINDS.HANDWRITTEN_OVERWRITE, code: "SFC2004", detail: "path matches handwritten patterns" };
|
|
379
1153
|
}
|
|
1154
|
+
if (!facts.managedSet.has(rel)) {
|
|
1155
|
+
return { path: rel, kind: KIT_ERROR_KINDS.UNAUTHORIZED_PATH, code: "SFC2004", detail: "path is not declared managed by the target" };
|
|
1156
|
+
}
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
380
1159
|
|
|
1160
|
+
async function classifyV1Entry(rootAbs, item) {
|
|
1161
|
+
const state = await readRegularState(rootAbs, item.rel);
|
|
1162
|
+
const desiredSha256 = digestBytes(item.desired);
|
|
1163
|
+
if (state.state === "absent" || state.state === "blocked-parent") {
|
|
1164
|
+
if (item.entry.expect?.state === "sha256") return { refusal: { kind: KIT_ERROR_KINDS.CONFLICT_DRIFT, code: "SFC2004", detail: "expect.sha256 declared prior content, but the path does not exist" } };
|
|
1165
|
+
return { type: "create", rel: item.rel, desired: item.desired, sha256: desiredSha256 };
|
|
1166
|
+
}
|
|
1167
|
+
if (state.state === "symlink") return { refusal: { kind: KIT_ERROR_KINDS.SYMLINK_ON_PLANNED_PATH, code: "SFC2004", detail: "a symbolic link occupies the planned path" } };
|
|
1168
|
+
// Preserve the v1 behavior for a regular file occupying an intermediate
|
|
1169
|
+
// component: the atomic writer reports its registered mechanism error.
|
|
1170
|
+
if (state.state !== "file" && state.state !== "blocked-parent") return { refusal: { kind: KIT_ERROR_KINDS.TYPE_CONFLICT, code: "SFC2004", detail: `a ${state.state} occupies the planned file path` } };
|
|
1171
|
+
if (state.sha256 === desiredSha256) return { type: "unchanged", rel: item.rel, sha256: desiredSha256 };
|
|
381
1172
|
const expect = item.entry.expect;
|
|
382
|
-
if (expect
|
|
383
|
-
|
|
1173
|
+
if (expect?.state === "sha256" && expect.value === state.sha256) return { type: "overwrite", rel: item.rel, desired: item.desired, sha256: desiredSha256 };
|
|
1174
|
+
return { refusal: { kind: KIT_ERROR_KINDS.CONFLICT_DRIFT, code: "SFC2004", detail: "existing content differs from the declared prior state" } };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async function rollbackV1(rootAbs, actions, written) {
|
|
1178
|
+
const restored = [];
|
|
1179
|
+
const removed = [];
|
|
1180
|
+
const failures = [];
|
|
1181
|
+
for (const item of [...written].reverse()) {
|
|
1182
|
+
const action = actions.find((entry) => entry.rel === item.path);
|
|
1183
|
+
try {
|
|
1184
|
+
if (action?.type === "overwrite" && action.priorBytes !== undefined) {
|
|
1185
|
+
await writeFileAtomic(rootAbs, item.path, action.priorBytes, { mode: action.priorMode });
|
|
1186
|
+
await chmod(path.join(rootAbs, item.path), action.priorMode);
|
|
1187
|
+
restored.push(item.path);
|
|
1188
|
+
} else {
|
|
1189
|
+
await unlink(path.join(rootAbs, item.path));
|
|
1190
|
+
removed.push(item.path);
|
|
1191
|
+
}
|
|
1192
|
+
} catch (cause) {
|
|
1193
|
+
failures.push({ path: item.path, message: cause?.message ?? "unknown" });
|
|
1194
|
+
}
|
|
384
1195
|
}
|
|
1196
|
+
return { restored, removed, failures, status: failures.length === 0 ? "RESTORED" : "FAILED" };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function runProjectionV1({ rootAbs, facts, manifestPath, manifest }) {
|
|
1200
|
+
const plan = [];
|
|
1201
|
+
const refusals = [];
|
|
1202
|
+
for (const entry of manifest.entries) {
|
|
1203
|
+
const rejection = await validateV1Entry({ rawPath: entry.path, facts, manifestPath, rootAbs });
|
|
1204
|
+
if (rejection) refusals.push(rejection);
|
|
1205
|
+
else plan.push({ rel: normalizeRelPath(entry.path), entry, desired: desiredBytes(entry) });
|
|
1206
|
+
}
|
|
1207
|
+
if (refusals.length > 0) throw refusalError(refusals, `projection refused: ${refusals.length} entries violated the write boundary; nothing was written`, { manifest: manifestPath });
|
|
1208
|
+
const actions = [];
|
|
1209
|
+
for (const item of plan) {
|
|
1210
|
+
const action = await classifyV1Entry(rootAbs, item);
|
|
1211
|
+
if (action.refusal) refusals.push({ path: item.rel, ...action.refusal });
|
|
1212
|
+
else actions.push(action);
|
|
1213
|
+
}
|
|
1214
|
+
if (refusals.length > 0) throw refusalError(refusals, `projection refused: ${refusals.length} conflicts detected; nothing was written`, { manifest: manifestPath });
|
|
1215
|
+
const written = [];
|
|
1216
|
+
const unchanged = [];
|
|
1217
|
+
try {
|
|
1218
|
+
for (const action of actions) {
|
|
1219
|
+
if (action.type === "unchanged") { unchanged.push({ path: action.rel, sha256: action.sha256 }); continue; }
|
|
1220
|
+
if (action.type === "overwrite") {
|
|
1221
|
+
const prior = await readRegularState(rootAbs, action.rel);
|
|
1222
|
+
action.priorBytes = prior.bytes;
|
|
1223
|
+
action.priorMode = prior.mode;
|
|
1224
|
+
}
|
|
1225
|
+
await writeFileAtomic(rootAbs, action.rel, action.desired);
|
|
1226
|
+
written.push({ path: action.rel, sha256: action.sha256, mode: action.type });
|
|
1227
|
+
}
|
|
1228
|
+
} catch (cause) {
|
|
1229
|
+
const rollback = await rollbackV1(rootAbs, actions, written);
|
|
1230
|
+
if (rollback.status === "FAILED") throw kitError(KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED, "fatal projection failure: rollback failed", { rollback, zeroWriteSuccess: false });
|
|
1231
|
+
if (cause instanceof ContractsError) throw cause;
|
|
1232
|
+
throw kitError(KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED, `projection write failed: ${cause?.message ?? "unknown"}`, rollback);
|
|
1233
|
+
}
|
|
1234
|
+
for (const item of written) {
|
|
1235
|
+
if (digestBytes(await readFile(path.join(rootAbs, item.path))) !== item.sha256) {
|
|
1236
|
+
const rollback = await rollbackV1(rootAbs, actions, written);
|
|
1237
|
+
throw kitError(KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED, `post-write verification failed for ${item.path}`, { path: item.path, rollback, zeroWriteSuccess: rollback.status === "RESTORED" });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const closure = await computeResourceClosure({ root: rootAbs, resources: [
|
|
1241
|
+
{ path: manifestPath, role: "input" },
|
|
1242
|
+
...written.map((item) => ({ path: item.path, role: "output" })),
|
|
1243
|
+
...unchanged.map((item) => ({ path: item.path, role: "output" })),
|
|
1244
|
+
] });
|
|
385
1245
|
return {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
: "existing content differs from the declared expect.sha256 prior state",
|
|
395
|
-
},
|
|
1246
|
+
kind: "skill-family.projection-receipt",
|
|
1247
|
+
schemaVersion: 1,
|
|
1248
|
+
generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
|
|
1249
|
+
manifest: manifestPath,
|
|
1250
|
+
written,
|
|
1251
|
+
unchanged,
|
|
1252
|
+
closure: { digest: closure.digest, resourceCount: closure.resources.length },
|
|
1253
|
+
policy: "projection wrote only manifest-authorized managed artifacts; handwritten and unauthorized paths were never touched",
|
|
396
1254
|
};
|
|
397
1255
|
}
|
|
1256
|
+
|
|
1257
|
+
/** Runs one backward-compatible projection transaction. */
|
|
1258
|
+
export async function runProjection({ root, manifest: manifestInput, candidateRoot, faultInjector, preparedProjection } = {}) {
|
|
1259
|
+
const rootAbs = await resolveTargetRoot(root ?? ".");
|
|
1260
|
+
let facts;
|
|
1261
|
+
let loaded;
|
|
1262
|
+
let verifiedCandidate;
|
|
1263
|
+
let canonicalPrepared;
|
|
1264
|
+
if (preparedProjection === undefined) {
|
|
1265
|
+
facts = await loadTargetFacts(rootAbs);
|
|
1266
|
+
loaded = await loadProjectionManifest(rootAbs, manifestInput);
|
|
1267
|
+
} else {
|
|
1268
|
+
loaded = await loadProjectionManifest(rootAbs, manifestInput);
|
|
1269
|
+
({ facts, candidate: verifiedCandidate, prepared: canonicalPrepared } = await validatePreparedProjection({
|
|
1270
|
+
prepared: preparedProjection,
|
|
1271
|
+
rootAbs,
|
|
1272
|
+
loaded,
|
|
1273
|
+
candidateRoot,
|
|
1274
|
+
}));
|
|
1275
|
+
}
|
|
1276
|
+
if (loaded.manifest.schemaVersion === 1) {
|
|
1277
|
+
if (candidateRoot !== undefined || faultInjector !== undefined || preparedProjection !== undefined || loaded.external) {
|
|
1278
|
+
if (loaded.external) throw invalidManifest("schemaVersion 1 manifest must remain target-local");
|
|
1279
|
+
}
|
|
1280
|
+
return runProjectionV1({ rootAbs, facts, manifestPath: loaded.manifestPath, manifest: loaded.manifest });
|
|
1281
|
+
}
|
|
1282
|
+
return runProjectionV2({ rootAbs, facts, manifestPath: loaded.manifestPath, manifest: loaded.manifest, externalManifest: loaded.external, candidateRoot, faultInjector, verifiedCandidate, preparedProjection: canonicalPrepared });
|
|
1283
|
+
}
|