// @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