@guilz-dev/belay 0.4.0 → 0.7.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/README.md +27 -2
- package/dist/adapters/claude/runtime-entry.js +54 -25
- package/dist/adapters/layouts/scope.js +3 -2
- package/dist/adapters/shared/gate-runtime.d.ts +8 -2
- package/dist/adapters/shared/gate-runtime.js +579 -181
- package/dist/bundle/claude-runtime.mjs +13574 -6334
- package/dist/bundle/codex-runtime.mjs +13108 -5903
- package/dist/bundle/cursor-runtime.mjs +12676 -5476
- package/dist/cli.js +80 -2
- package/dist/commands/approve.js +34 -10
- package/dist/commands/classify-for-report.js +1 -1
- package/dist/commands/config.d.ts +8 -0
- package/dist/commands/config.js +134 -11
- package/dist/commands/doctor.js +87 -1
- package/dist/commands/explain.js +11 -1
- package/dist/commands/judge.js +3 -2
- package/dist/commands/recovery-checkpoints.d.ts +164 -0
- package/dist/commands/recovery-checkpoints.js +347 -0
- package/dist/commands/revoke.js +19 -7
- package/dist/commands/session.d.ts +20 -0
- package/dist/commands/session.js +51 -0
- package/dist/commands/status.js +7 -0
- package/dist/config-io.js +91 -23
- package/dist/conformance/guarantee-posture.d.ts +21 -0
- package/dist/conformance/guarantee-posture.js +46 -0
- package/dist/conformance/guarantee-table.d.ts +1 -0
- package/dist/conformance/guarantee-table.js +20 -9
- package/dist/conformance/types.d.ts +1 -0
- package/dist/core/approval-replay.d.ts +1 -1
- package/dist/core/approval-replay.js +4 -1
- package/dist/core/approval-service.d.ts +6 -1
- package/dist/core/approval-service.js +87 -24
- package/dist/core/approval.d.ts +4 -0
- package/dist/core/approval.js +19 -5
- package/dist/core/audit-types.d.ts +13 -0
- package/dist/core/capability/approval-state-mutation.d.ts +32 -0
- package/dist/core/capability/approval-state-mutation.js +165 -0
- package/dist/core/capability/approval-v3.d.ts +32 -0
- package/dist/core/capability/approval-v3.js +114 -0
- package/dist/core/capability/attestation.d.ts +16 -0
- package/dist/core/capability/attestation.js +62 -0
- package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
- package/dist/core/capability/boundary-attestation-sign.js +84 -0
- package/dist/core/capability/boundary-driver-container.d.ts +19 -0
- package/dist/core/capability/boundary-driver-container.js +189 -0
- package/dist/core/capability/boundary-driver.d.ts +29 -0
- package/dist/core/capability/boundary-driver.js +44 -0
- package/dist/core/capability/boundary-egress.d.ts +21 -0
- package/dist/core/capability/boundary-egress.js +109 -0
- package/dist/core/capability/boundary-grant-materialize.d.ts +14 -0
- package/dist/core/capability/boundary-grant-materialize.js +93 -0
- package/dist/core/capability/boundary-profile.d.ts +15 -0
- package/dist/core/capability/boundary-profile.js +25 -0
- package/dist/core/capability/boundary-run.d.ts +48 -0
- package/dist/core/capability/boundary-run.js +51 -0
- package/dist/core/capability/boundary-session.d.ts +61 -0
- package/dist/core/capability/boundary-session.js +138 -0
- package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
- package/dist/core/capability/boundary-workspace-mount.js +68 -0
- package/dist/core/capability/capability-request-hash.d.ts +2 -0
- package/dist/core/capability/capability-request-hash.js +8 -0
- package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
- package/dist/core/capability/gate-latency-ratchet.js +18 -0
- package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
- package/dist/core/capability/gate-policy-shadow.js +130 -0
- package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
- package/dist/core/capability/gate-shadow-audit.js +1 -0
- package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
- package/dist/core/capability/gate-shadow-ratchet.js +81 -0
- package/dist/core/capability/grant-consumption.d.ts +4 -0
- package/dist/core/capability/grant-consumption.js +11 -0
- package/dist/core/capability/grant-lease.d.ts +44 -0
- package/dist/core/capability/grant-lease.js +243 -0
- package/dist/core/capability/grant-loader.d.ts +12 -0
- package/dist/core/capability/grant-loader.js +23 -0
- package/dist/core/capability/grant-match.d.ts +6 -0
- package/dist/core/capability/grant-match.js +96 -0
- package/dist/core/capability/grant.d.ts +16 -0
- package/dist/core/capability/grant.js +16 -0
- package/dist/core/capability/index.d.ts +8 -0
- package/dist/core/capability/index.js +7 -0
- package/dist/core/capability/limits.d.ts +7 -0
- package/dist/core/capability/limits.js +55 -0
- package/dist/core/capability/policy-bridge.d.ts +18 -0
- package/dist/core/capability/policy-bridge.js +51 -0
- package/dist/core/capability/policy-engine.d.ts +77 -0
- package/dist/core/capability/policy-engine.js +663 -0
- package/dist/core/capability/policy-types.d.ts +20 -0
- package/dist/core/capability/policy-types.js +1 -0
- package/dist/core/capability/request.d.ts +48 -0
- package/dist/core/capability/request.js +1 -0
- package/dist/core/capability/resolver.d.ts +4 -0
- package/dist/core/capability/resolver.js +4 -0
- package/dist/core/classify-subagent.d.ts +2 -1
- package/dist/core/classify-subagent.js +38 -2
- package/dist/core/classify-tool.js +126 -62
- package/dist/core/config.d.ts +34 -3
- package/dist/core/config.js +118 -3
- package/dist/core/effect-ir/audit.d.ts +9 -0
- package/dist/core/effect-ir/audit.js +86 -0
- package/dist/core/effect-ir/build.d.ts +31 -0
- package/dist/core/effect-ir/build.js +277 -0
- package/dist/core/effect-ir/index.d.ts +8 -0
- package/dist/core/effect-ir/index.js +7 -0
- package/dist/core/effect-ir/normalize.d.ts +4 -0
- package/dist/core/effect-ir/normalize.js +104 -0
- package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
- package/dist/core/effect-ir/package-exec-eval.js +152 -0
- package/dist/core/effect-ir/package-exec.d.ts +29 -0
- package/dist/core/effect-ir/package-exec.js +292 -0
- package/dist/core/effect-ir/policy.d.ts +13 -0
- package/dist/core/effect-ir/policy.js +77 -0
- package/dist/core/effect-ir/types.d.ts +55 -0
- package/dist/core/effect-ir/types.js +1 -0
- package/dist/core/egress/allowlist.d.ts +1 -0
- package/dist/core/egress/allowlist.js +8 -0
- package/dist/core/egress-approval.js +45 -51
- package/dist/core/gate-contract.d.ts +5 -0
- package/dist/core/gate-contract.js +5 -0
- package/dist/core/gate-engine.js +30 -8
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -1
- package/dist/core/judge-cloud-consent.js +29 -15
- package/dist/core/judge-doctor.js +39 -14
- package/dist/core/judge-fallback-hints.d.ts +13 -0
- package/dist/core/judge-fallback-hints.js +108 -0
- package/dist/core/network-endpoint.d.ts +10 -0
- package/dist/core/network-endpoint.js +51 -0
- package/dist/core/path-utils.d.ts +1 -0
- package/dist/core/path-utils.js +5 -2
- package/dist/core/process-runner.d.ts +10 -0
- package/dist/core/process-runner.js +124 -0
- package/dist/core/recover-advice.js +2 -1
- package/dist/core/recovery/artifact-store.d.ts +29 -0
- package/dist/core/recovery/artifact-store.js +336 -0
- package/dist/core/recovery/capability.d.ts +3 -0
- package/dist/core/recovery/capability.js +22 -0
- package/dist/core/recovery/checkpoint.d.ts +25 -0
- package/dist/core/recovery/checkpoint.js +260 -0
- package/dist/core/recovery/fail-closed.d.ts +6 -0
- package/dist/core/recovery/fail-closed.js +25 -0
- package/dist/core/recovery/index.d.ts +5 -0
- package/dist/core/recovery/index.js +4 -0
- package/dist/core/recovery/operator-guidance.d.ts +7 -0
- package/dist/core/recovery/operator-guidance.js +39 -0
- package/dist/core/recovery/reconcile.d.ts +3 -0
- package/dist/core/recovery/reconcile.js +48 -0
- package/dist/core/recovery/resource-identity.d.ts +4 -0
- package/dist/core/recovery/resource-identity.js +34 -0
- package/dist/core/recovery/restore.d.ts +13 -0
- package/dist/core/recovery/restore.js +114 -0
- package/dist/core/recovery/snapshot-node.d.ts +27 -0
- package/dist/core/recovery/snapshot-node.js +261 -0
- package/dist/core/recovery/types.d.ts +104 -0
- package/dist/core/recovery/types.js +1 -0
- package/dist/core/standing-allow.js +3 -0
- package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
- package/dist/core/transactional/apply-observed-changes.js +275 -0
- package/dist/core/transactional/backend-selector.d.ts +8 -0
- package/dist/core/transactional/backend-selector.js +118 -0
- package/dist/core/transactional/backend.d.ts +55 -0
- package/dist/core/transactional/backend.js +1 -0
- package/dist/core/transactional/diff-evaluator.js +4 -1
- package/dist/core/transactional/eligibility.js +4 -0
- package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
- package/dist/core/transactional/file-checkpoint-backend.js +278 -0
- package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
- package/dist/core/transactional/file-checkpoint-git.js +217 -0
- package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
- package/dist/core/transactional/file-checkpoint-staging.js +54 -0
- package/dist/core/transactional/file-clone.d.ts +15 -0
- package/dist/core/transactional/file-clone.js +139 -0
- package/dist/core/transactional/file-tree-path.d.ts +7 -0
- package/dist/core/transactional/file-tree-path.js +50 -0
- package/dist/core/transactional/file-tree.d.ts +41 -0
- package/dist/core/transactional/file-tree.js +186 -0
- package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
- package/dist/core/transactional/git-worktree-backend.js +50 -0
- package/dist/core/transactional/git-worktree.d.ts +14 -9
- package/dist/core/transactional/git-worktree.js +62 -87
- package/dist/core/transactional/index.d.ts +11 -0
- package/dist/core/transactional/index.js +10 -0
- package/dist/core/transactional/reasons.d.ts +1 -0
- package/dist/core/transactional/reasons.js +5 -0
- package/dist/core/transactional/runner.js +171 -23
- package/dist/core/transactional/snapshot-node.d.ts +26 -0
- package/dist/core/transactional/snapshot-node.js +87 -0
- package/dist/core/transactional/types.d.ts +21 -1
- package/dist/core/types.d.ts +30 -3
- package/dist/core/verdict/adapter.d.ts +3 -4
- package/dist/core/verdict/adapter.js +24 -12
- package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
- package/dist/core/verdict/cursor-acp-client.js +345 -0
- package/dist/core/verdict/egress-classify.d.ts +2 -2
- package/dist/core/verdict/egress-classify.js +2 -2
- package/dist/core/verdict/git-classifier.d.ts +26 -0
- package/dist/core/verdict/git-classifier.js +533 -0
- package/dist/core/verdict/judge-audit.js +1 -0
- package/dist/core/verdict/judge-baseline.d.ts +1 -1
- package/dist/core/verdict/judge-baseline.js +1 -0
- package/dist/core/verdict/judge-broker-service.js +12 -0
- package/dist/core/verdict/judge-catalog.js +1 -0
- package/dist/core/verdict/judge-cli-fingerprint.d.ts +1 -0
- package/dist/core/verdict/judge-cli-fingerprint.js +6 -0
- package/dist/core/verdict/judge-cli.js +5 -3
- package/dist/core/verdict/judge-provider-matrix.js +1 -1
- package/dist/core/verdict/judge-runtime-config.d.ts +2 -0
- package/dist/core/verdict/judge-runtime-config.js +10 -6
- package/dist/core/verdict/judge-session-broker.d.ts +1 -1
- package/dist/core/verdict/judge-session-broker.js +10 -5
- package/dist/core/verdict/judge-transport.d.ts +5 -1
- package/dist/core/verdict/judge-transport.js +24 -14
- package/dist/core/verdict/judge.d.ts +2 -15
- package/dist/core/verdict/judge.js +5 -68
- package/dist/core/verdict/launcher-resolve.d.ts +2 -0
- package/dist/core/verdict/launcher-resolve.js +14 -16
- package/dist/core/verdict/prescan.d.ts +15 -0
- package/dist/core/verdict/prescan.js +68 -0
- package/dist/core/verdict/shell-policy.d.ts +26 -0
- package/dist/core/verdict/shell-policy.js +40 -0
- package/dist/core/verdict/shell-semantics.d.ts +24 -0
- package/dist/core/verdict/shell-semantics.js +225 -0
- package/dist/core/verdict/types.d.ts +18 -1
- package/dist/core/verdict/verdict.js +313 -178
- package/dist/corpus/evaluate.js +3 -7
- package/dist/corpus/gate-latency-budget.d.ts +39 -0
- package/dist/corpus/gate-latency-budget.js +33 -0
- package/dist/corpus/mutators.d.ts +1 -1
- package/dist/corpus/mutators.js +11 -0
- package/dist/corpus/runtime-match.js +1 -3
- package/dist/corpus/standing-allow-catalog.generated.js +29 -9
- package/dist/judge-broker-daemon.js +15 -1
- package/dist/services/sandbox-service.d.ts +4 -0
- package/dist/services/sandbox-service.js +28 -2
- package/dist/types.d.ts +13 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/belay/SKILL.md +8 -3
- package/skills/belay/belay-approve.md +10 -7
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { capabilityRequestsBlockRecovery } from './capability.js';
|
|
2
|
+
export { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, prepareRecoveryCheckpoint, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_CHECKPOINT_QUOTA, RECOVERY_RESTORE_CONFLICT, reconcileRecoveryCheckpoint, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from './checkpoint.js';
|
|
3
|
+
export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, recoveryFailClosedResult, recoveryFailReasonFromSkip, } from './fail-closed.js';
|
|
4
|
+
export { formatRecoveryStateDiagnostic, recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from './operator-guidance.js';
|
|
5
|
+
export type { RecoveryBackend, RecoveryCheckpointEntry, RecoveryCheckpointEntryV1, RecoveryCheckpointEntryV2, RecoveryCheckpointManifest, RecoveryCheckpointManifestV1, RecoveryCheckpointManifestV2, RecoveryCheckpointState, RecoveryCheckpointSummary, RecoveryFileSnapshotV1, RecoveryFileSnapshotV2, RecoveryProofContext, RecoveryProofV1, RecoveryReceiptV1, } from './types.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { capabilityRequestsBlockRecovery } from './capability.js';
|
|
2
|
+
export { discardPreparedRecoveryCheckpoint, listRecoveryCheckpoints, markRecoveryCheckpointApplied, markRecoveryCheckpointApplying, prepareRecoveryCheckpoint, RECOVERY_CHECKPOINT_CORRUPT, RECOVERY_CHECKPOINT_QUOTA, RECOVERY_RESTORE_CONFLICT, reconcileRecoveryCheckpoint, recoveryCheckpointStorageBytes, recoveryRestoreBinding, restoreRecoveryCheckpoint, showRecoveryCheckpoint, } from './checkpoint.js';
|
|
3
|
+
export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, recoveryFailClosedResult, recoveryFailReasonFromSkip, } from './fail-closed.js';
|
|
4
|
+
export { formatRecoveryStateDiagnostic, recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from './operator-guidance.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BelayConfigV4 } from '../config.js';
|
|
2
|
+
import type { RecoveryCheckpointState, RecoveryCheckpointSummary } from './types.js';
|
|
3
|
+
export declare function recoveryNotificationConfigured(config: BelayConfigV4): boolean;
|
|
4
|
+
export declare function recoveryApprovalSetupNotes(): string[];
|
|
5
|
+
export declare function recoveryNotificationSetupWarning(): string;
|
|
6
|
+
export declare function formatRecoveryStateDiagnostic(state: RecoveryCheckpointState, detail?: string): string | null;
|
|
7
|
+
export declare function summarizeRecoveryCheckpointDiagnostics(checkpoints: RecoveryCheckpointSummary[]): string[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function recoveryNotificationConfigured(config) {
|
|
2
|
+
return Boolean(config.notifications.webhookUrl || config.notifications.commandHook);
|
|
3
|
+
}
|
|
4
|
+
export function recoveryApprovalSetupNotes() {
|
|
5
|
+
return [
|
|
6
|
+
'Recovery restore flow: run `belay recover apply <checkpoint-id>`, approve with `belay approve <approval-id> --token <signed-token>`, then run the same apply command again.',
|
|
7
|
+
'Recovery restore always requires a signed out-of-band token, even when approvalSigning.required is false for general approvals.',
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
export function recoveryNotificationSetupWarning() {
|
|
11
|
+
return ('Recovery checkpoint restore requires a signed out-of-band approval token, but no notification channel is configured. ' +
|
|
12
|
+
'Set notifications.webhookUrl or notifications.commandHook (e.g. via `belay config`) so `belay recover apply` can deliver the token.');
|
|
13
|
+
}
|
|
14
|
+
export function formatRecoveryStateDiagnostic(state, detail) {
|
|
15
|
+
switch (state) {
|
|
16
|
+
case 'needs_manual_repair':
|
|
17
|
+
return (detail ??
|
|
18
|
+
'Repository files no longer match the recorded before/after snapshots after an interrupted apply or restore. ' +
|
|
19
|
+
'Inspect the working tree manually; automatic restore is blocked until the mismatch is resolved or the checkpoint is removed.');
|
|
20
|
+
case 'conflict':
|
|
21
|
+
return (detail ??
|
|
22
|
+
'Current repository files do not match the recorded post-state for this checkpoint. Restore was refused to avoid overwriting newer changes.');
|
|
23
|
+
case 'corrupt':
|
|
24
|
+
return (detail ??
|
|
25
|
+
'Checkpoint artifacts failed integrity checks. Do not restore from this checkpoint; inspect or remove the artifact directory manually.');
|
|
26
|
+
default:
|
|
27
|
+
return detail ?? null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function summarizeRecoveryCheckpointDiagnostics(checkpoints) {
|
|
31
|
+
const advisories = [];
|
|
32
|
+
for (const checkpoint of checkpoints) {
|
|
33
|
+
const diagnostic = formatRecoveryStateDiagnostic(checkpoint.state, checkpoint.stateDetail);
|
|
34
|
+
if (!diagnostic)
|
|
35
|
+
continue;
|
|
36
|
+
advisories.push(`${checkpoint.checkpointId} (${checkpoint.state}): ${diagnostic}`);
|
|
37
|
+
}
|
|
38
|
+
return advisories;
|
|
39
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { RecoveryCheckpointEntry, RecoveryCheckpointState } from './types.js';
|
|
2
|
+
export declare function matchRecoverySide(resourceRoot: string, entries: RecoveryCheckpointEntry[], side: 'before' | 'after'): Promise<boolean>;
|
|
3
|
+
export declare function reconcileRecoveryCheckpoint(stateDir: string, checkpointId: string): Promise<RecoveryCheckpointState>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hashValue } from '../fingerprint.js';
|
|
5
|
+
import { checkpointDir, ensureRecoveryReceipt, RECOVERY_CHECKPOINT_CORRUPT, readRecoveryArtifact, writeRecoveryState, } from './artifact-store.js';
|
|
6
|
+
import { assertRecoverySafeTarget, recoverySnapshotMatches, withoutRecoveryBlob, } from './snapshot-node.js';
|
|
7
|
+
export async function matchRecoverySide(resourceRoot, entries, side) {
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
const target = await assertRecoverySafeTarget(resourceRoot, entry.path);
|
|
10
|
+
if (!(await recoverySnapshotMatches(target, withoutRecoveryBlob(entry[side]))))
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
export async function reconcileRecoveryCheckpoint(stateDir, checkpointId) {
|
|
16
|
+
let loaded;
|
|
17
|
+
try {
|
|
18
|
+
loaded = await readRecoveryArtifact(stateDir, checkpointId);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
const artifactDir = checkpointDir(stateDir, checkpointId);
|
|
22
|
+
if (existsSync(artifactDir)) {
|
|
23
|
+
const manifestPath = path.join(artifactDir, 'manifest.json');
|
|
24
|
+
const hash = existsSync(manifestPath)
|
|
25
|
+
? hashValue(await readFile(manifestPath, 'utf8'))
|
|
26
|
+
: 'unavailable';
|
|
27
|
+
await writeRecoveryState(artifactDir, 'corrupt', hash, RECOVERY_CHECKPOINT_CORRUPT);
|
|
28
|
+
}
|
|
29
|
+
return 'corrupt';
|
|
30
|
+
}
|
|
31
|
+
if (!['prepared', 'applying', 'restoring'].includes(loaded.state.state)) {
|
|
32
|
+
return loaded.state.state;
|
|
33
|
+
}
|
|
34
|
+
const before = await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'before');
|
|
35
|
+
const after = await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after');
|
|
36
|
+
if (before) {
|
|
37
|
+
const state = loaded.state.state === 'restoring' ? 'restored' : 'prepared';
|
|
38
|
+
await writeRecoveryState(loaded.artifactDir, state, loaded.manifestHash);
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
if (after) {
|
|
42
|
+
await ensureRecoveryReceipt(loaded.artifactDir, loaded.manifest, loaded.manifestHash);
|
|
43
|
+
await writeRecoveryState(loaded.artifactDir, 'applied', loaded.manifestHash);
|
|
44
|
+
return 'applied';
|
|
45
|
+
}
|
|
46
|
+
await writeRecoveryState(loaded.artifactDir, 'needs_manual_repair', loaded.manifestHash, 'mixed checkpoint state after interrupted operation');
|
|
47
|
+
return 'needs_manual_repair';
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RecoveryCheckpointManifest } from './types.js';
|
|
2
|
+
export type RecoveryResourceKind = 'git_repository' | 'directory';
|
|
3
|
+
export declare function currentRecoveryResourceIdentity(resourceRoot: string, resourceKind: RecoveryResourceKind): Promise<string>;
|
|
4
|
+
export declare function assertRecoveryResourceIdentity(manifest: RecoveryCheckpointManifest): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hashValue } from '../fingerprint.js';
|
|
4
|
+
export async function currentRecoveryResourceIdentity(resourceRoot, resourceKind) {
|
|
5
|
+
const resolvedRoot = await realpath(resourceRoot);
|
|
6
|
+
if (resourceKind === 'directory') {
|
|
7
|
+
const rootInfo = await lstat(resolvedRoot);
|
|
8
|
+
if (!rootInfo.isDirectory())
|
|
9
|
+
throw new Error('recovery_repo_identity_unavailable');
|
|
10
|
+
return hashValue(`${resolvedRoot}\0${rootInfo.dev}:${rootInfo.ino}:${rootInfo.birthtimeMs}`);
|
|
11
|
+
}
|
|
12
|
+
const dotGit = path.join(resolvedRoot, '.git');
|
|
13
|
+
const gitInfo = await lstat(dotGit);
|
|
14
|
+
let gitMetadataPath = dotGit;
|
|
15
|
+
if (gitInfo.isFile()) {
|
|
16
|
+
const marker = (await readFile(dotGit, 'utf8')).trim();
|
|
17
|
+
if (!marker.startsWith('gitdir:'))
|
|
18
|
+
throw new Error('recovery_repo_identity_unavailable');
|
|
19
|
+
gitMetadataPath = path.resolve(resolvedRoot, marker.slice('gitdir:'.length).trim());
|
|
20
|
+
}
|
|
21
|
+
else if (!gitInfo.isDirectory()) {
|
|
22
|
+
throw new Error('recovery_repo_identity_unavailable');
|
|
23
|
+
}
|
|
24
|
+
const resolvedGitMetadataPath = await realpath(gitMetadataPath);
|
|
25
|
+
const metadata = await lstat(resolvedGitMetadataPath);
|
|
26
|
+
return hashValue(`${resolvedRoot}\0${resolvedGitMetadataPath}\0${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}`);
|
|
27
|
+
}
|
|
28
|
+
export async function assertRecoveryResourceIdentity(manifest) {
|
|
29
|
+
const resourceKind = manifest.version === 1 ? 'git_repository' : manifest.resourceKind;
|
|
30
|
+
if ((await currentRecoveryResourceIdentity(manifest.repoRoot, resourceKind)) !==
|
|
31
|
+
manifest.repoIdentity) {
|
|
32
|
+
throw new Error('recovery_checkpoint_repo_mismatch');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const RECOVERY_RESTORE_CONFLICT = "recovery_restore_conflict";
|
|
2
|
+
export declare const RECOVERY_RESTORE_REASON = "recovery_restore";
|
|
3
|
+
export declare function recoveryRestoreBinding(stateDir: string, checkpointId: string, expectedRepoRoot?: string): Promise<{
|
|
4
|
+
fingerprint: string;
|
|
5
|
+
manifestHash: string;
|
|
6
|
+
postStateHash: string;
|
|
7
|
+
paths: string[];
|
|
8
|
+
repoRoot: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function restoreRecoveryCheckpoint(stateDir: string, checkpointId: string, expectedRepoRoot?: string): Promise<{
|
|
11
|
+
manifestHash: string;
|
|
12
|
+
changeCount: number;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { canonicalStringify, hashValue } from '../fingerprint.js';
|
|
5
|
+
import { canonicalPath } from '../path-utils.js';
|
|
6
|
+
import { artifactRepoRoot, RECOVERY_CHECKPOINT_CORRUPT, readRecoveryArtifact, writeRecoveryState, } from './artifact-store.js';
|
|
7
|
+
import { matchRecoverySide } from './reconcile.js';
|
|
8
|
+
import { assertRecoveryResourceIdentity } from './resource-identity.js';
|
|
9
|
+
import { applyRecoverySnapshot, assertRecoverySafeTarget, captureRecoverySnapshot, recoverySnapshotHash, recoveryStateHash, sortRecoveryEntriesForSide, withoutRecoveryBlob, } from './snapshot-node.js';
|
|
10
|
+
export const RECOVERY_RESTORE_CONFLICT = 'recovery_restore_conflict';
|
|
11
|
+
export const RECOVERY_RESTORE_REASON = 'recovery_restore';
|
|
12
|
+
async function assertExpectedRepoRoot(stateDir, checkpointId, expectedRepoRoot) {
|
|
13
|
+
if (expectedRepoRoot &&
|
|
14
|
+
(await artifactRepoRoot(stateDir, checkpointId)) !== canonicalPath(expectedRepoRoot)) {
|
|
15
|
+
throw new Error('recovery_checkpoint_repo_mismatch');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function recoveryRestoreBinding(stateDir, checkpointId, expectedRepoRoot) {
|
|
19
|
+
await assertExpectedRepoRoot(stateDir, checkpointId, expectedRepoRoot);
|
|
20
|
+
const loaded = await readRecoveryArtifact(stateDir, checkpointId);
|
|
21
|
+
await assertRecoveryResourceIdentity(loaded.manifest);
|
|
22
|
+
if (Date.parse(loaded.manifest.expiresAt) <= Date.now()) {
|
|
23
|
+
throw new Error('recovery_checkpoint_expired');
|
|
24
|
+
}
|
|
25
|
+
if (!['applied', 'conflict'].includes(loaded.state.state)) {
|
|
26
|
+
throw new Error(`recovery_checkpoint_not_applied:${loaded.state.state}`);
|
|
27
|
+
}
|
|
28
|
+
if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after'))) {
|
|
29
|
+
await writeRecoveryState(loaded.artifactDir, 'conflict', loaded.manifestHash, RECOVERY_RESTORE_CONFLICT);
|
|
30
|
+
throw new Error(RECOVERY_RESTORE_CONFLICT);
|
|
31
|
+
}
|
|
32
|
+
const paths = loaded.manifest.entries.map((entry) => entry.path).sort();
|
|
33
|
+
const postStateHash = recoveryStateHash(loaded.manifest.entries, 'after');
|
|
34
|
+
return {
|
|
35
|
+
fingerprint: hashValue(canonicalStringify({
|
|
36
|
+
action: 'recovery.restore',
|
|
37
|
+
repoRoot: loaded.manifest.repoRoot,
|
|
38
|
+
checkpointId,
|
|
39
|
+
manifestHash: loaded.manifestHash,
|
|
40
|
+
postStateHash,
|
|
41
|
+
paths,
|
|
42
|
+
})),
|
|
43
|
+
manifestHash: loaded.manifestHash,
|
|
44
|
+
postStateHash,
|
|
45
|
+
paths,
|
|
46
|
+
repoRoot: loaded.manifest.repoRoot,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function restoreRecoveryCheckpoint(stateDir, checkpointId, expectedRepoRoot) {
|
|
50
|
+
await assertExpectedRepoRoot(stateDir, checkpointId, expectedRepoRoot);
|
|
51
|
+
const loaded = await readRecoveryArtifact(stateDir, checkpointId);
|
|
52
|
+
await assertRecoveryResourceIdentity(loaded.manifest);
|
|
53
|
+
if (Date.parse(loaded.manifest.expiresAt) <= Date.now()) {
|
|
54
|
+
throw new Error('recovery_checkpoint_expired');
|
|
55
|
+
}
|
|
56
|
+
if (!['applied', 'conflict'].includes(loaded.state.state)) {
|
|
57
|
+
throw new Error(`recovery_checkpoint_not_applied:${loaded.state.state}`);
|
|
58
|
+
}
|
|
59
|
+
if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after'))) {
|
|
60
|
+
await writeRecoveryState(loaded.artifactDir, 'conflict', loaded.manifestHash, RECOVERY_RESTORE_CONFLICT);
|
|
61
|
+
throw new Error(RECOVERY_RESTORE_CONFLICT);
|
|
62
|
+
}
|
|
63
|
+
const rollbackDir = await mkdtemp(path.join(os.tmpdir(), 'belay-recovery-rollback-'));
|
|
64
|
+
const rollback = [];
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of loaded.manifest.entries) {
|
|
67
|
+
const target = await assertRecoverySafeTarget(loaded.manifest.repoRoot, entry.path);
|
|
68
|
+
const snapshot = await captureRecoverySnapshot(target, {
|
|
69
|
+
blobDir: path.join(rollbackDir, 'blobs'),
|
|
70
|
+
});
|
|
71
|
+
rollback.push({ path: entry.path, target, before: snapshot, after: snapshot });
|
|
72
|
+
}
|
|
73
|
+
const capturedPostStateMatches = rollback.every((item, index) => {
|
|
74
|
+
const expected = loaded.manifest.entries[index]?.after;
|
|
75
|
+
return (expected !== undefined &&
|
|
76
|
+
recoverySnapshotHash(withoutRecoveryBlob(item.before)) ===
|
|
77
|
+
recoverySnapshotHash(withoutRecoveryBlob(expected)));
|
|
78
|
+
});
|
|
79
|
+
if (!capturedPostStateMatches) {
|
|
80
|
+
await writeRecoveryState(loaded.artifactDir, 'conflict', loaded.manifestHash, RECOVERY_RESTORE_CONFLICT);
|
|
81
|
+
throw new Error(RECOVERY_RESTORE_CONFLICT);
|
|
82
|
+
}
|
|
83
|
+
await writeRecoveryState(loaded.artifactDir, 'restoring', loaded.manifestHash);
|
|
84
|
+
try {
|
|
85
|
+
for (const entry of sortRecoveryEntriesForSide(loaded.manifest.entries, 'before')) {
|
|
86
|
+
const target = await assertRecoverySafeTarget(loaded.manifest.repoRoot, entry.path);
|
|
87
|
+
await applyRecoverySnapshot(target, entry.before, loaded.artifactDir, RECOVERY_CHECKPOINT_CORRUPT);
|
|
88
|
+
}
|
|
89
|
+
if (!(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'before'))) {
|
|
90
|
+
throw new Error('recovery_restore_verification_failed');
|
|
91
|
+
}
|
|
92
|
+
await writeRecoveryState(loaded.artifactDir, 'restored', loaded.manifestHash);
|
|
93
|
+
return { manifestHash: loaded.manifestHash, changeCount: loaded.manifest.entries.length };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
let rollbackFailed = false;
|
|
97
|
+
for (const item of sortRecoveryEntriesForSide(rollback, 'before')) {
|
|
98
|
+
try {
|
|
99
|
+
await applyRecoverySnapshot(item.target, item.before, rollbackDir, RECOVERY_CHECKPOINT_CORRUPT);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
rollbackFailed = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const rollbackVerified = !rollbackFailed &&
|
|
106
|
+
(await matchRecoverySide(loaded.manifest.repoRoot, loaded.manifest.entries, 'after'));
|
|
107
|
+
await writeRecoveryState(loaded.artifactDir, rollbackVerified ? 'applied' : 'needs_manual_repair', loaded.manifestHash, error instanceof Error ? error.message : 'recovery_restore_failed');
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await rm(rollbackDir, { recursive: true, force: true }).catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RecoveryCheckpointEntry, RecoveryFileSnapshotV2 } from './types.js';
|
|
2
|
+
export declare const RECOVERY_UNSUPPORTED_FILE_KIND = "recovery_unsupported_file_kind";
|
|
3
|
+
export declare function validRecoveryRelativePath(relativePath: string): boolean;
|
|
4
|
+
export declare function assertRecoverySafeTarget(resourceRoot: string, relativePath: string): Promise<string>;
|
|
5
|
+
export declare function recoveryDirectoryHash(mode: number): string;
|
|
6
|
+
export declare function recoverySnapshotHash(snapshot: RecoveryFileSnapshotV2): string;
|
|
7
|
+
export declare function withoutRecoveryBlob(snapshot: RecoveryFileSnapshotV2): RecoveryFileSnapshotV2;
|
|
8
|
+
export declare function captureRecoverySnapshot(filePath: string, options?: {
|
|
9
|
+
blobDir?: string;
|
|
10
|
+
}): Promise<RecoveryFileSnapshotV2>;
|
|
11
|
+
export declare function recoverySnapshotMatches(filePath: string, expected: RecoveryFileSnapshotV2): Promise<boolean>;
|
|
12
|
+
export declare function validateRecoverySnapshot(params: {
|
|
13
|
+
snapshot: unknown;
|
|
14
|
+
side: 'before' | 'after';
|
|
15
|
+
manifestVersion: 1 | 2;
|
|
16
|
+
artifactDir: string;
|
|
17
|
+
corruptReason: string;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
export declare function applyRecoverySnapshot(target: string, snapshot: RecoveryFileSnapshotV2, artifactDir: string, corruptReason: string): Promise<void>;
|
|
20
|
+
interface RecoveryEntryLike {
|
|
21
|
+
path: string;
|
|
22
|
+
before: RecoveryFileSnapshotV2;
|
|
23
|
+
after: RecoveryFileSnapshotV2;
|
|
24
|
+
}
|
|
25
|
+
export declare function sortRecoveryEntriesForSide<T extends RecoveryEntryLike>(entries: T[], side: 'before' | 'after'): T[];
|
|
26
|
+
export declare function recoveryStateHash(entries: RecoveryCheckpointEntry[], side: 'before' | 'after'): string;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { chmod, copyFile, lstat, mkdir, open, readFile, readlink, rm, rmdir, symlink, writeFile, } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { canonicalStringify, hashValue } from '../fingerprint.js';
|
|
6
|
+
import { canonicalPath } from '../path-utils.js';
|
|
7
|
+
export const RECOVERY_UNSUPPORTED_FILE_KIND = 'recovery_unsupported_file_kind';
|
|
8
|
+
export function validRecoveryRelativePath(relativePath) {
|
|
9
|
+
if (!relativePath || relativePath.includes('\0') || path.isAbsolute(relativePath))
|
|
10
|
+
return false;
|
|
11
|
+
const normalized = path.normalize(relativePath);
|
|
12
|
+
return normalized !== '.' && normalized !== '..' && !normalized.startsWith(`..${path.sep}`);
|
|
13
|
+
}
|
|
14
|
+
export async function assertRecoverySafeTarget(resourceRoot, relativePath) {
|
|
15
|
+
if (!validRecoveryRelativePath(relativePath))
|
|
16
|
+
throw new Error('recovery_path_escape');
|
|
17
|
+
const root = canonicalPath(resourceRoot);
|
|
18
|
+
const target = path.resolve(root, relativePath);
|
|
19
|
+
const relative = path.relative(root, target);
|
|
20
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
21
|
+
throw new Error('recovery_path_escape');
|
|
22
|
+
}
|
|
23
|
+
let current = root;
|
|
24
|
+
const parentParts = path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean);
|
|
25
|
+
for (const part of parentParts) {
|
|
26
|
+
current = path.join(current, part);
|
|
27
|
+
if (!existsSync(current))
|
|
28
|
+
break;
|
|
29
|
+
const info = await lstat(current);
|
|
30
|
+
if (info.isSymbolicLink())
|
|
31
|
+
throw new Error('recovery_symlink_escape');
|
|
32
|
+
if (!info.isDirectory())
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
37
|
+
async function fsyncPath(filePath) {
|
|
38
|
+
const handle = await open(filePath, 'r');
|
|
39
|
+
try {
|
|
40
|
+
await handle.sync();
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await handle.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function normalizedMode(mode) {
|
|
47
|
+
return mode & 0o777;
|
|
48
|
+
}
|
|
49
|
+
export function recoveryDirectoryHash(mode) {
|
|
50
|
+
return hashValue(`directory:${normalizedMode(mode)}`);
|
|
51
|
+
}
|
|
52
|
+
export function recoverySnapshotHash(snapshot) {
|
|
53
|
+
return hashValue(canonicalStringify(snapshot));
|
|
54
|
+
}
|
|
55
|
+
export function withoutRecoveryBlob(snapshot) {
|
|
56
|
+
if (snapshot.kind !== 'file')
|
|
57
|
+
return { ...snapshot };
|
|
58
|
+
const { blob: _blob, ...rest } = snapshot;
|
|
59
|
+
return rest;
|
|
60
|
+
}
|
|
61
|
+
export async function captureRecoverySnapshot(filePath, options) {
|
|
62
|
+
let info;
|
|
63
|
+
try {
|
|
64
|
+
info = await lstat(filePath);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error.code ?? ''))
|
|
68
|
+
throw error;
|
|
69
|
+
return { kind: 'absent' };
|
|
70
|
+
}
|
|
71
|
+
if (info.isSymbolicLink()) {
|
|
72
|
+
const symlinkTarget = await readlink(filePath);
|
|
73
|
+
return {
|
|
74
|
+
kind: 'symlink',
|
|
75
|
+
symlinkTarget,
|
|
76
|
+
hash: hashValue(`symlink:${symlinkTarget}`),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (info.isDirectory()) {
|
|
80
|
+
const mode = normalizedMode(info.mode);
|
|
81
|
+
return { kind: 'directory', mode, hash: recoveryDirectoryHash(mode) };
|
|
82
|
+
}
|
|
83
|
+
if (!info.isFile())
|
|
84
|
+
throw new Error(RECOVERY_UNSUPPORTED_FILE_KIND);
|
|
85
|
+
const content = await readFile(filePath);
|
|
86
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
87
|
+
let blob;
|
|
88
|
+
if (options?.blobDir) {
|
|
89
|
+
await mkdir(options.blobDir, { recursive: true, mode: 0o700 });
|
|
90
|
+
const blobPath = path.join(options.blobDir, hash);
|
|
91
|
+
if (!existsSync(blobPath)) {
|
|
92
|
+
await writeFile(blobPath, content, { mode: 0o600 });
|
|
93
|
+
await fsyncPath(blobPath);
|
|
94
|
+
}
|
|
95
|
+
blob = `blobs/${hash}`;
|
|
96
|
+
}
|
|
97
|
+
return { kind: 'file', mode: normalizedMode(info.mode), hash, ...(blob ? { blob } : {}) };
|
|
98
|
+
}
|
|
99
|
+
export async function recoverySnapshotMatches(filePath, expected) {
|
|
100
|
+
try {
|
|
101
|
+
const current = await captureRecoverySnapshot(filePath);
|
|
102
|
+
return (recoverySnapshotHash(withoutRecoveryBlob(current)) ===
|
|
103
|
+
recoverySnapshotHash(withoutRecoveryBlob(expected)));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function validateRecoverySnapshot(params) {
|
|
110
|
+
const { snapshot } = params;
|
|
111
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
112
|
+
throw new Error(params.corruptReason);
|
|
113
|
+
}
|
|
114
|
+
const record = snapshot;
|
|
115
|
+
const hasExactKeys = (allowed) => {
|
|
116
|
+
const keys = Object.keys(record);
|
|
117
|
+
return keys.length === allowed.length && keys.every((key) => allowed.includes(key));
|
|
118
|
+
};
|
|
119
|
+
const validHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
120
|
+
const validMode = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 0o777;
|
|
121
|
+
if (record.kind === 'absent') {
|
|
122
|
+
if (!hasExactKeys(['kind']))
|
|
123
|
+
throw new Error(params.corruptReason);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (record.kind === 'directory') {
|
|
127
|
+
if (params.manifestVersion !== 2 ||
|
|
128
|
+
!hasExactKeys(['kind', 'mode', 'hash']) ||
|
|
129
|
+
!validMode(record.mode) ||
|
|
130
|
+
!validHash(record.hash) ||
|
|
131
|
+
record.hash !== recoveryDirectoryHash(record.mode)) {
|
|
132
|
+
throw new Error(params.corruptReason);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (record.kind === 'symlink') {
|
|
137
|
+
if (!hasExactKeys(['kind', 'symlinkTarget', 'hash']) ||
|
|
138
|
+
typeof record.symlinkTarget !== 'string' ||
|
|
139
|
+
!validHash(record.hash) ||
|
|
140
|
+
record.hash !== hashValue(`symlink:${record.symlinkTarget}`)) {
|
|
141
|
+
throw new Error(params.corruptReason);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (record.kind !== 'file')
|
|
146
|
+
throw new Error(params.corruptReason);
|
|
147
|
+
const allowedKeys = record.blob === undefined ? ['kind', 'mode', 'hash'] : ['kind', 'mode', 'hash', 'blob'];
|
|
148
|
+
if (!hasExactKeys(allowedKeys) ||
|
|
149
|
+
!validMode(record.mode) ||
|
|
150
|
+
!validHash(record.hash) ||
|
|
151
|
+
(record.blob !== undefined && typeof record.blob !== 'string')) {
|
|
152
|
+
throw new Error(params.corruptReason);
|
|
153
|
+
}
|
|
154
|
+
if (params.side === 'before' && !record.blob)
|
|
155
|
+
throw new Error(params.corruptReason);
|
|
156
|
+
if (!record.blob)
|
|
157
|
+
return;
|
|
158
|
+
if (record.blob !== `blobs/${record.hash}`)
|
|
159
|
+
throw new Error(params.corruptReason);
|
|
160
|
+
let content;
|
|
161
|
+
try {
|
|
162
|
+
content = await readFile(path.join(params.artifactDir, record.blob));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error(params.corruptReason);
|
|
166
|
+
}
|
|
167
|
+
if (createHash('sha256').update(content).digest('hex') !== record.hash) {
|
|
168
|
+
throw new Error(params.corruptReason);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function removeTargetWithoutRecursion(target) {
|
|
172
|
+
let info;
|
|
173
|
+
try {
|
|
174
|
+
info = await lstat(target);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error.code === 'ENOENT')
|
|
178
|
+
return;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
182
|
+
await rmdir(target);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await rm(target, { force: true, recursive: false });
|
|
186
|
+
}
|
|
187
|
+
export async function applyRecoverySnapshot(target, snapshot, artifactDir, corruptReason) {
|
|
188
|
+
if (snapshot.kind === 'absent') {
|
|
189
|
+
await removeTargetWithoutRecursion(target);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let current;
|
|
193
|
+
try {
|
|
194
|
+
current = await lstat(target);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code !== 'ENOENT')
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
if (snapshot.kind === 'directory') {
|
|
201
|
+
if (current && (!current.isDirectory() || current.isSymbolicLink())) {
|
|
202
|
+
await removeTargetWithoutRecursion(target);
|
|
203
|
+
current = undefined;
|
|
204
|
+
}
|
|
205
|
+
if (!current)
|
|
206
|
+
await mkdir(target, { recursive: false, mode: snapshot.mode });
|
|
207
|
+
await chmod(target, snapshot.mode);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (current)
|
|
211
|
+
await removeTargetWithoutRecursion(target);
|
|
212
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
213
|
+
if (snapshot.kind === 'symlink') {
|
|
214
|
+
if (snapshot.symlinkTarget === undefined)
|
|
215
|
+
throw new Error(corruptReason);
|
|
216
|
+
await symlink(snapshot.symlinkTarget, target);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!snapshot.blob || !snapshot.hash)
|
|
220
|
+
throw new Error(corruptReason);
|
|
221
|
+
const blobPath = path.join(artifactDir, snapshot.blob);
|
|
222
|
+
let content;
|
|
223
|
+
try {
|
|
224
|
+
content = await readFile(blobPath);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
throw new Error(corruptReason);
|
|
228
|
+
}
|
|
229
|
+
if (createHash('sha256').update(content).digest('hex') !== snapshot.hash) {
|
|
230
|
+
throw new Error(corruptReason);
|
|
231
|
+
}
|
|
232
|
+
await copyFile(blobPath, target);
|
|
233
|
+
if (snapshot.mode !== undefined)
|
|
234
|
+
await chmod(target, snapshot.mode);
|
|
235
|
+
}
|
|
236
|
+
function pathDepth(relativePath) {
|
|
237
|
+
return relativePath.split(path.sep).length;
|
|
238
|
+
}
|
|
239
|
+
export function sortRecoveryEntriesForSide(entries, side) {
|
|
240
|
+
const rank = (entry) => {
|
|
241
|
+
if (entry[side].kind === 'absent')
|
|
242
|
+
return 0;
|
|
243
|
+
if (entry[side].kind === 'directory')
|
|
244
|
+
return 1;
|
|
245
|
+
return 2;
|
|
246
|
+
};
|
|
247
|
+
return [...entries].sort((left, right) => {
|
|
248
|
+
const rankDifference = rank(left) - rank(right);
|
|
249
|
+
if (rankDifference !== 0)
|
|
250
|
+
return rankDifference;
|
|
251
|
+
const depthDifference = pathDepth(left.path) - pathDepth(right.path);
|
|
252
|
+
if (left[side].kind === 'absent' && depthDifference !== 0)
|
|
253
|
+
return -depthDifference;
|
|
254
|
+
if (depthDifference !== 0)
|
|
255
|
+
return depthDifference;
|
|
256
|
+
return left.path.localeCompare(right.path);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
export function recoveryStateHash(entries, side) {
|
|
260
|
+
return hashValue(canonicalStringify(entries.map((entry) => ({ path: entry.path, state: entry[side] }))));
|
|
261
|
+
}
|