@ccdd/project 3.2.0 → 3.3.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/dist/src/broker/index.d.ts +3 -2
- package/dist/src/broker/index.js +14 -7
- package/dist/src/broker/index.js.map +1 -1
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/executors/human-preparation.js +3 -1
- package/dist/src/executors/human-preparation.js.map +1 -1
- package/dist/src/executors/index.js +3 -1
- package/dist/src/executors/index.js.map +1 -1
- package/dist/src/project/cli.js +13 -6
- package/dist/src/project/cli.js.map +1 -1
- package/dist/src/project/identity.d.ts +2 -2
- package/dist/src/project/identity.js +9 -4
- package/dist/src/project/identity.js.map +1 -1
- package/dist/src/project/index.d.ts +3 -1
- package/dist/src/project/index.js +3 -3
- package/dist/src/project/index.js.map +1 -1
- package/dist/src/project/query.js +1 -1
- package/dist/src/project/query.js.map +1 -1
- package/dist/src/project/types.d.ts +5 -1
- package/dist/src/review/client.js +4 -3
- package/dist/src/review/client.js.map +1 -1
- package/dist/src/workspaces/index.d.ts +11 -3
- package/dist/src/workspaces/index.js +330 -146
- package/dist/src/workspaces/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,9 +3,12 @@ import { constants, watch } from 'node:fs';
|
|
|
3
3
|
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, symlink, unlink } from 'node:fs/promises';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
// Bound open files, filesystem requests and 128 KiB hash buffers independently of tree width.
|
|
7
|
+
const SCAN_CONCURRENCY = 8;
|
|
6
8
|
const errorCode = (error) => error !== null && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
7
9
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
8
10
|
const HASH = /^[a-f0-9]{64}$/;
|
|
11
|
+
const validHash = (value) => typeof value === 'string' && HASH.test(value);
|
|
9
12
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
10
13
|
const contained = (root, candidate) => {
|
|
11
14
|
const relative = path.relative(root, candidate);
|
|
@@ -43,10 +46,17 @@ export async function validateStateLocation(repoPath, stateDir) {
|
|
|
43
46
|
function metadata(info) {
|
|
44
47
|
return [info.dev, info.ino, info.mode, info.size, info.mtimeNs, info.ctimeNs, info.birthtimeNs].map(String);
|
|
45
48
|
}
|
|
49
|
+
/** Permit the root ctime change from publication; retain every child tuple and root identity. */
|
|
50
|
+
function publicationMetadataHash(entries) {
|
|
51
|
+
const [rootPath, dev, ino, mode, size, mtime, , birthtime] = entries[0];
|
|
52
|
+
return digest(JSON.stringify([[rootPath, dev, ino, mode, size, mtime, birthtime], ...entries.slice(1)]));
|
|
53
|
+
}
|
|
46
54
|
/** Every entry participates: no Git, ignore rules, extension filters or implicit exclusions. */
|
|
47
|
-
async function inspect(root, { signal, requireReadonly = false, contents = true, onProgress } = {}) {
|
|
55
|
+
async function inspect(root, { signal, requireReadonly = false, contents = true, publicationProof = false, onProgress } = {}) {
|
|
56
|
+
const mode = requireReadonly ? 'copy' : 'lock';
|
|
48
57
|
const entries = [];
|
|
49
58
|
const metadataEntries = [];
|
|
59
|
+
const rootNode = { relative: '' };
|
|
50
60
|
let files = 0, bytes = 0, lastProgress = 0;
|
|
51
61
|
const progress = (force = false, completed = false) => {
|
|
52
62
|
if (!onProgress || (!force && Date.now() - lastProgress < 1000))
|
|
@@ -55,95 +65,168 @@ async function inspect(root, { signal, requireReadonly = false, contents = true,
|
|
|
55
65
|
onProgress({ kind: contents ? 'content' : 'metadata', files, bytes, completed });
|
|
56
66
|
};
|
|
57
67
|
progress(true);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
68
|
+
await new Promise((resolve, reject) => {
|
|
69
|
+
const pending = [];
|
|
70
|
+
let next = 0, active = 0, stopped = false;
|
|
71
|
+
let firstError;
|
|
72
|
+
const check = () => { if (stopped)
|
|
73
|
+
throw firstError; signal?.throwIfAborted(); };
|
|
74
|
+
const enqueue = (task) => { if (!stopped)
|
|
75
|
+
pending.push(task); };
|
|
76
|
+
const complete = (node) => {
|
|
77
|
+
const parent = node.parent;
|
|
78
|
+
if (parent) {
|
|
79
|
+
parent.remaining = parent.remaining - 1;
|
|
80
|
+
if (!parent.remaining)
|
|
81
|
+
enqueue(() => finishDirectory(parent));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const finishDirectory = async (node) => {
|
|
85
|
+
check();
|
|
86
|
+
const after = await lstat(node.relative ? path.join(root, node.relative) : root, { bigint: true });
|
|
87
|
+
if (JSON.stringify(node.metadata) !== JSON.stringify(metadata(after)))
|
|
88
|
+
throw changed(mode, node.relative || '.');
|
|
89
|
+
complete(node);
|
|
90
|
+
};
|
|
91
|
+
const walk = async (node) => {
|
|
92
|
+
check();
|
|
93
|
+
const relative = node.relative;
|
|
94
|
+
const absolute = relative ? path.join(root, relative) : root;
|
|
95
|
+
const before = await lstat(absolute, { bigint: true });
|
|
96
|
+
node.metadata = metadata(before);
|
|
97
|
+
if (requireReadonly && !before.isSymbolicLink() && (before.mode & 146n))
|
|
98
|
+
throw changed('copy', `writable cache entry ${relative || '.'}`);
|
|
99
|
+
if (before.isDirectory()) {
|
|
100
|
+
if (relative)
|
|
101
|
+
node.entry = { path: relative, type: 'directory', executable: Number(before.mode & 73n) };
|
|
102
|
+
const names = (await readdir(absolute)).sort();
|
|
103
|
+
node.children = names.map(name => ({ relative: relative ? `${relative}/${name}` : name, parent: node }));
|
|
104
|
+
node.remaining = names.length;
|
|
105
|
+
for (const child of node.children)
|
|
106
|
+
enqueue(() => walk(child));
|
|
107
|
+
if (!node.remaining)
|
|
108
|
+
enqueue(() => finishDirectory(node));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
else if (before.isFile() && contents) {
|
|
112
|
+
const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
113
|
+
let content;
|
|
114
|
+
try {
|
|
115
|
+
const opened = await file.stat({ bigint: true });
|
|
116
|
+
if (JSON.stringify(metadata(before)) !== JSON.stringify(metadata(opened)))
|
|
117
|
+
throw changed(mode, relative);
|
|
118
|
+
const hash = createHash('sha256');
|
|
119
|
+
const buffer = Buffer.allocUnsafe(128 * 1024);
|
|
120
|
+
let position = 0;
|
|
121
|
+
while (true) {
|
|
122
|
+
check();
|
|
123
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
|
|
124
|
+
if (!bytesRead)
|
|
125
|
+
break;
|
|
126
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
127
|
+
position += bytesRead;
|
|
128
|
+
bytes += bytesRead;
|
|
129
|
+
progress();
|
|
130
|
+
}
|
|
131
|
+
content = hash.digest('hex');
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await file.close();
|
|
91
135
|
}
|
|
92
|
-
|
|
136
|
+
node.entry = { path: relative, type: 'file', executable: Number(before.mode & 73n), content };
|
|
137
|
+
files++;
|
|
138
|
+
progress();
|
|
93
139
|
}
|
|
94
|
-
|
|
95
|
-
|
|
140
|
+
else if (before.isFile()) {
|
|
141
|
+
node.entry = { path: relative, type: 'file', executable: Number(before.mode & 73n) };
|
|
142
|
+
files++;
|
|
143
|
+
progress();
|
|
96
144
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
145
|
+
else if (before.isSymbolicLink()) {
|
|
146
|
+
const target = await readlink(absolute);
|
|
147
|
+
if (path.isAbsolute(target) || !contained(root, path.resolve(path.dirname(absolute), target))) {
|
|
148
|
+
throw failure(`Workspace symlink must be relative and stay inside the workspace: ${relative}`);
|
|
149
|
+
}
|
|
150
|
+
let resolved;
|
|
151
|
+
try {
|
|
152
|
+
resolved = await realpath(absolute);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw failure(`Workspace symlink must resolve to an existing internal entry: ${relative}`);
|
|
156
|
+
}
|
|
157
|
+
if (!contained(root, resolved))
|
|
158
|
+
throw failure(`Workspace symlink escapes the workspace: ${relative}`);
|
|
159
|
+
node.entry = { path: relative, type: 'symlink', target };
|
|
110
160
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
resolved = await realpath(absolute);
|
|
161
|
+
else {
|
|
162
|
+
throw failure(`Unsupported workspace entry (only directories, regular files and internal symlinks are supported): ${relative}`);
|
|
114
163
|
}
|
|
115
|
-
|
|
116
|
-
|
|
164
|
+
const after = await lstat(absolute, { bigint: true });
|
|
165
|
+
if (JSON.stringify(metadata(before)) !== JSON.stringify(metadata(after)))
|
|
166
|
+
throw changed(mode, relative || '.');
|
|
167
|
+
complete(node);
|
|
168
|
+
};
|
|
169
|
+
const pump = () => {
|
|
170
|
+
while (!stopped && active < SCAN_CONCURRENCY && next < pending.length) {
|
|
171
|
+
const task = pending[next];
|
|
172
|
+
pending[next++] = undefined;
|
|
173
|
+
active++;
|
|
174
|
+
void task().then(() => { active--; pump(); }, error => {
|
|
175
|
+
if (!stopped) {
|
|
176
|
+
stopped = true;
|
|
177
|
+
firstError = error;
|
|
178
|
+
pending.length = 0;
|
|
179
|
+
next = 0;
|
|
180
|
+
}
|
|
181
|
+
active--;
|
|
182
|
+
pump();
|
|
183
|
+
});
|
|
117
184
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
185
|
+
// Reject only after active readers have closed their handles in finally blocks.
|
|
186
|
+
if (!active && (stopped || next === pending.length))
|
|
187
|
+
stopped ? reject(firstError) : resolve();
|
|
188
|
+
};
|
|
189
|
+
enqueue(() => walk(rootNode));
|
|
190
|
+
pump();
|
|
191
|
+
});
|
|
192
|
+
signal?.throwIfAborted();
|
|
193
|
+
// Preserve the existing sorted depth-first wire identity, regardless of IO completion order.
|
|
194
|
+
const stack = [rootNode];
|
|
195
|
+
while (stack.length) {
|
|
196
|
+
const node = stack.pop();
|
|
197
|
+
if (node.entry)
|
|
198
|
+
entries.push(node.entry);
|
|
199
|
+
metadataEntries.push([node.relative, ...node.metadata]);
|
|
200
|
+
if (node.children)
|
|
201
|
+
for (let i = node.children.length - 1; i >= 0; i--)
|
|
202
|
+
stack.push(node.children[i]);
|
|
203
|
+
}
|
|
130
204
|
progress(true, true);
|
|
131
|
-
|
|
205
|
+
const hash = digest(JSON.stringify(entries));
|
|
206
|
+
const structureHash = contents ? digest(JSON.stringify(entries.map(entry => entry.type === 'file'
|
|
207
|
+
? { path: entry.path, type: entry.type, executable: entry.executable } : entry))) : hash;
|
|
208
|
+
return { hash, metadataHash: digest(JSON.stringify(metadataEntries)), structureHash, entries,
|
|
209
|
+
...(publicationProof ? { publicationMetadataHash: publicationMetadataHash(metadataEntries) } : {}) };
|
|
132
210
|
}
|
|
133
211
|
export async function fingerprintWorkspace(workspacePath) {
|
|
134
212
|
return (await inspect(await realpath(workspacePath))).hash;
|
|
135
213
|
}
|
|
136
|
-
function observe(root, mode, externalSignal, onProgress) {
|
|
214
|
+
function observe(root, mode, externalSignal, onProgress, integrity = 'content') {
|
|
137
215
|
const controller = new AbortController();
|
|
138
216
|
let closed = false;
|
|
139
217
|
let watcher;
|
|
140
218
|
let timer;
|
|
141
219
|
let scanning;
|
|
220
|
+
let requestedGeneration = 0;
|
|
142
221
|
let baseline;
|
|
143
222
|
let initialized = false;
|
|
144
223
|
let eventPending = false;
|
|
145
|
-
const abort = (reason) => {
|
|
146
|
-
controller.
|
|
224
|
+
const abort = (reason) => {
|
|
225
|
+
if (!controller.signal.aborted)
|
|
226
|
+
controller.abort(reason);
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
timer = undefined;
|
|
229
|
+
};
|
|
147
230
|
const externalAbort = () => abort(externalSignal?.reason);
|
|
148
231
|
externalSignal?.addEventListener('abort', externalAbort, { once: true });
|
|
149
232
|
if (externalSignal?.aborted)
|
|
@@ -160,88 +243,157 @@ function observe(root, mode, externalSignal, onProgress) {
|
|
|
160
243
|
externalSignal?.removeEventListener('abort', externalAbort);
|
|
161
244
|
throw failure(`Cannot monitor review workspace: ${errorMessage(error)}`);
|
|
162
245
|
}
|
|
163
|
-
const check = async (contents =
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
246
|
+
const check = async (contents = integrity === 'content', fresh = false) => {
|
|
247
|
+
const requested = fresh ? ++requestedGeneration : 0;
|
|
248
|
+
const assertOpen = () => {
|
|
249
|
+
controller.signal.throwIfAborted();
|
|
250
|
+
if (closed)
|
|
251
|
+
throw failure('Workspace handle is closed.');
|
|
252
|
+
};
|
|
253
|
+
while (true) {
|
|
254
|
+
assertOpen();
|
|
255
|
+
const active = scanning;
|
|
256
|
+
if (active) {
|
|
257
|
+
const current = await active.promise;
|
|
258
|
+
assertOpen();
|
|
259
|
+
// Explicit boundaries may only share a traversal that started after
|
|
260
|
+
// their request. Background polls may reuse any adequate active scan.
|
|
261
|
+
if ((!contents || active.contents) && (!fresh || active.generation >= requested))
|
|
262
|
+
return current;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Defer progress callbacks until this operation owns the slot, including
|
|
266
|
+
// callers that reenter from a synchronous progress callback.
|
|
267
|
+
const operation = { contents, generation: 0, promise: Promise.resolve().then(async () => {
|
|
268
|
+
assertOpen();
|
|
269
|
+
operation.generation = requestedGeneration;
|
|
270
|
+
let current;
|
|
271
|
+
do {
|
|
272
|
+
eventPending = false;
|
|
273
|
+
current = await inspect(root, { signal: controller.signal, requireReadonly: mode === 'copy', contents, ...(contents ? { onProgress } : {}) });
|
|
274
|
+
if (baseline && ((contents && current.hash !== baseline.hash) || current.metadataHash !== baseline.metadataHash ||
|
|
275
|
+
(integrity === 'metadata' && current.structureHash !== baseline.structureHash)))
|
|
276
|
+
throw changed(mode);
|
|
277
|
+
controller.signal.throwIfAborted();
|
|
278
|
+
} while (eventPending);
|
|
279
|
+
return current;
|
|
280
|
+
}) };
|
|
281
|
+
scanning = operation;
|
|
282
|
+
operation.promise = operation.promise.catch((error) => {
|
|
283
|
+
if (controller.signal.aborted)
|
|
284
|
+
throw controller.signal.reason;
|
|
285
|
+
// An entry can disappear or stop being a directory between lstat and readdir.
|
|
286
|
+
// Once a baseline exists, these races are input mutations, not unrelated IO failures.
|
|
287
|
+
if (baseline && (errorCode(error) === 'ENOENT' || errorCode(error) === 'ENOTDIR'))
|
|
288
|
+
error = changed(mode, errorMessage(error));
|
|
289
|
+
if (mode === 'copy' && errorCode(error) === 'WORKSPACE_CHANGED')
|
|
290
|
+
error = changed(mode, errorMessage(error));
|
|
291
|
+
abort(error);
|
|
292
|
+
throw error;
|
|
293
|
+
}).finally(() => { if (scanning === operation)
|
|
294
|
+
scanning = undefined; });
|
|
295
|
+
const current = await operation.promise;
|
|
296
|
+
assertOpen();
|
|
182
297
|
return current;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const scheduleMetadataPoll = (waitMs = 1000) => {
|
|
301
|
+
if (closed || controller.signal.aborted)
|
|
302
|
+
return;
|
|
303
|
+
timer = setTimeout(() => {
|
|
304
|
+
timer = undefined;
|
|
305
|
+
const started = performance.now();
|
|
306
|
+
// Schedule only after this fallback finishes. Filesystem events still check
|
|
307
|
+
// immediately; slow trees must not accumulate interval callbacks behind IO.
|
|
308
|
+
void check(false).catch(() => { }).finally(() => {
|
|
309
|
+
scheduleMetadataPoll(Math.min(30_000, Math.max(1000, (performance.now() - started) * 10)));
|
|
310
|
+
});
|
|
311
|
+
}, waitMs);
|
|
312
|
+
timer.unref();
|
|
196
313
|
};
|
|
197
314
|
return {
|
|
198
315
|
signal: controller.signal,
|
|
199
|
-
async initialize(expected) {
|
|
200
|
-
const before = await inspect(root, { signal: controller.signal, requireReadonly: mode === 'copy', contents: false,
|
|
201
|
-
|
|
316
|
+
async initialize(expected, publishedFrom) {
|
|
317
|
+
const before = await inspect(root, { signal: controller.signal, requireReadonly: mode === 'copy', contents: false,
|
|
318
|
+
publicationProof: integrity === 'metadata' && publishedFrom !== undefined, onProgress });
|
|
319
|
+
// A staged full-content inspection may cross our own atomic rename under metadata
|
|
320
|
+
// policy only when directory identity, structure and all other metadata still match.
|
|
321
|
+
// Any unexpected difference falls back to actual bytes, as does content policy.
|
|
322
|
+
const publicationMatches = publishedFrom?.publicationMetadataHash !== undefined &&
|
|
323
|
+
publishedFrom.publicationMetadataHash === before.publicationMetadataHash && publishedFrom.structureHash === before.structureHash;
|
|
324
|
+
const current = integrity === 'metadata' && (expected || publicationMatches) ? { ...before, hash: expected?.hash ?? publishedFrom.hash }
|
|
325
|
+
: await inspect(root, { signal: controller.signal, requireReadonly: mode === 'copy', onProgress });
|
|
202
326
|
if (before.metadataHash !== current.metadataHash)
|
|
203
327
|
throw changed(mode, 'input changed while acquiring the workspace');
|
|
204
328
|
baseline = current;
|
|
205
|
-
if (expected && (expected.hash !== baseline.hash || expected.metadataHash !== baseline.metadataHash
|
|
329
|
+
if (expected && (expected.hash !== baseline.hash || expected.metadataHash !== baseline.metadataHash ||
|
|
330
|
+
(expected.structureHash !== undefined && expected.structureHash !== baseline.structureHash))) {
|
|
206
331
|
abort(changed(mode, 'input changed since the request was prepared'));
|
|
207
332
|
controller.signal.throwIfAborted();
|
|
208
333
|
}
|
|
209
334
|
initialized = true;
|
|
210
335
|
if (eventPending)
|
|
211
336
|
await check(false);
|
|
337
|
+
controller.signal.throwIfAborted();
|
|
212
338
|
// FSEvents can deliver notifications from before watch registration. Compare ctime/inode
|
|
213
339
|
// metadata rather than treating a delayed notification alone as a new mutation.
|
|
214
|
-
|
|
215
|
-
void check(false).catch(() => { }); }, 1000);
|
|
216
|
-
timer.unref();
|
|
340
|
+
scheduleMetadataPoll();
|
|
217
341
|
return baseline;
|
|
218
342
|
},
|
|
219
|
-
assertUnchanged: check,
|
|
343
|
+
assertUnchanged: () => check(integrity === 'content', true),
|
|
220
344
|
async close() {
|
|
221
|
-
if (closed)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
345
|
+
if (!closed) {
|
|
346
|
+
closed = true;
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
watcher.close();
|
|
349
|
+
externalSignal?.removeEventListener('abort', externalAbort);
|
|
350
|
+
}
|
|
351
|
+
// Repeated close calls drain the same owned operation. Queued callers see
|
|
352
|
+
// closed after their await and cannot restart scanning without a watcher.
|
|
353
|
+
await scanning?.promise.catch(() => { });
|
|
228
354
|
},
|
|
229
355
|
};
|
|
230
356
|
}
|
|
231
357
|
async function copyEntries(source, destination, entries, signal) {
|
|
358
|
+
// Parents must exist before copying children. Keep staging writable until every
|
|
359
|
+
// in-flight operation has settled, including when another operation fails.
|
|
232
360
|
for (const entry of entries) {
|
|
361
|
+
if (entry.type !== 'directory')
|
|
362
|
+
continue;
|
|
233
363
|
signal?.throwIfAborted();
|
|
234
|
-
|
|
235
|
-
if (entry.type === 'directory')
|
|
236
|
-
await mkdir(target, { mode: 0o700 });
|
|
237
|
-
else if (entry.type === 'symlink')
|
|
238
|
-
await symlink(entry.target, target);
|
|
239
|
-
else {
|
|
240
|
-
await copyFile(path.join(source, entry.path), target, constants.COPYFILE_EXCL);
|
|
241
|
-
await chmod(target, 0o444 | entry.executable);
|
|
242
|
-
}
|
|
364
|
+
await mkdir(path.join(destination, entry.path), { mode: 0o700 });
|
|
243
365
|
}
|
|
366
|
+
const leaves = entries.filter(entry => entry.type !== 'directory');
|
|
367
|
+
let next = 0, failed = false;
|
|
368
|
+
let failure;
|
|
369
|
+
const copyNext = async () => {
|
|
370
|
+
while (!failed && next < leaves.length) {
|
|
371
|
+
const entry = leaves[next++];
|
|
372
|
+
try {
|
|
373
|
+
signal?.throwIfAborted();
|
|
374
|
+
const target = path.join(destination, entry.path);
|
|
375
|
+
if (entry.type === 'symlink')
|
|
376
|
+
await symlink(entry.target, target);
|
|
377
|
+
else {
|
|
378
|
+
await copyFile(path.join(source, entry.path), target, constants.COPYFILE_EXCL);
|
|
379
|
+
await chmod(target, 0o444 | entry.executable);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (!failed) {
|
|
384
|
+
failed = true;
|
|
385
|
+
failure = error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
// Bound filesystem pressure independently of the number of input files.
|
|
391
|
+
await Promise.all(Array.from({ length: Math.min(8, leaves.length) }, copyNext));
|
|
392
|
+
if (failed)
|
|
393
|
+
throw failure;
|
|
394
|
+
signal?.throwIfAborted();
|
|
244
395
|
for (const entry of [...entries].reverse()) {
|
|
396
|
+
signal?.throwIfAborted();
|
|
245
397
|
if (entry.type === 'directory')
|
|
246
398
|
await chmod(path.join(destination, entry.path), 0o444 | entry.executable);
|
|
247
399
|
}
|
|
@@ -318,18 +470,21 @@ export async function removeOwnedWorkspaceTree(directory) {
|
|
|
318
470
|
throw error;
|
|
319
471
|
}
|
|
320
472
|
}
|
|
321
|
-
export async function prepareWorkspace({ repoPath, stateDir, mode = 'copy', signal } = {}) {
|
|
473
|
+
export async function prepareWorkspace({ repoPath, stateDir, mode = 'copy', integrity = 'content', signal } = {}) {
|
|
322
474
|
if (!['copy', 'lock'].includes(mode))
|
|
323
475
|
throw failure('Workspace mode must be copy or lock.');
|
|
476
|
+
if (!['content', 'metadata'].includes(integrity))
|
|
477
|
+
throw failure('Workspace integrity must be content or metadata.');
|
|
324
478
|
const canonical = await validateStateLocation(repoPath, stateDir);
|
|
325
479
|
const source = canonical.repoPath;
|
|
326
|
-
const observer = observe(source, 'lock', signal);
|
|
480
|
+
const observer = observe(source, 'lock', signal, undefined, integrity);
|
|
327
481
|
let stage;
|
|
328
482
|
let releasePublication;
|
|
329
483
|
try {
|
|
330
484
|
const initial = await observer.initialize();
|
|
331
485
|
if (mode === 'lock') {
|
|
332
|
-
const descriptor = Object.freeze({ version: 1, mode, sourcePath: source, path: source, hash: initial.hash, stateDir: canonical.stateDir, baselineMetadataHash: initial.metadataHash
|
|
486
|
+
const descriptor = Object.freeze({ version: 1, mode, sourcePath: source, path: source, hash: initial.hash, stateDir: canonical.stateDir, baselineMetadataHash: initial.metadataHash,
|
|
487
|
+
...(integrity === 'metadata' ? { integrity, structureHash: initial.structureHash } : {}) });
|
|
333
488
|
return { descriptor, signal: observer.signal, assertUnchanged: observer.assertUnchanged, close: observer.close };
|
|
334
489
|
}
|
|
335
490
|
const cacheRoot = path.join(canonical.stateDir, 'workspaces');
|
|
@@ -338,42 +493,63 @@ export async function prepareWorkspace({ repoPath, stateDir, mode = 'copy', sign
|
|
|
338
493
|
throw failure('Workspace cache directory must not be a symlink.');
|
|
339
494
|
const destination = path.join(cacheRoot, initial.hash);
|
|
340
495
|
releasePublication = await acquirePublication(cacheRoot, initial.hash, observer.signal);
|
|
341
|
-
let cached;
|
|
496
|
+
let cached = false;
|
|
497
|
+
let publishedFrom;
|
|
342
498
|
try {
|
|
343
499
|
if (!(await lstat(destination)).isDirectory())
|
|
344
500
|
throw changed('copy', 'cache root must be a regular directory');
|
|
345
|
-
cached =
|
|
501
|
+
cached = true;
|
|
346
502
|
}
|
|
347
503
|
catch (error) {
|
|
348
504
|
if (errorCode(error) !== 'ENOENT')
|
|
349
505
|
throw error;
|
|
350
506
|
}
|
|
351
|
-
if (cached && cached.hash !== initial.hash)
|
|
352
|
-
throw changed('copy', 'cached content does not match its hash');
|
|
353
507
|
if (!cached) {
|
|
354
508
|
stage = path.join(cacheRoot, `.capture-${randomUUID()}`);
|
|
355
509
|
await mkdir(stage, { mode: 0o700 });
|
|
356
510
|
await copyEntries(source, stage, initial.entries, observer.signal);
|
|
357
|
-
const copied = await inspect(stage, { signal: observer.signal, requireReadonly: true });
|
|
511
|
+
const copied = await inspect(stage, { signal: observer.signal, requireReadonly: true, publicationProof: integrity === 'metadata' });
|
|
358
512
|
if (copied.hash !== initial.hash)
|
|
359
513
|
throw changed('lock', 'source changed during copy');
|
|
514
|
+
publishedFrom = copied;
|
|
360
515
|
await observer.assertUnchanged();
|
|
361
516
|
await rename(stage, destination);
|
|
362
517
|
stage = undefined;
|
|
363
518
|
}
|
|
364
|
-
|
|
519
|
+
else {
|
|
520
|
+
// Cache reuse still validates the source whose identity selected this copy.
|
|
521
|
+
await observer.assertUnchanged();
|
|
522
|
+
}
|
|
523
|
+
// A new copy was already checked against stable source immediately before
|
|
524
|
+
// publication. No source bytes are consumed after that capture boundary.
|
|
525
|
+
observer.signal.throwIfAborted();
|
|
365
526
|
await observer.close();
|
|
527
|
+
observer.signal.throwIfAborted();
|
|
366
528
|
if (stage) {
|
|
367
529
|
await removeOwnedWorkspaceTree(stage);
|
|
368
530
|
stage = undefined;
|
|
369
531
|
}
|
|
370
532
|
await releasePublication();
|
|
371
533
|
releasePublication = undefined;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
534
|
+
// Cache hits have no staged proof and receive a full byte validation here under
|
|
535
|
+
// either policy. This same live observer owns every subsequent boundary.
|
|
536
|
+
if (await realpath(destination) !== destination || !(await lstat(destination)).isDirectory()) {
|
|
537
|
+
throw failure('Persisted workspace descriptor has an invalid input path.');
|
|
538
|
+
}
|
|
539
|
+
const copyObserver = observe(destination, 'copy', signal, undefined, integrity);
|
|
540
|
+
try {
|
|
541
|
+
const verified = await copyObserver.initialize(undefined, publishedFrom);
|
|
542
|
+
if (verified.hash !== initial.hash)
|
|
543
|
+
throw changed('copy', 'cached content does not match its hash');
|
|
544
|
+
copyObserver.signal.throwIfAborted();
|
|
545
|
+
const descriptor = Object.freeze({ version: 1, mode, sourcePath: source, path: destination, hash: initial.hash, stateDir: canonical.stateDir, baselineMetadataHash: verified.metadataHash,
|
|
546
|
+
...(integrity === 'metadata' ? { integrity, structureHash: verified.structureHash } : {}) });
|
|
547
|
+
return { descriptor, signal: copyObserver.signal, assertUnchanged: copyObserver.assertUnchanged, close: copyObserver.close };
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
await copyObserver.close();
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
377
553
|
}
|
|
378
554
|
catch (error) {
|
|
379
555
|
await observer.close();
|
|
@@ -383,12 +559,20 @@ export async function prepareWorkspace({ repoPath, stateDir, mode = 'copy', sign
|
|
|
383
559
|
throw error;
|
|
384
560
|
}
|
|
385
561
|
}
|
|
386
|
-
export async function reopenWorkspace(descriptor, { signal, onProgress } = {}) {
|
|
562
|
+
export async function reopenWorkspace(descriptor, { signal, onProgress, integrity } = {}) {
|
|
387
563
|
if (!descriptor || descriptor.version !== 1 || !['copy', 'lock'].includes(descriptor.mode) ||
|
|
388
|
-
!
|
|
564
|
+
!validHash(descriptor.hash) || !validHash(descriptor.baselineMetadataHash) ||
|
|
565
|
+
(descriptor.integrity !== undefined && !['content', 'metadata'].includes(descriptor.integrity)) ||
|
|
566
|
+
(descriptor.structureHash !== undefined && !validHash(descriptor.structureHash)) ||
|
|
567
|
+
(descriptor.integrity === 'metadata' && !validHash(descriptor.structureHash)) ||
|
|
389
568
|
['sourcePath', 'path', 'stateDir'].some(key => typeof descriptor[key] !== 'string' || !path.isAbsolute(descriptor[key]))) {
|
|
390
569
|
throw failure('Invalid persisted workspace descriptor.');
|
|
391
570
|
}
|
|
571
|
+
if (integrity !== undefined && !['content', 'metadata'].includes(integrity))
|
|
572
|
+
throw failure('Workspace integrity must be content or metadata.');
|
|
573
|
+
if (integrity === 'metadata' && descriptor.integrity !== 'metadata')
|
|
574
|
+
throw failure('Metadata integrity requires an explicitly captured metadata-policy descriptor.');
|
|
575
|
+
const effectiveIntegrity = integrity ?? descriptor.integrity ?? 'content';
|
|
392
576
|
const { mode, sourcePath, stateDir } = descriptor;
|
|
393
577
|
if (contained(sourcePath, stateDir))
|
|
394
578
|
throw failure('Persisted stateDir must be outside the source workspace.');
|
|
@@ -396,10 +580,10 @@ export async function reopenWorkspace(descriptor, { signal, onProgress } = {}) {
|
|
|
396
580
|
if (descriptor.path !== expectedPath || await realpath(descriptor.path) !== expectedPath || !(await lstat(expectedPath)).isDirectory()) {
|
|
397
581
|
throw failure('Persisted workspace descriptor has an invalid input path.');
|
|
398
582
|
}
|
|
399
|
-
const observer = observe(expectedPath, mode, signal, onProgress);
|
|
583
|
+
const observer = observe(expectedPath, mode, signal, onProgress, effectiveIntegrity);
|
|
400
584
|
try {
|
|
401
|
-
await observer.initialize({ hash: descriptor.hash, metadataHash: descriptor.baselineMetadataHash });
|
|
402
|
-
return { descriptor: Object.freeze({ ...descriptor }), signal: observer.signal, assertUnchanged: observer.assertUnchanged, close: observer.close };
|
|
585
|
+
await observer.initialize({ hash: descriptor.hash, metadataHash: descriptor.baselineMetadataHash, structureHash: descriptor.structureHash });
|
|
586
|
+
return { descriptor: Object.freeze({ ...descriptor, ...(integrity !== undefined ? { integrity } : {}) }), signal: observer.signal, assertUnchanged: observer.assertUnchanged, close: observer.close };
|
|
403
587
|
}
|
|
404
588
|
catch (error) {
|
|
405
589
|
await observer.close();
|