Something went wrong. Try again.
A nicer S3 CLI
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166import * as HTTP from 'node:http';import { createHash, randomUUID } from 'node:crypto';import { buffer } from 'node:stream/consumers';
// A small S3, in memory, path-style, for the end-to-end tests: buckets,// objects, ListObjectsV2 (prefix, delimiter, paging), copy and multipart.// Requests are not checked for a valid signature — the vendored client's own// tests verify its signer against AWS's vectors — only for being signed at// all. It answers in S3's XML; what it is sent, it reads as bytes or query// parameters, never as XML.
interface StoredObject { body: Buffer; type: string; modified: Date; etag: string;}
export interface FakeS3 { endpoint: string; buckets: Map<string, Map<string, StoredObject>>; requests: string[]; close(): Promise<void>;}
const esc = (text: string) => text.replace(/[&<>"']/g, (char) => `&${({ '&': 'amp', '<': 'lt', '>': 'gt', '"': 'quot', "'": 'apos' } as Record<string, string>)[char]};`);const md5 = (body: Buffer) => createHash('md5').update(body).digest('hex');
export async function startFakeS3(options: { pageSize?: number } = {}): Promise<FakeS3> { const pageSize = options.pageSize ?? 1000; const buckets = new Map<string, Map<string, StoredObject>>(); const uploads = new Map<string, { bucket: string; key: string; type: string; parts: Map<number, Buffer> }>(); const requests: string[] = [];
const server = HTTP.createServer(async (req, res) => { const body = await buffer(req); const url = new URL(req.url ?? '/', 'http://fake'); const [, bucketName = '', ...rest] = url.pathname.split('/'); const bucket = decodeURIComponent(bucketName); const key = rest.map(decodeURIComponent).join('/'); const method = req.method ?? 'GET'; requests.push(`${method} ${url.pathname}${url.search}`); const send = (status: number, xml?: string, headers: Record<string, string> = {}) => { res.writeHead(status, { ...(xml ? { 'content-type': 'application/xml' } : {}), ...headers }); res.end(xml ? `<?xml version="1.0" encoding="UTF-8"?>\n${xml}` : undefined); }; const error = (status: number, code: string, message = code) => send(status, `<Error><Code>${code}</Code><Message>${esc(message)}</Message></Error>`);
if (!String(req.headers['authorization'] ?? '').startsWith('AWS4-HMAC-SHA256 ')) return error(403, 'AccessDenied', 'unsigned');
if (!bucket) { if (method !== 'GET') return error(405, 'MethodNotAllowed'); const list = [...buckets.keys()].sort().map((name) => `<Bucket><Name>${esc(name)}</Name><CreationDate>2026-01-01T00:00:00.000Z</CreationDate></Bucket>`).join(''); return send(200, `<ListAllMyBucketsResult><Buckets>${list}</Buckets></ListAllMyBucketsResult>`); } const objects = buckets.get(bucket);
if (!key) { if (method === 'PUT') { if (objects) return error(409, 'BucketAlreadyOwnedByYou'); buckets.set(bucket, new Map()); return send(200); } if (!objects) return method === 'HEAD' ? send(404) : error(404, 'NoSuchBucket'); if (method === 'HEAD') return send(200, undefined, { 'x-amz-bucket-region': 'us-east-1' }); if (method === 'DELETE') { if (objects.size) return error(409, 'BucketNotEmpty'); buckets.delete(bucket); return send(204); } if (method === 'GET') { const prefix = url.searchParams.get('prefix') ?? ''; const delimiter = url.searchParams.get('delimiter') ?? ''; const after = url.searchParams.get('continuation-token') ?? ''; const entries: { key: string; prefix: boolean }[] = []; const seen = new Set<string>(); for (const name of [...objects.keys()].sort()) { if (!name.startsWith(prefix)) continue; const cut = delimiter ? name.indexOf(delimiter, prefix.length) : -1; if (cut >= 0) { const common = name.slice(0, cut + delimiter.length); if (!seen.has(common)) { seen.add(common); entries.push({ key: common, prefix: true }); } } else entries.push({ key: name, prefix: false }); } const remaining = entries.filter((entry) => entry.key > after); const page = remaining.slice(0, pageSize); const truncated = remaining.length > page.length; const contents = page.filter((entry) => !entry.prefix).map((entry) => { const object = objects.get(entry.key)!; return `<Contents><Key>${esc(entry.key)}</Key><LastModified>${object.modified.toISOString()}</LastModified><ETag>"${object.etag}"</ETag><Size>${object.body.length}</Size><StorageClass>STANDARD</StorageClass></Contents>`; }).join(''); const prefixes = page.filter((entry) => entry.prefix).map((entry) => `<CommonPrefixes><Prefix>${esc(entry.key)}</Prefix></CommonPrefixes>`).join(''); return send(200, `<ListBucketResult><Name>${esc(bucket)}</Name><Prefix>${esc(prefix)}</Prefix><KeyCount>${page.length}</KeyCount><IsTruncated>${truncated}</IsTruncated>${truncated ? `<NextContinuationToken>${esc(page[page.length - 1]!.key)}</NextContinuationToken>` : ''}${contents}${prefixes}</ListBucketResult>`); } return error(405, 'MethodNotAllowed'); }
if (!objects) return error(404, 'NoSuchBucket'); const uploadId = url.searchParams.get('uploadId'); if (method === 'POST' && url.searchParams.has('uploads')) { const id = randomUUID(); uploads.set(id, { bucket, key, type: String(req.headers['content-type'] ?? 'application/octet-stream'), parts: new Map() }); return send(200, `<InitiateMultipartUploadResult><Bucket>${esc(bucket)}</Bucket><Key>${esc(key)}</Key><UploadId>${id}</UploadId></InitiateMultipartUploadResult>`); } if (uploadId) { const upload = uploads.get(uploadId); if (!upload) return error(404, 'NoSuchUpload'); if (method === 'PUT') { upload.parts.set(Number(url.searchParams.get('partNumber')), body); return send(200, undefined, { etag: `"${md5(body)}"` }); } if (method === 'POST') { const whole = Buffer.concat([...upload.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part)); const etag = `${md5(whole)}-${upload.parts.size}`; objects.set(key, { body: whole, type: upload.type, modified: new Date(), etag }); uploads.delete(uploadId); return send(200, `<CompleteMultipartUploadResult><Key>${esc(key)}</Key><ETag>"${etag}"</ETag></CompleteMultipartUploadResult>`); } if (method === 'DELETE') { uploads.delete(uploadId); return send(204); } } const object = objects.get(key); switch (method) { case 'PUT': { const source = req.headers['x-amz-copy-source']; if (typeof source === 'string') { const [, fromBucket = '', ...fromKey] = source.split('/').map(decodeURIComponent); const from = buckets.get(fromBucket)?.get(fromKey.join('/')); if (!from) return error(404, 'NoSuchKey'); objects.set(key, { ...from, modified: new Date() }); return send(200, `<CopyObjectResult><ETag>"${from.etag}"</ETag><LastModified>${new Date().toISOString()}</LastModified></CopyObjectResult>`); } const etag = md5(body); objects.set(key, { body, type: String(req.headers['content-type'] ?? 'application/octet-stream'), modified: new Date(), etag }); return send(200, undefined, { etag: `"${etag}"` }); } case 'GET': case 'HEAD': if (!object) return method === 'HEAD' ? send(404) : error(404, 'NoSuchKey'); res.writeHead(200, { 'content-type': object.type, 'content-length': String(object.body.length), etag: `"${object.etag}"`, 'last-modified': object.modified.toUTCString() }); return res.end(method === 'GET' ? object.body : undefined); case 'DELETE': objects.delete(key); return send(204); } return error(405, 'MethodNotAllowed'); }); server.listen(0, '127.0.0.1'); await new Promise((resolve) => server.once('listening', resolve)); const { port } = server.address() as { port: number }; return { endpoint: `http://127.0.0.1:${port}`, buckets, requests, close: () => new Promise((resolve) => { server.closeAllConnections(); server.close(() => resolve()); }), };}