@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/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 snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/"));
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 hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
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 { value: JSON.parse(body) as T, etag: normalizeEtag(object.headers.get("etag")), missing: false };
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) throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
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, { method, headers, body: body ? new Uint8Array(body) : undefined });
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(response: Response, sessionToken: string | undefined) {
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.map((name) => `${name}:${headers[name]?.trim()}\n`).join("");
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 = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest))].join("\n");
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(config: SyncConfig, snapshot: Snapshot, checksum: string): LatestPointer {
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 text.includes("<Code>InvalidArgument</Code>") && text.includes("<Message>X-Amz-Security-Token</Message>");
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
+ }
@@ -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 { assertWithinRoot, isDeniedPath, isPathInside, parentPaths, safeJoin, toPosix } from "./paths.js";
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
- for (const target of plan.deletes) {
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) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
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) => snapshotTarget(root, relativePath, sessionDir)),
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 (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
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(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
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()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
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()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
234
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
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()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
253
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
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;