/** * @factory/storage — thin S3 client over an injectable fetch (PURE logic + * one I/O seam). * * No SDK. Every request is signed by {@link signS3Request} (presign.ts) and * executed through `fetchLike` — global `fetch` in production, a fake in * tests. The S3/MinIO endpoint is never hardcoded; it comes from * {@link S3Config} (environment). * * Error mapping (ARCHITECTURE.md error model): * - 404 → StorageError, code STORAGE_ERROR, retryable=false * - other 4xx → StorageError, code STORAGE_ERROR, retryable=false * - 5xx → StorageError, code STORAGE_ERROR, retryable=true * - network/timeout → StorageError, code STORAGE_TIMEOUT, retryable=true */ import { sha256Hex, signS3Request, EMPTY_BODY_SHA256, buildUrl, canonicalQuery, } from "./presign.js"; import { objectRequestParts } from "./presign.js"; import type { S3Config, FetchLike, FetchResponse, FetchInit } from "./types.js"; import { StorageError } from "./types.js"; export interface S3ClientOptions { config: S3Config; /** Injectable fetch — defaults to global fetch. Inject a fake in tests. */ fetchLike?: FetchLike; /** Observability hook — called before every request and after every response. */ onRequest?: (info: { method: string; url: string; headers?: Record; status?: number; ok?: boolean; durationMs?: number; }) => void; /** Injectable clock (defaults to `new Date()`) — determinism in tests. */ now?: () => Date; } /** Parsed entries of a ListBucketResult response. */ export interface ListObjectsResult { keys: string[]; truncated: boolean; nextToken?: string; } /** Minimal PURE XML parser for S3 ListBucketResult — extracts values * (and //) without a DOM. * Exposed separately so it is testable against a fixture. */ export function parseListBucketXml(xml: string): ListObjectsResult { const keys: string[] = []; const inKey = /([\s\S]*?)<\/Key>/g; let m: RegExpExecArray | null; while ((m = inKey.exec(xml)) !== null) { keys.push(decodeXmlEntities(m[1]!.trim())); } const trunc = /(true|false)<\/IsTruncated>/.exec(xml); const truncated = trunc?.[1] === "true"; const next = /([\s\S]*?)<\/NextContinuationToken>/.exec(xml)?.[1] ?? /([\s\S]*?)<\/NextMarker>/.exec(xml)?.[1]; const out: ListObjectsResult = { keys, truncated }; if (next !== undefined && next.trim() !== "") out.nextToken = next.trim(); return out; } function decodeXmlEntities(s: string): string { return s .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&"); } function decodeXmlEntitiesSafe(s: string | undefined): string { return s === undefined ? "" : decodeXmlEntities(s); } /** Virtual-host style bucket URL (bucket becomes a subdomain of the host). */ function buildVirtualHostUrl( origin: string, bucket: string, query: Record, ): string { const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]+)(.*)$/i.exec(origin); if (!m) return `${origin}/${bucket}/?${canonicalQuery(query)}`; return `${m[1]}${bucket}.${m[2]!}${m[3] ?? ""}/?${canonicalQuery(query)}`; } function parseHeadResponse(resp: FetchResponse): { size: number; contentType: string; etag: string; lastModified: string; } { const h = resp.headers; const lower: Record = {}; for (const [k, v] of Object.entries(h)) lower[k.toLowerCase()] = v; const size = Number.parseInt(lower["content-length"] ?? "0", 10); return { size: Number.isFinite(size) ? size : 0, contentType: lower["content-type"] ?? "application/octet-stream", etag: lower["etag"] ?? "", lastModified: lower["last-modified"] ?? "", }; } /** Extract the ETag from a CreateMultipartUpload response. */ export function parseUploadIdXml(xml: string): string { return decodeXmlEntitiesSafe(/([\s\S]*?)<\/UploadId>/.exec(xml)?.[1] ?? "").trim(); } /** Build the CompleteMultipartUpload request XML (PURE). */ export function buildCompleteMultipartXml( parts: readonly { partNumber: number; etag: string }[], ): string { const entries = parts .slice() .sort((a, b) => a.partNumber - b.partNumber) .map( (p) => ` \n ${p.partNumber}\n "${p.etag}"\n `, ) .join("\n"); return `\n\n${entries}\n`; } /** * Adapt the global `fetch` to {@link FetchLike}: lower-cased header map, * the body fully read into a `Uint8Array`, and `json()`/`text()` over that * buffer (a `Response` stream can only be consumed once, so the adapter * reads it eagerly — object sizes here are bounded by the upload limits). */ async function globalFetchLike(input: string, init?: FetchInit): Promise { const res = await fetch(input, init ?? undefined); const headers: Record = {}; res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); const bodyBytes: Uint8Array = new Uint8Array(await res.arrayBuffer()); return { ok: res.ok, status: res.status, headers, body: bodyBytes.length > 0 ? bodyBytes : null, json(): Promise { return Promise.resolve(JSON.parse(new TextDecoder().decode(bodyBytes)) as T); }, text(): Promise { return Promise.resolve(new TextDecoder().decode(bodyBytes)); }, }; } export class S3Client { private readonly config: S3Config; private readonly fetchLike: FetchLike; private readonly onRequest?: S3ClientOptions["onRequest"]; private readonly now: () => Date; constructor(opts: S3ClientOptions) { this.config = opts.config; this.fetchLike = opts.fetchLike ?? globalFetchLike; this.onRequest = opts.onRequest; this.now = opts.now ?? (() => new Date()); } /** PUT an object. Signs for the exact payload hash; the body is the raw bytes. */ async putObject(key: string, body: Uint8Array | string, contentType: string): Promise { const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body; const payloadHash = sha256Hex(bytes); const { path } = objectRequestParts(this.config, key); const signed = this.sign("PUT", path, { "content-type": contentType }, payloadHash); const url = buildUrl(this.config, path); await this.send(url, { method: "PUT", headers: signed.headers, body: bytes, }); } /** GET an object (full body). */ async getObject(key: string): Promise<{ body: Uint8Array; contentType: string; size: number }> { const { path } = objectRequestParts(this.config, key); const signed = this.sign("GET", path, {}, EMPTY_BODY_SHA256); const url = buildUrl(this.config, path); const resp = await this.send(url, { method: "GET", headers: signed.headers, body: null }); const h: Record = {}; for (const [k, v] of Object.entries(resp.headers)) h[k.toLowerCase()] = v; const size = Number.parseInt(h["content-length"] ?? "0", 10); return { body: resp.body ?? new Uint8Array(0), contentType: h["content-type"] ?? "application/octet-stream", size: Number.isFinite(size) ? size : (resp.body?.length ?? 0), }; } /** * Read the FIRST `n` bytes of an object (HTTP range GET) — the MIME * magic-byte sniff path (TASK-0101 §4): the server downloads only the * first 8 KiB of an uploaded object to run `sniffMagicBytes`. * Returns the partial body plus the total object size from * `Content-Range` (so the caller can still verify the declared size). */ async readPrefix(key: string, n: number): Promise<{ prefix: Uint8Array; totalSize: number }> { const { path } = objectRequestParts(this.config, key); const signed = this.sign("GET", path, { range: `bytes=0-${n - 1}` }, EMPTY_BODY_SHA256); const url = buildUrl(this.config, path); const resp = await this.send(url, { method: "GET", headers: signed.headers, body: null }); const h: Record = {}; for (const [k, v] of Object.entries(resp.headers)) h[k.toLowerCase()] = v; // Content-Range: bytes 0-7/482113 (total may be "*" if unknown). const m = /bytes\s+\d+-\d+\/(\d+|\*)/.exec(h["content-range"] ?? ""); const total = m?.[1] !== undefined && m[1] !== "*" ? Number.parseInt(m[1], 10) : NaN; return { prefix: resp.body ?? new Uint8Array(0), totalSize: Number.isFinite(total) ? total : (resp.body?.length ?? 0), }; } /** HEAD an object (metadata only). */ async headObject(key: string): Promise<{ size: number; contentType: string; etag: string; lastModified: string; }> { const { path } = objectRequestParts(this.config, key); const signed = this.sign("HEAD", path, {}, EMPTY_BODY_SHA256); const url = buildUrl(this.config, path); const resp = await this.send(url, { method: "HEAD", headers: signed.headers, body: null }); return parseHeadResponse(resp); } /** DELETE an object (idempotent — S3 deletes missing objects with 204). */ async deleteObject(key: string): Promise { const { path } = objectRequestParts(this.config, key); const signed = this.sign("DELETE", path, {}, EMPTY_BODY_SHA256); const url = buildUrl(this.config, path); await this.send(url, { method: "DELETE", headers: signed.headers, body: null }); } /** List objects under a prefix (GET bucket?list-type=2&prefix=…). * * Pagination: pass the previous page's `nextToken` as `continuationToken` * (TASK-0104 orphan sweeper — a full `files/` sweep must page past the * 1000-key cap, not silently truncate). */ async listObjects(prefix: string, maxKeys?: number, continuationToken?: string): Promise { const query: Record = { "list-type": "2", prefix }; if (maxKeys !== undefined) query["max-keys"] = String(maxKeys); if (continuationToken !== undefined) query["continuation-token"] = continuationToken; // List is a bucket-level operation. Path-style puts the bucket in the // path; virtual-host puts it in the host and the canonical path is "/". const origin = this.config.endpoint.replace(/\/+$/, ""); const vh = this.config.forcePathStyle !== true; const path = vh ? "/" : `/${this.config.bucket}`; const signed = this.sign("GET", path, {}, EMPTY_BODY_SHA256, query); const url = vh ? buildVirtualHostUrl(origin, this.config.bucket, query) : buildUrl(this.config, path, query); const resp = await this.send(url, { method: "GET", headers: signed.headers, body: null }); const xml = await resp.text(); return parseListBucketXml(xml); } /** Start a multipart upload → UploadId. (Large uploads use multipart.) */ async createMultipartUpload(key: string): Promise<{ uploadId: string }> { const { path } = objectRequestParts(this.config, key); const query: Record = { uploads: "" }; const signed = this.sign("POST", path, {}, EMPTY_BODY_SHA256, query); const url = buildUrl(this.config, path, query); const resp = await this.send(url, { method: "POST", headers: signed.headers, body: null }); const xml = await resp.text(); return { uploadId: parseUploadIdXml(xml) }; } /** Upload one part of a multipart upload → ETag. */ async uploadPart( key: string, uploadId: string, partNumber: number, body: Uint8Array | string, ): Promise<{ partNumber: number; etag: string }> { const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body; const payloadHash = sha256Hex(bytes); const { path } = objectRequestParts(this.config, key); const query: Record = { partNumber: String(partNumber), uploadId }; const signed = this.sign("PUT", path, {}, payloadHash, query); const url = buildUrl(this.config, path, query); const resp = await this.send(url, { method: "PUT", headers: signed.headers, body: bytes, }); const h: Record = {}; for (const [k, v] of Object.entries(resp.headers)) h[k.toLowerCase()] = v; const etag = (h["etag"] ?? "").replace(/^"|"$/g, ""); return { partNumber, etag }; } /** Complete a multipart upload (assembles the parts server-side). */ async completeMultipartUpload( key: string, uploadId: string, parts: readonly { partNumber: number; etag: string }[], ): Promise { const xml = buildCompleteMultipartXml(parts); const bytes = new TextEncoder().encode(xml); const payloadHash = sha256Hex(bytes); const { path } = objectRequestParts(this.config, key); const query: Record = { uploadId }; // content-type is part of the signed headers — sign it, don't add it after. const signed = this.sign( "POST", path, { "content-type": "application/xml" }, payloadHash, query, ); const url = buildUrl(this.config, path, query); await this.send(url, { method: "POST", headers: signed.headers, body: bytes, }); } // ── internals ────────────────────────────────────────────────────────── private sign( method: string, path: string, headers: Record, payloadHash: string, query?: Record, ): ReturnType { return signS3Request({ method, path, query: query ?? {}, headers, payloadHash, config: this.config, now: this.now(), // Authenticated header-signed requests MUST carry x-amz-date + // x-amz-content-sha256 in the signed set (AWS SigV4 requirement). alwaysSignContentSha: true, }); } private async send(url: string, init: FetchInit): Promise { const t0 = typeof performance !== "undefined" ? performance.now() : Date.now(); this.onRequest?.({ method: init.method ?? "GET", url }); let resp: FetchResponse; try { resp = await this.fetchLike(url, init); } catch (err) { const durationMs = typeof performance !== "undefined" ? performance.now() - t0 : 0; this.onRequest?.({ method: init.method ?? "GET", url, durationMs, ok: false, status: 0, }); throw new StorageError({ message: `storage request failed: ${(err as Error)?.message ?? "network error"}`, code: "STORAGE_TIMEOUT", retryable: true, cause: err, }); } const durationMs = typeof performance !== "undefined" ? performance.now() - t0 : 0; this.onRequest?.({ method: init.method ?? "GET", url, status: resp.status, ok: resp.ok, durationMs, }); if (!resp.ok) { const message = await safeErrorBody(resp, url); const retryable = resp.status >= 500 || resp.status === 429; throw new StorageError({ message: `storage ${init.method ?? "GET"} ${url} → ${resp.status}: ${message}`, code: retryable ? "STORAGE_ERROR" : "STORAGE_ERROR", status: resp.status, retryable, }); } return resp; } } async function safeErrorBody(resp: FetchResponse, url: string): Promise { try { if (resp.body && resp.body.length > 0) { const text = new TextDecoder("utf-8", { fatal: false }).decode(resp.body); const code = /([\s\S]*?)<\/Code>/.exec(text)?.[1] ?? text.slice(0, 200); return code.trim() || `HTTP ${resp.status}`; } } catch { // fall through } return `HTTP ${resp.status} on ${url.split("/").slice(-1)[0] ?? ""}`; }