From 0d2d354d91baedc6e16599ca8e45409cf55d01b6 Mon Sep 17 00:00:00 2001 From: Inqnuam Date: Fri, 31 Jan 2025 23:44:37 +0100 Subject: [PATCH] feat(s3Client): add support for ListObjectsV2 action --- packages/bun-types/bun.d.ts | 108 +++ src/bun.js/api/S3Client.classes.ts | 8 + src/bun.js/webcore/S3Client.zig | 26 + src/bun.js/webcore/blob.zig | 52 ++ src/s3/client.zig | 125 +++ src/s3/credentials.zig | 520 +++++++++--- src/s3/list_objects.zig | 559 +++++++++++++ src/s3/simple_request.zig | 34 + test/js/bun/s3/s3-list-objects.test.ts | 1001 ++++++++++++++++++++++++ 9 files changed, 2307 insertions(+), 126 deletions(-) create mode 100644 src/s3/list_objects.zig create mode 100644 test/js/bun/s3/s3-list-objects.test.ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index bfe9476bef6aff..d46e8e57e0f6b6 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1813,6 +1813,106 @@ declare module "bun" { * const url = bucket.presign("file.pdf"); * await bucket.unlink("old.txt"); */ + interface S3ListObjectsOptions { + /** Limits the response to keys that begin with the specified prefix. */ + Prefix?: string; + /** ContinuationToken indicates to S3 that the list is being continued on this bucket with a token. ContinuationToken is obfuscated and is not a real key. You can use this ContinuationToken for pagination of the list results. */ + ContinuationToken?: string; + /** A delimiter is a character that you use to group keys. */ + Delimiter?: string; + /** Sets the maximum number of keys returned in the response. By default, the action returns up to 1,000 key names. The response might contain fewer keys but will never contain more. */ + MaxKeys?: number; + /** StartAfter is where you want S3 to start listing from. S3 starts listing after this specified key. StartAfter can be any key in the bucket. */ + StartAfter?: string; + /** Encoding type used by S3 to encode the object keys in the response. Responses are encoded only in UTF-8. An object key can contain any Unicode character. However, the XML 1.0 parser can't parse certain characters, such as characters with an ASCII value from 0 to 10. For characters that aren't supported in XML 1.0, you can add this parameter to request that S3 encode the keys in the response. */ + EncodingType?: "url"; + /** If you want to return the owner field with each key in the result, then set the FetchOwner field to true. */ + FetchOwner?: boolean; + } + + interface S3ListObjectsResponse { + /** All of the keys (up to 1,000) that share the same prefix are grouped together. When counting the total numbers of returns by this API operation, this group of keys is considered as one item. + * + * A response can contain CommonPrefixes only if you specify a delimiter. + * + * CommonPrefixes contains all (if there are any) keys between Prefix and the next occurrence of the string specified by a delimiter. + * + * CommonPrefixes lists keys that act like subdirectories in the directory specified by Prefix. + * + * For example, if the prefix is notes/ and the delimiter is a slash (/) as in notes/summer/july, the common prefix is notes/summer/. All of the keys that roll up into a common prefix count as a single return when calculating the number of returns. */ + CommonPrefixes?: { Prefix: string }[]; + /** Metadata about each object returned. */ + Contents?: { + /** The algorithm that was used to create a checksum of the object. */ + ChecksumAlgorithm?: "CRC32" | "CRC32C" | "SHA1" | "SHA256" | "CRC64NVME"; + /** The checksum type that is used to calculate the object’s checksum value. */ + ChecksumType?: "COMPOSITE" | "FULL_OBJECT"; + /** + * The entity tag is a hash of the object. The ETag reflects changes only to the contents of an object, not its metadata. The ETag may or may not be an MD5 digest of the object data. Whether or not it is depends on how the object was created and how it is encrypted as described below: + * + * - Objects created by the PUT Object, POST Object, or Copy operation, or through the AWS Management Console, and are encrypted by SSE-S3 or plaintext, have ETags that are an MD5 digest of their object data. + * - Objects created by the PUT Object, POST Object, or Copy operation, or through the AWS Management Console, and are encrypted by SSE-C or SSE-KMS, have ETags that are not an MD5 digest of their object data. + * - If an object is created by either the Multipart Upload or Part Copy operation, the ETag is not an MD5 digest, regardless of the method of encryption. If an object is larger than 16 MB, the AWS Management Console will upload or copy that object as a Multipart Upload, and therefore the ETag will not be an MD5 digest. + * + * MD5 is not supported by directory buckets. + */ + ETag?: string; + /** The name that you assign to an object. You use the object key to retrieve the object. */ + Key: string; + /** Creation date of the object. */ + LastModified?: string; + /** The owner of the object */ + Owner?: { + /** The ID of the owner. */ + Id?: string; + /** The display name of the owner. */ + DisplayName?: string; + }; + /** Specifies the restoration status of an object. Objects in certain storage classes must be restored before they can be retrieved. */ + RestoreStatus?: { + /** Specifies whether the object is currently being restored. */ + IsRestoreInProgress?: boolean; + /** Indicates when the restored copy will expire. This value is populated only if the object has already been restored. */ + RestoreExpiryDate?: string; + }; + /** Size in bytes of the object */ + Size?: number; + /** The class of storage used to store the object. */ + StorageClass?: + | "STANDARD" + | "REDUCED_REDUNDANCY" + | "GLACIER" + | "STANDARD_IA" + | "ONEZONE_IA" + | "INTELLIGENT_TIERING" + | "DEEP_ARCHIVE" + | "OUTPOSTS" + | "GLACIER_IR" + | "SNOW" + | "EXPRESS_ONEZONE"; + }[]; + /** If ContinuationToken was sent with the request, it is included in the response. You can use the returned ContinuationToken for pagination of the list response. */ + ContinuationToken?: string; + /** Causes keys that contain the same string between the prefix and the first occurrence of the delimiter to be rolled up into a single result element in the CommonPrefixes collection. These rolled-up keys are not returned elsewhere in the response. Each rolled-up result counts as only one return against the MaxKeys value. */ + Delimiter?: string; + /** Encoding type used by Amazon S3 to encode object key names in the XML response. */ + EncodingType?: "url"; + /** Set to false if all of the results were returned. Set to true if more keys are available to return. If the number of results exceeds that specified by MaxKeys, all of the results might not be returned. */ + IsTruncated?: boolean; + /** KeyCount is the number of keys returned with this request. KeyCount will always be less than or equal to the MaxKeys field. For example, if you ask for 50 keys, your result will include 50 keys or fewer. */ + KeyCount?: number; + /** Sets the maximum number of keys returned in the response. By default, the action returns up to 1,000 key names. The response might contain fewer keys but will never contain more. */ + MaxKeys?: number; + /** The bucket name. */ + Name?: string; + /** NextContinuationToken is sent when isTruncated is true, which means there are more keys in the bucket that can be listed. The next list requests to Amazon S3 can be continued with this NextContinuationToken. NextContinuationToken is obfuscated and is not a real key. */ + NextContinuationToken?: string; + /** Keys that begin with the indicated prefix. */ + Prefix?: string; + /** If StartAfter was sent with the request, it is included in the response. */ + StartAfter?: string; + } + type S3Client = { /** * Create a new instance of an S3 bucket so that credentials can be managed @@ -1946,6 +2046,14 @@ declare module "bun" { unlink(path: string, options?: S3Options): Promise; delete: S3Client["unlink"]; + /** Returns some or all (up to 1,000) of the objects in a bucket with each request. + * + * You can use the request parameters as selection criteria to return a subset of the objects in a bucket. + */ + listObjects( + input?: S3ListObjectsOptions | null, + options?: Pick, + ): Promise; /** * Get the size of a file in bytes. * Uses HEAD request to efficiently get size. diff --git a/src/bun.js/api/S3Client.classes.ts b/src/bun.js/api/S3Client.classes.ts index 06839ca5685ee3..017ec624618569 100644 --- a/src/bun.js/api/S3Client.classes.ts +++ b/src/bun.js/api/S3Client.classes.ts @@ -20,6 +20,10 @@ export default [ fn: "staticUnlink", length: 2, }, + listObjects: { + fn: "staticListObjects", + length: 2, + }, presign: { fn: "staticPresign", length: 2, @@ -56,6 +60,10 @@ export default [ fn: "unlink", length: 2, }, + listObjects: { + fn: "listObjects", + length: 2, + }, presign: { fn: "presign", length: 2, diff --git a/src/bun.js/webcore/S3Client.zig b/src/bun.js/webcore/S3Client.zig index 18d93dc0b7bfb8..f26cf9cf73eeb7 100644 --- a/src/bun.js/webcore/S3Client.zig +++ b/src/bun.js/webcore/S3Client.zig @@ -236,6 +236,18 @@ pub const S3Client = struct { }); } + pub fn listObjects(ptr: *@This(), globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + const args = callframe.argumentsAsArray(2); + + const object_keys = args[0]; + const options = args[1]; + + var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, .{ .string = bun.PathString.empty }, options, ptr.credentials, ptr.options, null, null); + + defer blob.detach(); + return blob.store.?.data.s3.listObjects(blob.store.?, globalThis, object_keys, options); + } + pub fn unlink(ptr: *@This(), globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { const arguments = callframe.arguments_old(2).slice(); var args = JSC.Node.ArgumentsSlice.init(globalThis.bunVM(), arguments); @@ -297,4 +309,18 @@ pub const S3Client = struct { pub fn staticStat(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { return S3File.stat(globalThis, callframe); } + + pub fn staticListObjects(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + const args = callframe.argumentsAsArray(2); + const object_keys = args[0]; + const options = args[1]; + + // get credentials from env + const existing_credentials = globalThis.bunVM().transpiler.env.getS3Credentials(); + + var blob = try S3File.constructS3FileWithS3Credentials(globalThis, .{ .string = bun.PathString.empty }, options, existing_credentials); + + defer blob.detach(); + return blob.store.?.data.s3.listObjects(blob.store.?, globalThis, object_keys, options); + } }; diff --git a/src/bun.js/webcore/blob.zig b/src/bun.js/webcore/blob.zig index 3902dea342feba..d383d91353ae1d 100644 --- a/src/bun.js/webcore/blob.zig +++ b/src/bun.js/webcore/blob.zig @@ -3584,6 +3584,58 @@ pub const Blob = struct { return value; } + + pub fn listObjects(this: *@This(), store: *Store, globalThis: *JSC.JSGlobalObject, listOptions: JSValue, extra_options: ?JSValue) bun.JSError!JSValue { + if (!listOptions.isEmptyOrUndefinedOrNull() and !listOptions.isObject()) { + return globalThis.throwInvalidArguments("S3Client.listObjects() needs a S3ListObjectsOption as it's first argument", .{}); + } + + const Wrapper = struct { + promise: JSC.JSPromise.Strong, + store: *Store, + + pub usingnamespace bun.New(@This()); + + pub fn resolve(result: S3.S3ListObjectsResult, self: *@This()) void { + defer self.deinit(); + const globalObject = self.promise.globalObject().?; + switch (result) { + .success => |list_result| { + defer list_result.deinit(); + self.promise.resolve(globalObject, list_result.toJS(globalObject)); + }, + + inline .not_found, .failure => |err| { + self.promise.reject(globalObject, err.toJS(globalObject, self.store.getPath())); + }, + } + } + + fn deinit(self: *@This()) void { + self.store.deref(); + self.promise.deinit(); + self.destroy(); + } + }; + + const promise = JSC.JSPromise.Strong.init(globalThis); + const value = promise.value(); + const proxy_url = globalThis.bunVM().transpiler.env.getHttpProxy(true, null); + const proxy = if (proxy_url) |url| url.href else null; + var aws_options = try this.getCredentialsWithOptions(extra_options, globalThis); + defer aws_options.deinit(); + + const options = S3.getListObjectsOptionsFromJS(globalThis, listOptions) catch bun.outOfMemory(); + + S3.listObjects(&aws_options.credentials, options, @ptrCast(&Wrapper.resolve), Wrapper.new(.{ + .promise = promise, + .store = store, // store is needed in case of not found error + }), proxy); + store.ref(); + + return value; + } + pub fn initWithReferencedCredentials(pathlike: JSC.Node.PathLike, mime_type: ?http.MimeType, credentials: *S3Credentials) S3Store { credentials.ref(); return .{ diff --git a/src/s3/client.zig b/src/s3/client.zig index 6d527ff9f2afa0..38410c8f726f55 100644 --- a/src/s3/client.zig +++ b/src/s3/client.zig @@ -23,6 +23,10 @@ pub const S3UploadResult = S3SimpleRequest.S3UploadResult; pub const S3StatResult = S3SimpleRequest.S3StatResult; pub const S3DownloadResult = S3SimpleRequest.S3DownloadResult; pub const S3DeleteResult = S3SimpleRequest.S3DeleteResult; +const S3ListObjects = @import("./list_objects.zig"); +pub const S3ListObjectsResult = S3SimpleRequest.S3ListObjectsResult; +pub const S3ListObjectsOptions = @import("./list_objects.zig").S3ListObjectsOptions; +pub const getListObjectsOptionsFromJS = S3ListObjects.getListObjectsOptionsFromJS; pub fn stat( this: *S3Credentials, @@ -99,6 +103,127 @@ pub fn delete( }, .{ .delete = callback }, callback_context); } +pub fn listObjects( + this: *S3Credentials, + listOptions: S3ListObjectsOptions, + callback: *const fn (S3ListObjectsResult, *anyopaque) void, + callback_context: *anyopaque, + proxy_url: ?[]const u8, +) void { + var search_params: bun.ByteList = .{}; + + search_params.append(bun.default_allocator, "?") catch bun.outOfMemory(); + + if (listOptions.continuation_token) |continuation_token| { + var buff: [1024]u8 = undefined; + const encoded = S3Credentials.encodeURIComponent(continuation_token, &buff, true) catch bun.outOfMemory(); + search_params.appendFmt(bun.default_allocator, "continuation-token={s}", .{encoded}) catch bun.outOfMemory(); + } + + if (listOptions.delimiter) |delimiter| { + var buff: [1024]u8 = undefined; + const encoded = S3Credentials.encodeURIComponent(delimiter, &buff, true) catch bun.outOfMemory(); + + if (listOptions.continuation_token != null) { + search_params.appendFmt(bun.default_allocator, "&delimiter={s}", .{encoded}) catch bun.outOfMemory(); + } else { + search_params.appendFmt(bun.default_allocator, "delimiter={s}", .{encoded}) catch bun.outOfMemory(); + } + } + + if (listOptions.encoding_type != null) { + if (listOptions.continuation_token != null or listOptions.delimiter != null) { + search_params.append(bun.default_allocator, "&encoding-type=url") catch bun.outOfMemory(); + } else { + search_params.append(bun.default_allocator, "encoding-type=url") catch bun.outOfMemory(); + } + } + + if (listOptions.fetch_owner) |fetch_owner| { + if (listOptions.continuation_token != null or listOptions.delimiter != null or listOptions.encoding_type != null) { + search_params.appendFmt(bun.default_allocator, "&fetch-owner={}", .{fetch_owner}) catch bun.outOfMemory(); + } else { + search_params.appendFmt(bun.default_allocator, "fetch-owner={}", .{fetch_owner}) catch bun.outOfMemory(); + } + } + + if (listOptions.continuation_token != null or listOptions.delimiter != null or listOptions.encoding_type != null or listOptions.fetch_owner != null) { + search_params.append(bun.default_allocator, "&list-type=2") catch bun.outOfMemory(); + } else { + search_params.append(bun.default_allocator, "list-type=2") catch bun.outOfMemory(); + } + + if (listOptions.max_keys) |max_keys| { + search_params.appendFmt(bun.default_allocator, "&max-keys={}", .{max_keys}) catch bun.outOfMemory(); + } + + if (listOptions.prefix) |prefix| { + var buff: [1024]u8 = undefined; + const encoded = S3Credentials.encodeURIComponent(prefix, &buff, true) catch bun.outOfMemory(); + search_params.appendFmt(bun.default_allocator, "&prefix={s}", .{encoded}) catch bun.outOfMemory(); + } + + if (listOptions.start_after) |start_after| { + var buff: [1024]u8 = undefined; + const encoded = S3Credentials.encodeURIComponent(start_after, &buff, true) catch bun.outOfMemory(); + search_params.appendFmt(bun.default_allocator, "&start-after={s}", .{encoded}) catch bun.outOfMemory(); + } + + const result = this.signRequest(.{ + .path = "", + .method = .GET, + .search_params = search_params.slice(), + }, null) catch |sign_err| { + const error_code_and_message = Error.getSignErrorCodeAndMessage(sign_err); + callback(.{ .failure = .{ .code = error_code_and_message.code, .message = error_code_and_message.message } }, callback_context); + + return; + }; + + const headers = JSC.WebCore.Headers.fromPicoHttpHeaders(result.headers(), bun.default_allocator) catch bun.outOfMemory(); + + const task = bun.new(S3HttpSimpleTask, .{ + .http = undefined, + .range = null, + .sign_result = result, + .callback_context = callback_context, + .callback = .{ .listObjects = callback }, + .headers = headers, + .vm = JSC.VirtualMachine.get(), + }); + + task.poll_ref.ref(task.vm); + + const url = bun.URL.parse(result.url); + const proxy = proxy_url orelse ""; + + task.http = bun.http.AsyncHTTP.init( + bun.default_allocator, + .GET, + url, + task.headers.entries, + task.headers.buf.items, + &task.response_buffer, + "", + bun.http.HTTPClientResult.Callback.New( + *S3HttpSimpleTask, + S3HttpSimpleTask.httpCallback, + ).init(task), + .follow, + .{ + .http_proxy = if (proxy.len > 0) bun.URL.parse(proxy) else null, + .verbose = task.vm.getVerboseFetch(), + .reject_unauthorized = task.vm.getTLSRejectUnauthorized(), + }, + ); + + // queue http request + bun.http.HTTPThread.init(&.{}); + var batch = bun.ThreadPool.Batch{}; + task.http.schedule(bun.default_allocator, &batch); + bun.http.http_thread.schedule(batch); +} + pub fn upload( this: *S3Credentials, path: []const u8, diff --git a/src/s3/credentials.zig b/src/s3/credentials.zig index 2c10db4d8ad2a0..fe293e60e1b867 100644 --- a/src/s3/credentials.zig +++ b/src/s3/credentials.zig @@ -319,6 +319,7 @@ pub const S3Credentials = struct { url: []const u8, content_disposition: []const u8 = "", + content_md5: []const u8 = "", session_token: []const u8 = "", acl: ?ACL = null, storage_class: ?StorageClass = null, @@ -372,6 +373,10 @@ pub const S3Credentials = struct { if (this.url.len > 0) { bun.default_allocator.free(this.url); } + + if (this.content_md5.len > 0) { + bun.default_allocator.free(this.content_md5); + } } }; @@ -382,6 +387,7 @@ pub const S3Credentials = struct { path: []const u8, method: bun.http.Method, content_hash: ?[]const u8 = null, + content_md5: ?[]const u8 = null, search_params: ?[]const u8 = null, content_disposition: ?[]const u8 = null, acl: ?ACL = null, @@ -410,7 +416,7 @@ pub const S3Credentials = struct { else => error.InvalidHexChar, }; } - fn encodeURIComponent(input: []const u8, buffer: []u8, comptime encode_slash: bool) ![]const u8 { + pub fn encodeURIComponent(input: []const u8, buffer: []u8, comptime encode_slash: bool) ![]const u8 { var written: usize = 0; for (input) |c| { @@ -448,6 +454,13 @@ pub const S3Credentials = struct { const method = signOptions.method; const request_path = signOptions.path; const content_hash = signOptions.content_hash; + var content_md5 = signOptions.content_md5; + + if (content_md5) |content_md5_val| { + const len = bun.base64.encodeLen(content_md5_val); + const content_md5_as_base64 = bun.default_allocator.alloc(u8, len) catch bun.outOfMemory(); + content_md5 = content_md5_as_base64[0..bun.base64.encode(content_md5_as_base64, content_md5_val)]; + } const search_params = signOptions.search_params; @@ -505,6 +518,12 @@ pub const S3Credentials = struct { return error.InvalidPath; } } + + var isComplexeRequest = false; + if (search_params) |sp| { + isComplexeRequest = sp.len != 0; + } + if (strings.endsWith(path, "/")) { path = path[0..path.len]; } else if (strings.endsWith(path, "\\")) { @@ -517,7 +536,7 @@ pub const S3Credentials = struct { } // if we allow path.len == 0 it will list the bucket for now we disallow - if (path.len == 0) return error.InvalidPath; + if (!isComplexeRequest and path.len == 0) return error.InvalidPath; var normalized_path_buffer: [1024 + 63 + 2]u8 = undefined; // 1024 max key size and 63 max bucket name var path_buffer: [1024]u8 = undefined; @@ -532,63 +551,127 @@ pub const S3Credentials = struct { const amz_day = amz_date[0..8]; const signed_headers = if (signQuery) "host" else brk: { - if (storage_class != null) { - if (acl != null) { - if (content_disposition != null) { - if (session_token != null) { - break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class;x-amz-security-token"; + if (content_md5 != null) { + if (storage_class != null) { + if (acl != null) { + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "content-disposition;content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } else { - break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + if (session_token != null) { + break :brk "content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } } else { - if (session_token != null) { - break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class;x-amz-security-token"; + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "content-disposition;content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } else { - break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + if (session_token != null) { + break :brk "content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } } } else { - if (content_disposition != null) { - if (session_token != null) { - break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class;x-amz-security-token"; + if (acl != null) { + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "content-disposition;content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date"; + } } else { - break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + if (session_token != null) { + break :brk "content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "content-md5;host;x-amz-acl;x-amz-content-sha256;x-amz-date"; + } } } else { - if (session_token != null) { - break :brk "host;x-amz-content-sha256;x-amz-date;x-amz-storage-class;x-amz-security-token"; + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "content-disposition;content-md5;host;x-amz-content-sha256;x-amz-date"; + } } else { - break :brk "host;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + if (session_token != null) { + break :brk "content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "content-md5;host;x-amz-content-sha256;x-amz-date"; + } } } } } else { - if (acl != null) { - if (content_disposition != null) { - if (session_token != null) { - break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + if (storage_class != null) { + if (acl != null) { + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } else { - break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date"; + if (session_token != null) { + break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } } else { - if (session_token != null) { - break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } else { - break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date"; + if (session_token != null) { + break :brk "host;x-amz-content-sha256;x-amz-date;x-amz-security-token;x-amz-storage-class"; + } else { + break :brk "host;x-amz-content-sha256;x-amz-date;x-amz-storage-class"; + } } } } else { - if (content_disposition != null) { - if (session_token != null) { - break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + if (acl != null) { + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "content-disposition;host;x-amz-acl;x-amz-content-sha256;x-amz-date"; + } } else { - break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date"; + if (session_token != null) { + break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "host;x-amz-acl;x-amz-content-sha256;x-amz-date"; + } } } else { - if (session_token != null) { - break :brk "host;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + if (content_disposition != null) { + if (session_token != null) { + break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "content-disposition;host;x-amz-content-sha256;x-amz-date"; + } } else { - break :brk "host;x-amz-content-sha256;x-amz-date"; + if (session_token != null) { + break :brk "host;x-amz-content-sha256;x-amz-date;x-amz-security-token"; + } else { + break :brk "host;x-amz-content-sha256;x-amz-date"; + } } } } @@ -640,33 +723,73 @@ pub const S3Credentials = struct { if (session_token) |token| { encoded_session_token = encodeURIComponent(token, &token_encoded_buffer, true) catch return error.InvalidSessionToken; } + + var content_md5_encoded_buffer: [128]u8 = undefined; // MD5 as base64 (which is required for AWS SigV4) is always 44, when encoded its always 46 (44 + ==) + var encoded_content_md5: ?[]const u8 = null; + + if (content_md5) |content_md5_value| { + encoded_content_md5 = encodeURIComponent(content_md5_value, &content_md5_encoded_buffer, true) catch return error.FailedToGenerateSignature; + } + const canonical = brk_canonical: { - if (storage_class) |storage_class_value| { - if (acl) |acl_value| { - if (encoded_session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + if (encoded_content_md5) |encoded_content_md5_value| { + if (storage_class) |storage_class_value| { + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, storage_class_value, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, storage_class_value, host, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, storage_class_value, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, storage_class_value, host, signed_headers, aws_content_hash }); + } } } else { - if (encoded_session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nx-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nx-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nContent-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + } } } } else { - if (acl) |acl_value| { - if (encoded_session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + if (storage_class) |storage_class_value| { + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, storage_class_value, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, storage_class_value, host, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, storage_class_value, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&x-amz-storage-class={s}\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, storage_class_value, host, signed_headers, aws_content_hash }); + } } } else { - if (encoded_session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + if (encoded_session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, host, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host\nhost:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, host, signed_headers, aws_content_hash }); + } } } } @@ -678,64 +801,128 @@ pub const S3Credentials = struct { const signature = bun.hmac.generate(sigDateRegionServiceReq, signValue, .sha256, &hmac_sig_service) orelse return error.FailedToGenerateSignature; - if (storage_class) |storage_class_value| { - if (acl) |acl_value| { - if (encoded_session_token) |token| { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?X-Amz-Acl={s}&x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, acl_value, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (encoded_content_md5) |encoded_content_md5_value| { + if (storage_class) |storage_class_value| { + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&x-amz-storage-class={s}&Content-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, storage_class_value, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&x-amz-storage-class={s}&Content-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, storage_class_value, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } else { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?X-Amz-Acl={s}&x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, acl_value, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?x-amz-storage-class={s}&Content-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, storage_class_value, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?x-amz-storage-class={s}&Content-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, storage_class_value, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } } else { - if (encoded_session_token) |token| { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&Content-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&Content-MD5={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } else { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Algorithm=AWS4-HMAC-SHA256&Content-MD5={s}&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Algorithm=AWS4-HMAC-SHA256&Content-MD5={s}&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, encoded_content_md5_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } } } else { - if (acl) |acl_value| { - if (encoded_session_token) |token| { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (storage_class) |storage_class_value| { + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } else { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?x-amz-storage-class={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, storage_class_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } } else { - if (encoded_session_token) |token| { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (acl) |acl_value| { + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Acl={s}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, acl_value, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } else { - break :brk try std.fmt.allocPrint( - bun.default_allocator, - "{s}://{s}{s}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", - .{ protocol, host, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, - ); + if (encoded_session_token) |token| { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-Security-Token={s}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, token, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } else { + break :brk try std.fmt.allocPrint( + bun.default_allocator, + "{s}://{s}{s}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={s}%2F{s}%2F{s}%2F{s}%2Faws4_request&X-Amz-Date={s}&X-Amz-Expires={}&X-Amz-SignedHeaders=host&X-Amz-Signature={s}", + .{ protocol, host, normalizedPath, this.accessKeyId, amz_day, region, service_name, amz_date, expires, bun.fmt.bytesToHex(signature[0..DIGESTED_HMAC_256_LEN], .lower) }, + ); + } } } } @@ -743,63 +930,137 @@ pub const S3Credentials = struct { var encoded_content_disposition_buffer: [255]u8 = undefined; const encoded_content_disposition: []const u8 = if (content_disposition) |cd| encodeURIComponent(cd, &encoded_content_disposition_buffer, true) catch return error.ContentTypeIsTooLong else ""; const canonical = brk_canonical: { - if (storage_class) |storage_class_value| { - if (acl) |acl_value| { - if (content_disposition != null) { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, storage_class_value, token, signed_headers, aws_content_hash }); + if (content_md5) |content_md5_value| { + if (storage_class) |storage_class_value| { + if (acl) |acl_value| { + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } } else { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, storage_class_value, token, signed_headers, aws_content_hash }); + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } } } else { - if (content_disposition != null) { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, storage_class_value, token, signed_headers, aws_content_hash }); + if (acl) |acl_value| { + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } } else { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, storage_class_value, token, signed_headers, aws_content_hash }); + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, content_md5_value, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", content_md5_value, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-md5:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ + method_name, + normalizedPath, + if (search_params) |p| p[1..] else "", + content_md5_value, + host, + aws_content_hash, + amz_date, + signed_headers, + aws_content_hash, + }); + } } } } } else { - if (acl) |acl_value| { - if (content_disposition != null) { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + if (storage_class) |storage_class_value| { + if (acl) |acl_value| { + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } } else { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, token, storage_class_value, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-storage-class:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, storage_class_value, signed_headers, aws_content_hash }); + } } } } else { - if (content_disposition != null) { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + if (acl) |acl_value| { + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-acl:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, acl_value, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } } else { - if (session_token) |token| { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + if (content_disposition != null) { + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\ncontent-disposition:{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", encoded_content_disposition, host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } else { - break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + if (session_token) |token| { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, token, signed_headers, aws_content_hash }); + } else { + break :brk_canonical try std.fmt.bufPrint(&tmp_buffer, "{s}\n{s}\n{s}\nhost:{s}\nx-amz-content-sha256:{s}\nx-amz-date:{s}\n\n{s}\n{s}", .{ method_name, normalizedPath, if (search_params) |p| p[1..] else "", host, aws_content_hash, amz_date, signed_headers, aws_content_hash }); + } } } } @@ -879,6 +1140,13 @@ pub const S3Credentials = struct { result._headers_len += 1; } + if (content_md5) |c_md5| { + const content_md5_value = bun.default_allocator.dupe(u8, c_md5) catch bun.outOfMemory(); + result.content_md5 = content_md5_value; + result._headers[result._headers_len] = .{ .name = "content-md5", .value = content_md5_value }; + result._headers_len += 1; + } + return result; } }; diff --git a/src/s3/list_objects.zig b/src/s3/list_objects.zig new file mode 100644 index 00000000000000..e947f4607d734a --- /dev/null +++ b/src/s3/list_objects.zig @@ -0,0 +1,559 @@ +const std = @import("std"); +const bun = @import("root").bun; +const JSC = bun.JSC; +const JSValue = JSC.JSValue; +const JSGlobalObject = JSC.JSGlobalObject; +const strings = bun.strings; + +pub const S3ListObjectsOptions = struct { + continuation_token: ?[]const u8, + delimiter: ?[]const u8, + encoding_type: ?[]const u8, + fetch_owner: ?bool, + max_keys: ?i64, + prefix: ?[]const u8, + start_after: ?[]const u8, +}; + +const ObjectOwner = struct { + id: ?[]const u8, + display_name: ?[]const u8, +}; + +const ObjectRestoreStatus = struct { + is_restore_in_progress: ?bool, + restore_expiry_date: ?[]const u8, +}; + +const S3ListObjectsContents = struct { + key: []const u8, + etag: ?[]const u8, + checksum_type: ?[]const u8, + checksum_algorithme: ?[]const u8, + last_modified: ?[]const u8, + object_size: ?i64, + storage_class: ?[]const u8, + owner: ?ObjectOwner, + restore_status: ?ObjectRestoreStatus, +}; + +pub const S3ListObjectsV2Result = struct { + name: ?[]const u8, + prefix: ?[]const u8, + key_count: ?i64, + max_keys: ?i64, + delimiter: ?[]const u8, + encoding_type: ?[]const u8, + is_truncated: ?bool, + continuation_token: ?[]const u8, + next_continuation_token: ?[]const u8, + start_after: ?[]const u8, + common_prefixes: ?std.ArrayList([]const u8), + contents: ?std.ArrayList(S3ListObjectsContents), + + pub fn deinit(this: @This()) void { + if (this.contents) |contents| { + contents.deinit(); + } + if (this.common_prefixes) |common_prefixes| { + common_prefixes.deinit(); + } + } + + pub fn toJS(this: @This(), globalObject: *JSGlobalObject) JSValue { + const jsResult = JSValue.createEmptyObject(globalObject, 12); + + if (this.name) |name| { + jsResult.put(globalObject, JSC.ZigString.static("Name"), bun.String.init(name).toJS(globalObject)); + } + + if (this.prefix) |prefix| { + jsResult.put(globalObject, JSC.ZigString.static("Prefix"), bun.String.init(prefix).toJS(globalObject)); + } + + if (this.delimiter) |delimiter| { + jsResult.put(globalObject, JSC.ZigString.static("Delimiter"), bun.String.init(delimiter).toJS(globalObject)); + } + + if (this.start_after) |start_after| { + jsResult.put(globalObject, JSC.ZigString.static("StartAfter"), bun.String.init(start_after).toJS(globalObject)); + } + if (this.encoding_type) |encoding_type| { + jsResult.put(globalObject, JSC.ZigString.static("EncodingType"), bun.String.init(encoding_type).toJS(globalObject)); + } + + if (this.continuation_token) |continuation_token| { + jsResult.put(globalObject, JSC.ZigString.static("ContinuationToken"), bun.String.init(continuation_token).toJS(globalObject)); + } + + if (this.next_continuation_token) |next_continuation_token| { + jsResult.put(globalObject, JSC.ZigString.static("NextContinuationToken"), bun.String.init(next_continuation_token).toJS(globalObject)); + } + + if (this.is_truncated) |is_truncated| { + jsResult.put(globalObject, JSC.ZigString.static("IsTruncated"), JSValue.jsBoolean(is_truncated)); + } + + if (this.key_count) |key_count| { + jsResult.put(globalObject, JSC.ZigString.static("KeyCount"), JSValue.jsNumber(key_count)); + } + + if (this.max_keys) |max_keys| { + jsResult.put(globalObject, JSC.ZigString.static("MaxKeys"), JSValue.jsNumber(max_keys)); + } + + if (this.contents) |contents| { + const jsContents = JSValue.createEmptyArray(globalObject, contents.items.len); + + for (contents.items, 0..) |item, i| { + const objectInfo = JSValue.createEmptyObject(globalObject, 1); + objectInfo.put(globalObject, JSC.ZigString.static("Key"), bun.String.init(item.key).toJS(globalObject)); + + if (item.etag) |etag| { + objectInfo.put(globalObject, JSC.ZigString.static("ETag"), bun.String.init(etag).toJS(globalObject)); + } + + if (item.checksum_algorithme) |checksum_algorithme| { + objectInfo.put(globalObject, JSC.ZigString.static("ChecksumAlgorithme"), bun.String.init(checksum_algorithme).toJS(globalObject)); + } + + if (item.checksum_type) |checksum_type| { + objectInfo.put(globalObject, JSC.ZigString.static("ChecksumType"), bun.String.init(checksum_type).toJS(globalObject)); + } + + if (item.last_modified) |last_modified| { + objectInfo.put(globalObject, JSC.ZigString.static("LastModified"), bun.String.init(last_modified).toJS(globalObject)); + } + + if (item.object_size) |object_size| { + objectInfo.put(globalObject, JSC.ZigString.static("Size"), JSValue.jsNumber(object_size)); + } + + if (item.storage_class) |storage_class| { + objectInfo.put(globalObject, JSC.ZigString.static("StorageClass"), bun.String.init(storage_class).toJS(globalObject)); + } + + if (item.owner) |owner| { + const jsOwner = JSValue.createEmptyObject(globalObject, 2); + if (owner.id) |id| { + jsOwner.put(globalObject, JSC.ZigString.static("Id"), bun.String.init(id).toJS(globalObject)); + } + + if (owner.display_name) |display_name| { + jsOwner.put(globalObject, JSC.ZigString.static("DisplayName"), bun.String.init(display_name).toJS(globalObject)); + } + + objectInfo.put(globalObject, JSC.ZigString.static("Owner"), jsOwner); + } + + jsContents.putIndex(globalObject, @intCast(i), objectInfo); + } + + jsResult.put(globalObject, JSC.ZigString.static("Contents"), jsContents); + } + + if (this.common_prefixes) |common_prefixes| { + const jsCommonPrefixes = JSValue.createEmptyArray(globalObject, common_prefixes.items.len); + + for (common_prefixes.items, 0..) |prefix, i| { + const jsPrefix = JSValue.createEmptyObject(globalObject, 1); + jsPrefix.put(globalObject, JSC.ZigString.static("Prefix"), bun.String.init(prefix).toJS(globalObject)); + jsCommonPrefixes.putIndex(globalObject, @intCast(i), jsPrefix); + } + + jsResult.put(globalObject, JSC.ZigString.static("CommonPrefixes"), jsCommonPrefixes); + } + + return jsResult; + } +}; + +pub fn parseS3ListObjectsResult(xml: []const u8) !S3ListObjectsV2Result { + var result: S3ListObjectsV2Result = .{ + .contents = null, + .common_prefixes = null, + .continuation_token = null, + .delimiter = null, + .encoding_type = null, + .is_truncated = null, + .key_count = null, + .max_keys = null, + .name = null, + .next_continuation_token = null, + .prefix = null, + .start_after = null, + }; + + var contents = std.ArrayList(S3ListObjectsContents).init(bun.default_allocator); + var common_prefixes = std.ArrayList([]const u8).init(bun.default_allocator); + + // we dont use trailing ">" as it may finish with xmlns=... + if (strings.indexOf(xml, "")) |end| { + i = i + 1; + const tag_name_end_pos = i + end; // +1 for < + + const tagName = xml[i..tag_name_end_pos]; + i = tag_name_end_pos + 1; // +1 for > + + if (strings.eql(tagName, "Contents")) { + var looking_for_end_tag = true; + + var object_key: ?[]const u8 = null; + var last_modified: ?[]const u8 = null; + var object_size: ?i64 = null; + var storage_class: ?[]const u8 = null; + var etag: ?[]const u8 = null; + var checksum_type: ?[]const u8 = null; + var checksum_algorithme: ?[]const u8 = null; + var owner_id: ?[]const u8 = null; + var owner_display_name: ?[]const u8 = null; + var is_restore_in_progress: ?bool = null; + var restore_expiry_date: ?[]const u8 = null; + + while (looking_for_end_tag) { + if (i >= xml.len) { + break; + } + + if (xml[i] == '<') { + if (strings.indexOf(xml[i + 1 ..], ">")) |__end| { + const inner_tag_name_or_tag_end = xml[i + 1 .. i + 1 + __end]; + + i = i + 2 + __end; + + if (strings.eql(inner_tag_name_or_tag_end, "/Contents")) { + looking_for_end_tag = false; + } else if (strings.eql(inner_tag_name_or_tag_end, "Key")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + object_key = xml[i .. i + __tag_end]; + i = i + __tag_end + 6; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "LastModified")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + last_modified = xml[i .. i + __tag_end]; + i = i + __tag_end + 15; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "Size")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + const size = xml[i .. i + __tag_end]; + + object_size = std.fmt.parseInt(i64, size, 10) catch null; + i = i + __tag_end + 7; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "StorageClass")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + storage_class = xml[i .. i + __tag_end]; + i = i + __tag_end + 15; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "ChecksumType")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + checksum_type = xml[i .. i + __tag_end]; + i = i + __tag_end + 15; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "ChecksumAlgorithm")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + checksum_algorithme = xml[i .. i + __tag_end]; + i = i + __tag_end + 20; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "ETag")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + const input = xml[i .. i + __tag_end]; + + const size = std.mem.replacementSize(u8, input, """, "\""); + var output = try bun.default_allocator.alloc(u8, size); + + const len = std.mem.replace(u8, input, """, "\"", output); + + if (len != 0) { + etag = output[0 .. input.len - len * 5]; // 5 = """.len - 1 for replacement " + } else { + etag = input; + } + + i = i + __tag_end + 7; + } + } else if (strings.eql(inner_tag_name_or_tag_end, "Owner")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + const owner = xml[i .. i + __tag_end]; + i = i + __tag_end + 8; + + if (strings.indexOf(owner, "")) |id_start| { + const id_start_pos = id_start + 4; + if (strings.indexOf(owner, "")) |id_end| { + const isNotEmpty = id_start_pos < id_end; + if (isNotEmpty) { + owner_id = owner[id_start_pos..id_end]; + } + } + } + + if (strings.indexOf(owner, "")) |id_start| { + const id_start_pos = id_start + 13; + if (strings.indexOf(owner, "")) |id_end| { + const isNotEmpty = id_start_pos < id_end; + if (isNotEmpty) { + owner_display_name = owner[id_start_pos..id_end]; + } + } + } + } + } else if (strings.eql(inner_tag_name_or_tag_end, "RestoreStatus")) { + if (strings.indexOf(xml[i..], "")) |__tag_end| { + const restore_status = xml[i .. i + __tag_end]; + i = i + __tag_end + 16; + + if (strings.indexOf(restore_status, "")) |start| { + const start_pos = start + 21; + if (strings.indexOf(restore_status, "")) |_end| { + const isNotEmpty = start_pos < _end; + if (isNotEmpty) { + const is_restore_in_progress_string = restore_status[start_pos.._end]; + + if (strings.eql(is_restore_in_progress_string, "true")) { + is_restore_in_progress = true; + } else if (strings.eql(is_restore_in_progress_string, "false")) { + is_restore_in_progress = false; + } + } + } + } + + if (strings.indexOf(restore_status, "")) |start| { + const start_pos = start + 19; + if (strings.indexOf(restore_status, "")) |_end| { + const isNotEmpty = start_pos < _end; + if (isNotEmpty) { + restore_expiry_date = restore_status[start_pos.._end]; + } + } + } + } + } + } else { // char is not > + i += 1; + } + } else { // char is not < + i += 1; + } + } + + if (object_key) |object_key_val| { + var owner: ?ObjectOwner = null; + + if (owner_id != null or owner_display_name != null) { + owner = .{ + .id = owner_id, + .display_name = owner_display_name, + }; + } + + var restore_status: ?ObjectRestoreStatus = null; + + if (is_restore_in_progress != null or restore_expiry_date != null) { + restore_status = .{ + .is_restore_in_progress = is_restore_in_progress, + .restore_expiry_date = restore_expiry_date, + }; + } + + try contents.append(.{ + .key = object_key_val, + .etag = etag, + .checksum_type = checksum_type, + .checksum_algorithme = checksum_algorithme, + .last_modified = last_modified, + .object_size = object_size, + .storage_class = storage_class, + .owner = owner, + .restore_status = restore_status, + }); + } + } else if (strings.eql(tagName, "Name")) { + if (strings.indexOf(xml[i..], "")) |_end| { + result.name = xml[i .. i + _end]; + i = i + _end; + } + } else if (strings.eql(tagName, "Delimiter")) { + if (strings.indexOf(xml[i..], "")) |_end| { + result.delimiter = xml[i .. i + _end]; + i = i + _end; + } + } else if (strings.eql(tagName, "NextContinuationToken")) { + if (strings.indexOf(xml[i..], "")) |_end| { + result.next_continuation_token = xml[i .. i + _end]; + i = i + _end; + } + } else if (strings.eql(tagName, "ContinuationToken")) { + if (strings.indexOf(xml[i..], "")) |_end| { + result.continuation_token = xml[i .. i + _end]; + i = i + _end; + } + } else if (strings.eql(tagName, "StartAfter")) { + if (strings.indexOf(xml[i..], "")) |_end| { + result.start_after = xml[i .. i + _end]; + i = i + _end; + } + } else if (strings.eql(tagName, "EncodingType")) { + if (strings.indexOf(xml[i..], "")) |_end| { + result.encoding_type = xml[i .. i + _end]; + i = i + _end; + } + } else if (strings.eql(tagName, "KeyCount")) { + if (strings.indexOf(xml[i..], "")) |_end| { + const key_count = xml[i .. i + _end]; + result.key_count = std.fmt.parseInt(i64, key_count, 10) catch null; + + i = i + _end; + } + } else if (strings.eql(tagName, "MaxKeys")) { + if (strings.indexOf(xml[i..], "")) |_end| { + const max_keys = xml[i .. i + _end]; + result.max_keys = std.fmt.parseInt(i64, max_keys, 10) catch null; + + i = i + _end; + } + } else if (strings.eql(tagName, "Prefix")) { + if (strings.indexOf(xml[i..], "")) |_end| { + const prefix = xml[i .. i + _end]; + + if (prefix.len != 0) { + result.prefix = prefix; + } + + i = i + _end; + } + } else if (strings.eql(tagName, "IsTruncated")) { + if (strings.indexOf(xml[i..], "")) |_end| { + const is_truncated = xml[i .. i + _end]; + + if (strings.eql(is_truncated, "true")) { + result.is_truncated = true; + } else if (strings.eql(is_truncated, "false")) { + result.is_truncated = false; + } + + i = i + _end; + } + } else if (strings.eql(tagName, "CommonPrefixes")) { + if (strings.indexOf(xml[i..], "")) |_end| { + const common_prefixes_string = xml[i .. i + _end]; + i = i + _end; + + var j: usize = 0; + while (j < common_prefixes_string.len) { + if (strings.indexOf(common_prefixes_string[j..], "")) |start| { + j = j + start + 8; + + if (strings.indexOf(common_prefixes_string[j..], "")) |__end| { + try common_prefixes.append(common_prefixes_string[j .. j + __end]); + j = j + __end; + } + } else { + break; + } + } + } + } else { + i += 1; + } + } else { + i += 1; + } + } + + if (contents.items.len != 0) { + result.contents = contents; + } else { + contents.deinit(); + } + + if (common_prefixes.items.len != 0) { + result.common_prefixes = common_prefixes; + } else { + common_prefixes.deinit(); + } + } + + return result; +} + +pub fn getListObjectsOptionsFromJS(globalThis: *JSC.JSGlobalObject, listOptions: JSValue) !S3ListObjectsOptions { + var listObjectsOptions: S3ListObjectsOptions = .{ + .continuation_token = null, + .delimiter = null, + .encoding_type = null, + .fetch_owner = null, + .max_keys = null, + .prefix = null, + .start_after = null, + }; + + if (!listOptions.isObject()) { + return listObjectsOptions; + } + + if (try listOptions.getTruthyComptime(globalThis, "ContinuationToken")) |val| { + if (val.isString()) { + var zig_val: JSC.ZigString = JSC.ZigString.Empty; + val.toZigString(&zig_val, globalThis); + + listObjectsOptions.continuation_token = zig_val.slice(); + } + } + + if (try listOptions.getTruthyComptime(globalThis, "Delimiter")) |val| { + if (val.isString()) { + var zig_val: JSC.ZigString = JSC.ZigString.Empty; + val.toZigString(&zig_val, globalThis); + + listObjectsOptions.delimiter = zig_val.slice(); + } + } + + if (try listOptions.getTruthyComptime(globalThis, "EncodingType")) |val| { + if (val.isString()) { + var zig_val: JSC.ZigString = JSC.ZigString.Empty; + val.toZigString(&zig_val, globalThis); + + listObjectsOptions.encoding_type = zig_val.slice(); + } + } + + if (try listOptions.getBooleanLoose(globalThis, "FetchOwner")) |val| { + listObjectsOptions.fetch_owner = val; + } + + if (try listOptions.getTruthyComptime(globalThis, "MaxKeys")) |val| { + if (val.isNumber()) { + listObjectsOptions.max_keys = val.toInt32(); + } + } + + if (try listOptions.getTruthyComptime(globalThis, "Prefix")) |val| { + if (val.isString()) { + var zig_val: JSC.ZigString = JSC.ZigString.Empty; + val.toZigString(&zig_val, globalThis); + + listObjectsOptions.prefix = zig_val.slice(); + } + } + + if (try listOptions.getTruthyComptime(globalThis, "StartAfter")) |val| { + if (val.isString()) { + var zig_val: JSC.ZigString = JSC.ZigString.Empty; + val.toZigString(&zig_val, globalThis); + + listObjectsOptions.start_after = zig_val.slice(); + } + } + + return listObjectsOptions; +} diff --git a/src/s3/simple_request.zig b/src/s3/simple_request.zig index 0b03fc79f93f43..acb813068d8419 100644 --- a/src/s3/simple_request.zig +++ b/src/s3/simple_request.zig @@ -9,6 +9,7 @@ const S3Credentials = @import("./credentials.zig").S3Credentials; const picohttp = bun.picohttp; const ACL = @import("./acl.zig").ACL; const StorageClass = @import("./storage_class.zig").StorageClass; +const ListObjects = @import("./list_objects.zig"); pub const S3StatResult = union(enum) { success: struct { @@ -48,6 +49,14 @@ pub const S3DeleteResult = union(enum) { /// failure error is not owned and need to be copied if used after this callback failure: S3Error, }; +pub const S3ListObjectsResult = union(enum) { + success: ListObjects.S3ListObjectsV2Result, + not_found: S3Error, + + /// failure error is not owned and need to be copied if used after this callback + failure: S3Error, +}; + // commit result also fails if status 200 but with body containing an Error pub const S3CommitResult = union(enum) { success: void, @@ -86,6 +95,7 @@ pub const S3HttpSimpleTask = struct { download: *const fn (S3DownloadResult, *anyopaque) void, upload: *const fn (S3UploadResult, *anyopaque) void, delete: *const fn (S3DeleteResult, *anyopaque) void, + listObjects: *const fn (S3ListObjectsResult, *anyopaque) void, commit: *const fn (S3CommitResult, *anyopaque) void, part: *const fn (S3PartResult, *anyopaque) void, @@ -95,6 +105,7 @@ pub const S3HttpSimpleTask = struct { .download, .stat, .delete, + .listObjects, .commit, .part, => |callback| callback(.{ @@ -110,6 +121,7 @@ pub const S3HttpSimpleTask = struct { inline .download, .stat, .delete, + .listObjects, => |callback| callback(.{ .not_found = .{ .code = code, @@ -255,6 +267,28 @@ pub const S3HttpSimpleTask = struct { }, } }, + .listObjects => |callback| { + switch (response.status_code) { + 200 => { + if (this.result.body) |body| { + const success = ListObjects.parseS3ListObjectsResult(body.slice()) catch { + this.errorWithBody(.failure); + return; + }; + + callback(.{ .success = success }, this.callback_context); + } else { + this.errorWithBody(.failure); + } + }, + 404 => { + this.errorWithBody(.not_found); + }, + else => { + this.errorWithBody(.failure); + }, + } + }, .upload => |callback| { switch (response.status_code) { 200 => { diff --git a/test/js/bun/s3/s3-list-objects.test.ts b/test/js/bun/s3/s3-list-objects.test.ts new file mode 100644 index 00000000000000..266ecbbd9a5ac6 --- /dev/null +++ b/test/js/bun/s3/s3-list-objects.test.ts @@ -0,0 +1,1001 @@ +import { describe, it, expect } from "bun:test"; +import { randomUUIDv7, S3Client, S3ListObjectsResponse, S3Options } from "bun"; + +const options: S3Options = { + accessKeyId: "test", + secretAccessKey: "test", + region: "eu-west-3", + bucket: "my_bucket", +}; + +function createBunServer(fetch: Parameters[0]["fetch"]) { + // @ts-ignore + const server = Bun.serve({ + port: 0, + fetch, + }); + server.unref(); + + return server; +} + +describe("S3 - List Objects", () => { + it("Should set encoded continuation-token in the request url before list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + ContinuationToken: "continue=ation-_m^token", + }); + + expect(reqUrl!).toEndWith("/?continuation-token=continue%3Dation-_m%5Etoken&list-type=2"); + }); + + it("Should set encoded delimiter in the request url before list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + Delimiter: "files/", + }); + + expect(reqUrl!).toEndWith("/?delimiter=files%2F&list-type=2"); + }); + + it("Should set encoding-type in the request url before list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + EncodingType: "url", + }); + + expect(reqUrl!).toEndWith("/?encoding-type=url&list-type=2"); + }); + + it("Should set fetch-owner (true) in the request url before list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + FetchOwner: true, + }); + + expect(reqUrl!).toEndWith("/?fetch-owner=true&list-type=2"); + }); + + it("Should set fetch-owner (false) in the request url before list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + FetchOwner: false, + }); + + expect(reqUrl!).toEndWith("/?fetch-owner=false&list-type=2"); + }); + + it("Should set max-keys in the request url after list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + MaxKeys: 2034, + }); + + expect(reqUrl!).toEndWith("/?list-type=2&max-keys=2034"); + }); + + it("Should set encoded prefix in the request url after list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + Prefix: "some/sub/&folder", + }); + + expect(reqUrl!).toEndWith("/?list-type=2&prefix=some%2Fsub%2F%26folder"); + }); + + it("Should set encoded start-after in the request url after list-type", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + StartAfter: "àwsôme/fìles", + }); + + expect(reqUrl!).toEndWith("/?list-type=2&start-after=%E0ws%F4me%2Ff%ECles"); + }); + + it("Should work with multiple options all encoded in correct order", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response(`<>`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects({ + Prefix: "some/sub/&folder", + StartAfter: "àwsôme/fìles", + MaxKeys: 2034, + FetchOwner: true, + EncodingType: "url", + Delimiter: "files/", + ContinuationToken: "continue=ation-_m^token", + }); + + expect(reqUrl!).toEndWith( + "/?continuation-token=continue%3Dation-_m%5Etoken&delimiter=files%2F&encoding-type=url&fetch-owner=true&list-type=2&max-keys=2034&prefix=some%2Fsub%2F%26folder&start-after=%E0ws%F4me%2Ff%ECles", + ); + }); + + it("Should work without provided option", async () => { + using server = createBunServer(async => { + return new Response( + ` + my_bucket + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + Name: "my_bucket", + }); + }); + + it("Should work with extra options", async () => { + let reqHeaders: Headers; + using server = createBunServer(async req => { + reqHeaders = req.headers; + + return new Response( + ` + my_bucket + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + await client.listObjects(undefined, { + ...options, + bucket: "another-bucket", + sessionToken: "good token", + }); + + expect(reqHeaders!.get("x-amz-security-token")).toBe("good token"); + }); + + it("Should work without xmlns attrib", async () => { + using server = createBunServer(async => { + return new Response( + ` + my_bucket + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + Name: "my_bucket", + }); + }); + + it("Should return parsed response with bucket Name", async () => { + using server = createBunServer(async => { + return new Response( + ` + my_bucket + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + Name: "my_bucket", + }); + }); + it("Should return parsed response with Prefix", async () => { + using server = createBunServer(async => { + return new Response( + ` + some/prefix + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + Prefix: "some/prefix", + }); + }); + + it("Should return parsed response with KeyCount", async () => { + using server = createBunServer(async => { + return new Response( + ` + 18 + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + KeyCount: 18, + }); + }); + + it("Should return parsed response with MaxKeys", async () => { + using server = createBunServer(async => { + return new Response( + ` + 2323 + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + MaxKeys: 2323, + }); + }); + + it("Should return parsed response with Delimiter", async () => { + using server = createBunServer(async => { + return new Response( + ` + good@&/de$ + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + Delimiter: "good@&/de$ { + using server = createBunServer(async => { + return new Response( + ` + current pagination token + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + ContinuationToken: "current pagination token", + }); + }); + + it("Should return parsed response with EncodingType", async () => { + using server = createBunServer(async => { + return new Response( + ` + url + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + EncodingType: "url", + }); + }); + + it("Should return parsed response with NextContinuationToken", async () => { + using server = createBunServer(async => { + return new Response( + ` + some next token + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + NextContinuationToken: "some next token", + }); + }); + + it("Should return parsed response with IsTruncated (false)", async () => { + using server = createBunServer(async => { + return new Response( + ` + false + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + IsTruncated: false, + }); + }); + + it("Should return parsed response with IsTruncated (true)", async () => { + using server = createBunServer(async => { + return new Response( + ` + true + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + IsTruncated: true, + }); + }); + + it("Should return parsed response with StartAfter", async () => { + using server = createBunServer(async => { + return new Response( + ` + + + + some/file/name.pdf + + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + StartAfter: "some/file/name.pdf", + }); + }); + + it("Should return parsed response with CommonPrefixes", async () => { + using server = createBunServer(async => { + return new Response( + ` photos/videos/ + + + documents/public + + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + CommonPrefixes: [ + { + Prefix: "photos/", + }, + { + Prefix: "videos/", + }, + { + Prefix: "documents/public", + }, + ], + }); + }); + + it("Should return parsed response with Contents", async () => { + using server = createBunServer(async => { + return new Response( + ` + + my_files/important/bun.js + 2025-01-20T22:12:38.000Z + + false + 2012-12-21T00:00:00.000Z + + "4c6426ac7ef186464ecbb0d81cbfcb1e" + 102400 + + someId23Sodgopez + + STANDARD + + + + my_files/important/bun1.2.3.js + 2025-02-07 + "etag-with-quotes" + + + someId23Sodgopez + some display name + + GLACIER + + + + + all-empty_file + + + + + + + + + + + + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + Contents: [ + { + Key: "my_files/important/bun.js", + ETag: '"4c6426ac7ef186464ecbb0d81cbfcb1e"', + LastModified: "2025-01-20T22:12:38.000Z", + Size: 102400, + StorageClass: "STANDARD", + Owner: { + Id: "someId23Sodgopez", + }, + }, + { + Key: "my_files/important/bun1.2.3.js", + ETag: '"etag-with-quotes"', + LastModified: "2025-02-07", + StorageClass: "GLACIER", + Owner: { + Id: "someId23Sodgopez", + DisplayName: "some display name", + }, + }, + { + Key: "all-empty_file", + ETag: "", + LastModified: "", + // @ts-expect-error + StorageClass: "", + }, + ], + }); + }); + + it("Should return parsed response with all fields", async () => { + using server = createBunServer(async => { + return new Response( + ` + + inqnuam + / + 0 + 10000 + awsome.dummy thing + current pagination token + url + some next token + false + + from_static_file + 2025-01-20T23:02:53.000Z + "ef2b83534e23713ee9751d492178109e" + 922282819299999 + + some display name + some_id_ + + STANDARD_IA + + + + some/file/name.pdf + + + photos/videos/ +`, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects( + { + ContinuationToken: "token", + Prefix: "files/", + }, + {}, + ); + + expect(res).toEqual({ + Name: "inqnuam", + Prefix: "/", + Delimiter: "awsome.dummy thing", + StartAfter: "some/file/name.pdf", + EncodingType: "url", + ContinuationToken: "current pagination token", + NextContinuationToken: "some next token", + IsTruncated: false, + KeyCount: 0, + MaxKeys: 10000, + Contents: [ + { + Key: "from_static_file", + ETag: '"ef2b83534e23713ee9751d492178109e"', + LastModified: "2025-01-20T23:02:53.000Z", + Size: 922282819299999, + StorageClass: "STANDARD_IA", + Owner: { + DisplayName: "some display name", + Id: "some_id_", + }, + }, + ], + CommonPrefixes: [ + { + Prefix: "photos/", + }, + { + Prefix: "videos/", + }, + ], + }); + }); + + it("Should work with static method", async () => { + let reqUrl: string; + using server = createBunServer(async req => { + reqUrl = req.url; + return new Response( + ` + some next token + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const res = await S3Client.listObjects({ Prefix: "some/prefix" }, { ...options, endpoint: server.url.href }); + expect(reqUrl!).toEndWith("/my_bucket/?list-type=2&prefix=some%2Fprefix"); + expect(res).toEqual({ + NextContinuationToken: "some next token", + }); + }); + + it("Should work with big responses", async () => { + const Contents = new Array(40 * 1000).fill("").map(x => ({ + Key: randomUUIDv7(), + ETag: '"4c6426ac7ef186464ecbb0d81cbfcb1e"', + LastModified: new Date().toISOString(), + Size: 922282819299999, + StorageClass: "STANDARD_IA", + Owner: { + Id: "some_id_", + }, + })); + + using server = createBunServer(async => { + const asXml = Contents.map( + x => ` + ${x.Key} + ${x.LastModified} + "4c6426ac7ef186464ecbb0d81cbfcb1e" + 922282819299999 + + some_id_ + + STANDARD_IA + `, + ).join(""); + + return new Response(`${asXml}`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + + expect(res).toEqual({ + // @ts-ignore + Contents, + }); + }); + + it("Should not crash with bad xml", async () => { + using server = createBunServer(async => { + return new Response( + ` + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const res = await client.listObjects(); + expect(res).toEqual({}); + }); + + it("Should throw Error if request failed", async () => { + using server = createBunServer(async => { + return new Response( + ` + WhoKnows`, + { + headers: { + "Content-Type": "application/xml", + }, + status: 400, + }, + ); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + try { + await client.listObjects(); + expect.unreachable(); + } catch (error: any) { + expect(error.code).toBe("WhoKnows"); + } + }); + + it("Should throw if option is not an Object", async () => { + using server = createBunServer(async => { + return new Response(`Should not be errored here`, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + try { + // @ts-expect-error + await client.listObjects(11143n); + expect.unreachable(); + } catch (error: any) { + expect(error.code).toBe("ERR_INVALID_ARG_TYPE"); + } + }); +});