@narumitw/pi-sync 0.51.1 → 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 +5 -5
- 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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
normalizeWebDavUrl,
|
|
13
13
|
validateWebDavCredentials,
|
|
14
14
|
} from "../backends/webdav/webdav-config.js";
|
|
15
|
+
import { normalizeLocalFields } from "../sync/local-fields.js";
|
|
15
16
|
import { normalizeSyncInclude } from "../sync/sync-policy.js";
|
|
16
17
|
import { localConfigPath } from "./config-file.js";
|
|
17
18
|
import type {
|
|
@@ -33,9 +34,9 @@ export function normalizeOnSwitch(value: unknown): OnSwitchAction {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export function validateSettingsDocument(value: Record<string, unknown>): PiSyncSettingsV3 {
|
|
36
|
-
if (value.version !== 3) {
|
|
37
|
+
if (value.version !== 3 && value.version !== 4 && value.version !== 5) {
|
|
37
38
|
throw new Error(
|
|
38
|
-
`Unsupported pi-sync settings: version 3 is required. Keep the existing
|
|
39
|
+
`Unsupported pi-sync settings: version 3 or 4 or 5 is required. Keep the existing ${path.basename(localConfigPath())} for recovery and use a compatible pi-sync client or review a separate supported setup; do not downgrade portable field policies. pi-sync will not migrate or overwrite unsupported settings.`,
|
|
39
40
|
);
|
|
40
41
|
}
|
|
41
42
|
rejectLegacyFields(
|
|
@@ -95,6 +96,16 @@ export function validateSettingsDocument(value: Record<string, unknown>): PiSync
|
|
|
95
96
|
} else if (!activeSyncSetup || !Object.hasOwn(syncSetups, activeSyncSetup)) {
|
|
96
97
|
throw new Error("Invalid pi-sync settings: activeSyncSetup must reference an existing own-property sync setup.");
|
|
97
98
|
}
|
|
99
|
+
for (const setup of Object.values(syncSetups)) {
|
|
100
|
+
const policy = (setup as SyncSetupSettings).sync;
|
|
101
|
+
if (policy.localFields !== undefined && value.version !== 4 && value.version !== 5)
|
|
102
|
+
throw new Error("localFields requires explicit settings version 4; older binaries must refuse portable state.");
|
|
103
|
+
}
|
|
104
|
+
for (const setup of Object.values(syncSetups)) {
|
|
105
|
+
const policy = (setup as SyncSetupSettings).sync;
|
|
106
|
+
if ((policy.mergeContent || policy.partialSync) && value.version !== 5)
|
|
107
|
+
throw new Error("Content/partial sync requires explicit settings version 5.");
|
|
108
|
+
}
|
|
98
109
|
validateUniqueRemoteSyncSetups(syncSetups, storageConnections);
|
|
99
110
|
return value as PiSyncSettingsV3;
|
|
100
111
|
}
|
|
@@ -201,7 +212,17 @@ function validateSyncSetup(name: string, value: Record<string, unknown>, connect
|
|
|
201
212
|
if (!Object.hasOwn(sync, "include")) {
|
|
202
213
|
throw new Error(`Invalid pi-sync settings: sync setup “${name}” is missing sync.include.`);
|
|
203
214
|
}
|
|
204
|
-
normalizeSyncInclude(sync.include);
|
|
215
|
+
const include = normalizeSyncInclude(sync.include);
|
|
216
|
+
const localFields = normalizeLocalFields(sync.localFields);
|
|
217
|
+
if (localFields.length > 0 && !include.includes("settings.json"))
|
|
218
|
+
throw new Error("Nonempty localFields requires settings.json in sync.include.");
|
|
219
|
+
for (const key of ["mergeContent", "partialSync"])
|
|
220
|
+
if (sync[key] !== undefined && typeof sync[key] !== "boolean") throw new Error(`${key} must be boolean.`);
|
|
221
|
+
if (sync.mergeSettings !== undefined && typeof sync.mergeSettings !== "boolean")
|
|
222
|
+
throw new Error("mergeSettings must be boolean.");
|
|
223
|
+
if (sync.automaticTransfer !== undefined && typeof sync.automaticTransfer !== "boolean") {
|
|
224
|
+
throw new Error(`Invalid pi-sync settings: sync setup “${name}” sync.automaticTransfer must be boolean.`);
|
|
225
|
+
}
|
|
205
226
|
if (typeof sync.automatic !== "boolean") {
|
|
206
227
|
throw new Error(`Invalid pi-sync settings: sync setup “${name}” sync.automatic must be boolean.`);
|
|
207
228
|
}
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
5
5
|
import { readJsonIfExists } from "../state/json-file.js";
|
|
6
|
+
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
6
7
|
import type { Snapshot, SnapshotOptions } from "./snapshot-types.js";
|
|
7
8
|
|
|
8
9
|
export function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
@@ -19,7 +20,11 @@ export function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionCo
|
|
|
19
20
|
|
|
20
21
|
export async function configuredSessionDir() {
|
|
21
22
|
const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
|
|
22
|
-
return settings?.sessionDir ?
|
|
23
|
+
return settings?.sessionDir ? expandSessionDir(settings.sessionDir) : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function effectiveSessionRoot(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
27
|
+
return path.resolve(sessionStorageRoot(agentDir(), sessionDirFromContext(ctx) ?? (await configuredSessionDir())));
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export async function sessionDirForApply(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
|
|
@@ -31,17 +36,39 @@ export async function sessionDirForApply(ctx: ExtensionCommandContext | Extensio
|
|
|
31
36
|
return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
/** Hash-only merge plans cannot describe installation into a different session root. */
|
|
40
|
+
export function requireStableMergeSessionRoot(before: Snapshot, after: Snapshot) {
|
|
41
|
+
const root = agentDir();
|
|
42
|
+
if (
|
|
43
|
+
path.resolve(sessionStorageRoot(root, sessionDirFromSnapshot(before))) !==
|
|
44
|
+
path.resolve(sessionStorageRoot(root, sessionDirFromSnapshot(after)))
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Merged transfer changes the session root; no local apply or baseline acceptance is allowed. Review /sync diff and choose an explicit push or pull direction.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
function sessionDirFromSnapshot(snapshot: Snapshot) {
|
|
35
53
|
const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
|
|
36
54
|
if (!settingsFile) return undefined;
|
|
55
|
+
let settings: unknown;
|
|
37
56
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
settings = JSON.parse(
|
|
58
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
59
|
+
decodeBase64Strict(settingsFile.contentBase64, settingsFile.path),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
42
62
|
} catch {
|
|
43
|
-
|
|
63
|
+
// JSON parser errors can quote private settings values; never surface their payload.
|
|
64
|
+
throw new Error("Merged settings cannot be parsed; review a directional recovery.");
|
|
44
65
|
}
|
|
66
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
67
|
+
throw new Error("Merged settings must be a JSON object; review a directional recovery.");
|
|
68
|
+
const sessionDir = (settings as { sessionDir?: unknown }).sessionDir;
|
|
69
|
+
if (sessionDir !== undefined && typeof sessionDir !== "string")
|
|
70
|
+
throw new Error("Merged sessionDir must be a string; review a directional recovery.");
|
|
71
|
+
return sessionDir ? expandSessionDir(sessionDir) : undefined;
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
function decodeBase64Strict(value: string, filePath: string) {
|
|
@@ -55,7 +82,7 @@ export function agentDir() {
|
|
|
55
82
|
return getAgentDir();
|
|
56
83
|
}
|
|
57
84
|
|
|
58
|
-
function
|
|
85
|
+
export function expandSessionDir(value: string) {
|
|
59
86
|
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
60
87
|
}
|
|
61
88
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import type { Dirent } from "node:fs";
|
|
2
|
+
import type { Dirent, Stats } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { assertWithinRoot, isDeniedPath, isPathInside, parentPaths, safeJoin, toPosix } from "../paths.js";
|
|
6
|
+
import { syncDirectory } from "../state/json-file.js";
|
|
6
7
|
import { agentDir } from "./session-paths.js";
|
|
7
8
|
import {
|
|
8
9
|
createSnapshot,
|
|
@@ -26,11 +27,34 @@ function fileHashMap(snapshot: Snapshot) {
|
|
|
26
27
|
export async function applySnapshot(
|
|
27
28
|
snapshot: Snapshot,
|
|
28
29
|
protectedRelativePaths = new Set<string>(),
|
|
29
|
-
options: Pick<
|
|
30
|
+
options: Pick<
|
|
31
|
+
SnapshotOptions,
|
|
32
|
+
| "include"
|
|
33
|
+
| "sessionDir"
|
|
34
|
+
| "syncFiles"
|
|
35
|
+
| "syncSessions"
|
|
36
|
+
| "extraFiles"
|
|
37
|
+
| "signal"
|
|
38
|
+
| "validateMutation"
|
|
39
|
+
| "expectedFileHashes"
|
|
40
|
+
> = {},
|
|
30
41
|
) {
|
|
31
42
|
const root = agentDir();
|
|
32
43
|
const { sessionDir } = options;
|
|
33
|
-
|
|
44
|
+
const transactionOptions = {
|
|
45
|
+
sessionDir,
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
validateMutation: options.validateMutation,
|
|
48
|
+
protectedTargets: [...protectedRelativePaths].map((relative) => snapshotTarget(root, relative, sessionDir)),
|
|
49
|
+
expectedPreimages: new Map(
|
|
50
|
+
Object.entries(options.expectedFileHashes ?? {}).map(([relative, hash]) => [
|
|
51
|
+
snapshotTarget(root, relative, sessionDir),
|
|
52
|
+
hash === null ? "missing" : `file:${hash}`,
|
|
53
|
+
]),
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
options.validateMutation?.();
|
|
57
|
+
await recoverPendingSnapshotTransactions(transactionOptions);
|
|
34
58
|
const current = await createSnapshot(snapshot.profile, {
|
|
35
59
|
...options,
|
|
36
60
|
...(options.include === undefined ? { syncSessions: snapshotIncludesSessions(snapshot) } : {}),
|
|
@@ -46,8 +70,8 @@ export async function applySnapshot(
|
|
|
46
70
|
),
|
|
47
71
|
snapshot,
|
|
48
72
|
);
|
|
49
|
-
await preflightSnapshotMutations(root, plan, sessionDir);
|
|
50
|
-
await applySnapshotTransaction(plan,
|
|
73
|
+
await preflightSnapshotMutations(root, plan, sessionDir, options);
|
|
74
|
+
await applySnapshotTransaction(plan, transactionOptions);
|
|
51
75
|
return appliedFileHashMap(snapshot, current, protectedRelativePaths);
|
|
52
76
|
}
|
|
53
77
|
|
|
@@ -181,18 +205,43 @@ function decodeBase64Strict(value: string, filePath: string) {
|
|
|
181
205
|
return Buffer.from(value, "base64");
|
|
182
206
|
}
|
|
183
207
|
|
|
184
|
-
|
|
208
|
+
type MutationGuards = Pick<SnapshotOptions, "signal" | "validateMutation">;
|
|
209
|
+
|
|
210
|
+
export async function preflightSnapshotMutations(
|
|
185
211
|
root: string,
|
|
186
212
|
plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
|
|
187
213
|
sessionDir?: string,
|
|
214
|
+
options: MutationGuards = {},
|
|
188
215
|
) {
|
|
216
|
+
assertMutationCurrent(options);
|
|
189
217
|
const deletePaths = new Set(plan.deletes);
|
|
190
218
|
for (const target of plan.deletes) {
|
|
191
|
-
await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
|
|
219
|
+
await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target, options);
|
|
220
|
+
assertMutationCurrent(options);
|
|
192
221
|
}
|
|
193
222
|
for (const item of plan.writes) {
|
|
194
|
-
await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
|
|
223
|
+
await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths, options);
|
|
224
|
+
assertMutationCurrent(options);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function assertMutationCurrent(options: MutationGuards) {
|
|
229
|
+
options.validateMutation?.();
|
|
230
|
+
options.signal?.throwIfAborted();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function guardedStat(target: string, options: MutationGuards) {
|
|
234
|
+
assertMutationCurrent(options);
|
|
235
|
+
let stat: Stats;
|
|
236
|
+
try {
|
|
237
|
+
stat = await fs.lstat(target);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
assertMutationCurrent(options);
|
|
240
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
241
|
+
throw error;
|
|
195
242
|
}
|
|
243
|
+
assertMutationCurrent(options);
|
|
244
|
+
return stat;
|
|
196
245
|
}
|
|
197
246
|
|
|
198
247
|
function rootForTarget(root: string, target: string, sessionDir?: string) {
|
|
@@ -201,43 +250,46 @@ function rootForTarget(root: string, target: string, sessionDir?: string) {
|
|
|
201
250
|
return root;
|
|
202
251
|
}
|
|
203
252
|
|
|
204
|
-
async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string
|
|
205
|
-
const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths);
|
|
253
|
+
async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>, options: MutationGuards) {
|
|
254
|
+
const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths, options);
|
|
255
|
+
assertMutationCurrent(options);
|
|
206
256
|
if (parentWillBeReplaced) return;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
} catch (error) {
|
|
214
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
257
|
+
const stat = await guardedStat(target, options);
|
|
258
|
+
assertMutationCurrent(options);
|
|
259
|
+
if (stat?.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
|
|
260
|
+
if (stat?.isDirectory() && !deletePaths.has(target)) {
|
|
261
|
+
throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
|
|
215
262
|
}
|
|
216
263
|
}
|
|
217
264
|
|
|
218
|
-
async function ensureSafeDirectory(root: string, directory: string, deletePaths: Set<string
|
|
265
|
+
async function ensureSafeDirectory(root: string, directory: string, deletePaths: Set<string>, options: MutationGuards) {
|
|
266
|
+
assertMutationCurrent(options);
|
|
219
267
|
assertWithinRoot(root, directory);
|
|
220
268
|
const rootPath = path.resolve(root);
|
|
221
269
|
const relative = path.relative(rootPath, path.resolve(directory));
|
|
222
270
|
let current = rootPath;
|
|
223
271
|
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
224
272
|
current = path.join(current, part);
|
|
225
|
-
|
|
226
|
-
|
|
273
|
+
const stat = await guardedStat(current, options);
|
|
274
|
+
assertMutationCurrent(options);
|
|
275
|
+
if (stat) {
|
|
227
276
|
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
228
277
|
if (!stat.isDirectory()) {
|
|
229
278
|
if (deletePaths.has(current)) return true;
|
|
230
279
|
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
231
280
|
}
|
|
232
|
-
}
|
|
233
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
281
|
+
} else {
|
|
234
282
|
await fs.mkdir(current);
|
|
283
|
+
assertMutationCurrent(options);
|
|
284
|
+
await syncDirectory(path.dirname(current));
|
|
285
|
+
assertMutationCurrent(options);
|
|
235
286
|
}
|
|
236
287
|
}
|
|
237
288
|
return false;
|
|
238
289
|
}
|
|
239
290
|
|
|
240
|
-
async function assertNoSymlinkParents(root: string, target: string) {
|
|
291
|
+
async function assertNoSymlinkParents(root: string, target: string, options: MutationGuards) {
|
|
292
|
+
assertMutationCurrent(options);
|
|
241
293
|
assertWithinRoot(root, target);
|
|
242
294
|
const rootPath = path.resolve(root);
|
|
243
295
|
const relative = path.relative(rootPath, path.resolve(target));
|
|
@@ -245,13 +297,10 @@ async function assertNoSymlinkParents(root: string, target: string) {
|
|
|
245
297
|
const parts = relative.split(path.sep).filter(Boolean);
|
|
246
298
|
for (const part of parts.slice(0, -1)) {
|
|
247
299
|
current = path.join(current, part);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
254
|
-
throw error;
|
|
255
|
-
}
|
|
300
|
+
const stat = await guardedStat(current, options);
|
|
301
|
+
assertMutationCurrent(options);
|
|
302
|
+
if (!stat) return;
|
|
303
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
304
|
+
if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
256
305
|
}
|
|
257
306
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { SnapshotApplyPlan } from "./snapshot-types.js";
|
|
4
|
+
|
|
5
|
+
/** Only the top-level file correction produced by addTopLevelCaseVariantDeletes is eligible. */
|
|
6
|
+
export function isCaseReplacement(root: string, before: string, after: string) {
|
|
7
|
+
return (
|
|
8
|
+
before !== after &&
|
|
9
|
+
path.dirname(before) === root &&
|
|
10
|
+
path.dirname(after) === root &&
|
|
11
|
+
path.basename(before).toLowerCase() === path.basename(after).toLowerCase()
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Absence is handled by the journal's removal/replacement evidence, never by naming evidence. */
|
|
16
|
+
export async function caseReplacementSpelling(before: string, after: string): Promise<string | undefined> {
|
|
17
|
+
const observations = await Promise.all(
|
|
18
|
+
[before, after].map(async (target) => {
|
|
19
|
+
try {
|
|
20
|
+
const stat = await fs.lstat(target);
|
|
21
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error("Unsafe case replacement target.");
|
|
22
|
+
return await fs.realpath(target);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
if (observations.every((value) => value === undefined)) return undefined;
|
|
30
|
+
if (!observations[0] || observations[0] !== observations[1])
|
|
31
|
+
throw new Error("Case replacement no longer identifies one physical file; preserve evidence for review.");
|
|
32
|
+
const names = (await fs.readdir(path.dirname(before))).filter(
|
|
33
|
+
(name) => name.toLowerCase() === path.basename(before).toLowerCase(),
|
|
34
|
+
);
|
|
35
|
+
if (names.length !== 1 || (names[0] !== path.basename(before) && names[0] !== path.basename(after)))
|
|
36
|
+
throw new Error("Case replacement spelling changed; preserve evidence for review.");
|
|
37
|
+
return path.join(path.dirname(before), names[0]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function coalesceCaseReplacements(root: string, plan: SnapshotApplyPlan) {
|
|
41
|
+
const replacements = new Map<string, string>();
|
|
42
|
+
const deletesByCase = new Map<string, string[]>();
|
|
43
|
+
const writeTargets = new Set(plan.writes.map((item) => item.target));
|
|
44
|
+
for (const target of new Set(plan.deletes)) {
|
|
45
|
+
if (path.dirname(target) !== root || writeTargets.has(target)) continue;
|
|
46
|
+
const key = path.basename(target).toLowerCase();
|
|
47
|
+
const group = deletesByCase.get(key) ?? [];
|
|
48
|
+
group.push(target);
|
|
49
|
+
deletesByCase.set(key, group);
|
|
50
|
+
}
|
|
51
|
+
for (const item of plan.writes) {
|
|
52
|
+
if (path.dirname(item.target) !== root) continue;
|
|
53
|
+
const candidates = deletesByCase.get(path.basename(item.target).toLowerCase()) ?? [];
|
|
54
|
+
if (candidates.length !== 1) continue;
|
|
55
|
+
const before = candidates[0];
|
|
56
|
+
if (!before || !isCaseReplacement(root, before, item.target)) continue;
|
|
57
|
+
// Different real paths on a case-sensitive filesystem remain independent targets.
|
|
58
|
+
let beforePath: string;
|
|
59
|
+
let afterPath: string;
|
|
60
|
+
try {
|
|
61
|
+
[beforePath, afterPath] = await Promise.all([fs.realpath(before), fs.realpath(item.target)]);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (beforePath !== afterPath) continue;
|
|
67
|
+
if (replacements.has(before)) throw new Error("Multiple writes alias a case replacement target.");
|
|
68
|
+
if ((await caseReplacementSpelling(before, item.target)) !== before)
|
|
69
|
+
throw new Error("Case replacement preimage spelling changed; review is required.");
|
|
70
|
+
replacements.set(before, item.target);
|
|
71
|
+
}
|
|
72
|
+
const beforeByAfter = new Map([...replacements].map(([before, after]) => [after, before]));
|
|
73
|
+
return {
|
|
74
|
+
replacements,
|
|
75
|
+
plan: {
|
|
76
|
+
deletes: plan.deletes,
|
|
77
|
+
writes: plan.writes.map((item) => ({ ...item, target: beforeByAfter.get(item.target) ?? item.target })),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -36,9 +36,14 @@ export async function decodeSnapshot(
|
|
|
36
36
|
await pipeline(Readable.from([buffer]), createGunzip(), sink, { signal: options.signal });
|
|
37
37
|
throwIfAborted(options.signal);
|
|
38
38
|
const parsed = JSON.parse(Buffer.concat(chunks, total).toString("utf8")) as Snapshot;
|
|
39
|
-
if (parsed.version !== VERSION || !Array.isArray(parsed.files)) {
|
|
39
|
+
if ((parsed.version !== VERSION && parsed.version !== 2 && parsed.version !== 3) || !Array.isArray(parsed.files)) {
|
|
40
40
|
throw new Error("Unsupported snapshot format.");
|
|
41
41
|
}
|
|
42
|
+
if (parsed.version === 2 || parsed.version === 3) {
|
|
43
|
+
const { normalizeLocalFields, validatePortableSnapshot } = await import("../sync/local-fields.js");
|
|
44
|
+
validatePortableSnapshot(parsed);
|
|
45
|
+
if (parsed.localFields !== undefined) parsed.localFields = normalizeLocalFields(parsed.localFields);
|
|
46
|
+
} else if (parsed.localFields !== undefined) throw new Error("Portable field policy requires snapshot version 2.");
|
|
42
47
|
snapshotSelectionInclude(parsed);
|
|
43
48
|
return parsed;
|
|
44
49
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isPathInside } from "../paths.js";
|
|
4
|
+
import { syncDirectory } from "../state/json-file.js";
|
|
5
|
+
import { expandSessionDir } from "./session-paths.js";
|
|
6
|
+
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
7
|
+
import { fileImage } from "./snapshot-transaction-plan.js";
|
|
8
|
+
import type { SnapshotApplyPlan } from "./snapshot-types.js";
|
|
9
|
+
|
|
10
|
+
export interface SessionRootTransition {
|
|
11
|
+
beforeRoot: string;
|
|
12
|
+
/** null is a verified missing postimage, never an empty settings file. */
|
|
13
|
+
settingsAfterBase64?: string | null;
|
|
14
|
+
/** Reviewed bytes live in a private fsynced sidecar, pinned by the entry afterImage. */
|
|
15
|
+
settingsAfterFile?: true;
|
|
16
|
+
}
|
|
17
|
+
interface TransitionGuards {
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
validateMutation?: () => void;
|
|
20
|
+
}
|
|
21
|
+
function validateOwner(guards: TransitionGuards) {
|
|
22
|
+
guards.validateMutation?.();
|
|
23
|
+
guards.signal?.throwIfAborted();
|
|
24
|
+
}
|
|
25
|
+
interface SettingsEntry {
|
|
26
|
+
target: string;
|
|
27
|
+
afterTarget?: string;
|
|
28
|
+
backupName: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
beforeImage?: string;
|
|
31
|
+
afterImage?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rootFromSettings(root: string, bytes: Buffer) {
|
|
35
|
+
let settings: unknown;
|
|
36
|
+
try {
|
|
37
|
+
settings = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error("Invalid settings root-transition evidence.");
|
|
40
|
+
}
|
|
41
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
42
|
+
throw new Error("Invalid settings root-transition evidence.");
|
|
43
|
+
const value = (settings as { sessionDir?: unknown }).sessionDir;
|
|
44
|
+
if (value !== undefined && typeof value !== "string") throw new Error("Invalid settings root-transition evidence.");
|
|
45
|
+
return path.resolve(sessionStorageRoot(root, value ? expandSessionDir(value) : undefined));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function beforeRoot(directory: string, root: string, entry: SettingsEntry, guards: TransitionGuards) {
|
|
49
|
+
validateOwner(guards);
|
|
50
|
+
if (entry.kind === "missing" && entry.beforeImage === "missing") return path.join(root, "sessions");
|
|
51
|
+
if (entry.kind !== "file") throw new Error("Root transition requires regular settings preimage evidence.");
|
|
52
|
+
const backup = path.join(directory, "before", entry.backupName);
|
|
53
|
+
const stat = await fs.lstat(backup);
|
|
54
|
+
validateOwner(guards);
|
|
55
|
+
if (!stat.isFile()) throw new Error("Invalid settings root-transition backup.");
|
|
56
|
+
const bytes = await fs.readFile(backup);
|
|
57
|
+
validateOwner(guards);
|
|
58
|
+
if (fileImage(bytes) !== entry.beforeImage) throw new Error("Changed settings root-transition backup.");
|
|
59
|
+
return rootFromSettings(root, bytes);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function prepareSessionRootTransition(
|
|
63
|
+
directory: string,
|
|
64
|
+
root: string,
|
|
65
|
+
sessionRoot: string,
|
|
66
|
+
entries: SettingsEntry[],
|
|
67
|
+
plan: SnapshotApplyPlan,
|
|
68
|
+
guards: TransitionGuards = {},
|
|
69
|
+
): Promise<SessionRootTransition | undefined> {
|
|
70
|
+
const settingsTarget = path.join(root, "settings.json");
|
|
71
|
+
const entry = entries.find((item) => item.target === settingsTarget || item.afterTarget === settingsTarget);
|
|
72
|
+
const write = plan.writes.find((item) => item.target === settingsTarget || item.target === entry?.target);
|
|
73
|
+
if (!entry || !entries.some((item) => item !== entry && isPathInside(sessionRoot, item.target))) return undefined;
|
|
74
|
+
const deleted = !write && plan.deletes.includes(entry.target) && entry.afterImage === "missing";
|
|
75
|
+
if (!write && !deleted) return undefined;
|
|
76
|
+
const afterRoot = write ? rootFromSettings(root, write.content) : path.join(root, "sessions");
|
|
77
|
+
// An explicit context root is not a settings-driven transition.
|
|
78
|
+
if (afterRoot !== sessionRoot) return undefined;
|
|
79
|
+
const previous = await beforeRoot(directory, root, entry, guards);
|
|
80
|
+
if (previous === sessionRoot) return undefined;
|
|
81
|
+
if (write && fileImage(write.content) !== entry.afterImage) throw new Error("Changed settings transition postimage.");
|
|
82
|
+
if (!write) return { beforeRoot: previous, settingsAfterBase64: null };
|
|
83
|
+
validateOwner(guards);
|
|
84
|
+
const handle = await fs.open(path.join(directory, "settings-after"), "wx", 0o600);
|
|
85
|
+
try {
|
|
86
|
+
validateOwner(guards);
|
|
87
|
+
await handle.writeFile(write.content);
|
|
88
|
+
validateOwner(guards);
|
|
89
|
+
await handle.sync();
|
|
90
|
+
validateOwner(guards);
|
|
91
|
+
} finally {
|
|
92
|
+
await handle.close();
|
|
93
|
+
}
|
|
94
|
+
await syncDirectory(directory);
|
|
95
|
+
validateOwner(guards);
|
|
96
|
+
return { beforeRoot: previous, settingsAfterFile: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function validateSessionRootTransition(value: SessionRootTransition) {
|
|
100
|
+
if (
|
|
101
|
+
!value ||
|
|
102
|
+
typeof value.beforeRoot !== "string" ||
|
|
103
|
+
!path.isAbsolute(value.beforeRoot) ||
|
|
104
|
+
path.resolve(value.beforeRoot) !== value.beforeRoot ||
|
|
105
|
+
(value.settingsAfterFile !== undefined && value.settingsAfterFile !== true) ||
|
|
106
|
+
(value.settingsAfterFile === true
|
|
107
|
+
? value.settingsAfterBase64 !== undefined
|
|
108
|
+
: value.settingsAfterBase64 !== null &&
|
|
109
|
+
(typeof value.settingsAfterBase64 !== "string" ||
|
|
110
|
+
value.settingsAfterBase64.length > 32 * 1024 * 1024 ||
|
|
111
|
+
Buffer.from(value.settingsAfterBase64, "base64").toString("base64") !== value.settingsAfterBase64))
|
|
112
|
+
)
|
|
113
|
+
throw new Error("Invalid session root-transition evidence.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Authorization is bound to both settings images, never just an arbitrary pinned root. */
|
|
117
|
+
export async function resolveTransitionSessionRoot(
|
|
118
|
+
directory: string,
|
|
119
|
+
root: string,
|
|
120
|
+
sessionRoot: string,
|
|
121
|
+
transition: SessionRootTransition,
|
|
122
|
+
entries: SettingsEntry[],
|
|
123
|
+
currentRoot: string,
|
|
124
|
+
guards: TransitionGuards = {},
|
|
125
|
+
) {
|
|
126
|
+
const settingsTarget = path.join(root, "settings.json");
|
|
127
|
+
const entry = entries.find((item) => item.target === settingsTarget || item.afterTarget === settingsTarget);
|
|
128
|
+
if (!entry) throw new Error("Missing settings root-transition evidence.");
|
|
129
|
+
let after: Buffer | undefined;
|
|
130
|
+
if (transition.settingsAfterFile) {
|
|
131
|
+
const file = path.join(directory, "settings-after");
|
|
132
|
+
const stat = await fs.lstat(file);
|
|
133
|
+
validateOwner(guards);
|
|
134
|
+
if (
|
|
135
|
+
!stat.isFile() ||
|
|
136
|
+
stat.isSymbolicLink() ||
|
|
137
|
+
stat.nlink !== 1 ||
|
|
138
|
+
(process.platform !== "win32" && (stat.mode & 0o077) !== 0)
|
|
139
|
+
)
|
|
140
|
+
throw new Error("Unsafe settings root-transition postimage.");
|
|
141
|
+
after = await fs.readFile(file);
|
|
142
|
+
validateOwner(guards);
|
|
143
|
+
} else if (transition.settingsAfterBase64 !== null)
|
|
144
|
+
after = Buffer.from(transition.settingsAfterBase64 as string, "base64");
|
|
145
|
+
if (
|
|
146
|
+
(after ? fileImage(after) : "missing") !== entry.afterImage ||
|
|
147
|
+
(after ? rootFromSettings(root, after) : path.join(root, "sessions")) !== sessionRoot ||
|
|
148
|
+
(await beforeRoot(directory, root, entry, guards)) !== transition.beforeRoot
|
|
149
|
+
)
|
|
150
|
+
throw new Error("Inconsistent settings root-transition evidence.");
|
|
151
|
+
if (currentRoot !== transition.beforeRoot && currentRoot !== sessionRoot)
|
|
152
|
+
throw new Error("Transaction session root is not owned by this context; preserve evidence for review.");
|
|
153
|
+
return sessionRoot;
|
|
154
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isPathInside } from "../paths.js";
|
|
4
|
+
import type { SnapshotApplyPlan } from "./snapshot-types.js";
|
|
5
|
+
|
|
6
|
+
export function fileImage(content: Buffer) {
|
|
7
|
+
return `file:${createHash("sha256").update(content).digest("hex")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Work follows path depth and emitted evidence, not every target/write pair. */
|
|
11
|
+
export function indexTransactionPlan(plan: SnapshotApplyPlan, targets: readonly string[]) {
|
|
12
|
+
const targetIndex = indexTargets(targets);
|
|
13
|
+
const writeImages = new Map<string, string>();
|
|
14
|
+
const postFiles = new Map(targets.map((target) => [target, [] as { relative: string; image: string }[]]));
|
|
15
|
+
for (const write of plan.writes) {
|
|
16
|
+
const image = fileImage(write.content);
|
|
17
|
+
// Preserve the first exact write and the original descendant order, including duplicates.
|
|
18
|
+
if (!writeImages.has(write.target)) writeImages.set(write.target, image);
|
|
19
|
+
for (const ancestor of ancestorTargets(write.target, targetIndex, false)) {
|
|
20
|
+
if (ancestor !== write.target && isStrictDescendant(ancestor, write.target)) {
|
|
21
|
+
postFiles.get(ancestor)?.push({ relative: path.relative(ancestor, write.target), image });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const deleteIndex = indexTargets(plan.deletes);
|
|
26
|
+
const deletes = plan.deletes.filter((target) => {
|
|
27
|
+
for (const ancestor of ancestorTargets(target, deleteIndex, false)) {
|
|
28
|
+
if (ancestor !== target && isStrictDescendant(ancestor, target)) return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
});
|
|
32
|
+
const deletedWrites = new Set<string>();
|
|
33
|
+
const outerDeleteIndex = indexTargets(deletes);
|
|
34
|
+
for (const write of plan.writes) {
|
|
35
|
+
for (const ancestor of ancestorTargets(write.target, outerDeleteIndex)) {
|
|
36
|
+
if (ancestor === write.target || isPathInside(ancestor, write.target)) {
|
|
37
|
+
deletedWrites.add(write.target);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { writeImages, postFiles, deletes, deletedWrites };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isStrictDescendant(parent: string, target: string) {
|
|
46
|
+
return path.relative(path.resolve(parent), path.resolve(target)) !== "" && isPathInside(parent, target);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pathKey(target: string) {
|
|
50
|
+
const resolved = path.resolve(target);
|
|
51
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function indexTargets(targets: readonly string[]) {
|
|
55
|
+
const index = new Map<string, string[]>();
|
|
56
|
+
for (const target of new Set(targets)) {
|
|
57
|
+
const key = pathKey(target);
|
|
58
|
+
const rows = index.get(key) ?? [];
|
|
59
|
+
rows.push(target);
|
|
60
|
+
index.set(key, rows);
|
|
61
|
+
}
|
|
62
|
+
return index;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function* ancestorTargets(target: string, index: Map<string, string[]>, includeSelf = true) {
|
|
66
|
+
const resolved = path.resolve(target);
|
|
67
|
+
// Strict-descendant queries skip the same-address bucket, including native case aliases.
|
|
68
|
+
for (let ancestor = includeSelf ? resolved : path.dirname(resolved); ; ancestor = path.dirname(ancestor)) {
|
|
69
|
+
yield* index.get(pathKey(ancestor)) ?? [];
|
|
70
|
+
if (path.dirname(ancestor) === ancestor) return;
|
|
71
|
+
}
|
|
72
|
+
}
|