Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,8 @@ operations. The following are **not** currently supported (or are partial):
(`?acl`), website (`?website`), public access block (`?publicAccessBlock`),
replication, logging, inventory, metrics, ownership controls.
- **Object subresources:** Object ACLs, tagging, legal hold, retention (Object
Lock), S3 Select. Copy Object (`x-amz-copy-source`) and Multi-Object Delete
(`POST ?delete`) are not implemented.
Lock), S3 Select. Multi-Object Delete (`POST ?delete`) is not implemented.
Copy Object (`PUT` with `x-amz-copy-source`) is supported.
- **Object operations:** GetObjectAttributes (`?attributes`) is not implemented.
Checksum headers (`x-amz-checksum-*`) and conditional requests (`If-Match`,
etc.) are not fully supported.
Expand Down
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ implementation.
- [ ] **Unicode Metadata**: Fix support for non-ASCII characters in object
metadata. Currently failing across all backends. _(Focus tests:
`test_object_set_get_unicode_metadata`)_
- [ ] **Copy Object**: Support for `PUT` with `x-amz-copy-source` header.
- [x] **Copy Object**: Support for `PUT` with `x-amz-copy-source` header.
_(Focus tests: `test_object_copy`)_
- [ ] **Tagging**: Implementation of `GET/PUT/DELETE /?tagging` for objects.
_(Focus tests: `test_object_tagging`)_
Expand All @@ -88,7 +88,7 @@ implementation.
- [ ] **Checksums**: Support for `x-amz-checksum-sha1`, `x-amz-checksum-sha256`,
`x-amz-checksum-crc32`, and `x-amz-checksum-crc32c`. Currently failing
validation tests. _(Focus tests: `test_object_checksum_sha256`)_
- [ ] **Fix S3 Buffering**: Refactor S3 `putObject` and `uploadPart` to stream
- [x] **Fix S3 Buffering**: Refactor S3 `putObject` and `uploadPart` to stream
directly to the AWS SDK instead of collecting chunks into a
`Uint8Array`.
- [ ] **Fix Swift Validation Timing**: Move Swift checksum validation before
Expand Down
1 change: 1 addition & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@smithy/types": "npm:@smithy/types@^3.7.0",
"@aws-crypto/sha256": "npm:@aws-crypto/sha256-js@^5.2.0",
"@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.x",
"@aws-sdk/s3-request-presigner": "npm:@aws-sdk/s3-request-presigner@^3.x",
"effect": "npm:effect@^3.17.7",
"xml2js": "npm:xml2js@0.6.2",
"node-http": "node:http",
Expand Down
24 changes: 24 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions src/Backends/S3/Objects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CopyObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectAttributesCommand,
Expand Down Expand Up @@ -251,6 +252,7 @@ export const makeObjectOps = (
Key: key,
Range: normalized["range"],
PartNumber: s3Params.partNumber,
VersionId: s3Params.versionId,
ChecksumMode: s3Params.checksumMode as "ENABLED",
IfMatch: normalized["if-match"],
IfNoneMatch: normalized["if-none-match"],
Expand Down Expand Up @@ -649,4 +651,40 @@ export const makeObjectOps = (
storageClass: result.StorageClass,
};
}),

copyObject: (
sourceKey: string,
destKey: string,
metadataDirective: "COPY" | "REPLACE",
headers: Record<string, string | string[] | undefined>,
sourceBucket?: string,
) =>
Effect.gen(function* () {
const srcBucket = sourceBucket || bucketName;
const { s3Params, metadata } = headerService.fromRequestHeaders(headers);

const result = yield* Effect.tryPromise({
try: () =>
client.send(
new CopyObjectCommand({
Bucket: bucketName,
Key: destKey,
CopySource: `${encodeURIComponent(srcBucket)}/${
encodeURIComponent(
sourceKey,
)
}${s3Params.versionId ? `?versionId=${s3Params.versionId}` : ""}`,
MetadataDirective: metadataDirective,
Metadata: metadataDirective === "REPLACE" ? metadata : undefined,
}),
),
catch: (e) => mapS3Error(e, bucketName),
});

return {
etag: result.CopyObjectResult?.ETag,
versionId: result.VersionId,
lastModified: result.CopyObjectResult?.LastModified,
};
}),
});
139 changes: 121 additions & 18 deletions src/Backends/Swift/Objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,41 @@ import {
type SwiftTarget,
} from "./Utils.ts";

/**
* Resolves Content-Type from Swift response headers with multiple fallbacks.
*/
function resolveContentType(
response: HttpClientResponse.HttpClientResponse,
normalizedResp: Record<string, string | undefined>,
s3Headers: Record<string, string>,
): string | undefined {
let contentType = normalizedResp["content-type"];

// Platform may wrap Fetch Response; try native Response.headers first (case-insensitive get).
if (
contentType === undefined &&
(response as unknown as { source?: unknown }).source instanceof Response
) {
const src = (response as unknown as { source: Response }).source;
contentType = src.headers.get("content-type") ?? undefined;
}

if (contentType === undefined) {
const h = response.headers as unknown as {
get?: (n: string) => string | null;
};
if (typeof h.get === "function") {
contentType = h.get("content-type") ?? h.get("Content-Type") ?? undefined;
}
}

if (contentType === undefined) {
contentType = s3Headers["Content-Type"] ?? s3Headers["content-type"];
}

return contentType;
}

export interface SwiftObject {
readonly name?: string;
readonly hash?: string;
Expand Down Expand Up @@ -315,24 +350,17 @@ export const makeObjectOps = (
);
}

const normalizedResp = normalizeHeaders(response.headers);
const { metadata, s3Headers, checksums, partsCount } = headerService
.fromSwiftHeaders(response.headers);
.fromSwiftHeaders(normalizedResp);

const contentLengthHeader = response.headers["content-length"];
const contentLengthRaw = Array.isArray(contentLengthHeader)
? contentLengthHeader[0]
: contentLengthHeader;
const contentLengthRaw = normalizedResp["content-length"];
const contentLength = contentLengthRaw
? parseInt(contentLengthRaw, 10)
: NaN;

const etagHeader = response.headers["etag"];
const etag = Array.isArray(etagHeader) ? etagHeader[0] : etagHeader;

const lastModifiedHeader = response.headers["last-modified"];
const lastModified = Array.isArray(lastModifiedHeader)
? lastModifiedHeader[0]
: lastModifiedHeader;
const etag = normalizedResp["etag"];
const lastModified = normalizedResp["last-modified"];

// S3 clients (e.g. Restate) require Content-Length on GetObject; match old impl and fail if Swift omits it
if (
Expand Down Expand Up @@ -379,12 +407,16 @@ export const makeObjectOps = (
? (response as unknown as { source: Response }).source.body
: undefined;

const contentType = resolveContentType(
response,
normalizedResp,
s3Headers,
);

return {
stream: response.stream,
nativeStream: nativeStream || undefined,
contentType: (Array.isArray(response.headers["content-type"])
? response.headers["content-type"][0]
: response.headers["content-type"]) || undefined,
contentType,
contentLength,
etag: etag || undefined,
lastModified: lastModified ? new Date(lastModified) : undefined,
Expand Down Expand Up @@ -418,8 +450,6 @@ export const makeObjectOps = (

const swiftHeaders: Record<string, string> = {
"X-Auth-Token": token,
"Content-Type": (normalized["content-type"] ||
"application/octet-stream") as string,
...headerService.toSwiftHeaders(metadata, checksums),
};

Expand Down Expand Up @@ -469,8 +499,13 @@ export const makeObjectOps = (
: validatedStream;

const request = HttpClientRequest.put(`${url}/${encodedKey}`).pipe(
HttpClientRequest.setHeaders(swiftHeaders),
HttpClientRequest.bodyStream(bodyStream),
HttpClientRequest.setHeaders(swiftHeaders),
HttpClientRequest.setHeader(
"Content-Type",
(normalized["content-type"] ||
"application/octet-stream") as string,
),
);

const response: HttpClientResponse.HttpClientResponse = yield* client
Expand Down Expand Up @@ -761,5 +796,73 @@ export const makeObjectOps = (

return result;
}),

copyObject: (
sourceKey: string,
destKey: string,
metadataDirective: "COPY" | "REPLACE",
headers: Record<string, string | string[] | undefined>,
sourceBucket?: string,
) => {
const encodedDestKey = encodeObjectKeyForSwift(destKey);
const srcBucket = sourceBucket || container;
const srcPath = `/${srcBucket}/${encodeObjectKeyForSwift(sourceKey)}`;

return Effect.gen(function* () {
const { checksums, metadata } = headerService.fromRequestHeaders(
headers,
);
const normalized = normalizeHeaders(headers);

const swiftHeaders: Record<string, string> = {
"X-Auth-Token": token,
"X-Copy-From": srcPath,
"Content-Length": "0", // Swift COPY/X-Copy-From requires 0 length or no body
};

if (metadataDirective === "REPLACE") {
swiftHeaders["X-Fresh-Metadata"] = "True";
swiftHeaders["content-type"] = (normalized["content-type"] ||
"application/octet-stream") as string;
Object.assign(
swiftHeaders,
headerService.toSwiftHeaders(metadata, checksums),
);
}

const request = HttpClientRequest.put(`${url}/${encodedDestKey}`).pipe(
HttpClientRequest.setHeaders(swiftHeaders),
);

const response: HttpClientResponse.HttpClientResponse = yield* client
.execute(request).pipe(
Effect.mapError((e) =>
mapError(500, formatSwiftTransportError(e), container)
),
);

if (response.status < 200 || response.status >= 300) {
const message = yield* response.text.pipe(
Effect.orElseSucceed(() => "Error"),
);
return yield* Effect.fail(
mapError(
response.status,
message || "Error",
container,
"PUT",
destKey,
),
);
}

const etagHeader = response.headers["etag"];
const etag = Array.isArray(etagHeader) ? etagHeader[0] : etagHeader;

return {
etag: etag || undefined,
};
});
},
};
};
2 changes: 1 addition & 1 deletion src/Frontend/Multipart/Delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ export const abortMultipartUpload = Effect.gen(function* () {
yield* backend.abortMultipartUpload(key, s3Params.uploadId);
return HttpServerResponse.empty({
status: 204,
headers: { "Content-Length": "0" },
headers: {},
});
});
3 changes: 0 additions & 3 deletions src/Frontend/Multipart/Put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,6 @@ export const uploadPart = Effect.gen(function* () {
);

const headers = headerService.toResponseHeaders(result);
if (headers["Content-Length"] === undefined) {
headers["Content-Length"] = "0";
}
return HttpServerResponse.empty({
status: 200,
headers,
Expand Down
2 changes: 1 addition & 1 deletion src/Frontend/Objects/Delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ export const deleteObject = Effect.gen(function* () {
yield* backend.deleteObject(key);
return HttpServerResponse.empty({
status: 204,
headers: { "Content-Length": "0" },
headers: {},
});
});
Loading
Loading