@narumitw/pi-sync 0.25.0 → 0.29.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 +238 -121
- package/package.json +7 -2
- package/src/command.ts +162 -24
- package/src/config.ts +395 -89
- package/src/file-selection.ts +280 -0
- package/src/index.ts +1 -0
- package/src/lock.ts +3 -3
- package/src/manager-ui.ts +961 -0
- package/src/s3-client.ts +47 -13
- package/src/settings-management.ts +307 -0
- package/src/snapshot-apply.ts +41 -17
- package/src/snapshot-transaction.ts +201 -0
- package/src/snapshot.ts +76 -95
- package/src/sync-format.ts +168 -0
- package/src/sync-policy.ts +129 -0
- package/src/sync-state.ts +167 -0
- package/src/sync.ts +357 -351
- package/src/types.ts +47 -0
package/src/s3-client.ts
CHANGED
|
@@ -10,7 +10,10 @@ function iso8601Basic(date: Date) {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function snapshotIncludesSessions(snapshot: Snapshot) {
|
|
13
|
-
return
|
|
13
|
+
return (
|
|
14
|
+
snapshot.syncSessions === true ||
|
|
15
|
+
snapshot.files.some((file) => file.path.startsWith("sessions/"))
|
|
16
|
+
);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
@@ -18,7 +21,9 @@ function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
|
18
21
|
if (!value) return false;
|
|
19
22
|
try {
|
|
20
23
|
const hostname = new URL(value).hostname.toLowerCase();
|
|
21
|
-
return
|
|
24
|
+
return (
|
|
25
|
+
hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
|
|
26
|
+
);
|
|
22
27
|
} catch {
|
|
23
28
|
return false;
|
|
24
29
|
}
|
|
@@ -27,11 +32,13 @@ function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
|
27
32
|
export class S3Client {
|
|
28
33
|
private config: SyncConfig;
|
|
29
34
|
private endpoint: URL;
|
|
35
|
+
private signal?: AbortSignal;
|
|
30
36
|
private omitSessionTokenAfterRejection = false;
|
|
31
37
|
|
|
32
|
-
constructor(config: SyncConfig) {
|
|
38
|
+
constructor(config: SyncConfig, signal?: AbortSignal) {
|
|
33
39
|
this.config = config;
|
|
34
40
|
this.endpoint = new URL(config.endpoint);
|
|
41
|
+
this.signal = signal;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
async getJson<T>(key: string): Promise<RemoteObject<T>> {
|
|
@@ -48,11 +55,15 @@ export class S3Client {
|
|
|
48
55
|
// Retry so the transient blip is absorbed instead of surfacing as a
|
|
49
56
|
// "pi-sync auto sync skipped" warning on every session start.
|
|
50
57
|
if (body.length > 0) {
|
|
51
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
value: JSON.parse(body) as T,
|
|
60
|
+
etag: normalizeEtag(object.headers.get("etag")),
|
|
61
|
+
missing: false,
|
|
62
|
+
};
|
|
52
63
|
}
|
|
53
64
|
lastError = new Error(`S3 GET returned an empty body for ${key}`);
|
|
54
65
|
if (attempt < maxAttempts) {
|
|
55
|
-
await sleep(250 * attempt);
|
|
66
|
+
await sleep(250 * attempt, undefined, { signal: this.signal });
|
|
56
67
|
}
|
|
57
68
|
}
|
|
58
69
|
throw lastError;
|
|
@@ -78,7 +89,7 @@ export class S3Client {
|
|
|
78
89
|
}
|
|
79
90
|
lastError = new Error(`S3 GET returned an empty body for ${key}`);
|
|
80
91
|
if (attempt < maxAttempts) {
|
|
81
|
-
await sleep(250 * attempt);
|
|
92
|
+
await sleep(250 * attempt, undefined, { signal: this.signal });
|
|
82
93
|
}
|
|
83
94
|
}
|
|
84
95
|
throw lastError;
|
|
@@ -92,7 +103,8 @@ export class S3Client {
|
|
|
92
103
|
async putBuffer(key: string, body: Buffer, contentType: string) {
|
|
93
104
|
const headers: Record<string, string> = { "content-type": contentType };
|
|
94
105
|
const response = await this.request("PUT", key, body, headers);
|
|
95
|
-
if (!response.ok)
|
|
106
|
+
if (!response.ok)
|
|
107
|
+
throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
private async request(
|
|
@@ -114,7 +126,12 @@ export class S3Client {
|
|
|
114
126
|
sessionToken,
|
|
115
127
|
region: this.config.region,
|
|
116
128
|
});
|
|
117
|
-
return fetch(url, {
|
|
129
|
+
return fetch(url, {
|
|
130
|
+
method,
|
|
131
|
+
headers,
|
|
132
|
+
body: body ? new Uint8Array(body) : undefined,
|
|
133
|
+
signal: this.signal,
|
|
134
|
+
});
|
|
118
135
|
};
|
|
119
136
|
const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.sessionToken;
|
|
120
137
|
const response = await send(sessionToken);
|
|
@@ -125,7 +142,10 @@ export class S3Client {
|
|
|
125
142
|
return retry;
|
|
126
143
|
}
|
|
127
144
|
|
|
128
|
-
private async shouldRetryWithoutSessionToken(
|
|
145
|
+
private async shouldRetryWithoutSessionToken(
|
|
146
|
+
response: Response,
|
|
147
|
+
sessionToken: string | undefined,
|
|
148
|
+
) {
|
|
129
149
|
if (
|
|
130
150
|
!sessionToken ||
|
|
131
151
|
!isCloudflareR2Endpoint(this.config.endpoint) ||
|
|
@@ -160,7 +180,9 @@ async function signedHeaders(input: {
|
|
|
160
180
|
};
|
|
161
181
|
if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
|
|
162
182
|
const signedHeaderNames = Object.keys(headers).sort();
|
|
163
|
-
const canonicalHeaders = signedHeaderNames
|
|
183
|
+
const canonicalHeaders = signedHeaderNames
|
|
184
|
+
.map((name) => `${name}:${headers[name]?.trim()}\n`)
|
|
185
|
+
.join("");
|
|
164
186
|
const canonicalRequest = [
|
|
165
187
|
input.method,
|
|
166
188
|
input.url.pathname,
|
|
@@ -170,7 +192,12 @@ async function signedHeaders(input: {
|
|
|
170
192
|
payloadHash,
|
|
171
193
|
].join("\n");
|
|
172
194
|
const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
|
|
173
|
-
const stringToSign = [
|
|
195
|
+
const stringToSign = [
|
|
196
|
+
"AWS4-HMAC-SHA256",
|
|
197
|
+
amzDate,
|
|
198
|
+
scope,
|
|
199
|
+
sha256(Buffer.from(canonicalRequest)),
|
|
200
|
+
].join("\n");
|
|
174
201
|
const signingKey = hmac(
|
|
175
202
|
hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
|
|
176
203
|
"aws4_request",
|
|
@@ -206,7 +233,11 @@ export function profilePrefix(config: SyncConfig) {
|
|
|
206
233
|
return posixJoin(config.prefix, "profiles", config.profile);
|
|
207
234
|
}
|
|
208
235
|
|
|
209
|
-
export function pointerFor(
|
|
236
|
+
export function pointerFor(
|
|
237
|
+
config: SyncConfig,
|
|
238
|
+
snapshot: Snapshot,
|
|
239
|
+
checksum: string,
|
|
240
|
+
): LatestPointer {
|
|
210
241
|
return {
|
|
211
242
|
version: VERSION,
|
|
212
243
|
profile: config.profile,
|
|
@@ -227,5 +258,8 @@ function normalizeEtag(value: string | null) {
|
|
|
227
258
|
}
|
|
228
259
|
|
|
229
260
|
function isSecurityTokenInvalidArgument(text: string) {
|
|
230
|
-
return
|
|
261
|
+
return (
|
|
262
|
+
text.includes("<Code>InvalidArgument</Code>") &&
|
|
263
|
+
text.includes("<Message>X-Amz-Security-Token</Message>")
|
|
264
|
+
);
|
|
231
265
|
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
effectiveTargetRemoteIdentity,
|
|
6
|
+
localConfigPath,
|
|
7
|
+
readLocalConfigObject,
|
|
8
|
+
stateDir,
|
|
9
|
+
statePathForConfig,
|
|
10
|
+
writeLocalConfigObject,
|
|
11
|
+
} from "./config.js";
|
|
12
|
+
import { withLock } from "./lock.js";
|
|
13
|
+
import { safeName } from "./paths.js";
|
|
14
|
+
import type { StorageProfileSettings, SyncConfig, SyncTargetSettings } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const LEGACY_FIELDS = new Set([
|
|
17
|
+
"endpoint",
|
|
18
|
+
"bucket",
|
|
19
|
+
"region",
|
|
20
|
+
"accessKeyId",
|
|
21
|
+
"secretAccessKey",
|
|
22
|
+
"sessionToken",
|
|
23
|
+
"profile",
|
|
24
|
+
"prefix",
|
|
25
|
+
"autoSync",
|
|
26
|
+
"syncFiles",
|
|
27
|
+
"syncSessions",
|
|
28
|
+
"extraFiles",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export interface MigrationResult {
|
|
32
|
+
settings: Record<string, unknown>;
|
|
33
|
+
backupPath: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function migrateLegacySettings(
|
|
37
|
+
targetName: string,
|
|
38
|
+
storageProfileName: string,
|
|
39
|
+
): Promise<MigrationResult> {
|
|
40
|
+
validateName(targetName, "target");
|
|
41
|
+
validateName(storageProfileName, "storage profile");
|
|
42
|
+
return withLock("settings-migration", async () => {
|
|
43
|
+
const configPath = localConfigPath();
|
|
44
|
+
const originalBytes = await fs.readFile(configPath);
|
|
45
|
+
const current = await readLocalConfigObject();
|
|
46
|
+
if (!current) throw new Error("No legacy pi-sync settings are available to migrate.");
|
|
47
|
+
if (current.version === 2) {
|
|
48
|
+
throw new Error("pi-sync settings already use profiles and targets.");
|
|
49
|
+
}
|
|
50
|
+
const next = legacySettingsAsV2(current, targetName, storageProfileName);
|
|
51
|
+
const backupPath = await writeMigrationBackup(originalBytes);
|
|
52
|
+
await adoptLegacyState(current, next, targetName);
|
|
53
|
+
const latestBytes = await fs.readFile(configPath);
|
|
54
|
+
if (!latestBytes.equals(originalBytes)) {
|
|
55
|
+
throw new Error("pi-sync settings changed during migration; no settings were replaced.");
|
|
56
|
+
}
|
|
57
|
+
await writeLocalConfigObject(next);
|
|
58
|
+
return { settings: next, backupPath };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function legacySettingsAsV2(
|
|
63
|
+
legacy: Record<string, unknown>,
|
|
64
|
+
targetName: string,
|
|
65
|
+
storageProfileName: string,
|
|
66
|
+
) {
|
|
67
|
+
validateName(targetName, "target");
|
|
68
|
+
validateName(storageProfileName, "storage profile");
|
|
69
|
+
const unknown = Object.fromEntries(
|
|
70
|
+
Object.entries(legacy).filter(([key]) => !LEGACY_FIELDS.has(key) && key !== "version"),
|
|
71
|
+
);
|
|
72
|
+
const profile = compactObject({
|
|
73
|
+
kind: inferKind(legacy.endpoint),
|
|
74
|
+
endpoint: legacy.endpoint,
|
|
75
|
+
region: legacy.region,
|
|
76
|
+
accessKeyId: legacy.accessKeyId,
|
|
77
|
+
secretAccessKey: legacy.secretAccessKey,
|
|
78
|
+
sessionToken: legacy.sessionToken,
|
|
79
|
+
});
|
|
80
|
+
const target = compactObject({
|
|
81
|
+
profile: storageProfileName,
|
|
82
|
+
bucket: legacy.bucket,
|
|
83
|
+
prefix: legacy.prefix,
|
|
84
|
+
namespace: legacy.profile ?? targetName,
|
|
85
|
+
autoSync: legacy.autoSync,
|
|
86
|
+
syncFiles: legacy.syncFiles,
|
|
87
|
+
syncSessions: legacy.syncSessions,
|
|
88
|
+
extraFiles: legacy.extraFiles,
|
|
89
|
+
legacyStateProfile: legacy.profile ?? "default",
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
...unknown,
|
|
93
|
+
version: 2,
|
|
94
|
+
activeTarget: targetName,
|
|
95
|
+
profiles: { [storageProfileName]: profile },
|
|
96
|
+
targets: { [targetName]: target },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function saveNewV2Settings(input: {
|
|
101
|
+
targetName: string;
|
|
102
|
+
storageProfileName: string;
|
|
103
|
+
profile: StorageProfileSettings;
|
|
104
|
+
target: SyncTargetSettings;
|
|
105
|
+
}) {
|
|
106
|
+
validateName(input.targetName, "target");
|
|
107
|
+
validateName(input.storageProfileName, "storage profile");
|
|
108
|
+
const current = (await readLocalConfigObject()) ?? {};
|
|
109
|
+
if (Object.keys(current).length > 0)
|
|
110
|
+
throw new Error(`Settings already exist: ${localConfigPath()}`);
|
|
111
|
+
const settings = {
|
|
112
|
+
version: 2,
|
|
113
|
+
activeTarget: input.targetName,
|
|
114
|
+
profiles: { [input.storageProfileName]: { ...input.profile } },
|
|
115
|
+
targets: {
|
|
116
|
+
[input.targetName]: { ...input.target, profile: input.storageProfileName },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
await writeLocalConfigObject(settings);
|
|
120
|
+
return settings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function addStorageProfile(name: string, profile: StorageProfileSettings) {
|
|
124
|
+
validateName(name, "storage profile");
|
|
125
|
+
await updateV2Settings((settings) => {
|
|
126
|
+
const profiles = requireObject(settings.profiles, "profiles");
|
|
127
|
+
if (Object.hasOwn(profiles, name)) throw new Error(`Storage profile already exists: ${name}`);
|
|
128
|
+
return { ...settings, profiles: { ...profiles, [name]: { ...profile } } };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function updateStorageProfile(
|
|
133
|
+
name: string,
|
|
134
|
+
update: (profile: Record<string, unknown>) => Record<string, unknown>,
|
|
135
|
+
) {
|
|
136
|
+
validateName(name, "storage profile");
|
|
137
|
+
await updateV2Settings((settings) => {
|
|
138
|
+
const profiles = requireObject(settings.profiles, "profiles");
|
|
139
|
+
const profile = requireObject(profiles[name], "storage profile");
|
|
140
|
+
return { ...settings, profiles: { ...profiles, [name]: update(profile) } };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function addSyncTarget(name: string, target: SyncTargetSettings) {
|
|
145
|
+
validateName(name, "target");
|
|
146
|
+
await updateV2Settings((settings) => {
|
|
147
|
+
const targets = requireObject(settings.targets, "targets");
|
|
148
|
+
const profiles = requireObject(settings.profiles, "profiles");
|
|
149
|
+
if (Object.hasOwn(targets, name)) throw new Error(`Sync target already exists: ${name}`);
|
|
150
|
+
if (!target.profile || !Object.hasOwn(profiles, target.profile)) {
|
|
151
|
+
throw new Error(`Storage profile not found: ${target.profile ?? "missing"}`);
|
|
152
|
+
}
|
|
153
|
+
assertUniqueRemoteIdentity(targets, name, target);
|
|
154
|
+
return { ...settings, targets: { ...targets, [name]: { ...target } } };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function updateSyncTarget(
|
|
159
|
+
name: string,
|
|
160
|
+
update: (target: Record<string, unknown>) => Record<string, unknown>,
|
|
161
|
+
) {
|
|
162
|
+
validateName(name, "target");
|
|
163
|
+
await updateV2Settings((settings) => {
|
|
164
|
+
const targets = requireObject(settings.targets, "targets");
|
|
165
|
+
const target = requireObject(targets[name], "target");
|
|
166
|
+
const nextTarget = update(target);
|
|
167
|
+
assertUniqueRemoteIdentity(targets, name, nextTarget);
|
|
168
|
+
return { ...settings, targets: { ...targets, [name]: nextTarget } };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function removeSyncTarget(name: string) {
|
|
173
|
+
validateName(name, "target");
|
|
174
|
+
await updateV2Settings((settings) => {
|
|
175
|
+
const targets = requireObject(settings.targets, "targets");
|
|
176
|
+
if (!Object.hasOwn(targets, name)) throw new Error(`Sync target not found: ${name}`);
|
|
177
|
+
if (settings.activeTarget === name && Object.keys(targets).length > 1) {
|
|
178
|
+
throw new Error("Switch to another target before removing the current target.");
|
|
179
|
+
}
|
|
180
|
+
const nextTargets = { ...targets };
|
|
181
|
+
delete nextTargets[name];
|
|
182
|
+
return {
|
|
183
|
+
...settings,
|
|
184
|
+
targets: nextTargets,
|
|
185
|
+
activeTarget: Object.keys(nextTargets)[0],
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function removeStorageProfile(name: string) {
|
|
191
|
+
validateName(name, "storage profile");
|
|
192
|
+
await updateV2Settings((settings) => {
|
|
193
|
+
const profiles = requireObject(settings.profiles, "profiles");
|
|
194
|
+
const targets = requireObject(settings.targets, "targets");
|
|
195
|
+
const referenced = Object.entries(targets).find(
|
|
196
|
+
([, target]) => requireObject(target, "target").profile === name,
|
|
197
|
+
)?.[0];
|
|
198
|
+
if (referenced) throw new Error(`Storage profile “${name}” is used by target “${referenced}”.`);
|
|
199
|
+
if (!Object.hasOwn(profiles, name)) throw new Error(`Storage profile not found: ${name}`);
|
|
200
|
+
const nextProfiles = { ...profiles };
|
|
201
|
+
delete nextProfiles[name];
|
|
202
|
+
return { ...settings, profiles: nextProfiles };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function updateV2Settings(
|
|
207
|
+
update: (settings: Record<string, unknown>) => Record<string, unknown>,
|
|
208
|
+
) {
|
|
209
|
+
return withLock("settings", async () => {
|
|
210
|
+
const current = await readLocalConfigObject();
|
|
211
|
+
if (current?.version !== 2) {
|
|
212
|
+
throw new Error("Profiles and targets require version 2 pi-sync settings.");
|
|
213
|
+
}
|
|
214
|
+
const next = update(current);
|
|
215
|
+
await writeLocalConfigObject(next);
|
|
216
|
+
return next;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function writeMigrationBackup(bytes: Buffer) {
|
|
221
|
+
const directory = path.join(stateDir(), "backups", "config");
|
|
222
|
+
await fs.mkdir(directory, { recursive: true });
|
|
223
|
+
const backupPath = path.join(
|
|
224
|
+
directory,
|
|
225
|
+
`pi-sync.local.${new Date().toISOString().replace(/[:.]/gu, "-")}.${randomUUID().slice(0, 8)}.json`,
|
|
226
|
+
);
|
|
227
|
+
await fs.writeFile(backupPath, bytes, { flag: "wx", mode: 0o600 });
|
|
228
|
+
if (process.platform !== "win32") await fs.chmod(backupPath, 0o600);
|
|
229
|
+
return backupPath;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function adoptLegacyState(
|
|
233
|
+
legacy: Record<string, unknown>,
|
|
234
|
+
next: Record<string, unknown>,
|
|
235
|
+
targetName: string,
|
|
236
|
+
) {
|
|
237
|
+
const legacyProfile = typeof legacy.profile === "string" ? legacy.profile : "default";
|
|
238
|
+
const source = path.join(stateDir(), `${safeName(legacyProfile)}.state.json`);
|
|
239
|
+
let bytes: Buffer;
|
|
240
|
+
try {
|
|
241
|
+
bytes = await fs.readFile(source);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
const targets = requireObject(next.targets, "targets");
|
|
247
|
+
const target = requireObject(targets[targetName], "target");
|
|
248
|
+
const profile = typeof target.namespace === "string" ? target.namespace : targetName;
|
|
249
|
+
const destination = statePathForConfig({
|
|
250
|
+
settingsVersion: 2,
|
|
251
|
+
target: targetName,
|
|
252
|
+
profile,
|
|
253
|
+
} as SyncConfig);
|
|
254
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
255
|
+
try {
|
|
256
|
+
await fs.writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function assertUniqueRemoteIdentity(
|
|
263
|
+
targets: Record<string, unknown>,
|
|
264
|
+
name: string,
|
|
265
|
+
target: SyncTargetSettings,
|
|
266
|
+
) {
|
|
267
|
+
const identity = effectiveTargetRemoteIdentity(target as Record<string, unknown>, name);
|
|
268
|
+
for (const [otherName, value] of Object.entries(targets)) {
|
|
269
|
+
if (
|
|
270
|
+
otherName !== name &&
|
|
271
|
+
effectiveTargetRemoteIdentity(requireObject(value, "target"), otherName) === identity
|
|
272
|
+
) {
|
|
273
|
+
throw new Error(`Target “${name}” duplicates the remote destination of “${otherName}”.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function compactObject(value: Record<string, unknown>) {
|
|
279
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function inferKind(endpoint: unknown) {
|
|
283
|
+
return typeof endpoint === "string" && endpoint.includes(".r2.cloudflarestorage.com")
|
|
284
|
+
? "r2"
|
|
285
|
+
: "s3-compatible";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function requireObject(value: unknown, name: string): Record<string, unknown> {
|
|
289
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
290
|
+
throw new Error(`Invalid pi-sync settings: ${name} must be an object.`);
|
|
291
|
+
}
|
|
292
|
+
return value as Record<string, unknown>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validateName(value: string, label: string) {
|
|
296
|
+
if (
|
|
297
|
+
!value.trim() ||
|
|
298
|
+
value.length > 100 ||
|
|
299
|
+
value === "__proto__" ||
|
|
300
|
+
value === "prototype" ||
|
|
301
|
+
value === "constructor" ||
|
|
302
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Stored identifiers cannot contain controls.
|
|
303
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
|
|
304
|
+
) {
|
|
305
|
+
throw new Error(`Invalid ${label} name.`);
|
|
306
|
+
}
|
|
307
|
+
}
|
package/src/snapshot-apply.ts
CHANGED
|
@@ -3,7 +3,14 @@ import type { Dirent } from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { agentDir } from "./config.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
assertWithinRoot,
|
|
8
|
+
isDeniedPath,
|
|
9
|
+
isPathInside,
|
|
10
|
+
parentPaths,
|
|
11
|
+
safeJoin,
|
|
12
|
+
toPosix,
|
|
13
|
+
} from "./paths.js";
|
|
7
14
|
import {
|
|
8
15
|
createSnapshot,
|
|
9
16
|
isSessionFilePath,
|
|
@@ -12,6 +19,10 @@ import {
|
|
|
12
19
|
snapshotIncludesSessions,
|
|
13
20
|
snapshotTarget,
|
|
14
21
|
} from "./snapshot.js";
|
|
22
|
+
import {
|
|
23
|
+
applySnapshotTransaction,
|
|
24
|
+
recoverPendingSnapshotTransactions,
|
|
25
|
+
} from "./snapshot-transaction.js";
|
|
15
26
|
import type { Snapshot, SnapshotApplyPlan, SnapshotOptions } from "./types.js";
|
|
16
27
|
|
|
17
28
|
function sha256(value: Buffer) {
|
|
@@ -25,11 +36,13 @@ function fileHashMap(snapshot: Snapshot) {
|
|
|
25
36
|
export async function applySnapshot(
|
|
26
37
|
snapshot: Snapshot,
|
|
27
38
|
protectedRelativePaths = new Set<string>(),
|
|
28
|
-
options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
|
|
39
|
+
options: Pick<SnapshotOptions, "syncFiles" | "sessionDir" | "extraFiles"> = {},
|
|
29
40
|
) {
|
|
30
41
|
const root = agentDir();
|
|
31
42
|
const { sessionDir } = options;
|
|
43
|
+
await recoverPendingSnapshotTransactions();
|
|
32
44
|
const current = await createSnapshot(snapshot.profile, {
|
|
45
|
+
syncFiles: options.syncFiles,
|
|
33
46
|
syncSessions: snapshotIncludesSessions(snapshot),
|
|
34
47
|
sessionDir,
|
|
35
48
|
extraFiles: options.extraFiles,
|
|
@@ -45,12 +58,7 @@ export async function applySnapshot(
|
|
|
45
58
|
snapshot,
|
|
46
59
|
);
|
|
47
60
|
await preflightSnapshotMutations(root, plan, sessionDir);
|
|
48
|
-
|
|
49
|
-
await fs.rm(target, { force: true, recursive: true });
|
|
50
|
-
}
|
|
51
|
-
for (const item of plan.writes) {
|
|
52
|
-
await fs.writeFile(item.target, item.content);
|
|
53
|
-
}
|
|
61
|
+
await applySnapshotTransaction(plan, { sessionDir });
|
|
54
62
|
return appliedFileHashMap(snapshot, current, protectedRelativePaths);
|
|
55
63
|
}
|
|
56
64
|
|
|
@@ -79,7 +87,8 @@ export function preflightSnapshotApply(
|
|
|
79
87
|
|
|
80
88
|
const target = snapshotTarget(root, normalized, options.sessionDir);
|
|
81
89
|
const content = decodeBase64Strict(file.contentBase64, normalized);
|
|
82
|
-
if (sha256(content) !== file.sha256)
|
|
90
|
+
if (sha256(content) !== file.sha256)
|
|
91
|
+
throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
|
|
83
92
|
writes.push({ target, content });
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -108,7 +117,9 @@ export function protectSnapshotApplyPlan(
|
|
|
108
117
|
): SnapshotApplyPlan {
|
|
109
118
|
if (protectedRelativePaths.size === 0) return plan;
|
|
110
119
|
const protectedTargets = new Set(
|
|
111
|
-
[...protectedRelativePaths].map((relativePath) =>
|
|
120
|
+
[...protectedRelativePaths].map((relativePath) =>
|
|
121
|
+
snapshotTarget(root, relativePath, sessionDir),
|
|
122
|
+
),
|
|
112
123
|
);
|
|
113
124
|
return {
|
|
114
125
|
writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
|
|
@@ -141,7 +152,11 @@ export async function addTopLevelCaseVariantDeletes(
|
|
|
141
152
|
const deletes = new Set(plan.deletes);
|
|
142
153
|
for (const entry of entries) {
|
|
143
154
|
const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
|
|
144
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
canonicalPath &&
|
|
157
|
+
entry.name !== canonicalPath &&
|
|
158
|
+
(entry.isFile() || entry.isSymbolicLink())
|
|
159
|
+
) {
|
|
145
160
|
deletes.add(safeJoin(root, entry.name));
|
|
146
161
|
}
|
|
147
162
|
}
|
|
@@ -198,7 +213,11 @@ async function preflightSnapshotMutations(
|
|
|
198
213
|
await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
|
|
199
214
|
}
|
|
200
215
|
for (const item of plan.writes) {
|
|
201
|
-
await prepareSnapshotWrite(
|
|
216
|
+
await prepareSnapshotWrite(
|
|
217
|
+
rootForTarget(root, item.target, sessionDir),
|
|
218
|
+
item.target,
|
|
219
|
+
deletePaths,
|
|
220
|
+
);
|
|
202
221
|
}
|
|
203
222
|
}
|
|
204
223
|
|
|
@@ -212,7 +231,8 @@ async function prepareSnapshotWrite(root: string, target: string, deletePaths: S
|
|
|
212
231
|
await ensureSafeDirectory(root, path.dirname(target));
|
|
213
232
|
try {
|
|
214
233
|
const stat = await fs.lstat(target);
|
|
215
|
-
if (stat.isSymbolicLink())
|
|
234
|
+
if (stat.isSymbolicLink())
|
|
235
|
+
throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
|
|
216
236
|
if (stat.isDirectory() && !deletePaths.has(target)) {
|
|
217
237
|
throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
|
|
218
238
|
}
|
|
@@ -230,8 +250,10 @@ async function ensureSafeDirectory(root: string, directory: string) {
|
|
|
230
250
|
current = path.join(current, part);
|
|
231
251
|
try {
|
|
232
252
|
const stat = await fs.lstat(current);
|
|
233
|
-
if (stat.isSymbolicLink())
|
|
234
|
-
|
|
253
|
+
if (stat.isSymbolicLink())
|
|
254
|
+
throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
255
|
+
if (!stat.isDirectory())
|
|
256
|
+
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
235
257
|
} catch (error) {
|
|
236
258
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
237
259
|
await fs.mkdir(current);
|
|
@@ -249,8 +271,10 @@ async function assertNoSymlinkParents(root: string, target: string) {
|
|
|
249
271
|
current = path.join(current, part);
|
|
250
272
|
try {
|
|
251
273
|
const stat = await fs.lstat(current);
|
|
252
|
-
if (stat.isSymbolicLink())
|
|
253
|
-
|
|
274
|
+
if (stat.isSymbolicLink())
|
|
275
|
+
throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
276
|
+
if (!stat.isDirectory())
|
|
277
|
+
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
254
278
|
} catch (error) {
|
|
255
279
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
256
280
|
throw error;
|