@entrorove/skill 0.1.23 → 0.1.25
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/AGREEMENT.md +6 -6
- package/CAPABILITY-MAP.md +6 -0
- package/CHANGELOG.md +10 -0
- package/SKILL.md +18 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +35 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +24 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-chain-bindings.mjs +93 -0
- package/local-chain-broker.mjs +61 -0
- package/local-chain-contracts.mjs +21 -0
- package/local-chain-controller.mjs +321 -0
- package/local-chain-human.mjs +48 -0
- package/local-chain-lock.mjs +54 -0
- package/local-chain-operations.mjs +7 -0
- package/local-chain-plan.mjs +232 -0
- package/local-chain-release-labels.mjs +12 -0
- package/local-chain-results.mjs +75 -0
- package/local-cli.mjs +65 -24
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +126 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-error.mjs +23 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +106 -0
- package/local-formula-engine.mjs +125 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +142 -0
- package/local-ledger.mjs +15 -9
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +103 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +89 -0
- package/local-mutation-files.mjs +270 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +41 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +164 -3
- package/release-state.json +164 -18
- package/release.mjs +242 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1910 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createPublicKey, randomUUID, sign, verify } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { loadTaskHandoffSigner } from './local-task-handoff-observation.mjs';
|
|
4
|
+
import { canonicalHandoffSignature } from './local-task-handoff-overlay-wire.mjs';
|
|
5
|
+
import { mutationFail14, mutationPath14 } from './local-mutation-files.mjs';
|
|
6
|
+
import { mutationLifetime14 } from './local-mutation-wire.mjs';
|
|
7
|
+
|
|
8
|
+
const identity = ['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision', 'stepId', 'leaseId',
|
|
9
|
+
'generation', 'epoch', 'planId', 'planDigest', 'runnerId', 'runnerKeyId', 'repositoryId', 'repositoryDigest',
|
|
10
|
+
'scopeBindingDigest', 'scopeArtifactDigest', 'scopeContractDigest', 'paths'];
|
|
11
|
+
function cloudContract(contract, pass) {
|
|
12
|
+
const rollback = contract?.schemaVersion === 'entrorove.merge-rollback-authorization/1.0';
|
|
13
|
+
try {
|
|
14
|
+
const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY, der = Buffer.from(encoded ?? '', 'base64');
|
|
15
|
+
const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
|
|
16
|
+
const { signature, ...payload } = contract;
|
|
17
|
+
if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded
|
|
18
|
+
|| (!rollback && contract.schemaVersion !== 'entrorove.merge-contract/1.0') || (rollback && contract.schemaVersion !== 'entrorove.merge-rollback-authorization/1.0')
|
|
19
|
+
|| contract.keyId !== pass.authorityReceipt.keyId
|
|
20
|
+
|| contract.keyId !== sha256(der) || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(contract.nonce)
|
|
21
|
+
|| !verify(null, Buffer.from(canonical(payload)), key, canonicalHandoffSignature(signature))) throw new Error();
|
|
22
|
+
} catch { mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Batch merge contract requires its original pinned cloud signature'); }
|
|
23
|
+
mutationLifetime14(contract);
|
|
24
|
+
if (identity.some(field => canonical(contract[field] ?? null) !== canonical(pass[field] ?? null))
|
|
25
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(contract.mergeId)) {
|
|
26
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Batch merge contract differs from the original signed pass identity');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function contractImages(contract, pass, before, after) {
|
|
30
|
+
const rollback = contract?.schemaVersion === 'entrorove.merge-rollback-authorization/1.0';
|
|
31
|
+
if (rollback) {
|
|
32
|
+
if (!Array.isArray(contract.beforeFiles) || !Array.isArray(contract.restoreFiles)) mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Rollback authorization has no frozen current and restore images');
|
|
33
|
+
const priorRollback = contract.beforeFiles.filter(file => pass.paths.includes(file?.path));
|
|
34
|
+
const nextRollback = contract.restoreFiles.filter(file => pass.paths.includes(file?.path));
|
|
35
|
+
if (canonical(priorRollback) !== canonical(before) || canonical(nextRollback) !== canonical(after)) mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Rollback images differ from the original cloud-frozen restore images');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(contract.beforeFullManifest) || !Array.isArray(contract.sourceFiles)) {
|
|
39
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Original merge contract has no frozen physical file images');
|
|
40
|
+
}
|
|
41
|
+
const prior = contract.beforeFullManifest.filter(file => pass.paths.includes(file?.path));
|
|
42
|
+
const next = contract.sourceFiles.filter(file => pass.paths.includes(file?.path)).map(file => ({
|
|
43
|
+
path: mutationPath14(file.path), exists: true, sha256: file.sha256, mode: file.mode }));
|
|
44
|
+
if (canonical(prior) !== canonical(before) || canonical(next) !== canonical(after)) {
|
|
45
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Batch images differ from the original cloud-frozen merge images');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function mutationBatchReceiptBinding14(input, original, options, before, after) {
|
|
49
|
+
const pass = original.pass, contractDigest = pass.executionPlanSha256;
|
|
50
|
+
let transactionId = pass.passId, contractExpiresAt;
|
|
51
|
+
if (options.contract !== undefined) {
|
|
52
|
+
cloudContract(options.contract, pass);
|
|
53
|
+
if (sha256(canonical(options.contract)) !== contractDigest) {
|
|
54
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Batch cannot replace the original pass-bound merge contract');
|
|
55
|
+
}
|
|
56
|
+
contractImages(options.contract, pass, before, after);
|
|
57
|
+
transactionId = options.contract.mergeId; contractExpiresAt = options.contract.expiresAt;
|
|
58
|
+
} else if (options.contractDigest !== undefined || options.transactionId !== undefined) {
|
|
59
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Caller receipt bindings require the original cloud-signed merge contract');
|
|
60
|
+
}
|
|
61
|
+
if (options.contractDigest !== undefined && options.contractDigest !== contractDigest
|
|
62
|
+
|| options.transactionId !== undefined && options.transactionId !== transactionId) {
|
|
63
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTRACT', 'Caller cannot redefine the original batch contract or transaction');
|
|
64
|
+
}
|
|
65
|
+
const signer = await loadTaskHandoffSigner(original.binding.context,
|
|
66
|
+
input.runnerSignerConfigPath ?? input.signerConfigPath, pass.runnerKeyId);
|
|
67
|
+
return { signer, contractDigest, transactionId, contractExpiresAt };
|
|
68
|
+
}
|
|
69
|
+
export function signMutationBatchReceipt14(state, status, actual, failure = {}) {
|
|
70
|
+
const now = Date.now(), pass = state.pass;
|
|
71
|
+
const expires = status === 'written' ? Math.min(now + state.signer.config.receiptTtlMs,
|
|
72
|
+
Date.parse(pass.expiresAt), state.contractExpiresAt ? Date.parse(state.contractExpiresAt) : Infinity)
|
|
73
|
+
: now + state.signer.config.receiptTtlMs;
|
|
74
|
+
if (expires <= now) mutationFail14('ENTROROVE_MUTATION_AUTHORITY_EXPIRED', 'Original batch authority expired before its successful consumption proof');
|
|
75
|
+
const payload = { schemaVersion: 'entrorove.mutation-batch-consumption-receipt/1.0', status, consumed: true,
|
|
76
|
+
batchId: state.batchId, flowId: pass.flowId, taskId: pass.taskId, stepId: pass.stepId, leaseId: pass.leaseId,
|
|
77
|
+
generation: pass.generation, passDigest: state.passDigest, contractDigest: state.contractDigest,
|
|
78
|
+
transactionId: state.transactionId, executionAuthorityDigest: pass.executionAuthorityDigest,
|
|
79
|
+
executionAttemptNonce: pass.executionAttemptNonce, paths: [...pass.paths],
|
|
80
|
+
writes: actual.map(file => ({ path: file.path, status, contentDigest: file.sha256 })),
|
|
81
|
+
...(status === 'written' ? { afterFiles: actual } : { currentFiles: actual, ...failure }),
|
|
82
|
+
keyId: pass.runnerKeyId, nonce: randomUUID(), issuedAt: new Date(now).toISOString(), expiresAt: new Date(expires).toISOString() };
|
|
83
|
+
return { ...payload, signature: sign(null, Buffer.from(canonical(payload)), state.signer.privateKey).toString('base64url') };
|
|
84
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
4
|
+
import { assertTaskHandoffSnapshotBinding } from './local-task-handoff-fence.mjs';
|
|
5
|
+
import { mutationFail14, mutationPath14, mutationRead14, mutationTarget14 } from './local-mutation-files.mjs';
|
|
6
|
+
import { mutationPass14, mutationLifetime14 } from './local-mutation-wire.mjs';
|
|
7
|
+
import { mutationOriginalBinding14, mutationSnapshotBinding14 } from './local-mutation-binding.mjs';
|
|
8
|
+
import { readStoredMutationPass14, mutationPassLocations14, readMutationConsumption14,
|
|
9
|
+
saveMutationConsumption14 } from './local-mutation-pass-store.mjs';
|
|
10
|
+
import { auditMutation14, withMutationAudit14 } from './local-mutation-audit.mjs';
|
|
11
|
+
import { mutationBatchReceiptBinding14, signMutationBatchReceipt14 } from './local-mutation-batch-receipt.mjs';
|
|
12
|
+
|
|
13
|
+
const tokens = new WeakMap(), MISSING = sha256('entrorove.missing-file/1.0');
|
|
14
|
+
function clone(value) {
|
|
15
|
+
try { return structuredClone(value); }
|
|
16
|
+
catch { mutationFail14('ENTROROVE_MUTATION_BATCH_INPUT', 'Batch authority must be finite original data'); }
|
|
17
|
+
}
|
|
18
|
+
function files(value, paths) {
|
|
19
|
+
if (!Array.isArray(value) || canonical(value.map(file => file?.path)) !== canonical(paths)
|
|
20
|
+
|| value.some(file => !file || Object.keys(file).sort().join('|') !== 'exists|mode|path|sha256'
|
|
21
|
+
|| mutationPath14(file.path) !== file.path || typeof file.exists !== 'boolean' || !/^[0-9a-f]{64}$/.test(file.sha256)
|
|
22
|
+
|| (file.exists ? !Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777 : file.mode !== null || file.sha256 !== MISSING))) {
|
|
23
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_FILES', 'Batch images require the exact original sorted pass paths and complete file facts');
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function options(value) {
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
29
|
+
|| Object.keys(value).some(field => !['mutationPass', 'beforeFiles', 'afterFiles', 'contract', 'contractDigest', 'transactionId'].includes(field))) {
|
|
30
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_INPUT', 'Batch begin requires its original pass and exact before and after images');
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function tokenState(token, permitted) {
|
|
35
|
+
const state = token && typeof token === 'object' ? tokens.get(token) : null;
|
|
36
|
+
if (!state) mutationFail14('ENTROROVE_MUTATION_BATCH_TOKEN', 'Only the internal token of this real original batch can complete consumption');
|
|
37
|
+
if (!permitted.includes(state.status)) mutationFail14('ENTROROVE_MUTATION_BATCH_CLOSED', 'Original batch has already completed or failed; consumed paths cannot be reused');
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
async function authority(input, supplied) {
|
|
41
|
+
const pass = await readStoredMutationPass14(input);
|
|
42
|
+
for (const value of [supplied, input.mutationPass]) if (value !== undefined && canonical(mutationPass14(value)) !== canonical(pass)) {
|
|
43
|
+
mutationFail14('ENTROROVE_MUTATION_PASS_CHANGED', 'Batch cannot replace its stored original signed pass');
|
|
44
|
+
}
|
|
45
|
+
const binding = await mutationOriginalBinding14(input, pass), snapshot = await mutationSnapshotBinding14(input, pass);
|
|
46
|
+
assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
|
|
47
|
+
return { pass, binding, snapshot, locations: await mutationPassLocations14(input, binding, pass) };
|
|
48
|
+
}
|
|
49
|
+
function batchEntry(state, file, status = 'pending') {
|
|
50
|
+
return { status, contentDigest: file.sha256, at: state.at, batchId: state.batchId, batchDigest: state.batchDigest,
|
|
51
|
+
beforeDigest: state.beforeDigest, afterDigest: state.afterDigest };
|
|
52
|
+
}
|
|
53
|
+
async function originalLedger(state) {
|
|
54
|
+
const ledger = await readMutationConsumption14(state.pass, state.locations);
|
|
55
|
+
const status = state.status === 'completed' ? 'written' : 'pending';
|
|
56
|
+
if (state.after.some(file => canonical(ledger.writes[file.path]) !== canonical(batchEntry(state, file, status)))) {
|
|
57
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CHANGED', 'Persistent pending paths differ from the original indivisible batch consumption');
|
|
58
|
+
}
|
|
59
|
+
return ledger;
|
|
60
|
+
}
|
|
61
|
+
async function image(input, root, expected) {
|
|
62
|
+
const actual = [];
|
|
63
|
+
for (const file of expected) {
|
|
64
|
+
const target = await mutationTarget14(root, file.path, true);
|
|
65
|
+
if (!target.exists) actual.push({ path: file.path, exists: false, sha256: MISSING, mode: null });
|
|
66
|
+
else { const read = await mutationRead14(input, root, file.path);
|
|
67
|
+
actual.push({ path: file.path, exists: true, sha256: read.sha256, mode: read.mode }); }
|
|
68
|
+
}
|
|
69
|
+
return actual;
|
|
70
|
+
}
|
|
71
|
+
// The native transaction retains its physical/fence locks. This module reserves
|
|
72
|
+
// consumption only; no input can authorize skipping a signature or before image.
|
|
73
|
+
export async function beginMutationBatch14(rawInput, rawOptions) {
|
|
74
|
+
const input = clone(rawInput);
|
|
75
|
+
return withMutationAudit14(input, 'mutation-batch-begin', async () => {
|
|
76
|
+
const value = options(clone(rawOptions)), original = await authority(input, value.mutationPass);
|
|
77
|
+
const before = files(value.beforeFiles, original.pass.paths), after = files(value.afterFiles, original.pass.paths);
|
|
78
|
+
const receiptBinding = await mutationBatchReceiptBinding14(input, original, value, before, after);
|
|
79
|
+
return withStateLock(original.locations.consumptionPath, async () => {
|
|
80
|
+
const fresh = await authority(input, value.mutationPass);
|
|
81
|
+
if (canonical(fresh.pass) !== canonical(original.pass)) mutationFail14('ENTROROVE_MUTATION_PASS_CHANGED', 'Batch pass changed while reserving original consumption');
|
|
82
|
+
const ledger = await readMutationConsumption14(fresh.pass, fresh.locations);
|
|
83
|
+
if (fresh.pass.paths.some(target => Object.hasOwn(ledger.writes, target))) {
|
|
84
|
+
mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Batch contains an already consumed or unresolved original path');
|
|
85
|
+
}
|
|
86
|
+
const snapshot = await mutationSnapshotBinding14(input, fresh.pass, { allSources: true });
|
|
87
|
+
if (canonical(before) !== canonical(snapshot.manifest.files)) mutationFail14('ENTROROVE_MUTATION_BATCH_BEFORE', 'Batch before image differs from the real original signed snapshot');
|
|
88
|
+
mutationLifetime14(fresh.pass);
|
|
89
|
+
const state = { ...fresh, ...receiptBinding, input, before, after, status: 'pending', batchId: randomUUID(), at: new Date().toISOString(),
|
|
90
|
+
passDigest: sha256(canonical(fresh.pass)), beforeDigest: sha256(canonical(before)), afterDigest: sha256(canonical(after)) };
|
|
91
|
+
state.batchDigest = sha256(canonical({ passDigest: state.passDigest, beforeDigest: state.beforeDigest,
|
|
92
|
+
afterDigest: state.afterDigest, batchId: state.batchId }));
|
|
93
|
+
const writes = { ...ledger.writes };
|
|
94
|
+
for (const file of after) writes[file.path] = batchEntry(state, file);
|
|
95
|
+
await saveMutationConsumption14(fresh.locations, { ...ledger, writes });
|
|
96
|
+
await auditMutation14(input, 'mutation-batch-pending', { passId: fresh.pass.passId, batchId: state.batchId,
|
|
97
|
+
beforeDigest: state.beforeDigest, afterDigest: state.afterDigest });
|
|
98
|
+
const token = Object.freeze({ schemaVersion: 'entrorove.mutation-batch-token/1.0', batchId: state.batchId });
|
|
99
|
+
tokens.set(token, state); return token;
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export async function finishMutationBatch14(token) {
|
|
104
|
+
const state = tokenState(token, ['pending']);
|
|
105
|
+
return withMutationAudit14(state.input, 'mutation-batch-finish', () => withStateLock(state.locations.consumptionPath, async () => {
|
|
106
|
+
tokenState(token, ['pending']);
|
|
107
|
+
const fresh = await authority(state.input, state.pass);
|
|
108
|
+
if (fresh.binding.root !== state.binding.root || fresh.binding.home !== state.binding.home
|
|
109
|
+
|| fresh.locations.anchor !== state.locations.anchor || fresh.locations.consumptionPath !== state.locations.consumptionPath) {
|
|
110
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_CONTEXT', 'Batch cannot move its original registered repository, home or trusted consumption anchor');
|
|
111
|
+
}
|
|
112
|
+
const ledger = await originalLedger(state), actual = await image(state.input, state.binding.root, state.after);
|
|
113
|
+
if (canonical(actual) !== canonical(state.after)) mutationFail14('ENTROROVE_MUTATION_BATCH_AFTER', 'Only the actual complete original after image can finish consumption');
|
|
114
|
+
mutationLifetime14(fresh.pass);
|
|
115
|
+
const writes = { ...ledger.writes };
|
|
116
|
+
for (const file of state.after) writes[file.path] = batchEntry(state, file, 'written');
|
|
117
|
+
await saveMutationConsumption14(state.locations, { ...ledger, writes });
|
|
118
|
+
state.status = 'completed';
|
|
119
|
+
await auditMutation14(state.input, 'mutation-batch-finished', { passId: state.pass.passId, batchId: state.batchId,
|
|
120
|
+
beforeDigest: state.beforeDigest, afterDigest: state.afterDigest });
|
|
121
|
+
return signMutationBatchReceipt14(state, 'written', actual);
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
export async function failMutationBatch14(token, value = {}) {
|
|
125
|
+
const state = tokenState(token, ['pending', 'completed']), failure = clone(value);
|
|
126
|
+
return withMutationAudit14(state.input, 'mutation-batch-fail', () => withStateLock(state.locations.consumptionPath, async () => {
|
|
127
|
+
tokenState(token, ['pending', 'completed']);
|
|
128
|
+
if (!failure || typeof failure !== 'object' || Array.isArray(failure) || Object.keys(failure).some(field => !['code', 'rollbackOutcome'].includes(field))
|
|
129
|
+
|| failure.code !== undefined && (typeof failure.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,127}$/.test(failure.code))
|
|
130
|
+
|| failure.rollbackOutcome !== undefined && !['retained', 'restored', 'restoration-failed'].includes(failure.rollbackOutcome)) {
|
|
131
|
+
mutationFail14('ENTROROVE_MUTATION_BATCH_FAILURE', 'Batch failure accepts only an explicit code and reported recovery outcome');
|
|
132
|
+
}
|
|
133
|
+
const ledger = await originalLedger(state), writes = { ...ledger.writes };
|
|
134
|
+
for (const file of state.after) writes[file.path] = batchEntry(state, file, 'failed');
|
|
135
|
+
await saveMutationConsumption14(state.locations, { ...ledger, writes }); state.status = 'failed';
|
|
136
|
+
const code = failure.code ?? 'ENTROROVE_MUTATION_BATCH_FAILED';
|
|
137
|
+
await auditMutation14(state.input, 'mutation-batch-failed', { passId: state.pass.passId, batchId: state.batchId, code,
|
|
138
|
+
...(failure.rollbackOutcome ? { reportedRollbackOutcome: failure.rollbackOutcome } : {}) });
|
|
139
|
+
const actual = await image(state.input, state.binding.root, state.after);
|
|
140
|
+
return signMutationBatchReceipt14(state, 'failed', actual, { code,
|
|
141
|
+
...(failure.rollbackOutcome ? { reportedRollbackOutcome: failure.rollbackOutcome } : {}) });
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { sign } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { context, readRegistry } from './local-task-handoff-storage.mjs';
|
|
4
|
+
import { readTaskHandoffFence, assertTaskHandoffSnapshotBinding } from './local-task-handoff-fence.mjs';
|
|
5
|
+
import { loadTaskHandoffSigner } from './local-task-handoff-observation.mjs';
|
|
6
|
+
import { mutationFail14, mutationHome14, mutationRoot14, mutationStateRoot14 } from './local-mutation-files.mjs';
|
|
7
|
+
import { snapshotAuthority14, snapshotVerify14, verifySnapshotSources14 } from './local-mutation-snapshot.mjs';
|
|
8
|
+
import { mutationChallenge14, mutationPass14 } from './local-mutation-wire.mjs';
|
|
9
|
+
import { withMutationAudit14 } from './local-mutation-audit.mjs';
|
|
10
|
+
|
|
11
|
+
export async function mutationOriginalBinding14(input, authority, state) {
|
|
12
|
+
const root = await mutationRoot14(input.repositoryRoot), home = await mutationHome14(input.home);
|
|
13
|
+
await mutationStateRoot14(input);
|
|
14
|
+
const ctx = await context({ ...input, repositoryRoot: root });
|
|
15
|
+
const registration = (await readRegistry(ctx.registry)).bindings[ctx.registryKey];
|
|
16
|
+
const current = state ?? await readTaskHandoffFence(input);
|
|
17
|
+
if (authority.flowId !== input.flowId || authority.stepId !== input.stepId || authority.repositoryDigest !== sha256(root).slice(0, 32)
|
|
18
|
+
|| authority.epoch !== current.epoch || current.status !== 'active'
|
|
19
|
+
|| input.taskId !== undefined && input.taskId !== authority.taskId
|
|
20
|
+
|| ['taskId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId'].some(field => registration?.[field] !== authority[field])) {
|
|
21
|
+
mutationFail14('ENTROROVE_MUTATION_ORIGINAL_BINDING', 'Mutation authority differs from the original accepted task, step or registered receiver');
|
|
22
|
+
}
|
|
23
|
+
if (['leaseId', 'generation', 'executionAttemptNonce'].some(field => input[field] !== undefined && input[field] !== authority[field])) {
|
|
24
|
+
mutationFail14('ENTROROVE_MUTATION_ORIGINAL_ATTEMPT', 'Caller context differs from the original mutation lease, generation or execution nonce');
|
|
25
|
+
}
|
|
26
|
+
const attempt = current.executionAttempts.find(value => value.authority.leaseId === authority.leaseId
|
|
27
|
+
&& value.authority.generation === authority.generation);
|
|
28
|
+
if (!attempt || !['ready', 'used'].includes(attempt.status) || attempt.executionHome !== home
|
|
29
|
+
|| attempt.authority.authorityId !== authority.executionAuthorityId
|
|
30
|
+
|| sha256(canonical(attempt.authority)) !== authority.executionAuthorityDigest
|
|
31
|
+
|| attempt.authority.attemptNonce !== authority.executionAttemptNonce
|
|
32
|
+
|| attempt.authority.epoch !== authority.epoch) {
|
|
33
|
+
mutationFail14('ENTROROVE_MUTATION_ORIGINAL_ATTEMPT', 'Mutation authority must retain its original execution attempt and actual home');
|
|
34
|
+
}
|
|
35
|
+
if (current.revokedLeaseIds.includes(authority.leaseId)
|
|
36
|
+
|| authority.passId && current.revokedPassIds.includes(authority.passId)
|
|
37
|
+
|| current.receiverClaim && !current.receiverRestoration) {
|
|
38
|
+
mutationFail14('ENTROROVE_MUTATION_REVOKED', 'Original mutation authority is revoked or its receiver is temporarily handed off');
|
|
39
|
+
}
|
|
40
|
+
return { root, home, context: ctx, state: current };
|
|
41
|
+
}
|
|
42
|
+
export async function mutationSnapshotBinding14(input, pass, { allSources = false, targetPath, allowTargetDrift = false } = {}) {
|
|
43
|
+
mutationPass14(pass);
|
|
44
|
+
const root = await mutationRoot14(input.repositoryRoot);
|
|
45
|
+
const snapshot = await snapshotAuthority14(input, root, pass.snapshotReceipt);
|
|
46
|
+
const receipt = pass.snapshotReceipt;
|
|
47
|
+
if (receipt.snapshotId !== pass.snapshotId || receipt.snapshotDigest !== pass.snapshotDigest
|
|
48
|
+
|| receipt.receiptDigest !== pass.snapshotReceiptDigest || receipt.contractDigest !== pass.scopeContractDigest
|
|
49
|
+
|| sha256(canonical(snapshot.manifest)) !== pass.snapshotManifestDigest
|
|
50
|
+
|| canonical(receipt.paths) !== canonical(pass.paths)) {
|
|
51
|
+
mutationFail14('ENTROROVE_MUTATION_SNAPSHOT_BINDING', 'Mutation pass is bound to another snapshot, contract or path set');
|
|
52
|
+
}
|
|
53
|
+
if (snapshot.manifest.files.some(file => !file.exists && !pass.newFilePaths.includes(file.path))) {
|
|
54
|
+
mutationFail14('ENTROROVE_MUTATION_NEW_FILE_SCOPE', 'Missing source is outside the original planned new-file set');
|
|
55
|
+
}
|
|
56
|
+
// ArchGuard validates the changed target after a guarded write. The original
|
|
57
|
+
// snapshot still binds the pass, while its target source is expected to drift
|
|
58
|
+
// before checkpoint evaluation. Ordinary mutation paths keep the hash check.
|
|
59
|
+
if (allSources || targetPath && !allowTargetDrift) await verifySnapshotSources14(input, root, snapshot.manifest,
|
|
60
|
+
{ paths: allSources ? snapshot.manifest.paths : [targetPath] });
|
|
61
|
+
return { ...snapshot, root };
|
|
62
|
+
}
|
|
63
|
+
export async function attestMutationSnapshot14(input) {
|
|
64
|
+
return withMutationAudit14(input, 'mutation-snapshot-attest', async () => {
|
|
65
|
+
const challenge = mutationChallenge14(input.challenge);
|
|
66
|
+
const binding = await mutationOriginalBinding14(input, challenge);
|
|
67
|
+
if (input.snapshotId !== challenge.snapshotId) mutationFail14('ENTROROVE_MUTATION_SNAPSHOT_BINDING', 'Snapshot attestation requires the cloud-assigned original snapshot identifier');
|
|
68
|
+
const verified = await snapshotVerify14(input);
|
|
69
|
+
const snapshot = await snapshotAuthority14(input, binding.root, verified.receipt);
|
|
70
|
+
assertTaskHandoffSnapshotBinding(binding.state, verified.receipt);
|
|
71
|
+
if (snapshot.manifest.contractDigest !== challenge.scopeContractDigest
|
|
72
|
+
|| canonical(snapshot.manifest.paths) !== canonical(challenge.paths)
|
|
73
|
+
|| snapshot.manifest.files.some(file => !file.exists && !challenge.newFilePaths.includes(file.path))) {
|
|
74
|
+
mutationFail14('ENTROROVE_MUTATION_SNAPSHOT_BINDING', 'Snapshot attestation differs from its original cloud scope challenge');
|
|
75
|
+
}
|
|
76
|
+
const signer = await loadTaskHandoffSigner(binding.context, input.signerConfigPath, challenge.runnerKeyId);
|
|
77
|
+
const now = Date.now(), expires = Math.min(now + signer.config.receiptTtlMs, Date.parse(challenge.expiresAt));
|
|
78
|
+
if (expires <= now) mutationFail14('ENTROROVE_MUTATION_AUTHORITY_EXPIRED', 'Snapshot challenge expired before runner attestation');
|
|
79
|
+
const payload = { schemaVersion: 'entrorove.mutation-snapshot-attestation/1.0', challengeId: challenge.challengeId,
|
|
80
|
+
challengeDigest: sha256(canonical(challenge)), nonce: challenge.nonce, runnerId: challenge.runnerId,
|
|
81
|
+
keyId: challenge.runnerKeyId, flowId: challenge.flowId, taskId: challenge.taskId, ownerId: challenge.ownerId,
|
|
82
|
+
stepId: challenge.stepId, leaseId: challenge.leaseId, generation: challenge.generation,
|
|
83
|
+
repositoryId: challenge.repositoryId, repositoryDigest: challenge.repositoryDigest, snapshotId: challenge.snapshotId,
|
|
84
|
+
paths: [...challenge.paths], manifest: snapshot.manifest, receipt: verified.receipt,
|
|
85
|
+
issuedAt: new Date(now).toISOString(), expiresAt: new Date(expires).toISOString() };
|
|
86
|
+
return { schemaVersion: 'entrorove.local-execution/1.0', verified: true,
|
|
87
|
+
attestation: { ...payload, signature: sign(null, Buffer.from(canonical(payload)), signer.privateKey).toString('base64url') } };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
6
|
+
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
7
|
+
|
|
8
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
9
|
+
export const mutationFail14 = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
10
|
+
export function mutationId14(value, field) {
|
|
11
|
+
if (typeof value !== 'string' || !ID.test(value)) mutationFail14('ENTROROVE_ID_INVALID', `${field} must be a safe identifier`);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function mutationPath14(value, field = 'path') {
|
|
15
|
+
if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value)
|
|
16
|
+
|| /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value)
|
|
17
|
+
|| value.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
18
|
+
mutationFail14('ENTROROVE_PATH_UNSAFE', `${field} is unsafe`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function mutationPaths14(values) {
|
|
23
|
+
if (!Array.isArray(values) || !values.length) mutationFail14('ENTROROVE_PATHS_REQUIRED', 'paths must be a non-empty array');
|
|
24
|
+
const paths = [...new Set(values.map(value => mutationPath14(value)))].sort();
|
|
25
|
+
if (paths.length !== values.length) mutationFail14('ENTROROVE_PATHS_DUPLICATE', 'paths must be unique');
|
|
26
|
+
const selected = new Set(paths);
|
|
27
|
+
if (paths.some(value => value.split('/').slice(0, -1).some((_, index, parts) => selected.has(parts.slice(0, index + 1).join('/'))))) {
|
|
28
|
+
mutationFail14('ENTROROVE_PATHS_CONFLICT', 'file paths cannot contain another selected file path');
|
|
29
|
+
}
|
|
30
|
+
return paths;
|
|
31
|
+
}
|
|
32
|
+
const sameNode = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
33
|
+
const sameFile = (left, right) => sameNode(left, right) && left.size === right.size
|
|
34
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.mode === right.mode;
|
|
35
|
+
function absolute(value, field) {
|
|
36
|
+
if (typeof value !== 'string' || !value || !path.isAbsolute(value) || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
37
|
+
mutationFail14('ENTROROVE_PATH_UNSAFE', `${field} must be an absolute path`);
|
|
38
|
+
}
|
|
39
|
+
return path.resolve(value);
|
|
40
|
+
}
|
|
41
|
+
export function mutationInside14(root, target, field = 'path') {
|
|
42
|
+
const relative = path.relative(root, target);
|
|
43
|
+
if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) return;
|
|
44
|
+
mutationFail14('ENTROROVE_PATH_ESCAPE', `${field} escapes its bound directory`);
|
|
45
|
+
}
|
|
46
|
+
async function inspectPath(target, { allowMissing = false, final = 'file', code = 'ENTROROVE_FILE_INVALID' } = {}) {
|
|
47
|
+
const resolved = absolute(target, 'filesystem path');
|
|
48
|
+
const base = path.parse(resolved).root; const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
|
|
49
|
+
let current = base; const ancestors = [{ path: base, stat: await fs.lstat(base) }];
|
|
50
|
+
if (!parts.length) return { target: resolved, stat: ancestors[0].stat, exists: true, ancestors };
|
|
51
|
+
for (const [index, part] of parts.entries()) {
|
|
52
|
+
current = path.join(current, part); let stat;
|
|
53
|
+
try { stat = await fs.lstat(current); } catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT' && allowMissing) return { target: resolved, exists: false, stat: null, ancestors };
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const last = index === parts.length - 1;
|
|
58
|
+
if (stat.isSymbolicLink() || (!last && !stat.isDirectory())
|
|
59
|
+
|| (last && final === 'file' && !stat.isFile()) || (last && final === 'directory' && !stat.isDirectory())) {
|
|
60
|
+
mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
|
|
61
|
+
}
|
|
62
|
+
if (!last) ancestors.push({ path: current, stat });
|
|
63
|
+
else return { target: resolved, exists: true, stat, ancestors };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
|
|
67
|
+
const resolved = absolute(value, 'filesystem path');
|
|
68
|
+
const base = path.parse(resolved).root;
|
|
69
|
+
const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
|
|
70
|
+
let current = base;
|
|
71
|
+
for (const [index, part] of parts.entries()) {
|
|
72
|
+
const candidate = path.join(current, part);
|
|
73
|
+
let stat;
|
|
74
|
+
try { stat = await fs.lstat(candidate); } catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT' && allowMissing) return path.join(current, ...parts.slice(index));
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (stat.isSymbolicLink()) {
|
|
79
|
+
const linked = await fs.realpath(candidate);
|
|
80
|
+
const systemAlias = process.platform === 'darwin' && current === base
|
|
81
|
+
&& ['/var', '/tmp', '/etc'].includes(candidate) && linked === path.join('/private', candidate);
|
|
82
|
+
if (!systemAlias) mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
|
|
83
|
+
current = linked;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!stat.isDirectory()) mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
|
|
87
|
+
current = candidate;
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
|
|
92
|
+
for (const ancestor of item.ancestors) {
|
|
93
|
+
const stat = await fs.lstat(ancestor.path);
|
|
94
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || !sameNode(stat, ancestor.stat)) {
|
|
95
|
+
mutationFail14(code, 'mutation path ancestors changed during filesystem access');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function mutationRoot14(value) {
|
|
100
|
+
const canonicalRoot = await canonicalDirectoryPath(value, { code: 'ENTROROVE_ROOT_INVALID' });
|
|
101
|
+
await inspectPath(canonicalRoot, { final: 'directory', code: 'ENTROROVE_ROOT_INVALID' });
|
|
102
|
+
return canonicalRoot;
|
|
103
|
+
}
|
|
104
|
+
export async function mutationHome14(value = os.homedir()) {
|
|
105
|
+
const home = await canonicalDirectoryPath(value, { allowMissing: true, code: 'ENTROROVE_HOME_INVALID' });
|
|
106
|
+
await inspectPath(home, { allowMissing: true, final: 'directory', code: 'ENTROROVE_HOME_INVALID' });
|
|
107
|
+
return home;
|
|
108
|
+
}
|
|
109
|
+
export async function mutationTarget14(root, relative, allowMissing = false) {
|
|
110
|
+
const target = path.join(root, mutationPath14(relative));
|
|
111
|
+
mutationInside14(root, target);
|
|
112
|
+
return { ...await inspectPath(target, { allowMissing }), path: relative };
|
|
113
|
+
}
|
|
114
|
+
export const mutationFile14 = (file, options = {}) => inspectPath(file, options);
|
|
115
|
+
export async function mutationRead14(input, root, relative) {
|
|
116
|
+
const item = await mutationTarget14(root, relative);
|
|
117
|
+
const handle = await fs.open(item.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
118
|
+
try {
|
|
119
|
+
const before = await handle.stat();
|
|
120
|
+
if (!before.isFile() || !sameFile(before, item.stat)) mutationFail14('ENTROROVE_FILE_CHANGED', 'file changed before snapshot reading');
|
|
121
|
+
const content = await readBudgetedProjectFile(input, item.target, { sourcePath: relative });
|
|
122
|
+
const after = await handle.stat(); const latest = await mutationTarget14(root, relative);
|
|
123
|
+
await assertAncestors(item, 'ENTROROVE_FILE_CHANGED');
|
|
124
|
+
if (!sameFile(before, after) || !sameFile(after, latest.stat) || content.length !== after.size) {
|
|
125
|
+
mutationFail14('ENTROROVE_FILE_CHANGED', 'file changed during snapshot reading');
|
|
126
|
+
}
|
|
127
|
+
return { ...item, content, sha256: sha256(content), mode: after.mode & 0o777 };
|
|
128
|
+
} finally { await handle.close(); }
|
|
129
|
+
}
|
|
130
|
+
export async function privateMutationDirectory14(directory, { exclusive = false, privateFrom = directory } = {}) {
|
|
131
|
+
const target = absolute(directory, 'state directory'); const privateRoot = absolute(privateFrom, 'private state root');
|
|
132
|
+
mutationInside14(privateRoot, target, 'state directory');
|
|
133
|
+
const base = path.parse(target).root; const parts = path.relative(base, target).split(path.sep).filter(Boolean);
|
|
134
|
+
let current = base;
|
|
135
|
+
for (const [index, part] of parts.entries()) {
|
|
136
|
+
current = path.join(current, part); const last = index === parts.length - 1;
|
|
137
|
+
await inspectPath(path.dirname(current), { final: 'directory', code: 'ENTROROVE_STATE_INVALID' });
|
|
138
|
+
if (last && exclusive) {
|
|
139
|
+
try { await fs.mkdir(current, { mode: 0o700 }); } catch (error) {
|
|
140
|
+
if (error.code === 'EEXIST') mutationFail14('ENTROROVE_SNAPSHOT_EXISTS', 'snapshotId already exists and cannot be reused');
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
try { await fs.mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
145
|
+
}
|
|
146
|
+
const checked = await inspectPath(current, { final: 'directory', code: 'ENTROROVE_STATE_INVALID' });
|
|
147
|
+
if (current === privateRoot || current.startsWith(`${privateRoot}${path.sep}`)) {
|
|
148
|
+
if (process.platform !== 'win32') await fs.chmod(current, 0o700);
|
|
149
|
+
await assertAncestors(checked);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return inspectPath(target, { final: 'directory', code: 'ENTROROVE_STATE_INVALID' });
|
|
153
|
+
}
|
|
154
|
+
export async function mutationStateRoot14(input) {
|
|
155
|
+
return path.join(await mutationHome14(input.home), '.entrorove');
|
|
156
|
+
}
|
|
157
|
+
async function cleanupTemporary(file, saved) {
|
|
158
|
+
try {
|
|
159
|
+
await assertAncestors(saved);
|
|
160
|
+
const latest = await inspectPath(file, { allowMissing: true, code: 'ENTROROVE_STATE_INVALID' });
|
|
161
|
+
if (latest.exists && sameNode(latest.stat, saved.stat)) await fs.unlink(file);
|
|
162
|
+
} catch { /* Refuse cleanup through a changed or linked state ancestor. */ }
|
|
163
|
+
}
|
|
164
|
+
export async function writeMutationFile14(file, content, { privateFrom = path.dirname(file) } = {}) {
|
|
165
|
+
const parent = await privateMutationDirectory14(path.dirname(file), { privateFrom });
|
|
166
|
+
await inspectPath(file, { allowMissing: true, code: 'ENTROROVE_STATE_INVALID' });
|
|
167
|
+
const handle = await fs.open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
168
|
+
const saved = { target: file, stat: await handle.stat(), ancestors: [...parent.ancestors, { path: parent.target, stat: parent.stat }] };
|
|
169
|
+
try {
|
|
170
|
+
if (process.platform !== 'win32') await handle.chmod(0o600);
|
|
171
|
+
await handle.writeFile(content); await handle.sync();
|
|
172
|
+
await assertAncestors(saved);
|
|
173
|
+
const latest = await inspectPath(file, { code: 'ENTROROVE_STATE_INVALID' });
|
|
174
|
+
if (!sameNode(latest.stat, saved.stat)) mutationFail14('ENTROROVE_STATE_CHANGED', 'state file changed while writing');
|
|
175
|
+
return latest;
|
|
176
|
+
} catch (error) { await cleanupTemporary(file, saved); throw error; }
|
|
177
|
+
finally { await handle.close(); }
|
|
178
|
+
}
|
|
179
|
+
export async function atomicMutationJson14(file, value, { privateFrom = path.dirname(file), replace = false } = {}) {
|
|
180
|
+
const parent = await privateMutationDirectory14(path.dirname(file), { privateFrom });
|
|
181
|
+
const destination = await inspectPath(file, { allowMissing: true, code: 'ENTROROVE_STATE_INVALID' });
|
|
182
|
+
if (destination.exists && !replace) mutationFail14('ENTROROVE_STATE_EXISTS', 'state file already exists');
|
|
183
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
184
|
+
const saved = await writeMutationFile14(temporary, `${canonical(value)}\n`, { privateFrom });
|
|
185
|
+
try {
|
|
186
|
+
await assertAncestors(parent);
|
|
187
|
+
const latest = await inspectPath(file, { allowMissing: true, code: 'ENTROROVE_STATE_INVALID' });
|
|
188
|
+
if (replace && (latest.exists !== destination.exists || latest.exists && !sameFile(latest.stat, destination.stat))) {
|
|
189
|
+
mutationFail14('ENTROROVE_STATE_CHANGED', 'state destination changed before atomic publication');
|
|
190
|
+
}
|
|
191
|
+
if (replace) await fs.rename(temporary, file);
|
|
192
|
+
else { await fs.link(temporary, file); await fs.unlink(temporary); }
|
|
193
|
+
await assertAncestors(saved);
|
|
194
|
+
const published = await inspectPath(file, { code: 'ENTROROVE_STATE_INVALID' });
|
|
195
|
+
if (!sameNode(published.stat, saved.stat)) mutationFail14('ENTROROVE_STATE_CHANGED', 'state changed during atomic publication');
|
|
196
|
+
return file;
|
|
197
|
+
} finally { await cleanupTemporary(temporary, saved); }
|
|
198
|
+
}
|
|
199
|
+
export async function readMutationJson14(file) {
|
|
200
|
+
const item = await inspectPath(file, { code: 'ENTROROVE_STATE_INVALID' });
|
|
201
|
+
if (process.platform !== 'win32' && (item.stat.mode & 0o077) || process.getuid && item.stat.uid !== process.getuid()) {
|
|
202
|
+
mutationFail14('ENTROROVE_STATE_INVALID', 'state files must be private and owned by the current user');
|
|
203
|
+
}
|
|
204
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
205
|
+
try {
|
|
206
|
+
const before = await handle.stat();
|
|
207
|
+
if (!sameFile(before, item.stat)) mutationFail14('ENTROROVE_STATE_CHANGED', 'state file changed before reading');
|
|
208
|
+
const bytes = await handle.readFile(); const after = await handle.stat();
|
|
209
|
+
await assertAncestors(item);
|
|
210
|
+
const latest = await inspectPath(file, { code: 'ENTROROVE_STATE_INVALID' });
|
|
211
|
+
if (!sameFile(before, after) || !sameFile(after, latest.stat)) mutationFail14('ENTROROVE_STATE_CHANGED', 'state file changed during reading');
|
|
212
|
+
let value;
|
|
213
|
+
try { value = JSON.parse(bytes.toString('utf8')); } catch { mutationFail14('ENTROROVE_STATE_INVALID', 'state file must contain valid JSON'); }
|
|
214
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) mutationFail14('ENTROROVE_STATE_INVALID', 'state file must contain an object');
|
|
215
|
+
return value;
|
|
216
|
+
} finally { await handle.close(); }
|
|
217
|
+
}
|
|
218
|
+
export async function cleanupMutationDirectory14(saved) {
|
|
219
|
+
await assertAncestors(saved);
|
|
220
|
+
const latest = await inspectPath(saved.target, { allowMissing: true, final: 'directory', code: 'ENTROROVE_STATE_INVALID' });
|
|
221
|
+
if (!latest.exists) return;
|
|
222
|
+
if (!sameNode(latest.stat, saved.stat)) mutationFail14('ENTROROVE_STATE_CHANGED', 'snapshot cleanup refuses a replaced directory');
|
|
223
|
+
await fs.rm(saved.target, { recursive: true, force: false });
|
|
224
|
+
}
|
|
225
|
+
async function projectParents(root, relative) {
|
|
226
|
+
let current = root;
|
|
227
|
+
for (const part of relative.split('/').slice(0, -1)) {
|
|
228
|
+
current = path.join(current, part);
|
|
229
|
+
await inspectPath(path.dirname(current), { final: 'directory' });
|
|
230
|
+
try { await fs.mkdir(current, { mode: 0o755 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
231
|
+
await inspectPath(current, { final: 'directory' });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function expectedProjectFile(input, root, relative, expected) {
|
|
235
|
+
const source = await mutationTarget14(root, relative, true);
|
|
236
|
+
if (source.exists !== expected.exists) mutationFail14('ENTROROVE_SNAPSHOT_CHANGED', `${relative} changed before guarded writing`);
|
|
237
|
+
if (source.exists) {
|
|
238
|
+
const read = await mutationRead14(input, root, relative);
|
|
239
|
+
if (read.sha256 !== expected.sha256 || read.mode !== expected.mode) mutationFail14('ENTROROVE_SNAPSHOT_CHANGED', `${relative} changed before guarded writing`);
|
|
240
|
+
return read;
|
|
241
|
+
}
|
|
242
|
+
if (expected.sha256 !== sha256('entrorove.missing-file/1.0') || expected.mode !== null) {
|
|
243
|
+
mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'missing guarded-write baseline is invalid');
|
|
244
|
+
}
|
|
245
|
+
return source;
|
|
246
|
+
}
|
|
247
|
+
export async function writeMutationProjectFile14(input, root, relative, content, expected) {
|
|
248
|
+
if (await mutationRoot14(input.repositoryRoot) !== root) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'guarded-write root is not bound');
|
|
249
|
+
mutationPath14(relative, 'targetPath');
|
|
250
|
+
if (!expected || expected.path !== relative || typeof expected.exists !== 'boolean') mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'guarded-write requires its exact snapshot file entry');
|
|
251
|
+
await expectedProjectFile(input, root, relative, expected);
|
|
252
|
+
await projectParents(root, relative);
|
|
253
|
+
const before = await expectedProjectFile(input, root, relative, expected);
|
|
254
|
+
const temporary = `${before.target}.${randomUUID()}.entrorove.tmp`;
|
|
255
|
+
const handle = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), expected.mode ?? 0o644);
|
|
256
|
+
const saved = { stat: await handle.stat(), ancestors: before.ancestors };
|
|
257
|
+
try {
|
|
258
|
+
if (process.platform !== 'win32') await handle.chmod(expected.mode ?? 0o644);
|
|
259
|
+
await handle.writeFile(content); await handle.sync();
|
|
260
|
+
await assertAncestors(saved, 'ENTROROVE_FILE_CHANGED');
|
|
261
|
+
const latest = await expectedProjectFile(input, root, relative, expected);
|
|
262
|
+
if (before.exists && !sameNode(before.stat, latest.stat)) mutationFail14('ENTROROVE_FILE_CHANGED', 'guarded-write target changed before publication');
|
|
263
|
+
if (expected.exists) await fs.rename(temporary, before.target);
|
|
264
|
+
else { await fs.link(temporary, before.target); await fs.unlink(temporary); }
|
|
265
|
+
await assertAncestors(saved, 'ENTROROVE_FILE_CHANGED');
|
|
266
|
+
const published = await mutationTarget14(root, relative);
|
|
267
|
+
if (!sameNode(published.stat, saved.stat)) mutationFail14('ENTROROVE_FILE_CHANGED', 'guarded-write target changed during publication');
|
|
268
|
+
return { target: published.target, sha256: sha256(content), mode: published.stat.mode & 0o777 };
|
|
269
|
+
} finally { await handle.close(); await cleanupTemporary(temporary, saved); }
|
|
270
|
+
}
|