@narumitw/pi-sync 0.13.0 → 0.13.1

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.
@@ -0,0 +1,194 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import { encodeKey, posixJoin } from "./paths.js";
3
+ import type { LatestPointer, RemoteObject, Snapshot, SyncConfig } from "./types.js";
4
+
5
+ const VERSION = 1;
6
+
7
+ function iso8601Basic(date: Date) {
8
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
9
+ }
10
+
11
+ function snapshotIncludesSessions(snapshot: Snapshot) {
12
+ return snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/"));
13
+ }
14
+
15
+ function isCloudflareR2Endpoint(endpoint: string | undefined) {
16
+ const value = endpoint?.trim();
17
+ if (!value) return false;
18
+ try {
19
+ const hostname = new URL(value).hostname.toLowerCase();
20
+ return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export class S3Client {
27
+ private config: SyncConfig;
28
+ private endpoint: URL;
29
+ private omitSessionTokenAfterRejection = false;
30
+
31
+ constructor(config: SyncConfig) {
32
+ this.config = config;
33
+ this.endpoint = new URL(config.endpoint);
34
+ }
35
+
36
+ async getJson<T>(key: string): Promise<RemoteObject<T>> {
37
+ const object = await this.request("GET", key);
38
+ if (object.status === 404) return { missing: true };
39
+ if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
40
+ return { value: (await object.json()) as T, etag: normalizeEtag(object.headers.get("etag")), missing: false };
41
+ }
42
+
43
+ async getBuffer(key: string): Promise<RemoteObject<Buffer>> {
44
+ const object = await this.request("GET", key);
45
+ if (object.status === 404) return { missing: true };
46
+ if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
47
+ return { value: Buffer.from(await object.arrayBuffer()), etag: normalizeEtag(object.headers.get("etag")), missing: false };
48
+ }
49
+
50
+ async putJson(key: string, value: unknown) {
51
+ const body = Buffer.from(JSON.stringify(value, null, "\t"), "utf8");
52
+ await this.putBuffer(key, body, "application/json");
53
+ }
54
+
55
+ async putBuffer(key: string, body: Buffer, contentType: string) {
56
+ const headers: Record<string, string> = { "content-type": contentType };
57
+ const response = await this.request("PUT", key, body, headers);
58
+ if (!response.ok) throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
59
+ }
60
+
61
+ private async request(
62
+ method: "GET" | "PUT",
63
+ key: string,
64
+ body?: Buffer,
65
+ extraHeaders: Record<string, string> = {},
66
+ ) {
67
+ const url = new URL(this.endpoint.toString());
68
+ url.pathname = posixJoin(url.pathname, this.config.bucket, encodeKey(key));
69
+ const send = async (sessionToken: string | undefined) => {
70
+ const headers = await signedHeaders({
71
+ method,
72
+ url,
73
+ body,
74
+ extraHeaders,
75
+ accessKeyId: this.config.accessKeyId,
76
+ secretAccessKey: this.config.secretAccessKey,
77
+ sessionToken,
78
+ region: this.config.region,
79
+ });
80
+ return fetch(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
81
+ };
82
+ const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.sessionToken;
83
+ const response = await send(sessionToken);
84
+ if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
85
+
86
+ const retry = await send(undefined);
87
+ if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
88
+ return retry;
89
+ }
90
+
91
+ private async shouldRetryWithoutSessionToken(response: Response, sessionToken: string | undefined) {
92
+ if (
93
+ !sessionToken ||
94
+ !isCloudflareR2Endpoint(this.config.endpoint) ||
95
+ response.ok ||
96
+ response.status !== 400
97
+ ) {
98
+ return false;
99
+ }
100
+ return isSecurityTokenInvalidArgument(await response.clone().text());
101
+ }
102
+ }
103
+
104
+ async function signedHeaders(input: {
105
+ method: string;
106
+ url: URL;
107
+ body?: Buffer;
108
+ extraHeaders: Record<string, string>;
109
+ accessKeyId: string;
110
+ secretAccessKey: string;
111
+ sessionToken?: string;
112
+ region: string;
113
+ }) {
114
+ const now = new Date();
115
+ const amzDate = iso8601Basic(now);
116
+ const dateStamp = amzDate.slice(0, 8);
117
+ const payloadHash = sha256(input.body ?? Buffer.alloc(0));
118
+ const headers: Record<string, string> = {
119
+ ...lowercaseKeys(input.extraHeaders),
120
+ host: input.url.host,
121
+ "x-amz-content-sha256": payloadHash,
122
+ "x-amz-date": amzDate,
123
+ };
124
+ if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
125
+ const signedHeaderNames = Object.keys(headers).sort();
126
+ const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()}\n`).join("");
127
+ const canonicalRequest = [
128
+ input.method,
129
+ input.url.pathname,
130
+ input.url.searchParams.toString(),
131
+ canonicalHeaders,
132
+ signedHeaderNames.join(";"),
133
+ payloadHash,
134
+ ].join("\n");
135
+ const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
136
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest))].join("\n");
137
+ const signingKey = hmac(
138
+ hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
139
+ "aws4_request",
140
+ );
141
+ const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
142
+ return {
143
+ ...headers,
144
+ authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`,
145
+ };
146
+ }
147
+
148
+ function hmac(key: Buffer, value: string) {
149
+ return createHmac("sha256", key).update(value).digest();
150
+ }
151
+
152
+ function sha256(value: Buffer) {
153
+ return createHash("sha256").update(value).digest("hex");
154
+ }
155
+
156
+ export function latestKey(config: SyncConfig) {
157
+ return posixJoin(profilePrefix(config), "latest.json");
158
+ }
159
+
160
+ export function historyKey(config: SyncConfig) {
161
+ return posixJoin(profilePrefix(config), "history.json");
162
+ }
163
+
164
+ export function snapshotKey(config: SyncConfig, id: string) {
165
+ return posixJoin(profilePrefix(config), "snapshots", `${id}.json.gz`);
166
+ }
167
+
168
+ export function profilePrefix(config: SyncConfig) {
169
+ return posixJoin(config.prefix, "profiles", config.profile);
170
+ }
171
+
172
+ export function pointerFor(config: SyncConfig, snapshot: Snapshot, checksum: string): LatestPointer {
173
+ return {
174
+ version: VERSION,
175
+ profile: config.profile,
176
+ snapshot: snapshot.id,
177
+ sha256: checksum,
178
+ createdAt: snapshot.createdAt,
179
+ machine: snapshot.machine,
180
+ syncSessions: snapshotIncludesSessions(snapshot),
181
+ };
182
+ }
183
+
184
+ function lowercaseKeys(value: Record<string, string>) {
185
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
186
+ }
187
+
188
+ function normalizeEtag(value: string | null) {
189
+ return value ?? undefined;
190
+ }
191
+
192
+ function isSecurityTokenInvalidArgument(text: string) {
193
+ return text.includes("<Code>InvalidArgument</Code>") && text.includes("<Message>X-Amz-Security-Token</Message>");
194
+ }
@@ -0,0 +1,259 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { agentDir } from "./config.js";
6
+ import { assertWithinRoot, isDeniedPath, isPathInside, parentPaths, safeJoin, toPosix } from "./paths.js";
7
+ import {
8
+ createSnapshot,
9
+ isSessionFilePath,
10
+ isSessionPath,
11
+ sessionStorageRoot,
12
+ snapshotIncludesSessions,
13
+ snapshotTarget,
14
+ } from "./snapshot.js";
15
+ import type { Snapshot, SnapshotApplyPlan, SnapshotOptions } from "./types.js";
16
+
17
+ function sha256(value: Buffer) {
18
+ return createHash("sha256").update(value).digest("hex");
19
+ }
20
+
21
+ function fileHashMap(snapshot: Snapshot) {
22
+ return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
23
+ }
24
+
25
+ export async function applySnapshot(
26
+ snapshot: Snapshot,
27
+ protectedRelativePaths = new Set<string>(),
28
+ options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
29
+ ) {
30
+ const root = agentDir();
31
+ const { sessionDir } = options;
32
+ const current = await createSnapshot(snapshot.profile, {
33
+ syncSessions: snapshotIncludesSessions(snapshot),
34
+ sessionDir,
35
+ extraFiles: options.extraFiles,
36
+ });
37
+ const plan = await addTopLevelCaseVariantDeletes(
38
+ root,
39
+ protectSnapshotApplyPlan(
40
+ root,
41
+ preflightSnapshotApply(root, snapshot, current, { sessionDir }),
42
+ protectedRelativePaths,
43
+ sessionDir,
44
+ ),
45
+ snapshot,
46
+ );
47
+ 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
+ }
54
+ return appliedFileHashMap(snapshot, current, protectedRelativePaths);
55
+ }
56
+
57
+ export function preflightSnapshotApply(
58
+ root: string,
59
+ snapshot: Snapshot,
60
+ current: Snapshot,
61
+ options: { sessionDir?: string } = {},
62
+ ): SnapshotApplyPlan {
63
+ const seenPaths = new Set<string>();
64
+ const remotePaths = new Set<string>();
65
+ const writes: Array<{ target: string; content: Buffer }> = [];
66
+ const deletes: string[] = [];
67
+
68
+ for (const file of snapshot.files) {
69
+ const normalized = toPosix(file.path);
70
+ if (!isSafeSnapshotPath(file.path)) {
71
+ throw new Error(`Unsafe path in snapshot: ${file.path}`);
72
+ }
73
+ if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
74
+ throw new Error(`Unsafe session path in snapshot: ${file.path}`);
75
+ }
76
+ if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
77
+ seenPaths.add(normalized);
78
+ remotePaths.add(normalized);
79
+
80
+ const target = snapshotTarget(root, normalized, options.sessionDir);
81
+ const content = decodeBase64Strict(file.contentBase64, normalized);
82
+ if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
83
+ writes.push({ target, content });
84
+ }
85
+
86
+ const deletePaths = new Set<string>();
87
+ for (const file of current.files) {
88
+ const normalized = toPosix(file.path);
89
+ if (!remotePaths.has(normalized)) {
90
+ deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
91
+ }
92
+ for (const remotePath of parentPaths(normalized)) {
93
+ if (remotePaths.has(remotePath)) {
94
+ deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
95
+ }
96
+ }
97
+ }
98
+ deletes.push(...deletePaths);
99
+
100
+ return { writes, deletes };
101
+ }
102
+
103
+ export function protectSnapshotApplyPlan(
104
+ root: string,
105
+ plan: SnapshotApplyPlan,
106
+ protectedRelativePaths: Set<string>,
107
+ sessionDir?: string,
108
+ ): SnapshotApplyPlan {
109
+ if (protectedRelativePaths.size === 0) return plan;
110
+ const protectedTargets = new Set(
111
+ [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir)),
112
+ );
113
+ return {
114
+ writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
115
+ deletes: plan.deletes.filter((target) => !protectedTargets.has(target)),
116
+ };
117
+ }
118
+
119
+ export async function addTopLevelCaseVariantDeletes(
120
+ root: string,
121
+ plan: SnapshotApplyPlan,
122
+ snapshot: Pick<Snapshot, "files">,
123
+ ): Promise<SnapshotApplyPlan> {
124
+ const topLevelPaths = new Map<string, string>();
125
+ for (const file of snapshot.files) {
126
+ const normalized = toPosix(file.path);
127
+ if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
128
+ topLevelPaths.set(normalized.toLowerCase(), normalized);
129
+ }
130
+ }
131
+ if (topLevelPaths.size === 0) return plan;
132
+
133
+ let entries: Dirent[];
134
+ try {
135
+ entries = await fs.readdir(root, { withFileTypes: true });
136
+ } catch (error) {
137
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return plan;
138
+ throw error;
139
+ }
140
+
141
+ const deletes = new Set(plan.deletes);
142
+ for (const entry of entries) {
143
+ const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
144
+ if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
145
+ deletes.add(safeJoin(root, entry.name));
146
+ }
147
+ }
148
+ return { ...plan, deletes: [...deletes] };
149
+ }
150
+
151
+ export function appliedFileHashMap(
152
+ snapshot: Snapshot,
153
+ current: Snapshot,
154
+ protectedRelativePaths: Set<string>,
155
+ ) {
156
+ const hashes = fileHashMap(snapshot);
157
+ if (protectedRelativePaths.size === 0) return hashes;
158
+ const currentHashes = fileHashMap(current);
159
+ for (const relativePath of protectedRelativePaths) {
160
+ const normalized = toPosix(relativePath);
161
+ if (currentHashes[normalized]) {
162
+ hashes[normalized] = currentHashes[normalized];
163
+ } else {
164
+ delete hashes[normalized];
165
+ }
166
+ }
167
+ return hashes;
168
+ }
169
+
170
+ function isSafeSnapshotPath(relativePath: string) {
171
+ if (relativePath.includes("\\")) return false;
172
+ const normalized = toPosix(relativePath);
173
+ return (
174
+ Boolean(normalized) &&
175
+ normalized !== "." &&
176
+ normalized !== ".." &&
177
+ !normalized.startsWith("../") &&
178
+ !path.posix.isAbsolute(normalized) &&
179
+ path.posix.normalize(normalized) === normalized &&
180
+ !isDeniedPath(normalized)
181
+ );
182
+ }
183
+
184
+ function decodeBase64Strict(value: string, filePath: string) {
185
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
186
+ throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
187
+ }
188
+ return Buffer.from(value, "base64");
189
+ }
190
+
191
+ async function preflightSnapshotMutations(
192
+ root: string,
193
+ plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
194
+ sessionDir?: string,
195
+ ) {
196
+ const deletePaths = new Set(plan.deletes);
197
+ for (const target of plan.deletes) {
198
+ await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
199
+ }
200
+ for (const item of plan.writes) {
201
+ await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
202
+ }
203
+ }
204
+
205
+ function rootForTarget(root: string, target: string, sessionDir?: string) {
206
+ const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : undefined;
207
+ if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
208
+ return root;
209
+ }
210
+
211
+ async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>) {
212
+ await ensureSafeDirectory(root, path.dirname(target));
213
+ try {
214
+ const stat = await fs.lstat(target);
215
+ if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
216
+ if (stat.isDirectory() && !deletePaths.has(target)) {
217
+ throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
218
+ }
219
+ } catch (error) {
220
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
221
+ }
222
+ }
223
+
224
+ async function ensureSafeDirectory(root: string, directory: string) {
225
+ assertWithinRoot(root, directory);
226
+ const rootPath = path.resolve(root);
227
+ const relative = path.relative(rootPath, path.resolve(directory));
228
+ let current = rootPath;
229
+ for (const part of relative.split(path.sep).filter(Boolean)) {
230
+ current = path.join(current, part);
231
+ try {
232
+ 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}`);
235
+ } catch (error) {
236
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
237
+ await fs.mkdir(current);
238
+ }
239
+ }
240
+ }
241
+
242
+ async function assertNoSymlinkParents(root: string, target: string) {
243
+ assertWithinRoot(root, target);
244
+ const rootPath = path.resolve(root);
245
+ const relative = path.relative(rootPath, path.resolve(target));
246
+ let current = rootPath;
247
+ const parts = relative.split(path.sep).filter(Boolean);
248
+ for (const part of parts.slice(0, -1)) {
249
+ current = path.join(current, part);
250
+ try {
251
+ 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}`);
254
+ } catch (error) {
255
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
256
+ throw error;
257
+ }
258
+ }
259
+ }