import { s3Headers } from './headers'; const escapeXml = (str: string): string => str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); const encodeKey = (value: string, encodingType: string | null = null): string => encodingType === 'url' ? encodeURIComponent(value) : escapeXml(value); // ─────── Bucket operations ─────── export const listBucketsXml = ( buckets: { name: string; createdAt: Date }[], _requestId: string, ): string => ` ${buckets .map( (b) => ` ${escapeXml(b.name)} ${isoDate(b.createdAt)} `, ) .join('')} `; export const bucketVersioningConfigurationXml = (): string => ` `; // ─────── Object listing ─────── export const listBucketResultXml = ( bucketName: string, objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[], prefixes: string[], isTruncated: boolean, marker: string | null, maxKeys: number, prefix: string, delimiter: string | null, nextMarker: string | null, _requestId: string, encodingType: string | null = null, ): string => ` ${escapeXml(bucketName)} ${encodeKey(prefix, encodingType)} ${encodeKey(marker || '', encodingType)} ${maxKeys} ${encodeKey(delimiter || '', encodingType)} ${encodingType ? `${escapeXml(encodingType)}` : ''} ${isTruncated} ${objects .map( (o) => ` ${encodeKey(o.key, encodingType)} ${isoDate(o.lastModified)} "${o.etag}" ${o.sizeBytes} STANDARD `, ) .join('')} ${prefixes .map( (p) => ` ${encodeKey(p, encodingType)} `, ) .join('')} ${nextMarker ? `${encodeKey(nextMarker, encodingType)}` : ''} `; export const listBucketV2ResultXml = ( bucketName: string, objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[], prefixes: string[], isTruncated: boolean, maxKeys: number, prefix: string, delimiter: string | null, continuationToken: string | null, nextContinuationToken: string | null, keyCount: number, _requestId: string, encodingType: string | null = null, ): string => ` ${escapeXml(bucketName)} ${encodeKey(prefix, encodingType)} ${maxKeys} ${keyCount} ${delimiter ? `${encodeKey(delimiter, encodingType)}` : ''} ${encodingType ? `${escapeXml(encodingType)}` : ''} ${continuationToken ? `${encodeKey(continuationToken, encodingType)}` : ''} ${isTruncated} ${objects .map( (o) => ` ${encodeKey(o.key, encodingType)} ${isoDate(o.lastModified)} "${o.etag}" ${o.sizeBytes} STANDARD `, ) .join('')} ${prefixes .map( (p) => ` ${encodeKey(p, encodingType)} `, ) .join('')} ${nextContinuationToken ? `${encodeKey(nextContinuationToken, encodingType)}` : ''} `; // ─────── Multipart ─────── export const initiateMultipartUploadXml = ( bucketName: string, key: string, uploadId: string, ): string => ` ${escapeXml(bucketName)} ${escapeXml(key)} ${uploadId} `; export const listPartsXml = ( bucketName: string, key: string, uploadId: string, parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[], maxParts: number, isTruncated: boolean, _requestId: string, ): string => ` ${escapeXml(bucketName)} ${escapeXml(key)} ${uploadId} ${maxParts} ${isTruncated} ${parts .map( (p) => ` ${p.partNumber} ${isoDate(p.createdAt)} "${p.etag}" ${p.sizeBytes} `, ) .join('')} `; export const listMultipartUploadsXml = ( bucketName: string, uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[], maxUploads: number, isTruncated: boolean, nextKeyMarker: string | null, _requestId: string, ): string => ` ${escapeXml(bucketName)} ${nextKeyMarker ? `${escapeXml(nextKeyMarker)}` : ''} ${maxUploads} ${isTruncated} ${uploads .map( (u) => ` ${escapeXml(u.key)} ${u.uploadId} ${escapeXml(u.initiatedBy || 's3')}${escapeXml(u.initiatedBy || 's3')} ${escapeXml(u.initiatedBy || 's3')}${escapeXml(u.initiatedBy || 's3')} STANDARD ${isoDate(u.initiatedAt)} `, ) .join('')} `; export const completeMultipartUploadXml = ( bucketName: string, key: string, etag: string, location: string, ): string => ` ${escapeXml(location)} ${escapeXml(bucketName)} ${escapeXml(key)} "${etag}" `; // ─────── Delete result ─────── export const deleteResultXml = ( deleted: string[], errors: { key: string; code: string; message: string }[], ): string => ` ${deleted .map( (key) => ` ${escapeXml(key)} `, ) .join('')} ${errors .map( (e) => ` ${escapeXml(e.key)} ${e.code} ${escapeXml(e.message)} `, ) .join('')} `; // ─────── Copy ─────── export const copyObjectResultXml = ( etag: string, lastModified: Date, ): string => ` "${etag}" ${isoDate(lastModified)} `; // ─────── Error ─────── export const s3ErrorXml = ( code: string, message: string, resource: string, requestId: string, ): string => ` ${code} ${escapeXml(message)} ${escapeXml(resource)} ${requestId} ${requestId} `; export const s3ErrorResponse = ( code: string, message: string, resource: string, status: number, requestId: string = '', extraHeaders: Record = {}, ): Response => new Response(s3ErrorXml(code, message, resource, requestId), { status, headers: s3Headers(requestId, { 'content-type': 'application/xml', ...extraHeaders, }), }); // ─────── DeleteObjects XML parser ─────── export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => { // H9: Use non-greedy match to handle keys containing < character const keys = Array.from(body.matchAll(/([\s\S]*?)<\/Key>/g), (match) => match[1]); // Handle whitespace inside element + namespace prefix support const quiet = /<\w*:?Quiet\w*>\s*true\s*<\/\w*:?Quiet\w*>/i.test(body); return { keys, quiet }; }; // ─────── CompleteMultipartUpload XML parser ─────── export interface CompletePart { partNumber: number; etag: string; } export const parseCompleteMultipartBody = (body: string): CompletePart[] => { const parts: CompletePart[] = []; const partRegex = /[\s\S]*?<\/Part>/g; const partMatch = body.match(partRegex) || []; for (const partXml of partMatch) { const numMatch = partXml.match(/(\d+)<\/PartNumber>/); const etagMatch = partXml.match(/"?([^"<\s]+)"?<\/ETag>/); if (numMatch && etagMatch) { parts.push({ partNumber: Number.parseInt(numMatch[1], 10), etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''), }); } } return parts; };