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;
};