Mercurial
changeset 196:83f16548ba41
[AI] Adding s3 bucket uploader code using Seobeo.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sat, 14 Feb 2026 16:08:15 -0800 |
| parents | f8f5004a920a |
| children | 0106cb67d958 |
| files | s3/BUILD s3/s3_uploader.c s3/s3_uploader.h s3/tests/BUILD s3/tests/s3_uploader_test.c |
| diffstat | 5 files changed, 1156 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/BUILD Sat Feb 14 16:08:15 2026 -0800 @@ -0,0 +1,56 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") + +# File group +filegroup( + name = "s3_hdrs", + srcs = ["s3_uploader.h"], + visibility = ["//visibility:public"], +) + +# Main S3 uploader library (platform-aware) +alias( + name = "s3", + actual = select({ + "//config:macos": ":s3_macos", + "//config:linux": ":s3_linux", + "//conditions:default": ":s3_linux", + }), + visibility = ["//visibility:public"], +) + +cc_library( + name = "s3_macos", + srcs = ["s3_uploader.c"], + hdrs = [":s3_hdrs"], + deps = [ + "//dowa:dowa", + "//seobeo:seobeo_tcp_client", + "@openssl//:ssl", + ], + target_compatible_with = [ + "@platforms//os:osx", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "s3_linux", + srcs = ["s3_uploader.c"], + hdrs = [":s3_hdrs"], + deps = [ + "//dowa:dowa", + "//seobeo:seobeo_tcp_client", + "@openssl//:ssl", + ], + target_compatible_with = [ + "@platforms//os:linux", + ], + visibility = ["//visibility:public"], +) + +# Alias for convenience +alias( + name = "s3_uploader", + actual = ":s3", + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/s3_uploader.c Sat Feb 14 16:08:15 2026 -0800 @@ -0,0 +1,801 @@ +#include "s3_uploader.h" +#include "seobeo/seobeo.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <ctype.h> + +#include <openssl/hmac.h> +#include <openssl/sha.h> + +#define S3_ARENA_SIZE (10 * ONE_MEGA_BYTE) +#define S3_RESULT_ARENA_SIZE (64 * 1024) +#define S3_SERVICE_NAME "s3" +#define S3_AWS4_REQUEST "aws4_request" +#define S3_ALGORITHM "AWS4-HMAC-SHA256" + +// --- Internal Structures --- // + +typedef struct { + char date[9]; // YYYYMMDD + char datetime[17]; // YYYYMMDDTHHMMSSZ +} S3_Timestamp; + +// --- Forward Declarations --- // + +static void s3__get_timestamp(S3_Timestamp *p_ts); +static void s3__sha256_hex(const uint8 *data, size_t len, char *out); +static void s3__hmac_sha256(const uint8 *key, size_t key_len, + const uint8 *data, size_t data_len, + uint8 *out); +static void s3__hex_encode(const uint8 *data, size_t len, char *out); +static char *s3__uri_encode(const char *str, Dowa_Arena *p_arena); +static char *s3__build_canonical_request(const char *method, + const char *uri, + const char *query, + const char *headers, + const char *signed_headers, + const char *payload_hash, + Dowa_Arena *p_arena); +static char *s3__build_string_to_sign(const char *datetime, + const char *date, + const char *region, + const char *canonical_request, + Dowa_Arena *p_arena); +static void s3__calculate_signing_key(const char *secret_key, + const char *date, + const char *region, + uint8 *out); +static char *s3__build_authorization_header(const char *access_key, + const char *date, + const char *region, + const char *signed_headers, + const uint8 *signing_key, + const char *string_to_sign, + Dowa_Arena *p_arena); +static uint8 *s3__load_file(const char *path, size_t *p_size); + +// --- Public API Implementation --- // + +S3_Result S3_Upload_File(const S3_Config *p_config, + const char *local_path, + const char *s3_key) +{ + const char *content_type = S3_Guess_Content_Type(local_path); + return S3_Upload_File_With_Content_Type(p_config, local_path, s3_key, content_type); +} + +S3_Result S3_Upload_File_With_Content_Type(const S3_Config *p_config, + const char *local_path, + const char *s3_key, + const char *content_type) +{ + S3_Result result = {0}; + result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE); + + size_t file_size = 0; + uint8 *file_data = s3__load_file(local_path, &file_size); + if (!file_data) + { + result.success = FALSE; + result.status_code = 0; + result.error_message = Dowa_String_Copy_Arena("Failed to read file", result.p_arena); + return result; + } + + result = S3_Upload_Data(p_config, file_data, file_size, s3_key, content_type); + free(file_data); + + return result; +} + +S3_Result S3_Upload_Data(const S3_Config *p_config, + const uint8 *data, + size_t data_length, + const char *s3_key, + const char *content_type) +{ + S3_Result result = {0}; + result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE); + + if (!p_config || !data || !s3_key) + { + result.success = FALSE; + result.error_message = Dowa_String_Copy_Arena("Invalid parameters", result.p_arena); + return result; + } + + Dowa_Arena *p_arena = Dowa_Arena_Create(S3_ARENA_SIZE); + + // Get timestamp + S3_Timestamp ts; + s3__get_timestamp(&ts); + + // Calculate payload hash + char payload_hash[65]; + s3__sha256_hex(data, data_length, payload_hash); + + // Build host + char *host; + if (p_config->endpoint) + { + host = Dowa_String_Copy_Arena((char *)p_config->endpoint, p_arena); + } + else if (p_config->use_path_style) + { + size_t host_len = strlen("s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "s3.%s.amazonaws.com", p_config->region); + } + else + { + size_t host_len = strlen(p_config->bucket) + strlen(".s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "%s.s3.%s.amazonaws.com", p_config->bucket, p_config->region); + } + + // Build URI path + char *uri_path; + char *encoded_key = s3__uri_encode(s3_key, p_arena); + if (p_config->use_path_style) + { + size_t uri_len = strlen("/") + strlen(p_config->bucket) + strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s/%s", p_config->bucket, encoded_key); + } + else + { + size_t uri_len = strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s", encoded_key); + } + + // Build canonical headers (must be sorted alphabetically) + char content_length_str[32]; + snprintf(content_length_str, sizeof(content_length_str), "%zu", data_length); + + size_t headers_len = 512 + strlen(host) + strlen(content_type) + strlen(content_length_str); + char *canonical_headers = Dowa_Arena_Allocate(p_arena, headers_len); + snprintf(canonical_headers, headers_len, + "content-length:%s\n" + "content-type:%s\n" + "host:%s\n" + "x-amz-content-sha256:%s\n" + "x-amz-date:%s\n", + content_length_str, + content_type, + host, + payload_hash, + ts.datetime); + + const char *signed_headers = "content-length;content-type;host;x-amz-content-sha256;x-amz-date"; + + // Build canonical request + char *canonical_request = s3__build_canonical_request("PUT", + uri_path, + "", // No query string + canonical_headers, + signed_headers, + payload_hash, + p_arena); + + // Build string to sign + char *string_to_sign = s3__build_string_to_sign(ts.datetime, + ts.date, + p_config->region, + canonical_request, + p_arena); + + // Calculate signing key + uint8 signing_key[32]; + s3__calculate_signing_key(p_config->secret_access_key, + ts.date, + p_config->region, + signing_key); + + // Build authorization header + char *auth_header = s3__build_authorization_header(p_config->access_key_id, + ts.date, + p_config->region, + signed_headers, + signing_key, + string_to_sign, + p_arena); + + // Build URL + size_t url_len = strlen("https://") + strlen(host) + strlen(uri_path) + 1; + char *url = Dowa_Arena_Allocate(p_arena, url_len); + snprintf(url, url_len, "https://%s%s", host, uri_path); + + // Execute request using seobeo + Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Method(p_req, "PUT"); + Seobeo_Client_Request_Set_Body(p_req, (const char *)data, data_length); + Seobeo_Client_Request_Add_Header_Map(p_req, "Content-Type", content_type); + Seobeo_Client_Request_Add_Header_Map(p_req, "x-amz-date", ts.datetime); + Seobeo_Client_Request_Add_Header_Map(p_req, "x-amz-content-sha256", payload_hash); + Seobeo_Client_Request_Add_Header_Map(p_req, "Authorization", auth_header); + + Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); + + if (p_resp) + { + result.status_code = p_resp->status_code; + if (p_resp->status_code >= 200 && p_resp->status_code < 300) + { + result.success = TRUE; + + // Extract ETag from response headers + if (p_resp->headers) + { + char *etag = Dowa_HashMap_Get(p_resp->headers, "etag"); + if (!etag) etag = Dowa_HashMap_Get(p_resp->headers, "ETag"); + if (etag) + { + result.etag = Dowa_String_Copy_Arena(etag, result.p_arena); + } + } + } + else + { + result.success = FALSE; + if (p_resp->body && p_resp->body_length > 0) + { + result.error_message = Dowa_String_Copy_Arena(p_resp->body, result.p_arena); + } + else + { + result.error_message = Dowa_String_Copy_Arena("Upload failed", result.p_arena); + } + } + Seobeo_Client_Response_Destroy(p_resp); + } + else + { + result.success = FALSE; + result.error_message = Dowa_String_Copy_Arena("Failed to execute request", result.p_arena); + } + + Seobeo_Client_Request_Destroy(p_req); + Dowa_Arena_Free(p_arena); + + return result; +} + +void S3_Result_Destroy(S3_Result *p_result) +{ + if (p_result && p_result->p_arena) + { + Dowa_Arena_Free(p_result->p_arena); + p_result->p_arena = NULL; + p_result->error_message = NULL; + p_result->etag = NULL; + } +} + +// --- Presigned URL Implementation --- // + +static char *s3__uri_encode_strict(const char *str, Dowa_Arena *p_arena) +{ + // Stricter encoding for query string values (no forward slash allowed) + if (!str) return NULL; + + size_t len = strlen(str); + size_t max_len = len * 3 + 1; + char *out = Dowa_Arena_Allocate(p_arena, max_len); + char *p = out; + + for (size_t i = 0; i < len; i++) + { + char c = str[i]; + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~') + { + *p++ = c; + } + else + { + sprintf(p, "%%%02X", (unsigned char)c); + p += 3; + } + } + *p = '\0'; + return out; +} + +static S3_Presigned_URL s3__presign_url(const S3_Config *p_config, + const char *s3_key, + const char *method, + const char *content_type, + int32 expires_seconds) +{ + S3_Presigned_URL result = {0}; + result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE); + + if (!p_config || !s3_key) + { + result.success = FALSE; + result.error_message = Dowa_String_Copy_Arena("Invalid parameters", result.p_arena); + return result; + } + + Dowa_Arena *p_arena = Dowa_Arena_Create(S3_ARENA_SIZE); + + // Get timestamp + S3_Timestamp ts; + s3__get_timestamp(&ts); + + // Build host + char *host; + if (p_config->endpoint) + { + host = Dowa_String_Copy_Arena((char *)p_config->endpoint, p_arena); + } + else if (p_config->use_path_style) + { + size_t host_len = strlen("s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "s3.%s.amazonaws.com", p_config->region); + } + else + { + size_t host_len = strlen(p_config->bucket) + strlen(".s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "%s.s3.%s.amazonaws.com", p_config->bucket, p_config->region); + } + + // Build URI path + char *uri_path; + char *encoded_key = s3__uri_encode(s3_key, p_arena); + if (p_config->use_path_style) + { + size_t uri_len = strlen("/") + strlen(p_config->bucket) + strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s/%s", p_config->bucket, encoded_key); + } + else + { + size_t uri_len = strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s", encoded_key); + } + + // Build credential scope + size_t scope_len = strlen(p_config->access_key_id) + strlen(ts.date) + strlen(p_config->region) + + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 10; + char *credential = Dowa_Arena_Allocate(p_arena, scope_len); + snprintf(credential, scope_len, "%s/%s/%s/%s/%s", + p_config->access_key_id, ts.date, p_config->region, S3_SERVICE_NAME, S3_AWS4_REQUEST); + + char *encoded_credential = s3__uri_encode_strict(credential, p_arena); + + // Build signed headers (host is required, content-type for PUT) + const char *signed_headers; + if (content_type && strcmp(method, "PUT") == 0) + { + signed_headers = "content-type;host"; + } + else + { + signed_headers = "host"; + } + + // Build canonical query string (must be sorted alphabetically) + char expires_str[16]; + snprintf(expires_str, sizeof(expires_str), "%d", expires_seconds); + + size_t query_len = 1024 + strlen(encoded_credential); + char *canonical_query = Dowa_Arena_Allocate(p_arena, query_len); + + if (content_type && strcmp(method, "PUT") == 0) + { + char *encoded_content_type = s3__uri_encode_strict(content_type, p_arena); + snprintf(canonical_query, query_len, + "X-Amz-Algorithm=%s" + "&X-Amz-Credential=%s" + "&X-Amz-Date=%s" + "&X-Amz-Expires=%s" + "&X-Amz-SignedHeaders=%s" + "&x-amz-content-type=%s", + S3_ALGORITHM, + encoded_credential, + ts.datetime, + expires_str, + signed_headers, + encoded_content_type); + } + else + { + snprintf(canonical_query, query_len, + "X-Amz-Algorithm=%s" + "&X-Amz-Credential=%s" + "&X-Amz-Date=%s" + "&X-Amz-Expires=%s" + "&X-Amz-SignedHeaders=%s", + S3_ALGORITHM, + encoded_credential, + ts.datetime, + expires_str, + signed_headers); + } + + // Build canonical headers + size_t headers_len = 256 + strlen(host) + (content_type ? strlen(content_type) : 0); + char *canonical_headers = Dowa_Arena_Allocate(p_arena, headers_len); + + if (content_type && strcmp(method, "PUT") == 0) + { + snprintf(canonical_headers, headers_len, + "content-type:%s\nhost:%s\n", + content_type, host); + } + else + { + snprintf(canonical_headers, headers_len, "host:%s\n", host); + } + + // For presigned URLs, payload is UNSIGNED-PAYLOAD + const char *payload_hash = "UNSIGNED-PAYLOAD"; + + // Build canonical request + char *canonical_request = s3__build_canonical_request(method, + uri_path, + canonical_query, + canonical_headers, + signed_headers, + payload_hash, + p_arena); + + // Build string to sign + char *string_to_sign = s3__build_string_to_sign(ts.datetime, + ts.date, + p_config->region, + canonical_request, + p_arena); + + // Calculate signing key + uint8 signing_key[32]; + s3__calculate_signing_key(p_config->secret_access_key, + ts.date, + p_config->region, + signing_key); + + // Calculate signature + uint8 signature[32]; + s3__hmac_sha256(signing_key, 32, + (const uint8 *)string_to_sign, strlen(string_to_sign), + signature); + + char signature_hex[65]; + s3__hex_encode(signature, 32, signature_hex); + + // Build final presigned URL + size_t url_len = strlen("https://") + strlen(host) + strlen(uri_path) + 1 + + strlen(canonical_query) + strlen("&X-Amz-Signature=") + 64 + 1; + char *url = Dowa_Arena_Allocate(result.p_arena, url_len); + snprintf(url, url_len, "https://%s%s?%s&X-Amz-Signature=%s", + host, uri_path, canonical_query, signature_hex); + + result.success = TRUE; + result.url = url; + + Dowa_Arena_Free(p_arena); + return result; +} + +S3_Presigned_URL S3_Presign_Put(const S3_Config *p_config, + const char *s3_key, + const char *content_type, + int32 expires_seconds) +{ + return s3__presign_url(p_config, s3_key, "PUT", content_type, expires_seconds); +} + +S3_Presigned_URL S3_Presign_Get(const S3_Config *p_config, + const char *s3_key, + int32 expires_seconds) +{ + return s3__presign_url(p_config, s3_key, "GET", NULL, expires_seconds); +} + +void S3_Presigned_URL_Destroy(S3_Presigned_URL *p_url) +{ + if (p_url && p_url->p_arena) + { + Dowa_Arena_Free(p_url->p_arena); + p_url->p_arena = NULL; + p_url->url = NULL; + p_url->error_message = NULL; + } +} + +const char *S3_Guess_Content_Type(const char *filename) +{ + if (!filename) return "application/octet-stream"; + + const char *dot = strrchr(filename, '.'); + if (!dot) return "application/octet-stream"; + + dot++; // Skip the dot + + // Common content types + if (strcasecmp(dot, "html") == 0 || strcasecmp(dot, "htm") == 0) + return "text/html"; + if (strcasecmp(dot, "css") == 0) + return "text/css"; + if (strcasecmp(dot, "js") == 0) + return "application/javascript"; + if (strcasecmp(dot, "json") == 0) + return "application/json"; + if (strcasecmp(dot, "xml") == 0) + return "application/xml"; + if (strcasecmp(dot, "txt") == 0) + return "text/plain"; + if (strcasecmp(dot, "csv") == 0) + return "text/csv"; + + // Images + if (strcasecmp(dot, "png") == 0) + return "image/png"; + if (strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0) + return "image/jpeg"; + if (strcasecmp(dot, "gif") == 0) + return "image/gif"; + if (strcasecmp(dot, "svg") == 0) + return "image/svg+xml"; + if (strcasecmp(dot, "webp") == 0) + return "image/webp"; + if (strcasecmp(dot, "ico") == 0) + return "image/x-icon"; + + // Audio/Video + if (strcasecmp(dot, "mp3") == 0) + return "audio/mpeg"; + if (strcasecmp(dot, "mp4") == 0) + return "video/mp4"; + if (strcasecmp(dot, "webm") == 0) + return "video/webm"; + if (strcasecmp(dot, "ogg") == 0) + return "audio/ogg"; + if (strcasecmp(dot, "wav") == 0) + return "audio/wav"; + + // Documents + if (strcasecmp(dot, "pdf") == 0) + return "application/pdf"; + if (strcasecmp(dot, "zip") == 0) + return "application/zip"; + if (strcasecmp(dot, "gz") == 0 || strcasecmp(dot, "gzip") == 0) + return "application/gzip"; + if (strcasecmp(dot, "tar") == 0) + return "application/x-tar"; + + // Fonts + if (strcasecmp(dot, "woff") == 0) + return "font/woff"; + if (strcasecmp(dot, "woff2") == 0) + return "font/woff2"; + if (strcasecmp(dot, "ttf") == 0) + return "font/ttf"; + if (strcasecmp(dot, "otf") == 0) + return "font/otf"; + + return "application/octet-stream"; +} + +// --- Internal Implementation --- // + +static void s3__get_timestamp(S3_Timestamp *p_ts) +{ + time_t now = time(NULL); + struct tm *utc = gmtime(&now); + + strftime(p_ts->date, sizeof(p_ts->date), "%Y%m%d", utc); + strftime(p_ts->datetime, sizeof(p_ts->datetime), "%Y%m%dT%H%M%SZ", utc); +} + +static void s3__hex_encode(const uint8 *data, size_t len, char *out) +{ + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) + { + out[i * 2] = hex[(data[i] >> 4) & 0x0F]; + out[i * 2 + 1] = hex[data[i] & 0x0F]; + } + out[len * 2] = '\0'; +} + +static void s3__sha256_hex(const uint8 *data, size_t len, char *out) +{ + uint8 hash[SHA256_DIGEST_LENGTH]; + SHA256(data, len, hash); + s3__hex_encode(hash, SHA256_DIGEST_LENGTH, out); +} + +static void s3__hmac_sha256(const uint8 *key, size_t key_len, + const uint8 *data, size_t data_len, + uint8 *out) +{ + unsigned int out_len = 0; + HMAC(EVP_sha256(), key, (int)key_len, data, data_len, out, &out_len); +} + +static char *s3__uri_encode(const char *str, Dowa_Arena *p_arena) +{ + if (!str) return NULL; + + size_t len = strlen(str); + // Worst case: every char becomes %XX (3 chars) + size_t max_len = len * 3 + 1; + char *out = Dowa_Arena_Allocate(p_arena, max_len); + char *p = out; + + for (size_t i = 0; i < len; i++) + { + char c = str[i]; + // Unreserved characters per RFC 3986 + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + { + *p++ = c; + } + else + { + sprintf(p, "%%%02X", (unsigned char)c); + p += 3; + } + } + *p = '\0'; + return out; +} + +static char *s3__build_canonical_request(const char *method, + const char *uri, + const char *query, + const char *headers, + const char *signed_headers, + const char *payload_hash, + Dowa_Arena *p_arena) +{ + size_t len = strlen(method) + strlen(uri) + strlen(query) + + strlen(headers) + strlen(signed_headers) + strlen(payload_hash) + 10; + char *out = Dowa_Arena_Allocate(p_arena, len); + snprintf(out, len, "%s\n%s\n%s\n%s\n%s\n%s", + method, uri, query, headers, signed_headers, payload_hash); + return out; +} + +static char *s3__build_string_to_sign(const char *datetime, + const char *date, + const char *region, + const char *canonical_request, + Dowa_Arena *p_arena) +{ + // Hash the canonical request + char canonical_hash[65]; + s3__sha256_hex((const uint8 *)canonical_request, strlen(canonical_request), canonical_hash); + + // Build credential scope + size_t scope_len = strlen(date) + strlen(region) + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 4; + char *scope = Dowa_Arena_Allocate(p_arena, scope_len); + snprintf(scope, scope_len, "%s/%s/%s/%s", date, region, S3_SERVICE_NAME, S3_AWS4_REQUEST); + + // Build string to sign + size_t len = strlen(S3_ALGORITHM) + strlen(datetime) + strlen(scope) + strlen(canonical_hash) + 10; + char *out = Dowa_Arena_Allocate(p_arena, len); + snprintf(out, len, "%s\n%s\n%s\n%s", S3_ALGORITHM, datetime, scope, canonical_hash); + + return out; +} + +static void s3__calculate_signing_key(const char *secret_key, + const char *date, + const char *region, + uint8 *out) +{ + // AWS4 + SecretAccessKey + size_t key_len = 4 + strlen(secret_key) + 1; + char *k_secret = malloc(key_len); + snprintf(k_secret, key_len, "AWS4%s", secret_key); + + uint8 k_date[32]; + uint8 k_region[32]; + uint8 k_service[32]; + + s3__hmac_sha256((const uint8 *)k_secret, strlen(k_secret), + (const uint8 *)date, strlen(date), k_date); + + s3__hmac_sha256(k_date, 32, + (const uint8 *)region, strlen(region), k_region); + + s3__hmac_sha256(k_region, 32, + (const uint8 *)S3_SERVICE_NAME, strlen(S3_SERVICE_NAME), k_service); + + s3__hmac_sha256(k_service, 32, + (const uint8 *)S3_AWS4_REQUEST, strlen(S3_AWS4_REQUEST), out); + + free(k_secret); +} + +static char *s3__build_authorization_header(const char *access_key, + const char *date, + const char *region, + const char *signed_headers, + const uint8 *signing_key, + const char *string_to_sign, + Dowa_Arena *p_arena) +{ + // Calculate signature + uint8 signature[32]; + s3__hmac_sha256(signing_key, 32, + (const uint8 *)string_to_sign, strlen(string_to_sign), + signature); + + char signature_hex[65]; + s3__hex_encode(signature, 32, signature_hex); + + // Build credential + size_t cred_len = strlen(access_key) + strlen(date) + strlen(region) + + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 10; + char *credential = Dowa_Arena_Allocate(p_arena, cred_len); + snprintf(credential, cred_len, "%s/%s/%s/%s/%s", + access_key, date, region, S3_SERVICE_NAME, S3_AWS4_REQUEST); + + // Build full authorization header + size_t auth_len = strlen(S3_ALGORITHM) + strlen(credential) + + strlen(signed_headers) + strlen(signature_hex) + 64; + char *auth = Dowa_Arena_Allocate(p_arena, auth_len); + snprintf(auth, auth_len, + "%s Credential=%s, SignedHeaders=%s, Signature=%s", + S3_ALGORITHM, credential, signed_headers, signature_hex); + + return auth; +} + +static uint8 *s3__load_file(const char *path, size_t *p_size) +{ + FILE *f = fopen(path, "rb"); + if (!f) + { + *p_size = 0; + return NULL; + } + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (size <= 0) + { + fclose(f); + *p_size = 0; + return NULL; + } + + uint8 *data = malloc((size_t)size); + if (!data) + { + fclose(f); + *p_size = 0; + return NULL; + } + + size_t read = fread(data, 1, (size_t)size, f); + fclose(f); + + if (read != (size_t)size) + { + free(data); + *p_size = 0; + return NULL; + } + + *p_size = (size_t)size; + return data; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/s3_uploader.h Sat Feb 14 16:08:15 2026 -0800 @@ -0,0 +1,150 @@ +#ifndef S3_UPLOADER_H +#define S3_UPLOADER_H + +/** + * S3 Uploader + * ----------- + * + * Generic S3 bucket uploader using AWS Signature Version 4. + * Built on top of seobeo HTTP client. + * + * ## Examples + * + * ### 1. Upload a file from disk + * + * ```c + * S3_Config config = { + * .access_key_id = "AKIAIOSFODNN7EXAMPLE", + * .secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + * .region = "us-east-1", + * .bucket = "my-bucket" + * }; + * + * S3_Result result = S3_Upload_File(&config, "/path/to/local/file.txt", "uploads/file.txt"); + * if (result.success) + * { + * printf("Uploaded successfully!\n"); + * } + * else + * { + * printf("Error: %s\n", result.error_message); + * } + * S3_Result_Destroy(&result); + * ``` + * + * ### 2. Upload raw data from memory + * + * ```c + * const char *data = "Hello, S3!"; + * S3_Result result = S3_Upload_Data(&config, (const uint8 *)data, strlen(data), + * "hello.txt", "text/plain"); + * S3_Result_Destroy(&result); + * ``` + * + * ### 3. Upload with custom content type + * + * ```c + * S3_Result result = S3_Upload_File_With_Content_Type(&config, + * "/path/to/image.png", + * "images/photo.png", + * "image/png"); + * S3_Result_Destroy(&result); + * ``` + */ + +#include <stddef.h> + +#include "dowa/dowa.h" + +// S3 configuration +typedef struct { + const char *access_key_id; // AWS access key ID + const char *secret_access_key; // AWS secret access key + const char *region; // AWS region (e.g., "us-east-1") + const char *bucket; // S3 bucket name + const char *endpoint; // Optional custom endpoint (NULL for default AWS) + boolean use_path_style; // Use path-style URLs (bucket in path, not subdomain) +} S3_Config; + +// Upload result +typedef struct { + boolean success; // TRUE if upload succeeded + int32 status_code; // HTTP status code + char *error_message; // Error message (NULL on success) + char *etag; // ETag of uploaded object (on success) + Dowa_Arena *p_arena; // Internal arena (do not modify) +} S3_Result; + +// --- Core API --- // + +/* Upload file from disk to S3. Content-Type is auto-detected. */ +extern S3_Result S3_Upload_File(const S3_Config *p_config, + const char *local_path, + const char *s3_key); + +/* Upload file with explicit content type. */ +extern S3_Result S3_Upload_File_With_Content_Type(const S3_Config *p_config, + const char *local_path, + const char *s3_key, + const char *content_type); + +/* Upload raw data from memory to S3. */ +extern S3_Result S3_Upload_Data(const S3_Config *p_config, + const uint8 *data, + size_t data_length, + const char *s3_key, + const char *content_type); + +/* Free resources associated with result. */ +extern void S3_Result_Destroy(S3_Result *p_result); + +// --- Presigned URLs --- // + +// Presigned URL result +typedef struct { + boolean success; // TRUE if generation succeeded + char *url; // The presigned URL (on success) + char *error_message; // Error message (on failure) + Dowa_Arena *p_arena; // Internal arena (do not modify) +} S3_Presigned_URL; + +/* Generate a presigned URL for uploading (PUT) a file. + * + * The client can use this URL to upload directly to S3 without credentials. + * + * Example: + * S3_Presigned_URL url = S3_Presign_Put(&config, "uploads/file.txt", "image/png", 3600); + * if (url.success) { + * printf("Upload URL: %s\n", url.url); + * // Client does: curl -X PUT -H "Content-Type: image/png" --data-binary @file.png "$URL" + * } + * S3_Presigned_URL_Destroy(&url); + */ +extern S3_Presigned_URL S3_Presign_Put(const S3_Config *p_config, + const char *s3_key, + const char *content_type, + int32 expires_seconds); + +/* Generate a presigned URL for downloading (GET) a file. + * + * Example: + * S3_Presigned_URL url = S3_Presign_Get(&config, "uploads/file.txt", 3600); + * if (url.success) { + * printf("Download URL: %s\n", url.url); + * // Client does: curl "$URL" -o file.txt + * } + * S3_Presigned_URL_Destroy(&url); + */ +extern S3_Presigned_URL S3_Presign_Get(const S3_Config *p_config, + const char *s3_key, + int32 expires_seconds); + +/* Free resources associated with presigned URL result. */ +extern void S3_Presigned_URL_Destroy(S3_Presigned_URL *p_url); + +// --- Utility Functions --- // + +/* Guess content type from file extension. Returns "application/octet-stream" if unknown. */ +extern const char *S3_Guess_Content_Type(const char *filename); + +#endif
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/tests/BUILD Sat Feb 14 16:08:15 2026 -0800 @@ -0,0 +1,10 @@ +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") + +cc_binary( + name = "s3_uploader_test", + srcs = ["s3_uploader_test.c"], + deps = [ + "//s3:s3", + ], + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/tests/s3_uploader_test.c Sat Feb 14 16:08:15 2026 -0800 @@ -0,0 +1,139 @@ +#include "s3/s3_uploader.h" + +#include <stdio.h> +#include <string.h> + +// Simple test for S3 uploader +// Run with: bazel run //s3/tests:s3_uploader_test +// +// Set environment variables before running: +// export AWS_ACCESS_KEY_ID="your-access-key" +// export AWS_SECRET_ACCESS_KEY="your-secret-key" +// export AWS_REGION="us-east-1" +// export AWS_BUCKET="your-bucket" + +int main(int argc, char **argv) +{ + const char *access_key = getenv("AWS_ACCESS_KEY_ID"); + const char *secret_key = getenv("AWS_SECRET_ACCESS_KEY"); + const char *region = getenv("AWS_REGION"); + const char *bucket = getenv("AWS_BUCKET"); + + if (!access_key || !secret_key || !region || !bucket) + { + printf("Missing environment variables. Set:\n"); + printf(" AWS_ACCESS_KEY_ID\n"); + printf(" AWS_SECRET_ACCESS_KEY\n"); + printf(" AWS_REGION\n"); + printf(" AWS_BUCKET\n"); + return 1; + } + + S3_Config config = { + .access_key_id = access_key, + .secret_access_key = secret_key, + .region = region, + .bucket = bucket, + .endpoint = NULL, + .use_path_style = FALSE + }; + + printf("S3 Uploader Test\n"); + printf("================\n"); + printf("Region: %s\n", region); + printf("Bucket: %s\n", bucket); + printf("\n"); + + // Test 1: Upload string data + printf("Test 1: Uploading string data...\n"); + const char *test_data = "Hello from S3 uploader test!"; + S3_Result result = S3_Upload_Data(&config, + (const uint8 *)test_data, + strlen(test_data), + "test/hello.txt", + "text/plain"); + if (result.success) + { + printf(" SUCCESS! ETag: %s\n", result.etag ? result.etag : "(none)"); + } + else + { + printf(" FAILED! Status: %d, Error: %s\n", + result.status_code, + result.error_message ? result.error_message : "(unknown)"); + } + S3_Result_Destroy(&result); + + // Test 2: Content type detection + printf("\nTest 2: Content type detection...\n"); + printf(" .html -> %s\n", S3_Guess_Content_Type("page.html")); + printf(" .png -> %s\n", S3_Guess_Content_Type("image.png")); + printf(" .json -> %s\n", S3_Guess_Content_Type("data.json")); + printf(" .xyz -> %s\n", S3_Guess_Content_Type("unknown.xyz")); + + // Test 3: Generate presigned PUT URL + printf("\nTest 3: Generating presigned PUT URL...\n"); + S3_Presigned_URL put_url = S3_Presign_Put(&config, + "uploads/client-upload.png", + "image/png", + 3600); // 1 hour + if (put_url.success) + { + printf(" SUCCESS!\n"); + printf(" URL: %s\n", put_url.url); + printf("\n Client can upload with:\n"); + printf(" curl -X PUT -H \"Content-Type: image/png\" --data-binary @file.png \"$URL\"\n"); + } + else + { + printf(" FAILED! Error: %s\n", put_url.error_message ? put_url.error_message : "(unknown)"); + } + S3_Presigned_URL_Destroy(&put_url); + + // Test 4: Generate presigned GET URL + printf("\nTest 4: Generating presigned GET URL...\n"); + S3_Presigned_URL get_url = S3_Presign_Get(&config, + "uploads/client-upload.png", + 3600); // 1 hour + if (get_url.success) + { + printf(" SUCCESS!\n"); + printf(" URL: %s\n", get_url.url); + printf("\n Client can download with:\n"); + printf(" curl \"$URL\" -o file.png\n"); + } + else + { + printf(" FAILED! Error: %s\n", get_url.error_message ? get_url.error_message : "(unknown)"); + } + S3_Presigned_URL_Destroy(&get_url); + + // Test 5: Upload file (if provided as argument) + if (argc >= 3) + { + printf("\nTest 5: Uploading file...\n"); + printf(" Local: %s\n", argv[1]); + printf(" S3 Key: %s\n", argv[2]); + + result = S3_Upload_File(&config, argv[1], argv[2]); + if (result.success) + { + printf(" SUCCESS! ETag: %s\n", result.etag ? result.etag : "(none)"); + } + else + { + printf(" FAILED! Status: %d, Error: %s\n", + result.status_code, + result.error_message ? result.error_message : "(unknown)"); + } + S3_Result_Destroy(&result); + } + else + { + printf("\nTest 5: Skipped (no file path provided)\n"); + printf(" Usage: %s <local_file> <s3_key>\n", argv[0]); + } + + printf("\nDone!\n"); + return 0; +}