@narumitw/pi-sync 0.51.0 → 0.52.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 +30 -5
- package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
- package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
- package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
- package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
- package/dist/chunks/chunk-2GUXI2XT.ts +1702 -0
- package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
- package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
- package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
- package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
- package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
- package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
- package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
- package/dist/chunks/chunk-76NB4R7U.ts +107 -0
- package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
- package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
- package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
- package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
- package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
- package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
- package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
- package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
- package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
- package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
- package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
- package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
- package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
- package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
- package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
- package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
- package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
- package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
- package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
- package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
- package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
- package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
- package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
- package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
- package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
- package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
- package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
- package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
- package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
- package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
- package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
- package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
- package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
- package/dist/chunks/conflict-review-T3PB7ZSD.ts +175 -0
- package/dist/chunks/conflict-review-T3PB7ZSD.ts.map +7 -0
- package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
- package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
- package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
- package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
- package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
- package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
- package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
- package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
- package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
- package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
- package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
- package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
- package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
- package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
- package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
- package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
- package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
- package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
- package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
- package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-27VQUDH2.ts} +309 -508
- package/dist/chunks/sync-operations-27VQUDH2.ts.map +7 -0
- package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
- package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
- package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
- package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
- package/dist/index.ts +142 -43
- package/dist/index.ts.map +2 -2
- package/docs/content-merge-audit.md +156 -0
- package/docs/merge-implementation-audit.md +312 -0
- package/docs/settings-merge-audit.md +153 -0
- package/docs/settings.md +36 -0
- package/package.json +6 -6
- package/src/backends/git/git-backend.ts +2 -0
- package/src/backends/git/git-storage.ts +8 -3
- package/src/backends/s3/s3-backend.ts +2 -1
- package/src/backends/webdav/webdav-backend.ts +2 -1
- package/src/commands/command-execution.ts +10 -0
- package/src/commands/command.ts +4 -0
- package/src/settings/config.ts +12 -0
- package/src/settings/settings-management.ts +2 -2
- package/src/settings/settings-types.ts +14 -1
- package/src/settings/settings-validation.ts +24 -3
- package/src/snapshot/session-paths.ts +34 -7
- package/src/snapshot/snapshot-apply.ts +81 -32
- package/src/snapshot/snapshot-case-replacement.ts +80 -0
- package/src/snapshot/snapshot-codec.ts +6 -1
- package/src/snapshot/snapshot-root-transition.ts +154 -0
- package/src/snapshot/snapshot-transaction-plan.ts +72 -0
- package/src/snapshot/snapshot-transaction.ts +844 -102
- package/src/snapshot/snapshot-types.ts +5 -0
- package/src/state/json-file.ts +32 -5
- package/src/state/merge-baseline-store.ts +182 -0
- package/src/state/mutation-directory-sync.ts +34 -0
- package/src/state/state-types.ts +4 -0
- package/src/state/sync-state-store.ts +14 -1
- package/src/sync/automatic-sync.ts +11 -3
- package/src/sync/conflict-artifact-ownership.ts +63 -0
- package/src/sync/conflict-artifacts.ts +195 -0
- package/src/sync/conflict-groups.ts +68 -0
- package/src/sync/conflict-preview.ts +56 -0
- package/src/sync/conflict-resolution.ts +93 -0
- package/src/sync/conflict-retention.ts +72 -0
- package/src/sync/conflict-review.ts +95 -0
- package/src/sync/content-conflicts.ts +73 -0
- package/src/sync/field-migration.ts +39 -0
- package/src/sync/file-merge-planner.ts +143 -0
- package/src/sync/json-document.ts +184 -0
- package/src/sync/local-fields.ts +134 -0
- package/src/sync/merge-apply.ts +380 -0
- package/src/sync/merge-journal.ts +219 -0
- package/src/sync/merged-sync.ts +723 -0
- package/src/sync/partial-progress.ts +54 -0
- package/src/sync/remote-snapshot.ts +6 -0
- package/src/sync/session-merge.ts +194 -0
- package/src/sync/settings-conflicts.ts +52 -0
- package/src/sync/settings-merge.ts +57 -0
- package/src/sync/startup-check.ts +90 -5
- package/src/sync/sync-inspection.ts +1 -0
- package/src/sync/sync-local.ts +61 -0
- package/src/sync/sync-mutations.ts +256 -114
- package/src/sync/sync-queries.ts +6 -2
- package/src/sync/sync-state.ts +14 -5
- package/src/sync/text-merge.ts +133 -0
- package/src/sync-extension.ts +8 -0
- package/src/ui/manager-state.ts +12 -1
- package/src/ui/manager-ui.ts +10 -0
- package/src/ui/merge-review.ts +28 -0
- package/src/ui/remote-selection-ui.ts +1 -0
- package/src/ui/settings-ui.ts +183 -2
- package/src/ui/setup/setup-actions.ts +2 -2
- package/src/ui/setup/setup-switcher.ts +2 -1
- package/src/ui/storage-connections-ui.ts +11 -7
- package/src/ui/sync-attention.ts +3 -0
- package/src/ui/sync-format.ts +16 -12
- package/src/ui/sync-setups-ui.ts +1 -0
- package/src/ui/terminal-text.ts +9 -2
- package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
- package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
- package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
- package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
- package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
- package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
- package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
- package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
- package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
- package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
- package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
- package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
- package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
- package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
- package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
- package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
- package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
- package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
- /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
- /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
- /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
- /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
- /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
// @generated by scripts/build-runtime.mjs; do not edit.
|
|
2
|
+
// @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
|
|
3
|
+
import {
|
|
4
|
+
withLock
|
|
5
|
+
} from "./chunk-IJ6VV6FN.ts";
|
|
6
|
+
import {
|
|
7
|
+
mergePathIdentity
|
|
8
|
+
} from "./chunk-76NB4R7U.ts";
|
|
9
|
+
import {
|
|
10
|
+
stateDir
|
|
11
|
+
} from "./chunk-BVVP33U5.ts";
|
|
12
|
+
import {
|
|
13
|
+
agentDir,
|
|
14
|
+
configuredSessionDir,
|
|
15
|
+
expandSessionDir,
|
|
16
|
+
sessionStorageRoot
|
|
17
|
+
} from "./chunk-QOFMXPM5.ts";
|
|
18
|
+
import {
|
|
19
|
+
assertWithinRoot,
|
|
20
|
+
isDeniedPath,
|
|
21
|
+
isPathInside,
|
|
22
|
+
syncDirectory,
|
|
23
|
+
writeJson
|
|
24
|
+
} from "./chunk-HUZ5CR4W.ts";
|
|
25
|
+
|
|
26
|
+
// src/state/mutation-directory-sync.ts
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
async function syncMutationParents(targets, roots, options = {}) {
|
|
29
|
+
const directories = /* @__PURE__ */ new Set();
|
|
30
|
+
for (const target of targets) {
|
|
31
|
+
const root = roots.map((root2) => path.resolve(root2)).find((root2) => isPathInside(root2, target));
|
|
32
|
+
if (!root) throw new Error("Unowned mutation directory.");
|
|
33
|
+
for (let parent = path.dirname(target); ; parent = path.dirname(parent)) {
|
|
34
|
+
directories.add(parent);
|
|
35
|
+
if (mergePathIdentity(parent) === mergePathIdentity(root)) break;
|
|
36
|
+
if (path.dirname(parent) === parent) throw new Error("Mutation directory escaped its owned root.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
|
|
40
|
+
options.signal?.throwIfAborted();
|
|
41
|
+
options.validateMutation?.();
|
|
42
|
+
try {
|
|
43
|
+
await syncDirectory(directory);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const code = error.code;
|
|
46
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
47
|
+
}
|
|
48
|
+
options.signal?.throwIfAborted();
|
|
49
|
+
options.validateMutation?.();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/snapshot/snapshot-transaction.ts
|
|
54
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
55
|
+
import fs3 from "node:fs/promises";
|
|
56
|
+
import path5 from "node:path";
|
|
57
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
58
|
+
|
|
59
|
+
// src/snapshot/snapshot-case-replacement.ts
|
|
60
|
+
import fs from "node:fs/promises";
|
|
61
|
+
import path2 from "node:path";
|
|
62
|
+
function isCaseReplacement(root, before, after) {
|
|
63
|
+
return before !== after && path2.dirname(before) === root && path2.dirname(after) === root && path2.basename(before).toLowerCase() === path2.basename(after).toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
async function caseReplacementSpelling(before, after) {
|
|
66
|
+
const observations = await Promise.all(
|
|
67
|
+
[before, after].map(async (target) => {
|
|
68
|
+
try {
|
|
69
|
+
const stat = await fs.lstat(target);
|
|
70
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error("Unsafe case replacement target.");
|
|
71
|
+
return await fs.realpath(target);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code === "ENOENT") return void 0;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
if (observations.every((value) => value === void 0)) return void 0;
|
|
79
|
+
if (!observations[0] || observations[0] !== observations[1])
|
|
80
|
+
throw new Error("Case replacement no longer identifies one physical file; preserve evidence for review.");
|
|
81
|
+
const names = (await fs.readdir(path2.dirname(before))).filter(
|
|
82
|
+
(name) => name.toLowerCase() === path2.basename(before).toLowerCase()
|
|
83
|
+
);
|
|
84
|
+
if (names.length !== 1 || names[0] !== path2.basename(before) && names[0] !== path2.basename(after))
|
|
85
|
+
throw new Error("Case replacement spelling changed; preserve evidence for review.");
|
|
86
|
+
return path2.join(path2.dirname(before), names[0]);
|
|
87
|
+
}
|
|
88
|
+
async function coalesceCaseReplacements(root, plan) {
|
|
89
|
+
const replacements = /* @__PURE__ */ new Map();
|
|
90
|
+
const deletesByCase = /* @__PURE__ */ new Map();
|
|
91
|
+
const writeTargets = new Set(plan.writes.map((item) => item.target));
|
|
92
|
+
for (const target of new Set(plan.deletes)) {
|
|
93
|
+
if (path2.dirname(target) !== root || writeTargets.has(target)) continue;
|
|
94
|
+
const key = path2.basename(target).toLowerCase();
|
|
95
|
+
const group = deletesByCase.get(key) ?? [];
|
|
96
|
+
group.push(target);
|
|
97
|
+
deletesByCase.set(key, group);
|
|
98
|
+
}
|
|
99
|
+
for (const item of plan.writes) {
|
|
100
|
+
if (path2.dirname(item.target) !== root) continue;
|
|
101
|
+
const candidates = deletesByCase.get(path2.basename(item.target).toLowerCase()) ?? [];
|
|
102
|
+
if (candidates.length !== 1) continue;
|
|
103
|
+
const before = candidates[0];
|
|
104
|
+
if (!before || !isCaseReplacement(root, before, item.target)) continue;
|
|
105
|
+
let beforePath;
|
|
106
|
+
let afterPath;
|
|
107
|
+
try {
|
|
108
|
+
[beforePath, afterPath] = await Promise.all([fs.realpath(before), fs.realpath(item.target)]);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code === "ENOENT") continue;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
if (beforePath !== afterPath) continue;
|
|
114
|
+
if (replacements.has(before)) throw new Error("Multiple writes alias a case replacement target.");
|
|
115
|
+
if (await caseReplacementSpelling(before, item.target) !== before)
|
|
116
|
+
throw new Error("Case replacement preimage spelling changed; review is required.");
|
|
117
|
+
replacements.set(before, item.target);
|
|
118
|
+
}
|
|
119
|
+
const beforeByAfter = new Map([...replacements].map(([before, after]) => [after, before]));
|
|
120
|
+
return {
|
|
121
|
+
replacements,
|
|
122
|
+
plan: {
|
|
123
|
+
deletes: plan.deletes,
|
|
124
|
+
writes: plan.writes.map((item) => ({ ...item, target: beforeByAfter.get(item.target) ?? item.target }))
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/snapshot/snapshot-root-transition.ts
|
|
130
|
+
import fs2 from "node:fs/promises";
|
|
131
|
+
import path4 from "node:path";
|
|
132
|
+
|
|
133
|
+
// src/snapshot/snapshot-transaction-plan.ts
|
|
134
|
+
import { createHash } from "node:crypto";
|
|
135
|
+
import path3 from "node:path";
|
|
136
|
+
function fileImage(content) {
|
|
137
|
+
return `file:${createHash("sha256").update(content).digest("hex")}`;
|
|
138
|
+
}
|
|
139
|
+
function indexTransactionPlan(plan, targets) {
|
|
140
|
+
const targetIndex = indexTargets(targets);
|
|
141
|
+
const writeImages = /* @__PURE__ */ new Map();
|
|
142
|
+
const postFiles = new Map(targets.map((target) => [target, []]));
|
|
143
|
+
for (const write of plan.writes) {
|
|
144
|
+
const image2 = fileImage(write.content);
|
|
145
|
+
if (!writeImages.has(write.target)) writeImages.set(write.target, image2);
|
|
146
|
+
for (const ancestor of ancestorTargets(write.target, targetIndex, false)) {
|
|
147
|
+
if (ancestor !== write.target && isStrictDescendant(ancestor, write.target)) {
|
|
148
|
+
postFiles.get(ancestor)?.push({ relative: path3.relative(ancestor, write.target), image: image2 });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const deleteIndex = indexTargets(plan.deletes);
|
|
153
|
+
const deletes = plan.deletes.filter((target) => {
|
|
154
|
+
for (const ancestor of ancestorTargets(target, deleteIndex, false)) {
|
|
155
|
+
if (ancestor !== target && isStrictDescendant(ancestor, target)) return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
});
|
|
159
|
+
const deletedWrites = /* @__PURE__ */ new Set();
|
|
160
|
+
const outerDeleteIndex = indexTargets(deletes);
|
|
161
|
+
for (const write of plan.writes) {
|
|
162
|
+
for (const ancestor of ancestorTargets(write.target, outerDeleteIndex)) {
|
|
163
|
+
if (ancestor === write.target || isPathInside(ancestor, write.target)) {
|
|
164
|
+
deletedWrites.add(write.target);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { writeImages, postFiles, deletes, deletedWrites };
|
|
170
|
+
}
|
|
171
|
+
function isStrictDescendant(parent, target) {
|
|
172
|
+
return path3.relative(path3.resolve(parent), path3.resolve(target)) !== "" && isPathInside(parent, target);
|
|
173
|
+
}
|
|
174
|
+
function pathKey(target) {
|
|
175
|
+
const resolved = path3.resolve(target);
|
|
176
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
177
|
+
}
|
|
178
|
+
function indexTargets(targets) {
|
|
179
|
+
const index = /* @__PURE__ */ new Map();
|
|
180
|
+
for (const target of new Set(targets)) {
|
|
181
|
+
const key = pathKey(target);
|
|
182
|
+
const rows = index.get(key) ?? [];
|
|
183
|
+
rows.push(target);
|
|
184
|
+
index.set(key, rows);
|
|
185
|
+
}
|
|
186
|
+
return index;
|
|
187
|
+
}
|
|
188
|
+
function* ancestorTargets(target, index, includeSelf = true) {
|
|
189
|
+
const resolved = path3.resolve(target);
|
|
190
|
+
for (let ancestor = includeSelf ? resolved : path3.dirname(resolved); ; ancestor = path3.dirname(ancestor)) {
|
|
191
|
+
yield* index.get(pathKey(ancestor)) ?? [];
|
|
192
|
+
if (path3.dirname(ancestor) === ancestor) return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/snapshot/snapshot-root-transition.ts
|
|
197
|
+
function validateOwner(guards) {
|
|
198
|
+
guards.validateMutation?.();
|
|
199
|
+
guards.signal?.throwIfAborted();
|
|
200
|
+
}
|
|
201
|
+
function rootFromSettings(root, bytes) {
|
|
202
|
+
let settings;
|
|
203
|
+
try {
|
|
204
|
+
settings = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
205
|
+
} catch {
|
|
206
|
+
throw new Error("Invalid settings root-transition evidence.");
|
|
207
|
+
}
|
|
208
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
209
|
+
throw new Error("Invalid settings root-transition evidence.");
|
|
210
|
+
const value = settings.sessionDir;
|
|
211
|
+
if (value !== void 0 && typeof value !== "string") throw new Error("Invalid settings root-transition evidence.");
|
|
212
|
+
return path4.resolve(sessionStorageRoot(root, value ? expandSessionDir(value) : void 0));
|
|
213
|
+
}
|
|
214
|
+
async function beforeRoot(directory, root, entry, guards) {
|
|
215
|
+
validateOwner(guards);
|
|
216
|
+
if (entry.kind === "missing" && entry.beforeImage === "missing") return path4.join(root, "sessions");
|
|
217
|
+
if (entry.kind !== "file") throw new Error("Root transition requires regular settings preimage evidence.");
|
|
218
|
+
const backup = path4.join(directory, "before", entry.backupName);
|
|
219
|
+
const stat = await fs2.lstat(backup);
|
|
220
|
+
validateOwner(guards);
|
|
221
|
+
if (!stat.isFile()) throw new Error("Invalid settings root-transition backup.");
|
|
222
|
+
const bytes = await fs2.readFile(backup);
|
|
223
|
+
validateOwner(guards);
|
|
224
|
+
if (fileImage(bytes) !== entry.beforeImage) throw new Error("Changed settings root-transition backup.");
|
|
225
|
+
return rootFromSettings(root, bytes);
|
|
226
|
+
}
|
|
227
|
+
async function prepareSessionRootTransition(directory, root, sessionRoot, entries, plan, guards = {}) {
|
|
228
|
+
const settingsTarget = path4.join(root, "settings.json");
|
|
229
|
+
const entry = entries.find((item) => item.target === settingsTarget || item.afterTarget === settingsTarget);
|
|
230
|
+
const write = plan.writes.find((item) => item.target === settingsTarget || item.target === entry?.target);
|
|
231
|
+
if (!entry || !entries.some((item) => item !== entry && isPathInside(sessionRoot, item.target))) return void 0;
|
|
232
|
+
const deleted = !write && plan.deletes.includes(entry.target) && entry.afterImage === "missing";
|
|
233
|
+
if (!write && !deleted) return void 0;
|
|
234
|
+
const afterRoot = write ? rootFromSettings(root, write.content) : path4.join(root, "sessions");
|
|
235
|
+
if (afterRoot !== sessionRoot) return void 0;
|
|
236
|
+
const previous = await beforeRoot(directory, root, entry, guards);
|
|
237
|
+
if (previous === sessionRoot) return void 0;
|
|
238
|
+
if (write && fileImage(write.content) !== entry.afterImage) throw new Error("Changed settings transition postimage.");
|
|
239
|
+
if (!write) return { beforeRoot: previous, settingsAfterBase64: null };
|
|
240
|
+
validateOwner(guards);
|
|
241
|
+
const handle = await fs2.open(path4.join(directory, "settings-after"), "wx", 384);
|
|
242
|
+
try {
|
|
243
|
+
validateOwner(guards);
|
|
244
|
+
await handle.writeFile(write.content);
|
|
245
|
+
validateOwner(guards);
|
|
246
|
+
await handle.sync();
|
|
247
|
+
validateOwner(guards);
|
|
248
|
+
} finally {
|
|
249
|
+
await handle.close();
|
|
250
|
+
}
|
|
251
|
+
await syncDirectory(directory);
|
|
252
|
+
validateOwner(guards);
|
|
253
|
+
return { beforeRoot: previous, settingsAfterFile: true };
|
|
254
|
+
}
|
|
255
|
+
function validateSessionRootTransition(value) {
|
|
256
|
+
if (!value || typeof value.beforeRoot !== "string" || !path4.isAbsolute(value.beforeRoot) || path4.resolve(value.beforeRoot) !== value.beforeRoot || value.settingsAfterFile !== void 0 && value.settingsAfterFile !== true || (value.settingsAfterFile === true ? value.settingsAfterBase64 !== void 0 : value.settingsAfterBase64 !== null && (typeof value.settingsAfterBase64 !== "string" || value.settingsAfterBase64.length > 32 * 1024 * 1024 || Buffer.from(value.settingsAfterBase64, "base64").toString("base64") !== value.settingsAfterBase64)))
|
|
257
|
+
throw new Error("Invalid session root-transition evidence.");
|
|
258
|
+
}
|
|
259
|
+
async function resolveTransitionSessionRoot(directory, root, sessionRoot, transition, entries, currentRoot, guards = {}) {
|
|
260
|
+
const settingsTarget = path4.join(root, "settings.json");
|
|
261
|
+
const entry = entries.find((item) => item.target === settingsTarget || item.afterTarget === settingsTarget);
|
|
262
|
+
if (!entry) throw new Error("Missing settings root-transition evidence.");
|
|
263
|
+
let after;
|
|
264
|
+
if (transition.settingsAfterFile) {
|
|
265
|
+
const file = path4.join(directory, "settings-after");
|
|
266
|
+
const stat = await fs2.lstat(file);
|
|
267
|
+
validateOwner(guards);
|
|
268
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || process.platform !== "win32" && (stat.mode & 63) !== 0)
|
|
269
|
+
throw new Error("Unsafe settings root-transition postimage.");
|
|
270
|
+
after = await fs2.readFile(file);
|
|
271
|
+
validateOwner(guards);
|
|
272
|
+
} else if (transition.settingsAfterBase64 !== null)
|
|
273
|
+
after = Buffer.from(transition.settingsAfterBase64, "base64");
|
|
274
|
+
if ((after ? fileImage(after) : "missing") !== entry.afterImage || (after ? rootFromSettings(root, after) : path4.join(root, "sessions")) !== sessionRoot || await beforeRoot(directory, root, entry, guards) !== transition.beforeRoot)
|
|
275
|
+
throw new Error("Inconsistent settings root-transition evidence.");
|
|
276
|
+
if (currentRoot !== transition.beforeRoot && currentRoot !== sessionRoot)
|
|
277
|
+
throw new Error("Transaction session root is not owned by this context; preserve evidence for review.");
|
|
278
|
+
return sessionRoot;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/snapshot/snapshot-transaction.ts
|
|
282
|
+
var JOURNAL_VERSION = 4;
|
|
283
|
+
async function applySnapshotTransaction(plan, options = {}) {
|
|
284
|
+
options.validateMutation?.();
|
|
285
|
+
await recoverPendingSnapshotTransactions(options);
|
|
286
|
+
const coalesced = await coalesceCaseReplacements(path5.resolve(agentDir()), plan);
|
|
287
|
+
options.validateMutation?.();
|
|
288
|
+
options.signal?.throwIfAborted();
|
|
289
|
+
const targets = [.../* @__PURE__ */ new Set([...coalesced.plan.deletes, ...coalesced.plan.writes.map((item) => item.target)])].sort();
|
|
290
|
+
if (targets.length > 16384)
|
|
291
|
+
throw new Error("Snapshot transaction exceeds its target bound; review a smaller transfer.");
|
|
292
|
+
return withTargetQueues(targets, async () => {
|
|
293
|
+
options.signal?.throwIfAborted();
|
|
294
|
+
if (targets.some(
|
|
295
|
+
(target) => options.protectedTargets?.some(
|
|
296
|
+
(protectedTarget) => mergePathIdentity(target) === mergePathIdentity(protectedTarget) || isPathInside(mergePathIdentity(target), mergePathIdentity(protectedTarget))
|
|
297
|
+
)
|
|
298
|
+
))
|
|
299
|
+
throw new Error("Snapshot transaction targets the current session; review is required.");
|
|
300
|
+
for (const [before, after] of coalesced.replacements) {
|
|
301
|
+
if (await caseReplacementSpelling(before, after) !== before)
|
|
302
|
+
throw new Error("Case replacement changed before preparation; review is required.");
|
|
303
|
+
options.validateMutation?.();
|
|
304
|
+
options.signal?.throwIfAborted();
|
|
305
|
+
}
|
|
306
|
+
const originalTargets = new Map([...coalesced.replacements].map(([before, after]) => [after, before]));
|
|
307
|
+
const expectedPreimages = new Map(
|
|
308
|
+
[...options.expectedPreimages ?? []].map(([target, expected]) => [
|
|
309
|
+
originalTargets.get(target) ?? target,
|
|
310
|
+
expected
|
|
311
|
+
])
|
|
312
|
+
);
|
|
313
|
+
for (const [target, expected] of options.expectedPreimages ?? []) {
|
|
314
|
+
if (originalTargets.has(target)) continue;
|
|
315
|
+
const variants = coalesced.plan.deletes.filter(
|
|
316
|
+
(candidate) => candidate !== target && path5.dirname(candidate) === path5.dirname(target) && mergePathIdentity(candidate) === mergePathIdentity(target)
|
|
317
|
+
);
|
|
318
|
+
if (variants.length > 1) throw new Error("Ambiguous reviewed file preimage spellings; review required.");
|
|
319
|
+
const original = variants[0];
|
|
320
|
+
if (!original) continue;
|
|
321
|
+
const destination = await image(target);
|
|
322
|
+
options.validateMutation?.();
|
|
323
|
+
options.signal?.throwIfAborted();
|
|
324
|
+
if (destination !== "missing" && destination !== expected)
|
|
325
|
+
throw new Error("Reviewed destination preimage changed before apply.");
|
|
326
|
+
expectedPreimages.set(original, expected);
|
|
327
|
+
expectedPreimages.set(target, destination);
|
|
328
|
+
}
|
|
329
|
+
for (const [target, expected] of expectedPreimages) {
|
|
330
|
+
const current = await image(target);
|
|
331
|
+
options.validateMutation?.();
|
|
332
|
+
options.signal?.throwIfAborted();
|
|
333
|
+
if (current !== expected)
|
|
334
|
+
throw new Error("Reviewed file preimage changed before apply; snapshot installation refused.");
|
|
335
|
+
}
|
|
336
|
+
const transaction = await prepareTransaction(
|
|
337
|
+
coalesced.plan,
|
|
338
|
+
targets,
|
|
339
|
+
{ ...options, expectedPreimages },
|
|
340
|
+
coalesced.replacements
|
|
341
|
+
);
|
|
342
|
+
const entriesByTarget = new Map(
|
|
343
|
+
transaction.journal.entries.flatMap((entry) => [
|
|
344
|
+
[entry.target, entry],
|
|
345
|
+
...entry.afterTarget ? [[entry.afterTarget, entry]] : []
|
|
346
|
+
])
|
|
347
|
+
);
|
|
348
|
+
try {
|
|
349
|
+
for (const target of transaction.deletes.sort((a, b) => a.length - b.length)) {
|
|
350
|
+
options.signal?.throwIfAborted();
|
|
351
|
+
const entry = entriesByTarget.get(target);
|
|
352
|
+
if (!entry) throw new Error("Unowned transaction target.");
|
|
353
|
+
await removeOwnedTarget(transaction.directory, entry, transaction.journal, options);
|
|
354
|
+
await syncDirectory(path5.dirname(target));
|
|
355
|
+
}
|
|
356
|
+
await withCaseDestinationQueues([...coalesced.replacements], options, async () => {
|
|
357
|
+
await retireRemovalIntent(
|
|
358
|
+
transaction.directory,
|
|
359
|
+
transaction.journal,
|
|
360
|
+
transaction.journal.entries.filter(
|
|
361
|
+
(entry) => entry.removalPending && (entry.afterImage !== "missing" || (entry.postFiles?.length ?? 0) > 0)
|
|
362
|
+
),
|
|
363
|
+
options
|
|
364
|
+
);
|
|
365
|
+
const uninstalledWrites = new Set(transaction.deletedWrites);
|
|
366
|
+
for (const item of plan.writes) {
|
|
367
|
+
options.signal?.throwIfAborted();
|
|
368
|
+
const entry = entriesByTarget.get(item.target);
|
|
369
|
+
if (!entry) throw new Error("Unowned transaction target.");
|
|
370
|
+
const deletedByThisCall = uninstalledWrites.delete(entry.target);
|
|
371
|
+
await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
|
|
372
|
+
options.validateMutation?.();
|
|
373
|
+
options.signal?.throwIfAborted();
|
|
374
|
+
await fs3.mkdir(path5.dirname(item.target), { recursive: true });
|
|
375
|
+
options.validateMutation?.();
|
|
376
|
+
options.signal?.throwIfAborted();
|
|
377
|
+
const temp = path5.join(path5.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
|
|
378
|
+
try {
|
|
379
|
+
let mode = 384;
|
|
380
|
+
try {
|
|
381
|
+
const stat = await fs3.lstat(item.target);
|
|
382
|
+
if (stat.isFile()) mode = stat.mode & 511;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error.code !== "ENOENT") throw error;
|
|
385
|
+
}
|
|
386
|
+
const handle = await fs3.open(temp, "wx", mode);
|
|
387
|
+
try {
|
|
388
|
+
await handle.writeFile(item.content);
|
|
389
|
+
await handle.sync();
|
|
390
|
+
} finally {
|
|
391
|
+
await handle.close();
|
|
392
|
+
}
|
|
393
|
+
await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
|
|
394
|
+
options.signal?.throwIfAborted();
|
|
395
|
+
options.validateMutation?.();
|
|
396
|
+
await fs3.rename(temp, item.target);
|
|
397
|
+
await syncDirectory(path5.dirname(item.target));
|
|
398
|
+
} finally {
|
|
399
|
+
await fs3.rm(temp, { force: true });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
options.validateMutation?.();
|
|
403
|
+
options.signal?.throwIfAborted();
|
|
404
|
+
await syncMutationParents(
|
|
405
|
+
targets,
|
|
406
|
+
[transaction.journal.root, ...transaction.journal.sessionRoot ? [transaction.journal.sessionRoot] : []],
|
|
407
|
+
options
|
|
408
|
+
);
|
|
409
|
+
options.validateMutation?.();
|
|
410
|
+
options.signal?.throwIfAborted();
|
|
411
|
+
});
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (options.signal?.aborted)
|
|
414
|
+
throw new Error("Snapshot apply cancelled; guarded transaction evidence retained for review.");
|
|
415
|
+
if (coalesced.replacements.size)
|
|
416
|
+
throw new Error("Case replacement apply failed; guarded transaction evidence retained for review.", {
|
|
417
|
+
cause: error
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
await restoreTransaction(transaction.directory, transaction.journal, options);
|
|
421
|
+
} catch (recoveryError) {
|
|
422
|
+
throw new AggregateError(
|
|
423
|
+
[error, recoveryError],
|
|
424
|
+
"Snapshot apply failed and guarded recovery requires review. Transaction and backup retained; newer bytes were not restored over."
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
await completeTransaction(transaction.directory, transaction.journal);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
async function recoverSnapshotTransactionsOnStartup(options = {}) {
|
|
433
|
+
if (!(await pendingTransactionEntries()).some((entry) => entry.isDirectory())) return;
|
|
434
|
+
options.validateMutation?.();
|
|
435
|
+
options.signal?.throwIfAborted();
|
|
436
|
+
await withLock(
|
|
437
|
+
"recovery",
|
|
438
|
+
() => recoverPendingSnapshotTransactions({ ...options, resolveConfiguredSessionDir: true }),
|
|
439
|
+
{
|
|
440
|
+
reclaimStale: true
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
async function recoverPendingSnapshotTransactions(options = {}) {
|
|
445
|
+
for (const entry of (await pendingTransactionEntries()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
446
|
+
if (!entry.isDirectory()) continue;
|
|
447
|
+
const directory = path5.join(transactionRoot(), entry.name);
|
|
448
|
+
let journal;
|
|
449
|
+
try {
|
|
450
|
+
const file = path5.join(directory, "journal.json");
|
|
451
|
+
const stat = await fs3.lstat(file);
|
|
452
|
+
if (!stat.isFile() || stat.size > 32 * 1024 * 1024) throw new Error("Invalid journal file.");
|
|
453
|
+
journal = JSON.parse(await fs3.readFile(file, "utf8"));
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error.code === "ENOENT") continue;
|
|
456
|
+
throw new Error("Cannot recover malformed pi-sync transaction; preserve private evidence for review.");
|
|
457
|
+
}
|
|
458
|
+
let sessionDir = options.sessionDir;
|
|
459
|
+
if (sessionDir === void 0 && options.resolveConfiguredSessionDir && !journal.completed && transactionHasSessionTargets(journal)) {
|
|
460
|
+
sessionDir = await configuredSessionDir();
|
|
461
|
+
}
|
|
462
|
+
options.validateMutation?.();
|
|
463
|
+
options.signal?.throwIfAborted();
|
|
464
|
+
if (journal.sessionRootTransition && !journal.completed) {
|
|
465
|
+
validateJournal(directory, journal, journal.sessionRoot);
|
|
466
|
+
sessionDir = await resolveTransitionSessionRoot(
|
|
467
|
+
directory,
|
|
468
|
+
journal.root,
|
|
469
|
+
journal.sessionRoot,
|
|
470
|
+
journal.sessionRootTransition,
|
|
471
|
+
journal.entries,
|
|
472
|
+
path5.resolve(sessionStorageRoot(journal.root, sessionDir)),
|
|
473
|
+
options
|
|
474
|
+
);
|
|
475
|
+
options.validateMutation?.();
|
|
476
|
+
options.signal?.throwIfAborted();
|
|
477
|
+
}
|
|
478
|
+
validateJournal(directory, journal, sessionDir);
|
|
479
|
+
if (journal.completed) {
|
|
480
|
+
await removeTransaction(directory);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
await withTargetQueues(
|
|
484
|
+
journal.entries.map((entry2) => entry2.target).sort(),
|
|
485
|
+
() => restoreTransaction(directory, journal, { ...options, sessionDir })
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function pendingTransactionEntries() {
|
|
490
|
+
try {
|
|
491
|
+
return await fs3.readdir(transactionRoot(), { withFileTypes: true });
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (error.code === "ENOENT") return [];
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function prepareTransaction(plan, targets, options, replacements = /* @__PURE__ */ new Map()) {
|
|
498
|
+
const { sessionDir } = options;
|
|
499
|
+
const root = path5.resolve(agentDir());
|
|
500
|
+
const sessionRoot = sessionDir ? path5.resolve(sessionStorageRoot(root, sessionDir)) : void 0;
|
|
501
|
+
const indexed = indexTransactionPlan(plan, targets);
|
|
502
|
+
const directory = path5.join(transactionRoot(), randomUUID());
|
|
503
|
+
const backupDirectory = path5.join(directory, "before");
|
|
504
|
+
await fs3.mkdir(backupDirectory, { recursive: true, mode: 448 });
|
|
505
|
+
const entries = [];
|
|
506
|
+
for (const [index, target] of targets.entries()) {
|
|
507
|
+
options.signal?.throwIfAborted();
|
|
508
|
+
options.validateMutation?.();
|
|
509
|
+
assertAllowedTarget(root, sessionRoot, target);
|
|
510
|
+
await assertSafeParents(root, sessionRoot, target);
|
|
511
|
+
const entry = { target, backupName: `${index}`, kind: "missing" };
|
|
512
|
+
if (replacements.has(target)) entry.afterTarget = replacements.get(target);
|
|
513
|
+
const backup = path5.join(backupDirectory, entry.backupName);
|
|
514
|
+
try {
|
|
515
|
+
const stat = await fs3.lstat(target);
|
|
516
|
+
if (stat.isSymbolicLink()) {
|
|
517
|
+
entry.kind = "symlink";
|
|
518
|
+
entry.linkTarget = await fs3.readlink(target);
|
|
519
|
+
} else if (stat.isDirectory()) {
|
|
520
|
+
entry.kind = "directory";
|
|
521
|
+
await fs3.cp(target, backup, {
|
|
522
|
+
recursive: true,
|
|
523
|
+
dereference: false,
|
|
524
|
+
verbatimSymlinks: true,
|
|
525
|
+
preserveTimestamps: true,
|
|
526
|
+
filter: () => {
|
|
527
|
+
options.signal?.throwIfAborted();
|
|
528
|
+
options.validateMutation?.();
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
} else if (stat.isFile()) {
|
|
533
|
+
entry.kind = "file";
|
|
534
|
+
await fs3.copyFile(target, backup);
|
|
535
|
+
await fs3.chmod(backup, stat.mode);
|
|
536
|
+
} else throw new Error("Unsupported existing snapshot target.");
|
|
537
|
+
} catch (error) {
|
|
538
|
+
const code = error.code;
|
|
539
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
540
|
+
}
|
|
541
|
+
entry.beforeImage = entry.kind === "symlink" ? `symlink:${entry.linkTarget}` : entry.kind === "missing" ? "missing" : await image(backup, true);
|
|
542
|
+
options.validateMutation?.();
|
|
543
|
+
options.signal?.throwIfAborted();
|
|
544
|
+
const expected = options.expectedPreimages?.get(target);
|
|
545
|
+
if (expected !== void 0 && entry.beforeImage !== expected)
|
|
546
|
+
throw new Error("Reviewed file preimage changed during backup; private preparation evidence retained.");
|
|
547
|
+
if (entry.afterTarget) {
|
|
548
|
+
if (entry.kind !== "file" || await caseReplacementSpelling(target, entry.afterTarget) !== target)
|
|
549
|
+
throw new Error("Case replacement preimage changed during backup; evidence retained for review.");
|
|
550
|
+
options.validateMutation?.();
|
|
551
|
+
options.signal?.throwIfAborted();
|
|
552
|
+
}
|
|
553
|
+
entry.afterImage = indexed.writeImages.get(target) ?? "missing";
|
|
554
|
+
entry.postFiles = indexed.postFiles.get(target) ?? [];
|
|
555
|
+
entries.push(entry);
|
|
556
|
+
}
|
|
557
|
+
await syncDirectory(backupDirectory);
|
|
558
|
+
const effectiveRoot = sessionRoot ?? path5.join(root, "sessions");
|
|
559
|
+
const sessionRootTransition = await prepareSessionRootTransition(
|
|
560
|
+
directory,
|
|
561
|
+
root,
|
|
562
|
+
effectiveRoot,
|
|
563
|
+
entries,
|
|
564
|
+
plan,
|
|
565
|
+
options
|
|
566
|
+
);
|
|
567
|
+
const journal = {
|
|
568
|
+
version: sessionRootTransition ? 7 : replacements.size ? 5 : JOURNAL_VERSION,
|
|
569
|
+
root,
|
|
570
|
+
sessionRoot: sessionRootTransition ? effectiveRoot : sessionRoot,
|
|
571
|
+
sessionRootTransition,
|
|
572
|
+
entries
|
|
573
|
+
};
|
|
574
|
+
options.signal?.throwIfAborted();
|
|
575
|
+
options.validateMutation?.();
|
|
576
|
+
await writeJson(path5.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
577
|
+
await syncDirectory(transactionRoot());
|
|
578
|
+
await syncDirectory(stateDir());
|
|
579
|
+
await syncDirectory(path5.dirname(stateDir()));
|
|
580
|
+
return { directory, journal, deletes: indexed.deletes, deletedWrites: indexed.deletedWrites };
|
|
581
|
+
}
|
|
582
|
+
async function restoreTransaction(directory, journal, options) {
|
|
583
|
+
validateJournal(directory, journal, options.sessionDir);
|
|
584
|
+
options.validateMutation?.();
|
|
585
|
+
options.signal?.throwIfAborted();
|
|
586
|
+
for (const entry of journal.entries) {
|
|
587
|
+
if (options.protectedTargets?.some(
|
|
588
|
+
(target) => mergePathIdentity(target) === mergePathIdentity(entry.target) || isPathInside(mergePathIdentity(entry.target), mergePathIdentity(target))
|
|
589
|
+
))
|
|
590
|
+
throw new Error(
|
|
591
|
+
"Pending transaction touches the current session; resume a different session before guarded recovery."
|
|
592
|
+
);
|
|
593
|
+
await verifyTarget(directory, entry, journal);
|
|
594
|
+
}
|
|
595
|
+
for (const entry of journal.entries) {
|
|
596
|
+
if (!entry.recoveryStaged) continue;
|
|
597
|
+
options.validateMutation?.();
|
|
598
|
+
options.signal?.throwIfAborted();
|
|
599
|
+
await assertSafeParents(journal.root, journal.sessionRoot, entry.target);
|
|
600
|
+
options.validateMutation?.();
|
|
601
|
+
options.signal?.throwIfAborted();
|
|
602
|
+
await fs3.rm(recoveryStagePath(directory, entry), { recursive: true, force: true });
|
|
603
|
+
options.validateMutation?.();
|
|
604
|
+
options.signal?.throwIfAborted();
|
|
605
|
+
}
|
|
606
|
+
for (const entry of [...journal.entries].sort((a, b) => a.target.length - b.target.length)) {
|
|
607
|
+
const current = await verifyTarget(directory, entry, journal);
|
|
608
|
+
const before = await beforeImage(directory, entry);
|
|
609
|
+
const spellingMatches = async () => !entry.afterTarget || await caseReplacementSpelling(entry.target, entry.afterTarget) === entry.target;
|
|
610
|
+
if (current === before && await spellingMatches()) continue;
|
|
611
|
+
options.signal?.throwIfAborted();
|
|
612
|
+
options.validateMutation?.();
|
|
613
|
+
if (entry.kind === "file") {
|
|
614
|
+
const temporary2 = await prepareRecoveryStage(directory, journal, entry, options);
|
|
615
|
+
try {
|
|
616
|
+
await fs3.mkdir(path5.dirname(entry.target), { recursive: true });
|
|
617
|
+
await fs3.copyFile(path5.join(directory, "before", entry.backupName), temporary2);
|
|
618
|
+
const handle = await fs3.open(temporary2, "r");
|
|
619
|
+
try {
|
|
620
|
+
await handle.sync();
|
|
621
|
+
} finally {
|
|
622
|
+
await handle.close();
|
|
623
|
+
}
|
|
624
|
+
const verified = await verifyTarget(directory, entry, journal);
|
|
625
|
+
options.validateMutation?.();
|
|
626
|
+
options.signal?.throwIfAborted();
|
|
627
|
+
if (verified === before && await spellingMatches()) continue;
|
|
628
|
+
const needsCaseQueue = entry.afterTarget !== void 0 && await caseReplacementSpelling(entry.target, entry.afterTarget) === entry.afterTarget;
|
|
629
|
+
if (needsCaseQueue) {
|
|
630
|
+
await removeOwnedTarget(directory, entry, journal, options);
|
|
631
|
+
} else if (verified.startsWith("directory:") && !await removeOwnedTarget(directory, entry, journal, options, before))
|
|
632
|
+
continue;
|
|
633
|
+
await withCaseDestinationQueues(
|
|
634
|
+
needsCaseQueue && entry.afterTarget ? [[entry.afterTarget, entry.target]] : [],
|
|
635
|
+
options,
|
|
636
|
+
async () => {
|
|
637
|
+
const atRename = await verifyTarget(directory, entry, journal);
|
|
638
|
+
options.validateMutation?.();
|
|
639
|
+
options.signal?.throwIfAborted();
|
|
640
|
+
if (atRename === before && await spellingMatches()) return;
|
|
641
|
+
await retireRestorationIntent(directory, journal, entry, options);
|
|
642
|
+
const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
|
|
643
|
+
options.validateMutation?.();
|
|
644
|
+
options.signal?.throwIfAborted();
|
|
645
|
+
if (atInstall === before && await spellingMatches()) return;
|
|
646
|
+
await fs3.rename(temporary2, entry.target);
|
|
647
|
+
await syncDirectory(path5.dirname(entry.target));
|
|
648
|
+
}
|
|
649
|
+
);
|
|
650
|
+
} finally {
|
|
651
|
+
await fs3.rm(temporary2, { force: true });
|
|
652
|
+
}
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (entry.kind === "missing") {
|
|
656
|
+
await removeOwnedTarget(directory, entry, journal, options, before);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const temporary = await prepareRecoveryStage(directory, journal, entry, options);
|
|
660
|
+
try {
|
|
661
|
+
await fs3.mkdir(path5.dirname(entry.target), { recursive: true });
|
|
662
|
+
await assertSafeParents(journal.root, journal.sessionRoot, entry.target);
|
|
663
|
+
if (entry.kind === "directory") {
|
|
664
|
+
await fs3.cp(path5.join(directory, "before", entry.backupName), temporary, {
|
|
665
|
+
recursive: true,
|
|
666
|
+
dereference: false,
|
|
667
|
+
verbatimSymlinks: true,
|
|
668
|
+
preserveTimestamps: true,
|
|
669
|
+
filter: () => {
|
|
670
|
+
options.validateMutation?.();
|
|
671
|
+
options.signal?.throwIfAborted();
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
} else if (entry.linkTarget !== void 0) await fs3.symlink(entry.linkTarget, temporary);
|
|
676
|
+
if (await image(temporary, true) !== before)
|
|
677
|
+
throw new Error("Staged recovery image differs from the backup; evidence retained.");
|
|
678
|
+
options.validateMutation?.();
|
|
679
|
+
options.signal?.throwIfAborted();
|
|
680
|
+
if (!await removeOwnedTarget(directory, entry, journal, options, before)) continue;
|
|
681
|
+
const atRename = await verifyTarget(directory, entry, journal);
|
|
682
|
+
options.validateMutation?.();
|
|
683
|
+
options.signal?.throwIfAborted();
|
|
684
|
+
if (atRename === before) continue;
|
|
685
|
+
await retireRestorationIntent(directory, journal, entry, options);
|
|
686
|
+
const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
|
|
687
|
+
options.validateMutation?.();
|
|
688
|
+
options.signal?.throwIfAborted();
|
|
689
|
+
if (atInstall === before) continue;
|
|
690
|
+
await fs3.rename(temporary, entry.target);
|
|
691
|
+
await syncDirectory(path5.dirname(entry.target));
|
|
692
|
+
} finally {
|
|
693
|
+
await fs3.rm(temporary, { recursive: true, force: true });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
await syncMutationParents(
|
|
697
|
+
journal.entries.map((entry) => entry.target),
|
|
698
|
+
[journal.root, ...journal.sessionRoot ? [journal.sessionRoot] : []],
|
|
699
|
+
options
|
|
700
|
+
);
|
|
701
|
+
options.validateMutation?.();
|
|
702
|
+
options.signal?.throwIfAborted();
|
|
703
|
+
await completeTransaction(directory, journal);
|
|
704
|
+
}
|
|
705
|
+
async function verifyTarget(directory, entry, journal, deletedByThisCall = false) {
|
|
706
|
+
await assertSafeParents(journal.root, journal.sessionRoot, entry.target);
|
|
707
|
+
const before = await beforeImage(directory, entry);
|
|
708
|
+
if (journal.version >= 2 && before !== entry.beforeImage)
|
|
709
|
+
throw new Error("Transaction backup changed; preserve evidence for review.");
|
|
710
|
+
if (entry.afterTarget) await caseReplacementSpelling(entry.target, entry.afterTarget);
|
|
711
|
+
const current = await image(entry.target);
|
|
712
|
+
if (current === before) return current;
|
|
713
|
+
if (current === "missing") {
|
|
714
|
+
if (deletedByThisCall || journal.version >= 4 && entry.removalPending && !entry.replacementStarted)
|
|
715
|
+
return current;
|
|
716
|
+
} else if (journal.version >= 2 && (current === entry.afterImage || await ownedPostTree(
|
|
717
|
+
entry.target,
|
|
718
|
+
entry.postFiles ?? [],
|
|
719
|
+
journal.version < 4 || entry.replacementStarted === true || !entry.removalPending
|
|
720
|
+
)))
|
|
721
|
+
return current;
|
|
722
|
+
throw new Error(
|
|
723
|
+
"Transaction target has unrecognized or newer bytes; automatic rollback refused. Preserve the transaction and review its backup with Pi closed."
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
async function removeOwnedTarget(directory, entry, journal, options, restoringBefore) {
|
|
727
|
+
const current = await verifyTarget(directory, entry, journal);
|
|
728
|
+
options.validateMutation?.();
|
|
729
|
+
options.signal?.throwIfAborted();
|
|
730
|
+
if (restoringBefore !== void 0 && current === restoringBefore) return false;
|
|
731
|
+
upgradeRemovalEvidence(journal);
|
|
732
|
+
for (const affected of journal.entries) {
|
|
733
|
+
if (affected.target === entry.target || isStrictlyInside(entry.target, affected.target)) {
|
|
734
|
+
affected.removalPending = true;
|
|
735
|
+
affected.replacementStarted = false;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
await writeJson(path5.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
739
|
+
const verified = await verifyTarget(directory, entry, journal);
|
|
740
|
+
options.validateMutation?.();
|
|
741
|
+
options.signal?.throwIfAborted();
|
|
742
|
+
if (restoringBefore !== void 0 && verified === restoringBefore) return false;
|
|
743
|
+
await fs3.rm(entry.target, { recursive: true, force: true });
|
|
744
|
+
await syncDirectory(path5.dirname(entry.target));
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
function upgradeRemovalEvidence(journal) {
|
|
748
|
+
if (journal.version < JOURNAL_VERSION) {
|
|
749
|
+
for (const entry of journal.entries) entry.removalPending = false;
|
|
750
|
+
journal.version = JOURNAL_VERSION;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async function retireRemovalIntent(directory, journal, entries, options) {
|
|
754
|
+
options.validateMutation?.();
|
|
755
|
+
options.signal?.throwIfAborted();
|
|
756
|
+
if (!entries.length) return;
|
|
757
|
+
upgradeRemovalEvidence(journal);
|
|
758
|
+
for (const entry of entries) {
|
|
759
|
+
entry.removalPending = false;
|
|
760
|
+
entry.replacementStarted = true;
|
|
761
|
+
}
|
|
762
|
+
await writeJson(path5.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
763
|
+
options.validateMutation?.();
|
|
764
|
+
options.signal?.throwIfAborted();
|
|
765
|
+
}
|
|
766
|
+
async function retireRestorationIntent(directory, journal, entry, options) {
|
|
767
|
+
for (const child of journal.entries) {
|
|
768
|
+
if (isStrictlyInside(entry.target, child.target)) {
|
|
769
|
+
child.removalPending = false;
|
|
770
|
+
child.replacementStarted = false;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
await retireRemovalIntent(directory, journal, [entry], options);
|
|
774
|
+
}
|
|
775
|
+
function recoveryStagePath(directory, entry) {
|
|
776
|
+
const token = createHash2("sha256").update(directory).update("\\0").update(entry.backupName).digest("hex");
|
|
777
|
+
return path5.join(
|
|
778
|
+
path5.dirname(entry.target),
|
|
779
|
+
`.pi-sync.json.${token}.${entry.kind === "file" ? "restore" : "restore-tree"}`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
async function prepareRecoveryStage(directory, journal, entry, options) {
|
|
783
|
+
const temporary = recoveryStagePath(directory, entry);
|
|
784
|
+
if (!entry.recoveryStaged) {
|
|
785
|
+
try {
|
|
786
|
+
await fs3.lstat(temporary);
|
|
787
|
+
throw new Error("Unowned recovery staging path; preserve evidence for review.");
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error.code !== "ENOENT") throw error;
|
|
790
|
+
}
|
|
791
|
+
options.validateMutation?.();
|
|
792
|
+
options.signal?.throwIfAborted();
|
|
793
|
+
entry.recoveryStaged = true;
|
|
794
|
+
await writeJson(path5.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
795
|
+
options.validateMutation?.();
|
|
796
|
+
options.signal?.throwIfAborted();
|
|
797
|
+
}
|
|
798
|
+
return temporary;
|
|
799
|
+
}
|
|
800
|
+
async function beforeImage(directory, entry) {
|
|
801
|
+
if (entry.kind === "missing") return "missing";
|
|
802
|
+
if (entry.kind === "symlink") return `symlink:${entry.linkTarget}`;
|
|
803
|
+
const value = await image(path5.join(directory, "before", entry.backupName));
|
|
804
|
+
if (value === "missing") throw new Error("Transaction backup is missing; preserve evidence for review.");
|
|
805
|
+
return value;
|
|
806
|
+
}
|
|
807
|
+
async function image(target, durable = false) {
|
|
808
|
+
let stat;
|
|
809
|
+
try {
|
|
810
|
+
stat = await fs3.lstat(target);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
const code = error.code;
|
|
813
|
+
if (code === "ENOENT" || code === "ENOTDIR") return "missing";
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
if (stat.isSymbolicLink()) return `symlink:${await fs3.readlink(target)}`;
|
|
817
|
+
if (stat.isFile()) {
|
|
818
|
+
const content = await fs3.readFile(target);
|
|
819
|
+
if (durable) {
|
|
820
|
+
const handle = await fs3.open(target, "r");
|
|
821
|
+
try {
|
|
822
|
+
await handle.sync();
|
|
823
|
+
} finally {
|
|
824
|
+
await handle.close();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return fileImage(content);
|
|
828
|
+
}
|
|
829
|
+
if (!stat.isDirectory()) throw new Error("Unsupported transaction image.");
|
|
830
|
+
const entries = [];
|
|
831
|
+
for (const name of (await fs3.readdir(target)).sort())
|
|
832
|
+
entries.push([name, await image(path5.join(target, name), durable)]);
|
|
833
|
+
if (durable) await syncDirectory(target);
|
|
834
|
+
return `directory:${createHash2("sha256").update(JSON.stringify(entries)).digest("hex")}`;
|
|
835
|
+
}
|
|
836
|
+
async function ownedPostTree(target, files, requireComplete) {
|
|
837
|
+
if (!files.length) return false;
|
|
838
|
+
const expected = new Map(files.map((file) => [mergePathIdentity(file.relative), file.image]));
|
|
839
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
840
|
+
for (const relative of expected.keys()) {
|
|
841
|
+
for (let parent = path5.dirname(relative); parent !== "." && parent !== path5.dirname(parent); parent = path5.dirname(parent))
|
|
842
|
+
prefixes.add(parent);
|
|
843
|
+
}
|
|
844
|
+
const seen = /* @__PURE__ */ new Set();
|
|
845
|
+
async function visit(directory) {
|
|
846
|
+
let stat;
|
|
847
|
+
try {
|
|
848
|
+
stat = await fs3.lstat(directory);
|
|
849
|
+
} catch {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
853
|
+
for (const name of await fs3.readdir(directory)) {
|
|
854
|
+
const child = path5.join(directory, name);
|
|
855
|
+
const relative = mergePathIdentity(path5.relative(target, child));
|
|
856
|
+
const stat2 = await fs3.lstat(child);
|
|
857
|
+
if (stat2.isDirectory() && !stat2.isSymbolicLink()) {
|
|
858
|
+
if (!prefixes.has(relative) || !await visit(child)) return false;
|
|
859
|
+
} else {
|
|
860
|
+
if (expected.get(relative) !== await image(child)) return false;
|
|
861
|
+
seen.add(relative);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
return await visit(target) && (!requireComplete || [...expected.keys()].every((key) => seen.has(key)));
|
|
867
|
+
}
|
|
868
|
+
function validateJournal(directory, journal, sessionDir) {
|
|
869
|
+
if (![1, 2, 3, 4, 5, 6, 7].includes(journal.version) || !Array.isArray(journal.entries) || journal.entries.length > 16384)
|
|
870
|
+
throw new Error("Unsupported pi-sync transaction journal; preserve evidence for review.");
|
|
871
|
+
if (journal.completed !== void 0 && (![6, 7].includes(journal.version) || journal.completed !== true))
|
|
872
|
+
throw new Error("Invalid transaction completion evidence.");
|
|
873
|
+
const root = path5.resolve(agentDir());
|
|
874
|
+
if (typeof journal.root !== "string" || path5.resolve(journal.root) !== root)
|
|
875
|
+
throw new Error("Transaction root no longer matches the Pi agent directory.");
|
|
876
|
+
if (journal.sessionRootTransition !== void 0) {
|
|
877
|
+
if (journal.version !== 7 || typeof journal.sessionRoot !== "string")
|
|
878
|
+
throw new Error("Invalid session root-transition version.");
|
|
879
|
+
validateSessionRootTransition(journal.sessionRootTransition);
|
|
880
|
+
} else if (journal.version === 7) throw new Error("Missing session root-transition evidence.");
|
|
881
|
+
const trustedSessionRoot = sessionDir ? path5.resolve(sessionStorageRoot(root, sessionDir)) : path5.join(root, "sessions");
|
|
882
|
+
if (journal.sessionRoot !== void 0 && (typeof journal.sessionRoot !== "string" || !path5.isAbsolute(journal.sessionRoot) || path5.resolve(journal.sessionRoot) !== journal.sessionRoot || !journal.completed && transactionHasSessionTargets(journal) && journal.sessionRoot !== trustedSessionRoot))
|
|
883
|
+
throw new Error("Transaction session root is not owned by this context; preserve evidence for review.");
|
|
884
|
+
for (const entry of journal.entries) {
|
|
885
|
+
if (!entry || typeof entry.target !== "string" || !path5.isAbsolute(entry.target) || path5.resolve(entry.target) !== entry.target || typeof entry.backupName !== "string" || !/^\d+$/u.test(entry.backupName) || !["missing", "file", "directory", "symlink"].includes(entry.kind))
|
|
886
|
+
throw new Error("Invalid pi-sync transaction entry.");
|
|
887
|
+
assertWithinRoot(directory, path5.join(directory, "before", entry.backupName));
|
|
888
|
+
assertAllowedTarget(root, journal.sessionRoot, entry.target);
|
|
889
|
+
if (entry.afterTarget !== void 0 && (![5, 6, 7].includes(journal.version) || typeof entry.afterTarget !== "string" || !isCaseReplacement(root, entry.target, entry.afterTarget) || path5.resolve(entry.afterTarget) !== entry.afterTarget || entry.kind !== "file"))
|
|
890
|
+
throw new Error("Invalid transaction case replacement evidence.");
|
|
891
|
+
if (entry.afterTarget) assertAllowedTarget(root, journal.sessionRoot, entry.afterTarget);
|
|
892
|
+
if (journal.version >= 2 && ((entry.beforeImage === void 0 ? !journal.completed : typeof entry.beforeImage !== "string") || (entry.afterImage === void 0 ? !journal.completed : typeof entry.afterImage !== "string") || (entry.postFiles === void 0 ? !journal.completed : !Array.isArray(entry.postFiles))))
|
|
893
|
+
throw new Error("Invalid transaction postimage evidence.");
|
|
894
|
+
if (entry.recoveryStaged !== void 0 && typeof entry.recoveryStaged !== "boolean")
|
|
895
|
+
throw new Error("Invalid recovery staging evidence.");
|
|
896
|
+
if (entry.removalPending !== void 0 && (journal.version < 3 || typeof entry.removalPending !== "boolean"))
|
|
897
|
+
throw new Error("Invalid transaction removal evidence.");
|
|
898
|
+
if (entry.replacementStarted !== void 0 && (journal.version < 4 || typeof entry.replacementStarted !== "boolean"))
|
|
899
|
+
throw new Error("Invalid transaction replacement evidence.");
|
|
900
|
+
if (entry.removalPending && entry.replacementStarted)
|
|
901
|
+
throw new Error("Conflicting transaction replacement evidence.");
|
|
902
|
+
for (const file of entry.postFiles ?? []) {
|
|
903
|
+
if (typeof file.relative !== "string" || typeof file.image !== "string" || !isStrictlyInside(entry.target, path5.resolve(entry.target, file.relative)))
|
|
904
|
+
throw new Error("Invalid transaction subtree evidence.");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function transactionHasSessionTargets(journal) {
|
|
909
|
+
const root = path5.resolve(agentDir());
|
|
910
|
+
return Array.isArray(journal.entries) && journal.entries.some(
|
|
911
|
+
(entry) => typeof entry?.target === "string" && (!isPathInside(root, entry.target) || isPathInside(path5.join(root, "sessions"), entry.target) || typeof journal.sessionRoot === "string" && isPathInside(journal.sessionRoot, entry.target))
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
function isStrictlyInside(root, target) {
|
|
915
|
+
return path5.relative(path5.resolve(root), path5.resolve(target)) !== "" && isPathInside(root, target);
|
|
916
|
+
}
|
|
917
|
+
function assertAllowedTarget(root, sessionRoot, target) {
|
|
918
|
+
const resolved = path5.resolve(target);
|
|
919
|
+
if (isStrictlyInside(root, resolved)) {
|
|
920
|
+
if (isDeniedPath(path5.relative(root, resolved))) throw new Error("Transaction targets denied private storage.");
|
|
921
|
+
assertWithinRoot(root, resolved);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (sessionRoot && isStrictlyInside(sessionRoot, resolved)) {
|
|
925
|
+
if (isDeniedPath(`sessions/${path5.relative(sessionRoot, resolved)}`))
|
|
926
|
+
throw new Error("Transaction targets denied session storage.");
|
|
927
|
+
assertWithinRoot(sessionRoot, resolved);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
throw new Error("Transaction target is outside configured roots.");
|
|
931
|
+
}
|
|
932
|
+
async function assertSafeParents(root, sessionRoot, target) {
|
|
933
|
+
assertAllowedTarget(root, sessionRoot, target);
|
|
934
|
+
const boundary = isPathInside(root, target) ? root : sessionRoot;
|
|
935
|
+
for (let parent = path5.dirname(target); boundary && mergePathIdentity(parent) !== mergePathIdentity(boundary); parent = path5.dirname(parent)) {
|
|
936
|
+
if (path5.dirname(parent) === parent) throw new Error("Transaction parent did not reach its owned boundary.");
|
|
937
|
+
try {
|
|
938
|
+
const stat = await fs3.lstat(parent);
|
|
939
|
+
if (!stat.isDirectory() && !stat.isFile() || stat.isSymbolicLink())
|
|
940
|
+
throw new Error("Unsafe transaction parent.");
|
|
941
|
+
} catch (error) {
|
|
942
|
+
const code = error.code;
|
|
943
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async function withTargetQueues(targets, action) {
|
|
948
|
+
const keys = /* @__PURE__ */ new Set();
|
|
949
|
+
for (const target of targets) {
|
|
950
|
+
let key = path5.resolve(target);
|
|
951
|
+
try {
|
|
952
|
+
key = await fs3.realpath(target);
|
|
953
|
+
} catch (error) {
|
|
954
|
+
const code = error.code;
|
|
955
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
956
|
+
}
|
|
957
|
+
if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
|
|
958
|
+
keys.add(key);
|
|
959
|
+
}
|
|
960
|
+
async function acquire(index) {
|
|
961
|
+
const target = targets[index];
|
|
962
|
+
return target ? withFileMutationQueue(target, () => acquire(index + 1)) : action();
|
|
963
|
+
}
|
|
964
|
+
return acquire(0);
|
|
965
|
+
}
|
|
966
|
+
async function withCaseDestinationQueues(spellings, options, action) {
|
|
967
|
+
if (!spellings.length) return action();
|
|
968
|
+
const requireAbsent = async () => {
|
|
969
|
+
for (const [before, after] of spellings) {
|
|
970
|
+
options.validateMutation?.();
|
|
971
|
+
options.signal?.throwIfAborted();
|
|
972
|
+
const spelling = await caseReplacementSpelling(before, after);
|
|
973
|
+
options.validateMutation?.();
|
|
974
|
+
options.signal?.throwIfAborted();
|
|
975
|
+
if (spelling !== void 0)
|
|
976
|
+
throw new Error("Case replacement changed before destination queue reservation; evidence retained.");
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
await requireAbsent();
|
|
980
|
+
return withTargetQueues(
|
|
981
|
+
spellings.map(([, after]) => after),
|
|
982
|
+
async () => {
|
|
983
|
+
await requireAbsent();
|
|
984
|
+
return action();
|
|
985
|
+
}
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
async function completeTransaction(directory, journal) {
|
|
989
|
+
await writeJson(
|
|
990
|
+
path5.join(directory, "journal.json"),
|
|
991
|
+
{ ...journal, version: journal.sessionRootTransition ? 7 : 6, completed: true },
|
|
992
|
+
{
|
|
993
|
+
maxBytes: 32 * 1024 * 1024
|
|
994
|
+
}
|
|
995
|
+
);
|
|
996
|
+
await removeTransaction(directory);
|
|
997
|
+
}
|
|
998
|
+
async function removeTransaction(directory) {
|
|
999
|
+
await fs3.rm(directory, { recursive: true, force: true });
|
|
1000
|
+
await syncDirectory(transactionRoot()).catch(() => {
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
function transactionRoot() {
|
|
1004
|
+
return path5.join(stateDir(), "transactions");
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export {
|
|
1008
|
+
syncMutationParents,
|
|
1009
|
+
applySnapshotTransaction,
|
|
1010
|
+
recoverSnapshotTransactionsOnStartup,
|
|
1011
|
+
recoverPendingSnapshotTransactions
|
|
1012
|
+
};
|
|
1013
|
+
//# sourceMappingURL=chunk-ANWNQIR7.ts.map
|