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