@6reduk/workspace-pipeline 0.1.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/LICENSE +21 -0
- package/README.md +78 -0
- package/docs/collaboration.md +59 -0
- package/docs/config-fields.md +65 -0
- package/docs/contracts.md +149 -0
- package/docs/doctor.md +165 -0
- package/docs/launch.md +50 -0
- package/docs/lifecycle-cli.md +141 -0
- package/docs/lifecycle.md +42 -0
- package/docs/migrations/unity.md +288 -0
- package/docs/native-provider-format.md +149 -0
- package/docs/provider-bundles.md +81 -0
- package/docs/provider-resources.md +37 -0
- package/docs/release.md +25 -0
- package/docs/remove.md +70 -0
- package/docs/repair.md +80 -0
- package/docs/repositories.md +209 -0
- package/docs/repository-manual-recovery.md +102 -0
- package/docs/repository-observations.md +29 -0
- package/docs/repository-recovery.md +204 -0
- package/docs/repository-retention.md +46 -0
- package/docs/repository-transport-budgets.md +26 -0
- package/docs/retention.md +237 -0
- package/docs/source.md +50 -0
- package/docs/switch.md +412 -0
- package/package.json +39 -0
- package/schemas/common.schema.json +251 -0
- package/schemas/inventory.schema.json +15 -0
- package/schemas/operation.schema.json +286 -0
- package/schemas/pipeline.schema.json +317 -0
- package/schemas/state.schema.json +302 -0
- package/schemas/workspace.schema.json +67 -0
- package/src/cli.js +7 -0
- package/src/commands/adopt.js +2 -0
- package/src/commands/bootstrap-recovery.js +90 -0
- package/src/commands/dispatch.js +394 -0
- package/src/commands/init.js +84 -0
- package/src/commands/launch.js +69 -0
- package/src/commands/migration-apply.js +53 -0
- package/src/commands/migration.js +50 -0
- package/src/commands/repositories.js +61 -0
- package/src/commands/repository-abandon.js +16 -0
- package/src/commands/repository-ancestors.js +21 -0
- package/src/commands/repository-locks.js +77 -0
- package/src/contracts/parse.js +57 -0
- package/src/contracts/semantic.js +240 -0
- package/src/contracts/validate.js +21 -0
- package/src/launch/grok.js +20 -0
- package/src/migrations/legacy-unity-begin.js +35 -0
- package/src/migrations/legacy-unity-compensate.js +82 -0
- package/src/migrations/legacy-unity-deactivate.js +60 -0
- package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
- package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
- package/src/migrations/legacy-unity-finalize.js +88 -0
- package/src/migrations/legacy-unity-install-recovery.js +76 -0
- package/src/migrations/legacy-unity-install-resume.js +60 -0
- package/src/migrations/legacy-unity-install.js +88 -0
- package/src/migrations/legacy-unity-lease.js +136 -0
- package/src/migrations/legacy-unity-preflight.js +67 -0
- package/src/migrations/legacy-unity-preview.js +91 -0
- package/src/migrations/legacy-unity-resume-apply.js +39 -0
- package/src/migrations/legacy-unity-resume.js +41 -0
- package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
- package/src/migrations/legacy-unity.js +99 -0
- package/src/operations/apply.js +576 -0
- package/src/operations/backup.js +94 -0
- package/src/operations/bootstrap-lock.js +87 -0
- package/src/operations/bootstrap-owner-retirement.js +121 -0
- package/src/operations/bundle-update.js +54 -0
- package/src/operations/config-fields.js +24 -0
- package/src/operations/continuation-lifecycle.js +77 -0
- package/src/operations/doctor.js +214 -0
- package/src/operations/history.js +170 -0
- package/src/operations/installer-identity.js +49 -0
- package/src/operations/journal.js +142 -0
- package/src/operations/lifecycle.js +133 -0
- package/src/operations/lineage-guard.js +20 -0
- package/src/operations/lock.js +109 -0
- package/src/operations/maintenance.js +62 -0
- package/src/operations/migration-pending.js +22 -0
- package/src/operations/ownership.js +122 -0
- package/src/operations/plan.js +278 -0
- package/src/operations/reconciliation.js +112 -0
- package/src/operations/recovery-lease.js +66 -0
- package/src/operations/remove.js +140 -0
- package/src/operations/repair.js +188 -0
- package/src/operations/repository-abandon.js +192 -0
- package/src/operations/repository-ancestors.js +158 -0
- package/src/operations/repository-apply.js +122 -0
- package/src/operations/repository-authorization.js +48 -0
- package/src/operations/repository-bootstrap-continuation.js +190 -0
- package/src/operations/repository-bootstrap-reconcile.js +106 -0
- package/src/operations/repository-bootstrap-recover.js +114 -0
- package/src/operations/repository-bootstrap.js +82 -0
- package/src/operations/repository-clone.js +63 -0
- package/src/operations/repository-history.js +108 -0
- package/src/operations/repository-inputs.js +41 -0
- package/src/operations/repository-journal.js +127 -0
- package/src/operations/repository-lock-reconcile.js +401 -0
- package/src/operations/repository-pending.js +29 -0
- package/src/operations/repository-reconcile.js +182 -0
- package/src/operations/repository-resumption-approvals.js +77 -0
- package/src/operations/repository-retention-apply.js +75 -0
- package/src/operations/repository-retention.js +137 -0
- package/src/operations/repository-workspace.js +78 -0
- package/src/operations/retention-apply.js +133 -0
- package/src/operations/retention-combined-scan.js +30 -0
- package/src/operations/retention-combined.js +41 -0
- package/src/operations/retention-policy.js +65 -0
- package/src/operations/retention-receipts.js +126 -0
- package/src/operations/retention-scan.js +86 -0
- package/src/operations/retention.js +56 -0
- package/src/operations/state.js +210 -0
- package/src/operations/switch-activate.js +64 -0
- package/src/operations/switch-backups.js +29 -0
- package/src/operations/switch-continuation-journal.js +94 -0
- package/src/operations/switch-continuation-pending.js +60 -0
- package/src/operations/switch-continuation-records.js +109 -0
- package/src/operations/switch-continuation-recovery.js +91 -0
- package/src/operations/switch-continuation-runtime.js +135 -0
- package/src/operations/switch-continuation-store.js +120 -0
- package/src/operations/switch-continuation.js +76 -0
- package/src/operations/switch-execute.js +68 -0
- package/src/operations/switch-inspect.js +45 -0
- package/src/operations/switch-journal-store.js +109 -0
- package/src/operations/switch-journal.js +71 -0
- package/src/operations/switch-lifecycle.js +72 -0
- package/src/operations/switch-pending.js +43 -0
- package/src/operations/switch-preflight.js +73 -0
- package/src/operations/switch-prepare.js +75 -0
- package/src/operations/switch-records.js +60 -0
- package/src/operations/switch-recovery-store.js +74 -0
- package/src/operations/switch.js +52 -0
- package/src/operations/toml-fields.js +133 -0
- package/src/providers/bundles.js +42 -0
- package/src/providers/common-entry.js +16 -0
- package/src/providers/grok.js +26 -0
- package/src/providers/interface.js +25 -0
- package/src/providers/kimi.js +26 -0
- package/src/providers/native.js +155 -0
- package/src/providers/registry.js +10 -0
- package/src/providers/shared.js +51 -0
- package/src/providers/source.js +30 -0
- package/src/source/git.js +303 -0
- package/src/source/inventory.js +87 -0
- package/src/source/repository-budget.js +18 -0
- package/src/source/snapshot.js +37 -0
- package/src/workspace/paths.js +54 -0
- package/src/workspace/profiles.js +8 -0
- package/src/workspace/repositories.js +57 -0
- package/src/workspace/repository-inventory.js +77 -0
- package/src/workspace/repository-observation.js +38 -0
- package/src/workspace/repository-preflight.js +129 -0
- package/src/workspace/repository-preview.js +196 -0
- package/src/workspace/repository-tree.js +57 -0
- package/src/workspace/reserved.js +11 -0
- package/src/workspace/resolve.js +53 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fail } from '../contracts/parse.js';
|
|
2
|
+
|
|
3
|
+
export const REPOSITORY_TRANSPORT_LIMITS = Object.freeze({
|
|
4
|
+
packLimit: 100 * 1024 * 1024 * 1024,
|
|
5
|
+
gitMs: 30 * 60 * 1000,
|
|
6
|
+
acquisitionMs: 2 * 60 * 60 * 1000
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function repositoryBudget(options = {}) {
|
|
10
|
+
const result = {};
|
|
11
|
+
for (const [key, maximum] of Object.entries(REPOSITORY_TRANSPORT_LIMITS)) {
|
|
12
|
+
const value = options[key] ?? maximum;
|
|
13
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) fail('repository-source.' + key + '-limit');
|
|
14
|
+
result[key] = value;
|
|
15
|
+
}
|
|
16
|
+
if (result.gitMs > result.acquisitionMs) fail('repository-source.time-limits');
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, writeFile, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { fail, ContractError } from '../contracts/parse.js';
|
|
5
|
+
import { portablePath } from '../contracts/semantic.js';
|
|
6
|
+
import { LIMITS, sha256, cap } from './inventory.js';
|
|
7
|
+
|
|
8
|
+
export function checkDestination(destination, platform = process.platform) {
|
|
9
|
+
cap(platform === 'win32' ? destination.length : Buffer.byteLength(destination),
|
|
10
|
+
platform === 'win32' ? LIMITS.windowsPath : LIMITS.posixPath, 'snapshot.path-length');
|
|
11
|
+
}
|
|
12
|
+
// Disposable preparation only. Does not activate an installed workspace.
|
|
13
|
+
// No implicit cleanup: failed preparations remain identifiable to the caller.
|
|
14
|
+
export async function materialize(verified, tempRoot = tmpdir()) {
|
|
15
|
+
const parent = await realpath(tempRoot);
|
|
16
|
+
const root = await mkdtemp(path.join(parent, 'wpc-snapshot-'));
|
|
17
|
+
try {
|
|
18
|
+
const prepared = [];
|
|
19
|
+
for (const [name, bytes] of verified.files) {
|
|
20
|
+
portablePath(name);
|
|
21
|
+
if (!Buffer.isBuffer(bytes) || sha256(bytes) !== verified.fileHashes[name]) fail('snapshot.input');
|
|
22
|
+
const target = path.join(root, ...name.split('/'));
|
|
23
|
+
checkDestination(target);
|
|
24
|
+
prepared.push([target, bytes]);
|
|
25
|
+
}
|
|
26
|
+
for (const [target, bytes] of prepared) {
|
|
27
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
28
|
+
await writeFile(target, bytes, { flag: 'wx', mode: 0o600 });
|
|
29
|
+
if (sha256(await readFile(target)) !== sha256(bytes)) fail('snapshot.readback');
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
const error = cause instanceof ContractError ? cause : new ContractError('snapshot.io');
|
|
34
|
+
error.snapshotPath = root;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, readdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import { fail, ContractError } from '../contracts/parse.js';
|
|
4
|
+
import { portablePath } from '../contracts/semantic.js';
|
|
5
|
+
|
|
6
|
+
export function absoluteRoot(value) {
|
|
7
|
+
if (typeof value !== 'string' || !path.isAbsolute(value) || /[\u0000-\u001f\u007f]/.test(value)) fail('layout.absolute-root');
|
|
8
|
+
return path.resolve(value);
|
|
9
|
+
}
|
|
10
|
+
export function pathBudget(value, platform = process.platform) {
|
|
11
|
+
if ((platform === 'win32' ? value.length > 240 : Buffer.byteLength(value) > 1024)) fail('layout.path-length');
|
|
12
|
+
}
|
|
13
|
+
export function resolveChild(root, relative) {
|
|
14
|
+
if (relative !== '.') portablePath(relative);
|
|
15
|
+
const target = path.resolve(root, ...relative.split('/'));
|
|
16
|
+
const rel = path.relative(root, target);
|
|
17
|
+
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) fail('layout.escape');
|
|
18
|
+
pathBudget(target);
|
|
19
|
+
return target;
|
|
20
|
+
}
|
|
21
|
+
// Read-only observation, not an atomic preflight. S5 must recheck before writes.
|
|
22
|
+
// Reject links (including internal ones) rather than guessing their ownership.
|
|
23
|
+
export async function inspectDirectory(value) {
|
|
24
|
+
const absolute = absoluteRoot(value), parsed = path.parse(absolute);
|
|
25
|
+
let current = parsed.root, missing = false;
|
|
26
|
+
try {
|
|
27
|
+
for (const segment of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
28
|
+
if (!missing) {
|
|
29
|
+
let names;
|
|
30
|
+
try { names = await readdir(current); }
|
|
31
|
+
catch (e) {
|
|
32
|
+
// Traversal may be permitted while parent enumeration is forbidden.
|
|
33
|
+
// Only an existing exact canonical child can justify continuing.
|
|
34
|
+
if (!['EACCES', 'EPERM'].includes(e.code)) throw e;
|
|
35
|
+
const actual = await realpath(path.join(current, segment));
|
|
36
|
+
if (path.basename(actual) !== segment) fail('layout.case-alias');
|
|
37
|
+
names = [segment];
|
|
38
|
+
}
|
|
39
|
+
const matches = names.filter(n => n.toLowerCase() === segment.toLowerCase());
|
|
40
|
+
if (matches.length > 1 || (matches.length === 1 && matches[0] !== segment)) fail('layout.case-alias');
|
|
41
|
+
}
|
|
42
|
+
current = path.join(current, segment);
|
|
43
|
+
if (missing) continue;
|
|
44
|
+
let stat;
|
|
45
|
+
try { stat = await lstat(current); }
|
|
46
|
+
catch (e) { if (e.code === 'ENOENT') { missing = true; continue; } throw e; }
|
|
47
|
+
if (stat.isSymbolicLink()) fail('layout.link');
|
|
48
|
+
if (!stat.isDirectory()) fail('layout.not-directory');
|
|
49
|
+
const actual = await realpath(current);
|
|
50
|
+
if (path.resolve(actual).toLowerCase() !== path.resolve(current).toLowerCase()) fail('layout.reparse');
|
|
51
|
+
}
|
|
52
|
+
return { path: absolute, exists: !missing };
|
|
53
|
+
} catch (e) { throw e instanceof ContractError ? e : new ContractError('layout.io'); }
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { validateBundle } from '../contracts/semantic.js';
|
|
2
|
+
|
|
3
|
+
// Pipeline is the verified manifest of the selected S2 snapshot, not a live ref.
|
|
4
|
+
// No filesystem/network lookup or merging is performed at this boundary.
|
|
5
|
+
export function selectProfile(pipeline, workspace, adapters) {
|
|
6
|
+
const selected = validateBundle(pipeline, workspace, adapters);
|
|
7
|
+
return structuredClone({ ...selected, profile: workspace.profile ?? null });
|
|
8
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fail } from '../contracts/parse.js';
|
|
3
|
+
import { absoluteRoot, pathBudget } from './paths.js';
|
|
4
|
+
import { planLayout } from './resolve.js';
|
|
5
|
+
|
|
6
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const overlaps = (a, b) => {
|
|
8
|
+
const left = a.toLowerCase(), right = b.toLowerCase();
|
|
9
|
+
const relative = path.relative(left, right);
|
|
10
|
+
const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
|
11
|
+
if (inside) return true;
|
|
12
|
+
const reverse = path.relative(right, left);
|
|
13
|
+
return reverse === '' || (!path.isAbsolute(reverse) && reverse !== '..' && !reverse.startsWith('..' + path.sep));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Internal pure intent planner, NOT a saved executable preview. No filesystem,
|
|
17
|
+
// Git, network, approval or installation state is inspected/changed here.
|
|
18
|
+
export function planRepositoryIntents(pipeline, workspace, wrapper, choices, { command, ...layoutOptions } = {}) {
|
|
19
|
+
if (!['init', 'adopt', 'wrap'].includes(command)) fail('repositories.command');
|
|
20
|
+
if (!record(choices)) fail('repositories.choices');
|
|
21
|
+
const layout = planLayout(pipeline, workspace, wrapper, layoutOptions);
|
|
22
|
+
const ids = Object.keys(layout.repositories).sort();
|
|
23
|
+
if (Object.keys(choices).length !== ids.length || ids.some(id => !Object.hasOwn(choices, id)))
|
|
24
|
+
fail('repositories.selection');
|
|
25
|
+
const operations = [], sources = [];
|
|
26
|
+
for (const id of ids) {
|
|
27
|
+
const choice = choices[id];
|
|
28
|
+
if (!record(choice) || !['keep', 'directory', 'init', 'clone', 'move'].includes(choice.action))
|
|
29
|
+
fail('repositories.action');
|
|
30
|
+
const allowed = choice.action === 'move' ? ['action', 'from'] : ['action'];
|
|
31
|
+
if (Object.keys(choice).some(key => !allowed.includes(key))) fail('repositories.fields');
|
|
32
|
+
if (command === 'init' && choice.action === 'move') fail('repositories.move-command');
|
|
33
|
+
const target = layout.repositories[id].path;
|
|
34
|
+
const operation = { repository: id, action: choice.action, target };
|
|
35
|
+
if (choice.action === 'init') operation.initialization = { initialBranch:'main', templates:'disabled' };
|
|
36
|
+
if (choice.action === 'clone') {
|
|
37
|
+
const source = layout.layout.repositories[id].source;
|
|
38
|
+
if (!source) fail('repositories.clone-source');
|
|
39
|
+
// Local path base and ref must be resolved/frozen by the later IO preview.
|
|
40
|
+
operation.source = structuredClone(source);
|
|
41
|
+
operation.clonePolicy = {head:'detached',history:'depth-1',origin:'not-configured',templates:'disabled'};
|
|
42
|
+
}
|
|
43
|
+
if (choice.action === 'move') {
|
|
44
|
+
const from = absoluteRoot(choice.from); pathBudget(from);
|
|
45
|
+
// Do not move a wrapper ancestor, any destination or overlapping sources.
|
|
46
|
+
const relative = path.relative(from.toLowerCase(), layout.wrapper.toLowerCase());
|
|
47
|
+
if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep)))
|
|
48
|
+
fail('repositories.wrapper-source');
|
|
49
|
+
if (Object.values(layout.repositories).some(repo => overlaps(from, repo.path)) || sources.some(source => overlaps(from, source)))
|
|
50
|
+
fail('repositories.overlap');
|
|
51
|
+
operation.from = from; sources.push(from);
|
|
52
|
+
}
|
|
53
|
+
operations.push(operation);
|
|
54
|
+
}
|
|
55
|
+
return { command: command === 'wrap' ? 'adopt' : command, wrapper: layout.wrapper,
|
|
56
|
+
layout, operations, status: 'intent-only', filesystem: 'not-inspected', executionAuthorized: false };
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, readdir } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { ContractError, fail } from '../contracts/parse.js';
|
|
6
|
+
import { absoluteRoot, inspectDirectory } from './paths.js';
|
|
7
|
+
|
|
8
|
+
// Separate from S2 package caps: user repositories may contain large assets.
|
|
9
|
+
// A cap failure never yields a partial successful inventory.
|
|
10
|
+
const defaults = Object.freeze({ entries: 200000, bytes: 100 * 1024 ** 3, depth: 128 });
|
|
11
|
+
const stamp = s => [s.dev,s.ino,s.mode,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(':');
|
|
12
|
+
const identity = s => ({ dev:String(s.dev), ino:String(s.ino), mode:String(s.mode), nlink:String(s.nlink),
|
|
13
|
+
size:String(s.size), mtimeNs:String(s.mtimeNs), ctimeNs:String(s.ctimeNs) });
|
|
14
|
+
|
|
15
|
+
// Read-only observation, not an atomic snapshot or proof of movable Git metadata.
|
|
16
|
+
// Includes ignored/untracked data and .git without invoking Git or user hooks.
|
|
17
|
+
export async function inventoryRepository(directory, options = {}) {
|
|
18
|
+
if (Object.keys(options).some(key => !Object.hasOwn(defaults,key))) fail('repositories.inventory-options');
|
|
19
|
+
const limits = { ...defaults, ...options };
|
|
20
|
+
for (const key of Object.keys(defaults))
|
|
21
|
+
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1 || limits[key] > defaults[key])
|
|
22
|
+
fail('repositories.inventory-limit');
|
|
23
|
+
const root = absoluteRoot(directory), entries = [];
|
|
24
|
+
let bytes = 0;
|
|
25
|
+
try {
|
|
26
|
+
if (!(await inspectDirectory(root)).exists) fail('repositories.missing');
|
|
27
|
+
const visit = async (filename, relative, depth) => {
|
|
28
|
+
if (depth > limits.depth) fail('repositories.depth');
|
|
29
|
+
if (entries.length >= limits.entries) fail('repositories.entries');
|
|
30
|
+
const before = await lstat(filename,{bigint:true});
|
|
31
|
+
if (before.isSymbolicLink()) fail('repositories.link');
|
|
32
|
+
if (before.isDirectory()) {
|
|
33
|
+
await inspectDirectory(filename);
|
|
34
|
+
const names = (await readdir(filename)).sort();
|
|
35
|
+
if (names.length > limits.entries - entries.length - 1) fail('repositories.entries');
|
|
36
|
+
entries.push({path:relative,type:'directory',identity:identity(before)});
|
|
37
|
+
for (const name of names) await visit(path.join(filename,name),relative === '.' ? name : relative+'/'+name,depth+1);
|
|
38
|
+
const after = await lstat(filename,{bigint:true});
|
|
39
|
+
if (stamp(before) !== stamp(after) || JSON.stringify(names) !== JSON.stringify((await readdir(filename)).sort()))
|
|
40
|
+
fail('repositories.drift');
|
|
41
|
+
} else if (before.isFile()) {
|
|
42
|
+
// Shared mutable bytes would invalidate preservation/independence claims.
|
|
43
|
+
if (before.nlink !== 1n) fail('repositories.hardlink');
|
|
44
|
+
if (before.size > BigInt(limits.bytes - bytes)) fail('repositories.bytes');
|
|
45
|
+
await inspectDirectory(path.dirname(filename));
|
|
46
|
+
const handle = await open(filename,constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
47
|
+
let total = 0;
|
|
48
|
+
const hash = createHash('sha256');
|
|
49
|
+
try {
|
|
50
|
+
if (stamp(before) !== stamp(await handle.stat({bigint:true}))) fail('repositories.drift');
|
|
51
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
52
|
+
for (;;) {
|
|
53
|
+
const {bytesRead} = await handle.read(buffer,0,buffer.length,null);
|
|
54
|
+
if (!bytesRead) break;
|
|
55
|
+
total += bytesRead;
|
|
56
|
+
if (bytes + total > limits.bytes || BigInt(total) > before.size) fail('repositories.bytes');
|
|
57
|
+
hash.update(buffer.subarray(0,bytesRead));
|
|
58
|
+
}
|
|
59
|
+
if (BigInt(total) !== before.size || stamp(before) !== stamp(await handle.stat({bigint:true})))
|
|
60
|
+
fail('repositories.drift');
|
|
61
|
+
} finally { await handle.close(); }
|
|
62
|
+
if (stamp(before) !== stamp(await lstat(filename,{bigint:true}))) fail('repositories.drift');
|
|
63
|
+
bytes += total;
|
|
64
|
+
entries.push({path:relative,type:'file',identity:identity(before),sha256:hash.digest('hex')});
|
|
65
|
+
} else fail('repositories.special-file');
|
|
66
|
+
};
|
|
67
|
+
await visit(root,'.',0);
|
|
68
|
+
// Catch changes to an earlier file while later entries were read.
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const current = await lstat(entry.path === '.' ? root : path.join(root,...entry.path.split('/')),{bigint:true});
|
|
71
|
+
if (current.isSymbolicLink() || JSON.stringify(identity(current)) !== JSON.stringify(entry.identity)) fail('repositories.drift');
|
|
72
|
+
}
|
|
73
|
+
return { root, entries, bytes, status:'observation-only', git:'not-verified',
|
|
74
|
+
executionAuthorized:false, digest:'sha256:'+createHash('sha256').update(JSON.stringify(entries)).digest('hex') };
|
|
75
|
+
} catch (error) { throw error instanceof ContractError ? error : new ContractError(
|
|
76
|
+
error.code==='EBUSY'?'repositories.busy':['EACCES','EPERM'].includes(error.code)?'repositories.access-denied':'repositories.inventory-io'); }
|
|
77
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
2
|
+
import {fail} from '../contracts/parse.js';
|
|
3
|
+
|
|
4
|
+
const hash=v=>typeof v==='string' && /^sha256:[a-f0-9]{64}$/.test(v);
|
|
5
|
+
const exact=(v,keys)=>v && typeof v==='object' && !Array.isArray(v) &&
|
|
6
|
+
Object.keys(v).sort().join(',')===[...keys].sort().join(',');
|
|
7
|
+
const identityKeys=['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'];
|
|
8
|
+
|
|
9
|
+
// Full observations are transient. Saved records bind every entry through the
|
|
10
|
+
// exact identity digest and the separate rename-stable content projection.
|
|
11
|
+
// This is an observation, not proof of execution or permission to replay it.
|
|
12
|
+
export function validateInventorySummary(v) {
|
|
13
|
+
if(!exact(v,['kind','root','rootIdentity','entryCount','bytes','digest','contentDigest']) ||
|
|
14
|
+
v.kind!=='repository-inventory-summary' || typeof v.root!=='string' || !v.root ||
|
|
15
|
+
!exact(v.rootIdentity,identityKeys) || identityKeys.some(k=>typeof v.rootIdentity[k]!=='string' || !/^-?\d+$/.test(v.rootIdentity[k])) ||
|
|
16
|
+
!Number.isSafeInteger(v.entryCount) || v.entryCount<1 || v.entryCount>200000 ||
|
|
17
|
+
!Number.isSafeInteger(v.bytes) || v.bytes<0 || v.bytes>100*1024**3 ||
|
|
18
|
+
!hash(v.digest) || !hash(v.contentDigest))fail('repositories.inventory-summary');
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
export function repositoryContentDigest(inventory) {
|
|
22
|
+
if(inventory?.kind==='repository-inventory-summary')return validateInventorySummary(inventory).contentDigest;
|
|
23
|
+
return contractDigest(inventory.entries.map(e=>({path:e.path,type:e.type,mode:e.identity.mode,
|
|
24
|
+
...(e.type==='file'?{size:e.identity.size,sha256:e.sha256}:{})})));
|
|
25
|
+
}
|
|
26
|
+
export function summarizeInventory(inventory) {
|
|
27
|
+
if(inventory?.kind==='repository-inventory-summary')return structuredClone(validateInventorySummary(inventory));
|
|
28
|
+
if(!Array.isArray(inventory?.entries) || inventory.entries[0]?.path!=='.' ||
|
|
29
|
+
inventory.entries[0]?.type!=='directory')fail('repositories.inventory-summary');
|
|
30
|
+
return validateInventorySummary({kind:'repository-inventory-summary',root:inventory.root,
|
|
31
|
+
rootIdentity:structuredClone(inventory.entries[0].identity),entryCount:inventory.entries.length,
|
|
32
|
+
bytes:inventory.bytes,digest:inventory.digest,contentDigest:repositoryContentDigest(inventory)});
|
|
33
|
+
}
|
|
34
|
+
export function summarizeTree(tree) {
|
|
35
|
+
if(!Array.isArray(tree?.entries))fail('repository-tree.summary');
|
|
36
|
+
const {entries,...body}=tree;
|
|
37
|
+
return {kind:'repository-tree-summary',...body,entryCount:entries.length};
|
|
38
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { fail, ContractError } from '../contracts/parse.js';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { runRepositoryGit } from '../source/git.js';
|
|
7
|
+
import { repositoryBudget } from '../source/repository-budget.js';
|
|
8
|
+
import { sha256 } from '../source/inventory.js';
|
|
9
|
+
import { absoluteRoot, inspectDirectory } from './paths.js';
|
|
10
|
+
import { inventoryRepository } from './repository-inventory.js';
|
|
11
|
+
|
|
12
|
+
// Refuse occupied destinations, even empty directories. No overwrite/merge implied.
|
|
13
|
+
export async function inspectRepositoryDestination(directory) {
|
|
14
|
+
const target = absoluteRoot(directory);
|
|
15
|
+
try {
|
|
16
|
+
if ((await inspectDirectory(target)).exists) fail('repositories.destination-exists');
|
|
17
|
+
let ancestor = path.dirname(target);
|
|
18
|
+
while (!(await inspectDirectory(ancestor)).exists) ancestor = path.dirname(ancestor);
|
|
19
|
+
const stat = await lstat(ancestor,{bigint:true});
|
|
20
|
+
return {target,exists:false,ancestor,ancestorIdentity:{dev:String(stat.dev),ino:String(stat.ino)},
|
|
21
|
+
executionAuthorized:false};
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw error instanceof ContractError ? error : new ContractError(error.code==='EBUSY'?'repositories.busy':
|
|
24
|
+
['EACCES','EPERM'].includes(error.code)?'repositories.access-denied':'repositories.destination-io');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Conservative local metadata screening. Passing this is NOT Git health, full
|
|
29
|
+
// relocatability, lock availability or an executable preview. Unknown config blocks.
|
|
30
|
+
export async function inspectRepositoryMetadata(directory, requestedBudget = {}) {
|
|
31
|
+
const budget=repositoryBudget(requestedBudget);
|
|
32
|
+
const options={...budget,deadline:requestedBudget.deadline ?? performance.now()+budget.acquisitionMs};
|
|
33
|
+
const inventory = await inventoryRepository(directory);
|
|
34
|
+
const entries = new Map(inventory.entries.map(entry=>[entry.path,entry]));
|
|
35
|
+
const blockers = [];
|
|
36
|
+
const block = code => { if (!blockers.includes(code)) blockers.push(code); };
|
|
37
|
+
if (entries.get('.git')?.type !== 'directory') block('git.embedded-directory-required');
|
|
38
|
+
for (const entry of inventory.entries) {
|
|
39
|
+
const name = entry.path.toLowerCase();
|
|
40
|
+
if (name.endsWith('/.git') && name !== '.git') block('git.nested-repository');
|
|
41
|
+
if (name === '.gitmodules' || name.endsWith('/.gitmodules') || name.startsWith('.git/modules/')) block('git.submodules');
|
|
42
|
+
if (name === '.git/commondir' || name === '.git/gitdir' || name === '.git/worktrees' || name.startsWith('.git/worktrees/')) block('git.linked-worktrees');
|
|
43
|
+
if (name === '.git/objects/info/alternates' || name === '.git/objects/info/http-alternates') block('git.external-objects');
|
|
44
|
+
if (name.startsWith('.git/') && name.endsWith('.lock')) block('git.lock-present');
|
|
45
|
+
if (name === '.git/config.worktree') block('git.worktree-config');
|
|
46
|
+
if (['.git/merge_head','.git/cherry_pick_head','.git/revert_head','.git/bisect_start',
|
|
47
|
+
'.git/rebase-merge','.git/rebase-apply','.git/sequencer'].includes(name)) block('git.operation-pending');
|
|
48
|
+
if (name.startsWith('.git/hooks/') && entry.type === 'file' && !name.endsWith('.sample')) block('git.active-hooks');
|
|
49
|
+
}
|
|
50
|
+
const config = entries.get('.git/config');
|
|
51
|
+
if (config?.type !== 'file') block('git.config-missing');
|
|
52
|
+
// Only read embedded config once shape is safe. --no-includes avoids reading
|
|
53
|
+
// arbitrary include paths. Native Git parses quoting and continuation lines.
|
|
54
|
+
if (!blockers.length) {
|
|
55
|
+
const filename = path.join(inventory.root,'.git','config');
|
|
56
|
+
const verify = async () => {
|
|
57
|
+
await inspectDirectory(path.dirname(filename));
|
|
58
|
+
const stat = await lstat(filename,{bigint:true});
|
|
59
|
+
if (!stat.isFile() || String(stat.ino)!==config.identity.ino || String(stat.dev)!==config.identity.dev || stat.nlink!==1n)
|
|
60
|
+
fail('repositories.drift');
|
|
61
|
+
if ('sha256' in config && createHash('sha256').update(await readFile(filename)).digest('hex')!==config.sha256)
|
|
62
|
+
fail('repositories.drift');
|
|
63
|
+
};
|
|
64
|
+
await verify();
|
|
65
|
+
const output = (await runRepositoryGit(inventory.root,['--git-dir='+path.dirname(filename),
|
|
66
|
+
'--work-tree='+inventory.root,'config','--file',filename,'--no-includes','--null','--list'],options)).bytes.toString('utf8');
|
|
67
|
+
await verify();
|
|
68
|
+
for (const record of output.split('\0').filter(Boolean)) {
|
|
69
|
+
const split=record.indexOf('\n'), key=(split<0?record:record.slice(0,split)).toLowerCase();
|
|
70
|
+
const value=split<0?'':record.slice(split+1);
|
|
71
|
+
if (/^core\.(repositoryformatversion|filemode|logallrefupdates|symlinks|ignorecase|autocrlf|safecrlf|eol|protectntfs|protecthfs)$/.test(key)) continue;
|
|
72
|
+
if (key==='core.bare' && value==='false') continue;
|
|
73
|
+
if (key==='extensions.objectformat' && ['sha1','sha256'].includes(value)) continue;
|
|
74
|
+
if (/^(user\.(name|email)|remote\.[^.]+\.fetch|branch\.[^.]+\.(remote|merge))$/.test(key)) continue;
|
|
75
|
+
if (/^remote\.[^.]+\.(url|pushurl)$/.test(key) && /^(https:\/\/|ssh:\/\/|[^\s/:]+@[^\s/:]+:)/.test(value)) continue;
|
|
76
|
+
block('git.config-requires-review');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Config parse must not replace evidence with a later state unnoticed.
|
|
80
|
+
const after = await inventoryRepository(inventory.root);
|
|
81
|
+
if (after.digest !== inventory.digest) fail('repositories.drift');
|
|
82
|
+
return {inventory,blockers,status:blockers.length?'blocked':'metadata-screened',
|
|
83
|
+
gitHealth:'not-verified',executionAuthorized:false};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Native, read-only local structure checks. Dirty state is evidence, not a
|
|
87
|
+
// rejection. Still not move authorization or certification of relocated behavior.
|
|
88
|
+
export async function inspectRepositoryGit(directory, requestedBudget = {}) {
|
|
89
|
+
const budget=repositoryBudget(requestedBudget);
|
|
90
|
+
const execution={...budget,deadline:requestedBudget.deadline ?? performance.now()+budget.acquisitionMs};
|
|
91
|
+
const screened = await inspectRepositoryMetadata(directory,execution);
|
|
92
|
+
if (screened.blockers.length) return screened;
|
|
93
|
+
const root = screened.inventory.root;
|
|
94
|
+
const prefix = ['--git-dir='+path.join(root,'.git'),'--work-tree='+root];
|
|
95
|
+
const git = (args, options) => runRepositoryGit(root,[...prefix,...args],{...execution,...options});
|
|
96
|
+
const text = async args => (await git(args)).bytes.toString('utf8').trim();
|
|
97
|
+
const format = await text(['rev-parse','--show-object-format=storage']);
|
|
98
|
+
if (!['sha1','sha256'].includes(format)) fail('repositories.git-format');
|
|
99
|
+
const oid = value => new RegExp('^[a-f0-9]{'+(format==='sha1'?40:64)+'}$').test(value);
|
|
100
|
+
const headResult = await git(['rev-parse','--verify','--quiet','HEAD'],{allowMissing:true});
|
|
101
|
+
let head = headResult.code === 0 ? headResult.bytes.toString('utf8').trim() : null;
|
|
102
|
+
if (head !== null && !oid(head)) fail('repositories.git-head');
|
|
103
|
+
if (head === null) {
|
|
104
|
+
// Missing HEAD is allowed only as an unborn local branch, not detached damage.
|
|
105
|
+
const branch = await text(['symbolic-ref','HEAD']);
|
|
106
|
+
if (!branch.startsWith('refs/heads/')) fail('repositories.git-head');
|
|
107
|
+
const ref = await git(['show-ref','--verify','--quiet',branch],{allowMissing:true});
|
|
108
|
+
if (ref.code !== 1) fail('repositories.git-head');
|
|
109
|
+
} else await git(['cat-file','-e',head+'^{commit}']);
|
|
110
|
+
const index = (await git(['ls-files','--stage','-z'],{outputLimit:256*1024*1024})).bytes;
|
|
111
|
+
const blockers = [];
|
|
112
|
+
for (const record of index.toString('utf8').split('\0').filter(Boolean)) {
|
|
113
|
+
const match = /^(\d{6}) ([a-f0-9]+) ([0-3])\t/.exec(record);
|
|
114
|
+
if (!match || !oid(match[2])) fail('repositories.git-index');
|
|
115
|
+
if (match[1]==='160000') blockers.push('git.submodules');
|
|
116
|
+
if (match[3]!=='0') blockers.push('git.unmerged-index');
|
|
117
|
+
}
|
|
118
|
+
// No --lost-found or other repair/write mode. Repository transport budget only.
|
|
119
|
+
await git(['fsck','--connectivity-only','--no-reflogs','--no-progress']);
|
|
120
|
+
const status = (await git(['status','--porcelain=v1','-z','--untracked-files=all','--ignore-submodules=all'],
|
|
121
|
+
{outputLimit:256*1024*1024})).bytes;
|
|
122
|
+
const after = await inventoryRepository(root);
|
|
123
|
+
if (after.digest !== screened.inventory.digest) fail('repositories.drift');
|
|
124
|
+
return {...screened,blockers:[...new Set(blockers)],status:blockers.length?'blocked':'git-inspected',
|
|
125
|
+
gitHealth:'local-connectivity-checked',head,objectFormat:format,
|
|
126
|
+
indexDigest:sha256(index),statusDigest:sha256(status),dirty:status.length!==0,
|
|
127
|
+
// Raw file names/status are intentionally not included in diagnostics.
|
|
128
|
+
executionAuthorized:false};
|
|
129
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import { fail, parse } from '../contracts/parse.js';
|
|
4
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
5
|
+
import { validateSource, runRepositoryGit, acquireRemoteRepository } from '../source/git.js';
|
|
6
|
+
import {repositoryBudget} from '../source/repository-budget.js';
|
|
7
|
+
import {performance} from 'node:perf_hooks';
|
|
8
|
+
import { absoluteRoot, inspectDirectory } from './paths.js';
|
|
9
|
+
import { planRepositoryIntents } from './repositories.js';
|
|
10
|
+
import { inventoryRepository } from './repository-inventory.js';
|
|
11
|
+
import {summarizeInventory,summarizeTree} from './repository-observation.js';
|
|
12
|
+
import { inspectRepositoryGit, inspectRepositoryDestination } from './repository-preflight.js';
|
|
13
|
+
import { inspectRepositoryTree } from './repository-tree.js';
|
|
14
|
+
import { readRecord } from '../operations/state.js';
|
|
15
|
+
|
|
16
|
+
async function manifestEvidence(workspace, options) {
|
|
17
|
+
if (!options.manifestPath) return null; // Internal domain-only callers, never execution-ready.
|
|
18
|
+
const filename=absoluteRoot(options.manifestPath), extension=path.extname(filename).toLowerCase();
|
|
19
|
+
if (!['.json','.yaml','.yml'].includes(extension)) fail('manifest.format');
|
|
20
|
+
const record=await readRecord(filename,extension==='.json'?'json':'yaml');
|
|
21
|
+
if (contractDigest(record.value)!==contractDigest(workspace)) fail('repositories.manifest-mismatch');
|
|
22
|
+
const base=path.dirname(record.path);
|
|
23
|
+
if (options.manifestBase && absoluteRoot(options.manifestBase)!==base) fail('repositories.manifest-base');
|
|
24
|
+
return {path:record.path,base,bytesDigest:record.digest};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// User repositories are NOT S2 pipeline packages: no pipeline manifest/8 MiB
|
|
28
|
+
// blob limit or supplier code is used to resolve their commit.
|
|
29
|
+
export async function bindLocalRepositorySource(source, manifestBase, options={}) {
|
|
30
|
+
const budget=repositoryBudget(options),deadline=performance.now()+budget.acquisitionMs;
|
|
31
|
+
validateSource(source);
|
|
32
|
+
if (source.subdirectory !== '.') fail('repositories.clone-subdirectory');
|
|
33
|
+
if (source.transport !== 'local') fail('repositories.local-source-required');
|
|
34
|
+
const base = absoluteRoot(manifestBase);
|
|
35
|
+
if (!(await inspectDirectory(base)).exists) fail('repositories.manifest-base');
|
|
36
|
+
const root = path.resolve(base,source.path);
|
|
37
|
+
const observed = await inspectRepositoryGit(root,budget);
|
|
38
|
+
if (observed.blockers.length) fail('repositories.clone-source-blocked');
|
|
39
|
+
const isOid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.ref);
|
|
40
|
+
const refs = isOid || source.ref === 'HEAD' || source.ref.startsWith('refs/')
|
|
41
|
+
? [source.ref] : ['refs/heads/'+source.ref,'refs/tags/'+source.ref];
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const ref of refs) {
|
|
44
|
+
const result = await runRepositoryGit(root,['--git-dir='+path.join(root,'.git'),'--work-tree='+root,
|
|
45
|
+
'rev-parse','--verify','--quiet','--end-of-options',ref+'^{commit}'],{...budget,deadline,allowMissing:true});
|
|
46
|
+
if (result.code === 0) {
|
|
47
|
+
const commit = result.bytes.toString('utf8').trim();
|
|
48
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) fail('repositories.clone-ref-output');
|
|
49
|
+
found.push(commit);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (found.length > 1) fail('repositories.clone-ambiguous-ref');
|
|
53
|
+
if (!found.length) fail('repositories.clone-missing-ref');
|
|
54
|
+
if (isOid && found[0] !== source.ref) fail('repositories.clone-not-commit');
|
|
55
|
+
if ((await inventoryRepository(root)).digest !== observed.inventory.digest) fail('repositories.drift');
|
|
56
|
+
return { source:structuredClone(source),manifestBase:base,resolvedSource:root,commit:found[0],...budget,
|
|
57
|
+
sourceObservation:observed,checkout:'commit-only',uncommittedSourceFiles:'not-cloned' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Internal read-only candidate. No approval parser/executor may treat this as
|
|
61
|
+
// authorization. Portable saved-preview schema and executable approval are pending.
|
|
62
|
+
export async function prepareRepositoryPreview(pipeline, workspace, wrapper, choices, options = {}) {
|
|
63
|
+
return prepare(pipeline,workspace,wrapper,choices,options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function preparedRemoteBinding(binding, source, preparationRoot, options) {
|
|
67
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) fail('repositories.saved-binding');
|
|
68
|
+
const keys=['source','resolvedSource','preparation','repo','commit','packLimit','gitMs','acquisitionMs','history','checkout','executionAuthorized','preparedInventory'];
|
|
69
|
+
const budget=repositoryBudget(options),deadline=performance.now()+budget.acquisitionMs;
|
|
70
|
+
if (Object.keys(binding).sort().join(',')!==keys.sort().join(',')) fail('repositories.saved-binding');
|
|
71
|
+
const preparation=absoluteRoot(binding.preparation), repo=absoluteRoot(binding.repo);
|
|
72
|
+
if (path.dirname(preparation)!==preparationRoot || !/^wpc-repository-[A-Za-z0-9_-]+$/.test(path.basename(preparation)) ||
|
|
73
|
+
repo!==path.join(preparation,'objects.git') || binding.resolvedSource!==source.url ||
|
|
74
|
+
contractDigest(binding.source)!==contractDigest(source) ||
|
|
75
|
+
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(binding.commit) ||
|
|
76
|
+
Object.keys(budget).some(k=>binding[k]!==budget[k]) || binding.history!=='shallow-depth-1' ||
|
|
77
|
+
binding.checkout!=='not-performed' || binding.executionAuthorized!==false) fail('repositories.saved-binding');
|
|
78
|
+
const inventory=await inventoryRepository(repo);
|
|
79
|
+
if (contractDigest(summarizeInventory(inventory))!==contractDigest(binding.preparedInventory)) fail('repositories.preparation-drift');
|
|
80
|
+
await runRepositoryGit(repo,['cat-file','-e',binding.commit+'^{commit}'],{...budget,deadline});
|
|
81
|
+
await runRepositoryGit(repo,['fsck','--connectivity-only','--no-reflogs','--no-progress'],{...budget,deadline});
|
|
82
|
+
if ((await inventoryRepository(repo)).digest!==inventory.digest) fail('repositories.preparation-drift');
|
|
83
|
+
return structuredClone(binding);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function prepare(pipeline, workspace, wrapper, choices, options, savedBindings) {
|
|
87
|
+
const manifest=await manifestEvidence(workspace,options);
|
|
88
|
+
options={...options,...(manifest?{manifestBase:manifest.base}:{})};
|
|
89
|
+
const intents = planRepositoryIntents(pipeline,workspace,wrapper,choices,options);
|
|
90
|
+
const wrapperState=await inspectDirectory(intents.wrapper);
|
|
91
|
+
const wrapperStat=wrapperState.exists?await lstat(intents.wrapper,{bigint:true}):null;
|
|
92
|
+
const wrapperObservation=wrapperStat
|
|
93
|
+
? {action:'keep',path:intents.wrapper,identity:{dev:String(wrapperStat.dev),ino:String(wrapperStat.ino)}}
|
|
94
|
+
: {action:'create',...(await inspectRepositoryDestination(intents.wrapper))};
|
|
95
|
+
let preparationRoot;
|
|
96
|
+
if (intents.operations.some(o=>o.action==='clone' && o.source.transport==='remote')) {
|
|
97
|
+
if (!savedBindings && options.network !== true) fail('source.network-required');
|
|
98
|
+
preparationRoot = absoluteRoot(options.tempRoot);
|
|
99
|
+
if (!(await inspectDirectory(preparationRoot)).exists) fail('repository-source.temp-root');
|
|
100
|
+
const protectedRoots = [intents.wrapper,...intents.operations.filter(o=>o.from).map(o=>o.from),
|
|
101
|
+
...intents.operations.filter(o=>o.action==='clone' && o.source.transport==='local')
|
|
102
|
+
.map(o=>path.resolve(absoluteRoot(options.manifestBase),o.source.path))];
|
|
103
|
+
for (const root of protectedRoots) {
|
|
104
|
+
const relative=path.relative(root.toLowerCase(),preparationRoot.toLowerCase());
|
|
105
|
+
if (relative==='' || (!path.isAbsolute(relative) && relative!=='..' && !relative.startsWith('..'+path.sep)))
|
|
106
|
+
fail('repository-source.preparation-location');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const operations = [], blockers = [];
|
|
110
|
+
if(!wrapperState.exists && wrapperObservation.ancestor!==path.dirname(intents.wrapper))
|
|
111
|
+
blockers.push({repository:null,code:'repositories.wrapper-parent-missing'});
|
|
112
|
+
for (const intent of intents.operations) {
|
|
113
|
+
const operation = {...intent};
|
|
114
|
+
if (intent.action === 'keep') operation.existing = await inspectRepositoryGit(intent.target);
|
|
115
|
+
else operation.destination = await inspectRepositoryDestination(intent.target);
|
|
116
|
+
if(operation.destination &&
|
|
117
|
+
(wrapperState.exists ? operation.destination.ancestor!==path.dirname(intent.target) : path.dirname(intent.target)!==intents.wrapper))
|
|
118
|
+
blockers.push({repository:intent.repository,code:'repositories.target-parent-missing'});
|
|
119
|
+
if (intent.action === 'move') {
|
|
120
|
+
operation.existing = await inspectRepositoryGit(intent.from);
|
|
121
|
+
if (operation.existing.inventory.entries[0].identity.dev !== operation.destination.ancestorIdentity.dev)
|
|
122
|
+
blockers.push({repository:intent.repository,code:'repositories.cross-device-not-supported'});
|
|
123
|
+
}
|
|
124
|
+
if (operation.existing)
|
|
125
|
+
for (const code of operation.existing.blockers) blockers.push({repository:intent.repository,code});
|
|
126
|
+
if (intent.action === 'clone') {
|
|
127
|
+
if (intent.source.transport==='local') operation.binding = await bindLocalRepositorySource(intent.source,options.manifestBase,options);
|
|
128
|
+
else {
|
|
129
|
+
if (savedBindings) operation.binding=await preparedRemoteBinding(savedBindings.get(intent.repository),intent.source,preparationRoot,options);
|
|
130
|
+
else {
|
|
131
|
+
const prepared = await acquireRemoteRepository(intent.source,{tempRoot:preparationRoot,network:options.network,...repositoryBudget(options)});
|
|
132
|
+
operation.binding = {...prepared,preparedInventory:await inventoryRepository(prepared.repo)};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
operation.checkoutTree=await inspectRepositoryTree(operation.binding.repo ?? operation.binding.resolvedSource,
|
|
136
|
+
operation.binding.commit,intent.target,operation.binding);
|
|
137
|
+
}
|
|
138
|
+
operation.compensation = intent.action === 'keep' ? 'none' : 'fresh-preview-required; no automatic deletion or rollback';
|
|
139
|
+
operations.push(operation);
|
|
140
|
+
}
|
|
141
|
+
// A multi-repository scan may take time: validate every earlier observation
|
|
142
|
+
// again before returning. This is still not an atomic filesystem snapshot.
|
|
143
|
+
for (const operation of operations) {
|
|
144
|
+
const observation = operation.existing ?? operation.binding?.sourceObservation;
|
|
145
|
+
if (observation && (await inventoryRepository(observation.inventory.root)).digest !== observation.inventory.digest)
|
|
146
|
+
fail('repositories.drift');
|
|
147
|
+
if (operation.binding?.preparedInventory &&
|
|
148
|
+
(await inventoryRepository(operation.binding.repo)).digest !== operation.binding.preparedInventory.digest)
|
|
149
|
+
fail('repositories.preparation-drift');
|
|
150
|
+
if (operation.destination && contractDigest(await inspectRepositoryDestination(operation.target)) !== contractDigest(operation.destination))
|
|
151
|
+
fail('repositories.destination-drift');
|
|
152
|
+
}
|
|
153
|
+
if (contractDigest(await manifestEvidence(workspace,options))!==contractDigest(manifest)) fail('repositories.manifest-drift');
|
|
154
|
+
const finalWrapper=await inspectDirectory(intents.wrapper);
|
|
155
|
+
if(finalWrapper.exists!==wrapperState.exists)fail('repositories.wrapper-drift');
|
|
156
|
+
if(finalWrapper.exists) {
|
|
157
|
+
const stat=await lstat(intents.wrapper,{bigint:true});
|
|
158
|
+
if(String(stat.dev)!==wrapperObservation.identity.dev || String(stat.ino)!==wrapperObservation.identity.ino)
|
|
159
|
+
fail('repositories.wrapper-drift');
|
|
160
|
+
} else if(contractDigest({action:'create',...(await inspectRepositoryDestination(intents.wrapper))})!==contractDigest(wrapperObservation))
|
|
161
|
+
fail('repositories.wrapper-drift');
|
|
162
|
+
for(const op of operations) {
|
|
163
|
+
if(op.existing)op.existing.inventory=summarizeInventory(op.existing.inventory);
|
|
164
|
+
if(op.binding?.sourceObservation)op.binding.sourceObservation.inventory=summarizeInventory(op.binding.sourceObservation.inventory);
|
|
165
|
+
if(op.binding?.preparedInventory)op.binding.preparedInventory=summarizeInventory(op.binding.preparedInventory);
|
|
166
|
+
if(op.checkoutTree)op.checkoutTree=summarizeTree(op.checkoutTree);
|
|
167
|
+
}
|
|
168
|
+
const result = {kind:'repository-preview-candidate',command:intents.command,wrapper:intents.wrapper,wrapperObservation,manifest,
|
|
169
|
+
inputDigest:contractDigest({pipeline,workspace,choices}),layout:intents.layout,
|
|
170
|
+
operations,blockers,status:blockers.length?'blocked':'review-only',executionAuthorized:false};
|
|
171
|
+
return {...result,digest:contractDigest(result)};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Caller supplies independently retained/approved digest and current trusted
|
|
175
|
+
// inputs. A digest copied from the loaded file itself is NOT an approval.
|
|
176
|
+
// Reconstructs semantics from inputs and observations, rejects extra/missing
|
|
177
|
+
// fields through whole-object equality, and never reacquires remote sources.
|
|
178
|
+
export async function revalidateRepositoryPreview(text, expectedDigest, pipeline, workspace, wrapper, choices, options = {}) {
|
|
179
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(expectedDigest ?? '')) fail('repositories.expected-digest');
|
|
180
|
+
const saved=parse(text,'json');
|
|
181
|
+
if (!saved || typeof saved!=='object' || Array.isArray(saved)) fail('repositories.saved-preview');
|
|
182
|
+
const {digest,...body}=saved;
|
|
183
|
+
if (digest!==expectedDigest || contractDigest(body)!==expectedDigest) fail('repositories.preview-digest');
|
|
184
|
+
if (saved.kind!=='repository-preview-candidate' || saved.executionAuthorized!==false ||
|
|
185
|
+
!Array.isArray(saved.operations)) fail('repositories.saved-preview');
|
|
186
|
+
const bindings=new Map();
|
|
187
|
+
for (const operation of saved.operations) {
|
|
188
|
+
if (!operation || typeof operation!=='object' || typeof operation.repository!=='string' || bindings.has(operation.repository))
|
|
189
|
+
fail('repositories.saved-preview');
|
|
190
|
+
bindings.set(operation.repository,operation.binding);
|
|
191
|
+
}
|
|
192
|
+
const fresh=await prepare(pipeline,workspace,wrapper,choices,{...options,network:false},bindings);
|
|
193
|
+
if (contractDigest(fresh)!==contractDigest(saved)) fail('repositories.preview-drift');
|
|
194
|
+
if (fresh.blockers.length || fresh.status!=='review-only') fail('repositories.preview-blocked');
|
|
195
|
+
return fresh;
|
|
196
|
+
}
|