// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. import { sessionTokenWarnings } from "./chunk-APZVBSM6.ts"; import { decodeSnapshot, encodeSnapshot } from "./chunk-HGV24P4T.ts"; import "./chunk-F5QL2GPY.ts"; import { SyncBackendConflictError, SyncBackendPublicationOutcomeUnknownError } from "./chunk-5MQ76BK5.ts"; import { encodeKey, portableSnapshotSelection, posixJoin } from "./chunk-YQ6UW7IF.ts"; // src/s3-backend.ts import { createHash as createHash2 } from "node:crypto"; // src/s3-client.ts import { createHash, createHmac } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; var MAX_JSON_RESPONSE_BYTES = 1024 * 1024; var MAX_SNAPSHOT_RESPONSE_BYTES = 256 * 1024 * 1024; var MAX_ERROR_RESPONSE_BYTES = 64 * 1024; var DEFAULT_REQUEST_TIMEOUT_MS = 3e4; function iso8601Basic(date) { return date.toISOString().replace(/[:-]|\.\d{3}/g, ""); } function isCloudflareR2Endpoint(endpoint) { const value = endpoint?.trim(); if (!value) return false; try { const hostname = new URL(value).hostname.toLowerCase(); return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com"); } catch { return false; } } var S3ObjectAlreadyExistsError = class extends Error { constructor(key) { super(`S3 object already exists: ${key}`); this.key = key; this.name = "S3ObjectAlreadyExistsError"; } key; }; var S3Client = class { config; endpoint; signal; requestTimeoutMs; omitSessionTokenAfterRejection = false; constructor(config, signal, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) { this.config = config; this.endpoint = new URL(config.profile.endpoint); this.signal = signal; this.requestTimeoutMs = requestTimeoutMs; } async getJson(key) { const maxAttempts = 3; let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const object = await this.request("GET", key); if (object.status === 404) return { missing: true }; if (!object.ok) { throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`); } const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response"); if (body.length > 0) { return { value: JSON.parse(body), etag: normalizeEtag(object.headers.get("etag")), missing: false }; } lastError = new Error(`S3 GET returned an empty body for ${key}`); if (attempt < maxAttempts) { await sleep(250 * attempt, void 0, { signal: this.signal }); } } throw lastError; } async getBuffer(key) { const maxAttempts = 3; let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const object = await this.request("GET", key); if (object.status === 404) return { missing: true }; if (!object.ok) { throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`); } const buffer = await readBoundedBuffer( object, MAX_SNAPSHOT_RESPONSE_BYTES, "S3 snapshot response" ); if (buffer.length > 0) { return { value: buffer, etag: normalizeEtag(object.headers.get("etag")), missing: false }; } lastError = new Error(`S3 GET returned an empty body for ${key}`); if (attempt < maxAttempts) { await sleep(250 * attempt, void 0, { signal: this.signal }); } } throw lastError; } async putJson(key, value) { const body = Buffer.from(JSON.stringify(value, null, " "), "utf8"); await this.putBuffer(key, body, "application/json"); } async putBuffer(key, body, contentType, options = {}) { const headers = { "content-type": contentType }; if (options.ifAbsent) headers["if-none-match"] = "*"; const response = await this.request("PUT", key, body, headers); if (options.ifAbsent && response.status === 412) { throw new S3ObjectAlreadyExistsError(key); } if (!response.ok) { throw new Error(`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`); } } async request(method, key, body, extraHeaders = {}) { const url = new URL(this.endpoint.toString()); url.pathname = posixJoin(url.pathname, this.config.destination.bucket, encodeKey(key)); const send = async (sessionToken2) => { const transportSignal = this.transportSignal(); const headers = await signedHeaders({ method, url, body, extraHeaders, accessKeyId: this.config.profile.accessKeyId, secretAccessKey: this.config.profile.secretAccessKey, sessionToken: sessionToken2, region: this.config.profile.region }); try { return await fetch(url, { method, headers, body: body ? new Uint8Array(body) : void 0, signal: transportSignal }); } catch (error) { if (error instanceof Error && error.name === "AbortError") throw error; if (error instanceof Error && error.name === "TimeoutError") { throw new Error("S3 request timed out.", { cause: error }); } throw new Error(`S3 request failed: ${this.redact(errorMessage(error))}`, { cause: error }); } }; const sessionToken = this.omitSessionTokenAfterRejection ? void 0 : this.config.profile.sessionToken; const response = await send(sessionToken); if (!await this.shouldRetryWithoutSessionToken(response, sessionToken)) return response; const retry = await send(void 0); if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true; return retry; } transportSignal() { const timeout = AbortSignal.timeout(this.requestTimeoutMs); return this.signal ? AbortSignal.any([this.signal, timeout]) : timeout; } async shouldRetryWithoutSessionToken(response, sessionToken) { if (!sessionToken || !isCloudflareR2Endpoint(this.config.profile.endpoint) || response.ok || response.status !== 400) { return false; } return isSecurityTokenInvalidArgument( await readBoundedText(response.clone(), MAX_ERROR_RESPONSE_BYTES, "S3 error response") ); } async readErrorText(response) { try { return this.redact( await readBoundedText(response, MAX_ERROR_RESPONSE_BYTES, "S3 error response") ); } catch (error) { return this.redact(errorMessage(error)); } } redact(value) { let redacted = value; let endpointUsername; let endpointPassword; let endpointQueryValues = []; try { const endpoint = new URL(this.config.profile.endpoint); endpointUsername = endpoint.username; endpointPassword = endpoint.password; endpointQueryValues = [...endpoint.searchParams.values()]; } catch { } for (const secret of [ this.config.profile.accessKeyId, this.config.profile.secretAccessKey, this.config.profile.sessionToken, endpointUsername, endpointPassword, ...endpointQueryValues ]) { if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]"); } return redacted; } }; async function readBoundedText(response, limit, label) { return (await readBoundedBuffer(response, limit, label)).toString("utf8"); } async function readBoundedBuffer(response, limit, label) { const contentLength = Number(response.headers.get("content-length")); if (Number.isFinite(contentLength) && contentLength > limit) { throw new Error(`${label} exceeds the ${limit}-byte limit.`); } if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > limit) { await reader.cancel().catch(() => void 0); throw new Error(`${label} exceeds the ${limit}-byte limit.`); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); } return Buffer.concat(chunks, total); } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } async function signedHeaders(input) { const now = /* @__PURE__ */ new Date(); const amzDate = iso8601Basic(now); const dateStamp = amzDate.slice(0, 8); const payloadHash = sha256(input.body ?? Buffer.alloc(0)); const headers = { ...lowercaseKeys(input.extraHeaders), host: input.url.host, "x-amz-content-sha256": payloadHash, "x-amz-date": amzDate }; if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken; const signedHeaderNames = Object.keys(headers).sort(); const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()} `).join(""); const canonicalRequest = [ input.method, input.url.pathname, input.url.searchParams.toString(), canonicalHeaders, signedHeaderNames.join(";"), payloadHash ].join("\n"); const scope = `${dateStamp}/${input.region}/s3/aws4_request`; const stringToSign = [ "AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest)) ].join("\n"); const signingKey = hmac( hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"), "aws4_request" ); const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex"); return { ...headers, authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}` }; } function hmac(key, value) { return createHmac("sha256", key).update(value).digest(); } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } function lowercaseKeys(value) { return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item])); } function normalizeEtag(value) { return value ?? void 0; } function isSecurityTokenInvalidArgument(text) { return text.includes("InvalidArgument") && text.includes("X-Amz-Security-Token"); } // src/s3-backend.ts var VERSION = 1; var POST_COMMIT_TIMEOUT_MS = 3e4; var S3SyncBackend = class { constructor(config, postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS) { this.config = config; this.postCommitTimeoutMs = postCommitTimeoutMs; assertSafeDestination(config); this.identity = s3BackendIdentity(config); this.destination = s3Destination(config); } config; postCommitTimeoutMs; identity; destination; capability = "read-check-write-verify"; checksums = /* @__PURE__ */ new Map(); sameRevision(left, right) { return left === right; } async readHead(signal) { const object = await new S3Client(this.config, signal).getJson( latestKey(this.config) ); throwIfAborted(signal); if (object.missing) return void 0; const pointer = requirePointer( object.value, "Remote latest pointer is malformed.", this.config.destination.namespace ); this.registerChecksum(pointer.snapshot, pointer.sha256); return remoteHead(pointer, this.identity, object.etag); } async readSnapshot(reference, signal) { const expectedChecksum = this.checksums.get(reference) ?? await this.resolveChecksum(reference, signal); const object = await new S3Client(this.config, signal).getBuffer( snapshotKey(this.config, reference) ); throwIfAborted(signal); if (!object.value) throw new Error(`Snapshot not found: ${reference}`); if (expectedChecksum && sha2562(object.value) !== expectedChecksum) { throw new Error("Remote snapshot checksum mismatch."); } const snapshot = await decodeSnapshot(object.value, { signal }); if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) { throw new Error("Remote snapshot identity mismatch."); } validateSnapshotBundle(snapshot); return snapshot; } async publishSnapshot(snapshot, expected, options = {}) { throwIfAborted(options.signal); assertSnapshotIdentity(snapshot, this.config.destination.namespace); const stagedKey = snapshotKey(this.config, snapshot.id); const encoded = await encodeSnapshot(snapshot); throwIfAborted(options.signal); const pointer = pointerFor(this.config, snapshot, sha2562(encoded)); const cancellableClient = new S3Client(this.config, options.signal); try { await cancellableClient.putBuffer(stagedKey, encoded, "application/gzip", { ifAbsent: true }); } catch (error) { if (!(error instanceof S3ObjectAlreadyExistsError)) throw error; const existing = await cancellableClient.getBuffer(stagedKey); if (!existing.value || sha2562(existing.value) !== pointer.sha256) { throw new SyncBackendConflictError( `Immutable snapshot id already exists with different content: ${snapshot.id}` ); } } const currentObject = await cancellableClient.getJson(latestKey(this.config)); throwIfAborted(options.signal); let current; if (!currentObject.missing) { const currentPointer = requirePointer( currentObject.value, "Remote latest pointer is malformed.", this.config.destination.namespace ); this.registerChecksum(currentPointer.snapshot, currentPointer.sha256); current = remoteHead(currentPointer, this.identity, currentObject.etag); } if (!matchesExpected(current, expected)) { throw new SyncBackendConflictError( "Remote changed while pushing. Run /sync pull first, then retry.", { currentHead: current } ); } throwIfAborted(options.signal); options.onCommit?.(); const commitClient = new S3Client(this.config, AbortSignal.timeout(this.postCommitTimeoutMs)); try { await commitClient.putJson(latestKey(this.config), pointer); } catch (error) { throw new SyncBackendPublicationOutcomeUnknownError( `Remote publication outcome is unknown: ${errorMessage2(error)}`, { cause: error } ); } let verifiedObject; try { verifiedObject = await commitClient.getJson(latestKey(this.config)); } catch (error) { throw new SyncBackendPublicationOutcomeUnknownError( `Remote snapshot may be active, but publication could not be verified: ${errorMessage2(error)}`, { cause: error } ); } let verifiedPointer; try { verifiedPointer = requirePointer( verifiedObject.value, "Remote latest pointer is malformed after publication.", this.config.destination.namespace ); } catch (error) { throw new SyncBackendPublicationOutcomeUnknownError( `Remote snapshot may be active, but publication verification was malformed: ${errorMessage2(error)}`, { cause: error } ); } if (!samePointer(verifiedPointer, pointer)) { throw new SyncBackendConflictError( "Remote latest changed immediately after push. Run /sync status before continuing.", { phase: "after-commit", currentHead: remoteHead(verifiedPointer, this.identity, verifiedObject.etag), candidateMayHaveBeenActive: true } ); } this.registerChecksum(verifiedPointer.snapshot, verifiedPointer.sha256); const head = remoteHead(verifiedPointer, this.identity, verifiedObject.etag); const warning = await this.updateHistorySafely(commitClient, pointer); return { head, warnings: warning ? [warning] : [] }; } async listHistory(signal) { const object = await new S3Client(this.config, signal).getJson(historyKey(this.config)); throwIfAborted(signal); if (object.missing) return []; const pointers = requireHistory(object.value, this.config.destination.namespace); for (const pointer of pointers) { const known = this.checksums.get(pointer.snapshot); if (known && known !== pointer.sha256) { throw new Error("Remote history conflicts with an already observed snapshot checksum."); } } for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256); return pointers.map(remoteHistoryEntry); } async diagnose(signal) { throwIfAborted(signal); return [ { key: "s3-config", level: "info", message: `s3 config: ok (${this.config.destination.bucket}/${storageRoot(this.config)})` }, ...sessionTokenWarnings(this.config.profile).map((message) => ({ key: "s3-session-token", level: "warning", message })) ]; } async resolveChecksum(reference, signal) { await this.readHead(signal); const headChecksum = this.checksums.get(reference); if (headChecksum) return headChecksum; await this.listHistory(signal); return this.checksums.get(reference); } registerChecksum(reference, checksum) { const known = this.checksums.get(reference); if (known && known !== checksum) { throw new Error("Remote snapshot reference was rebound to a different checksum."); } this.checksums.set(reference, checksum); } async updateHistorySafely(client, pointer) { try { await this.updateHistory(client, pointer); return void 0; } catch (error) { return `Remote snapshot is active, but history could not be updated: ${errorMessage2(error)}. Run /sync doctor before relying on history.`; } } async updateHistory(client, pointer) { const object = await client.getJson( historyKey(this.config) ); const snapshots = object.missing ? [] : requireHistory(object.value, this.config.destination.namespace); const next = [ ...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot), pointer ].slice(-100); await client.putJson(historyKey(this.config), { version: VERSION, snapshots: next }); } }; function latestKey(config) { return posixJoin(storageRoot(config), "latest.json"); } function historyKey(config) { return posixJoin(storageRoot(config), "history.json"); } function snapshotKey(config, id) { requireSnapshotReference(id); return posixJoin(storageRoot(config), "snapshots", `${id}.json.gz`); } function storageRoot(config) { return config.destination.prefix; } function pointerFor(config, snapshot, checksum) { return { version: VERSION, profile: config.destination.namespace, snapshot: snapshot.id, sha256: checksum, createdAt: snapshot.createdAt, machine: snapshot.machine, syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")), ...snapshot.selection === void 0 ? {} : { selection: snapshot.selection } }; } function s3BackendIdentity(config) { const destination = JSON.stringify([ secretFreeEndpoint(config.profile.endpoint), trimSlashes(config.destination.bucket), trimSlashes(config.destination.prefix) ]); return `s3:${sha2562(Buffer.from(destination))}`; } function s3Destination(config) { let host = secretFreeEndpoint(config.profile.endpoint); try { host = new URL(host).hostname; } catch { host = "invalid S3 endpoint"; } return `${host} \xB7 ${config.destination.bucket}/${storageRoot(config)}`; } function secretFreeEndpoint(value) { const normalized = value.trim(); try { const url = new URL(normalized); url.username = ""; url.password = ""; return url.toString(); } catch { return normalized.replace(/\/\/[^/@\s]+@/u, "//"); } } function trimSlashes(value) { return value.replace(/^\/+|\/+$/g, ""); } function remoteHistoryEntry(pointer) { return { snapshotRef: pointer.snapshot, snapshotId: pointer.snapshot, createdAt: pointer.createdAt, machine: pointer.machine, syncSessions: pointer.syncSessions === true }; } function remoteHead(pointer, identity, etag) { return { snapshotRef: pointer.snapshot, snapshotId: pointer.snapshot, revision: `s3:${sha2562(Buffer.from(canonicalJson([identity, etag ?? null, pointer])))}`, createdAt: pointer.createdAt, machine: pointer.machine, syncSessions: pointer.syncSessions === true, ...pointer.selection === void 0 ? {} : { selection: pointer.selection } }; } function samePointer(left, right) { return canonicalJson(left) === canonicalJson(right); } function canonicalJson(value) { return JSON.stringify(canonicalValue(value)); } function canonicalValue(value) { if (Array.isArray(value)) return value.map(canonicalValue); if (!value || typeof value !== "object") return value; return Object.fromEntries( Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]) ); } function matchesExpected(head, expected) { if (expected.kind === "missing") return head === void 0; return head?.revision === expected.revision; } function requirePointer(value, message, expectedProfile) { if (!value || value.version !== VERSION || value.profile !== expectedProfile || typeof value.snapshot !== "string" || !isSafeSnapshotReference(value.snapshot) || typeof value.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(value.sha256) || typeof value.createdAt !== "string" || !isSafeMetadata(value.createdAt, 64) || typeof value.machine !== "string" || !isSafeMetadata(value.machine, 256) || value.syncSessions !== void 0 && typeof value.syncSessions !== "boolean") { throw new Error(message); } if (value.selection !== void 0) portableSnapshotSelection(value.selection); return value; } function requireHistory(value, expectedProfile) { if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) { throw new Error("Remote history is malformed."); } const pointers = value.snapshots.map( (pointer) => requirePointer(pointer, "Remote history entry is malformed.", expectedProfile) ); const references = /* @__PURE__ */ new Set(); for (const pointer of pointers) { if (references.has(pointer.snapshot)) { throw new Error("Remote history contains duplicate snapshot references."); } references.add(pointer.snapshot); } return pointers; } function assertSnapshotIdentity(snapshot, expectedProfile) { if (snapshot.version !== VERSION || snapshot.profile !== expectedProfile || !isSafeSnapshotReference(snapshot.id) || !isSafeMetadata(snapshot.createdAt, 64) || !isSafeMetadata(snapshot.machine, 256) || !Array.isArray(snapshot.files)) { throw new Error("Invalid snapshot identity for S3 publication."); } } function validateSnapshotBundle(snapshot) { const paths = /* @__PURE__ */ new Set(); for (const file of snapshot.files) { if (!file || typeof file.path !== "string" || typeof file.contentBase64 !== "string" || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.sha256) || paths.has(file.path)) { throw new Error("Remote snapshot bundle is malformed."); } const content = Buffer.from(file.contentBase64, "base64"); if (content.toString("base64") !== file.contentBase64 || sha2562(content) !== file.sha256) { throw new Error("Remote snapshot file checksum mismatch."); } paths.add(file.path); } } function assertSafeDestination(config) { for (const [label, value, allowEmpty] of [ ["bucket", config.destination.bucket, false], ["prefix", config.destination.prefix, true], ["namespace", config.destination.namespace, false] ]) { if (!allowEmpty && value.length === 0 || value.includes("\\") || hasControlCharacter(value) || value.split("/").some((segment) => segment === "." || segment === "..")) { throw new Error(`Invalid S3 storage location ${label}.`); } } } function requireSnapshotReference(reference) { if (!isSafeSnapshotReference(reference)) { throw new Error("Invalid S3 snapshot reference."); } } function isSafeSnapshotReference(reference) { return reference.length > 0 && reference.length <= 512 && reference !== "." && reference !== ".." && !reference.includes("/") && !reference.includes("\\") && !hasControlCharacter(reference); } function isSafeMetadata(value, maxLength) { return value.length > 0 && value.length <= maxLength && !hasControlCharacter(value); } function hasControlCharacter(value) { return [...value].some((character) => { const code = character.codePointAt(0) ?? 0; return code < 32 || code === 127; }); } function throwIfAborted(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } function sha2562(value) { return createHash2("sha256").update(value).digest("hex"); } function errorMessage2(error) { return error instanceof Error ? error.message : String(error); } export { S3SyncBackend, encodeKey, historyKey, latestKey, pointerFor, s3BackendIdentity, snapshotKey, storageRoot }; //# sourceMappingURL=s3-backend-3QZGO2EF.ts.map