Mercurial
changeset 221:ce7f4400c2de hg-web
[hg-web] Harden forge and add changeset UI
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 09:01:24 -0700 |
| parents | 8c9bb0b0759e |
| children | a8d6435dc021 |
| files | hg-web/README.md hg-web/deploy.sh hg-web/main.c hg-web/src/components/app.tsx hg-web/src/components/graph.tsx hg-web/src/index.css markdown_converter/markdown_to_html.c markdown_converter/tests/BUILD markdown_converter/tests/markdown_to_html_test.c seobeo/s_http_client.c seobeo/s_web.c seobeo/seobeo.h seobeo/seobeo_internal.h seobeo/tests/BUILD seobeo/tests/seobeo_http_content_length_test.c seobeo/tests/seobeo_http_framing_test.c seobeo/tests/seobeo_http_timeout_test.c seobeo/tests/seobeo_response_test.c |
| diffstat | 18 files changed, 1650 insertions(+), 543 deletions(-) [+] |
line wrap: on
line diff
--- a/hg-web/README.md Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/README.md Sun Aug 02 09:01:24 2026 -0700 @@ -111,6 +111,13 @@ -> ApiGetGraph -> GET hg-serve/graph/<revision>?... -> React rows + custom pencil/panda canvas + +graph row selection + -> /changeset/<revision> + -> GET /api/changeset/<revision> + -> ApiGetChangeset + -> GET hg-serve/json-rev/<revision> + -> changeset metadata + rendered diff ``` ### Mercurial clone, pull, and push @@ -129,11 +136,14 @@ | --- | --- | --- | --- | | `GET` | `/` | `GetReactHome` | Application shell | | `GET` | `/directories` | `GetReactHome` | Legacy application-shell route | +| `GET` | `/directory` | `GetReactHome` | Repository browser application route | | `GET` | `/graph` | `GetReactHome` | Commit graph application route | +| `GET` | `/changeset/:changeset_id` | `GetReactHome` | Changeset application route | | `GET` | `/api/repo/list` | `ApiListDirectory` | Directory listing JSON | | `GET` | `/api/repo/file` | `ApiGetFile` | Raw tracked file | -| `GET` | `/api/repo/readme` | `ApiGetReadme` | Alias of the raw-file handler | +| `GET` | `/api/repo/readme` | `ApiGetReadme` | Directory README content | | `GET` | `/api/graph/:graph_id` | `ApiGetGraph` | Mercurial graph JSON | +| `GET` | `/api/changeset/:changeset_id` | `ApiGetChangeset` | Changeset metadata and diff JSON | | `GET`, `POST` | `/repo` | `StreamHgWireProtocol` | Mercurial wire protocol | ## Frontend ownership @@ -159,6 +169,8 @@ ```bash bazel build //hg-web:hg_web_server bazel build //hg-web:hg_web_server_bundle +bazel test //markdown_converter/tests:markdown_to_html_test +bazel test //seobeo/tests:all ``` Run the Mercurial backend: @@ -189,9 +201,12 @@ -> hg serve service on 127.0.0.1:4444 ``` -`deploy.sh` currently builds an optimized bundle, copies it to `/opt`, swaps -the active directory, changes ownership to `hg_web_server:zenbu_team`, and -restarts `hg_web_server.service`. +`deploy.sh` builds an optimized bundle into a revisioned release directory, +atomically repoints `/opt/hg_web_server_bundle_active`, restarts +`hg_web_server.service`, and checks `http://127.0.0.1:6970/`. A failed restart +or health check restores the previous release and restarts it. The service, +release root, active path, health URL, user, and group can be overridden with +environment variables. ## Forge capability tree @@ -202,7 +217,7 @@ │ ├── Syntax highlighting available │ ├── README rendering available │ ├── Commit graph available -│ ├── Changeset detail and diff planned +│ ├── Changeset detail and diff available │ ├── Branches, bookmarks, and tags planned │ ├── File history and blame planned │ └── Search planned @@ -218,7 +233,7 @@ ├── Live logs and job status API planned ├── Forge status and log screens planned ├── Artifact retention planned - └── Atomic deploy, health check, rollback planned + └── Atomic deploy, health check, rollback available ``` ## Automation data flow @@ -245,12 +260,10 @@ ## Safe extension order -1. Harden proxy errors, path validation, response headers, and timeouts. -2. Add changeset detail and diff APIs, then wire graph clicks to those screens. -3. Make deployment atomic with health checks and rollback. -4. Add the hook, SQLite queue, single runner, and retained logs. -5. Add run/status/log pages using the existing custom UI. -6. Add authentication and authorization before accepting public pushes or +1. Add branches, bookmarks, tags, file history, blame, and search. +2. Add the hook, SQLite queue, single runner, and retained logs. +3. Add run/status/log pages using the existing custom UI. +4. Add authentication and authorization before accepting public pushes or user-defined automation. ## Invariants
--- a/hg-web/deploy.sh Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/deploy.sh Sun Aug 02 09:01:24 2026 -0700 @@ -1,15 +1,82 @@ -#!/bin/bash -# sudo groupadd zenbu_team -- already added -# sudo useradd -r -s /usr/sbin/nologin -G zenbu_team hg_web_server +#!/usr/bin/env bash +set -Eeuo pipefail + +SERVICE_NAME="${SERVICE_NAME:-hg_web_server.service}" +RELEASE_ROOT="${RELEASE_ROOT:-/opt/hg_web_server_releases}" +ACTIVE_PATH="${ACTIVE_PATH:-/opt/hg_web_server_bundle_active}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}" +SERVICE_USER="${SERVICE_USER:-hg_web_server}" +SERVICE_GROUP="${SERVICE_GROUP:-zenbu_team}" + +workspace="$(hg root)" +cd "$workspace" + +revision="$(hg log -r . -T '{node|short}')" +release_name="${revision}-$(date -u +%Y%m%dT%H%M%SZ)" +release_dir="${RELEASE_ROOT}/${release_name}" +staging_dir="${RELEASE_ROOT}/.${release_name}.tmp" +next_link="${ACTIVE_PATH}.next" +bundle_dir="bazel-bin/hg-web/hg_web_server_bundle" +previous_release="" +promoted=0 + +health_check() { + for _ in $(seq 1 20); do + if curl --fail --silent --max-time 3 "$HEALTH_URL" >/dev/null; then + return 0 + fi + sleep 1 + done + echo "Health check failed: $HEALTH_URL" >&2 + return 1 +} + +point_active_at() { + local target="$1" + sudo rm -f "$next_link" + sudo ln -s "$target" "$next_link" + sudo mv -Tf "$next_link" "$ACTIVE_PATH" +} + +rollback() { + trap - ERR + if [[ "$promoted" -eq 1 && -n "$previous_release" && -d "$previous_release" ]]; then + echo "Deployment failed; rolling back to $previous_release" >&2 + point_active_at "$previous_release" + sudo systemctl restart "$SERVICE_NAME" + health_check || echo "Rollback completed, but the health check still fails." >&2 + else + echo "Deployment failed and no previous release is available for rollback." >&2 + fi + sudo rm -rf "$staging_dir" + exit 1 +} +trap rollback ERR + bazel build -c opt //hg-web:hg_web_server_bundle -# Create -sudo cp -a bazel-bin/hg-web/hg_web_server_bundle /opt/hg_web_server_bundle_new -sudo chown -R hg_web_server:zenbu_team /opt/hg_web_server_bundle_new +sudo install -d -o root -g "$SERVICE_GROUP" -m 0755 "$RELEASE_ROOT" +sudo rm -rf "$staging_dir" +sudo install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$staging_dir" +sudo cp -a "${bundle_dir}/." "$staging_dir/" +sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$staging_dir" + +sudo test -x "$staging_dir/hg_web_server" +sudo test -f "$staging_dir/hg-web/src/index.html" +sudo test -f "$staging_dir/hg-web/src/page.js" +sudo mv "$staging_dir" "$release_dir" -# Swap -sudo rm -rf /opt/hg_web_server_bundle_active -sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active +if [[ -L "$ACTIVE_PATH" ]]; then + previous_release="$(readlink -f "$ACTIVE_PATH")" +elif [[ -d "$ACTIVE_PATH" ]]; then + previous_release="${RELEASE_ROOT}/legacy-$(date -u +%Y%m%dT%H%M%SZ)" + sudo mv "$ACTIVE_PATH" "$previous_release" +fi -sudo systemctl restart hg_web_server.service -echo "Deployment complete!" +point_active_at "$release_dir" +promoted=1 +sudo systemctl restart "$SERVICE_NAME" +health_check + +trap - ERR +echo "Deployment complete: $release_dir"
--- a/hg-web/main.c Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/main.c Sun Aug 02 09:01:24 2026 -0700 @@ -1,450 +1,680 @@ #include "seobeo/seobeo.h" #include "dowa/dowa.h" + +#include <ctype.h> +#include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> -#include <ctype.h> +#include <strings.h> +#include <time.h> #include <unistd.h> -#include <sys/socket.h> -#include <netinet/in.h> -#include <arpa/inet.h> -#include <netdb.h> #define HG_SERVE_HOST "127.0.0.1" #define HG_SERVE_PORT "4444" - -#define MAX_PATH 4096 +#define HG_API_TIMEOUT_MS 15000 +#define HG_STREAM_IDLE_TIMEOUT_MS 60000 +#define MAX_PATH_LENGTH 4096 +#define MAX_WIRE_QUERY_LENGTH 8192 +#define MAX_WIRE_HEADER_LENGTH 8192 -static char* sanitize_path(const char *input_path, Dowa_Arena *arena) +static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key) { - if (!input_path || strlen(input_path) == 0) + if (!map || !key) + return NULL; + + for (size_t i = 0; i < Dowa_Array_Length(map); i++) { - char *empty = Dowa_Arena_Allocate(arena, 1); - empty[0] = '\0'; - return empty; + if (map[i].key && strcasecmp(map[i].key, key) == 0) + return map[i].value; } + return NULL; +} + +static char *arena_string(Dowa_Arena *arena, const char *value) +{ + size_t length = strlen(value); + char *copy = Dowa_Arena_Allocate(arena, length + 1); + memcpy(copy, value, length + 1); + return copy; +} - size_t len = strlen(input_path); - char *result = Dowa_Arena_Allocate(arena, len + 1); - size_t j = 0; +static Seobeo_Request_Entry *text_response( + Dowa_Arena *arena, + const char *status, + const char *content_type, + const char *body) +{ + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena); + Dowa_HashMap_Push_Arena( + response, "content-type", arena_string(arena, content_type), arena); + Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena); + return response; +} - for (size_t i = 0; i < len; i++) +static boolean decode_url_component( + const char *encoded, + Dowa_Arena *arena, + char **decoded_out, + size_t *decoded_length_out) +{ + if (!encoded || !decoded_out) + return FALSE; + + size_t encoded_length = strlen(encoded); + if (encoded_length >= MAX_PATH_LENGTH) + return FALSE; + + char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1); + size_t output_length = 0; + for (size_t i = 0; i < encoded_length; i++) { - if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) + unsigned char value = (unsigned char)encoded[i]; + if (encoded[i] == '%') { - if (i + 1 < len && input_path[i+1] == '.') - { - // Skip ".." - i++; - continue; - } - // Skip "." - continue; + if (i + 2 >= encoded_length || + !isxdigit((unsigned char)encoded[i + 1]) || + !isxdigit((unsigned char)encoded[i + 2])) + return FALSE; + + char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'}; + value = (unsigned char)strtoul(hex, NULL, 16); + i += 2; + if (value == '\0') + return FALSE; } - result[j++] = input_path[i]; + decoded[output_length++] = (char)value; } - result[j] = '\0'; + decoded[output_length] = '\0'; - // Remove leading/trailing slashes - while (result[0] == '/') - memmove(result, result + 1, strlen(result)); - while (j > 0 && result[j-1] == '/') - result[--j] = '\0'; - - return result; + *decoded_out = decoded; + if (decoded_length_out) + *decoded_length_out = output_length; + return TRUE; } -Seobeo_Client_Response *hg_proxy_request( - const char *method, - const char *path, - const char *req_body, - const char *hg_custom) +static boolean normalize_repository_path( + const char *encoded_path, + Dowa_Arena *arena, + char **normalized_out) { - char full_path[MAX_PATH]; - snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path); - Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path); - Seobeo_Client_Request_Set_Method(p_req, method); - Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0"); - Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json"); + char *decoded = NULL; + size_t decoded_length = 0; + if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length)) + return FALSE; - if (hg_custom && hg_custom[0] != '\0') + size_t start = 0; + size_t end = decoded_length; + if (start < end && decoded[start] == '/') { - char buffer[1024]; - snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom); - Seobeo_Client_Request_Add_Header_Array(p_req, buffer); - Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer); + start++; + if (start < end && decoded[start] == '/') + return FALSE; + } + if (end > start && decoded[end - 1] == '/') + { + if (end - 1 > start && decoded[end - 2] == '/') + return FALSE; + end--; } - if (req_body) - Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body)); - Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); - Seobeo_Client_Request_Destroy(p_req); - return p_resp; + size_t segment_start = start; + for (size_t i = start; i <= end; i++) + { + boolean at_end = i == end; + unsigned char c = at_end ? '/' : (unsigned char)decoded[i]; + if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#')) + return FALSE; + + if (c == '/') + { + size_t segment_length = i - segment_start; + if (segment_length == 0 && !at_end) + return FALSE; + if ((segment_length == 1 && decoded[segment_start] == '.') || + (segment_length == 2 && decoded[segment_start] == '.' && + decoded[segment_start + 1] == '.')) + return FALSE; + segment_start = i + 1; + } + } + + size_t normalized_length = end - start; + char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1); + memcpy(normalized, decoded + start, normalized_length); + normalized[normalized_length] = '\0'; + *normalized_out = normalized; + return TRUE; +} + +static boolean validate_revision(const char *revision) +{ + if (!revision || revision[0] == '\0') + return FALSE; + if (strcmp(revision, "tip") == 0) + return TRUE; + + size_t length = strlen(revision); + if (length > 40) + return FALSE; + for (size_t i = 0; i < length; i++) + { + if (!isxdigit((unsigned char)revision[i])) + return FALSE; + } + return TRUE; } -Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena) +static char *encode_repository_path(const char *path, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; - - void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; + static const char hex[] = "0123456789ABCDEF"; + size_t length = strlen(path); + char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1); + size_t output = 0; + for (size_t i = 0; i < length; i++) + { + unsigned char c = (unsigned char)path[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + encoded[output++] = (char)c; + else + { + encoded[output++] = '%'; + encoded[output++] = hex[c >> 4]; + encoded[output++] = hex[c & 0x0F]; + } + } + encoded[output] = '\0'; + return encoded; +} - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - - char *safe_path = sanitize_path(decoded_path, arena); - - Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path); +static boolean safe_header_value(const char *value, size_t maximum_length) +{ + if (!value) + return TRUE; + size_t length = strlen(value); + return length <= maximum_length && + strchr(value, '\r') == NULL && + strchr(value, '\n') == NULL; +} - char hg_path[MAX_PATH]; - if (strlen(safe_path) > 0) - snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path); - else - snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json"); - - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); +static Seobeo_Client_Response *hg_proxy_request( + const char *method, + const char *path, + const char *request_body, + size_t request_body_length, + const char *hg_argument, + const char *accept) +{ + char url[MAX_PATH_LENGTH]; + int url_length = snprintf( + url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); + if (url_length < 0 || (size_t)url_length >= sizeof(url)) + return NULL; - Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + if (!request) + return NULL; - if (hg_response->status_code != 200) + Seobeo_Client_Request_Set_Method(request, method); + Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0"); + Seobeo_Client_Request_Add_Header_Map( + request, "Accept", accept ? accept : "application/json"); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS); + + if (hg_argument && hg_argument[0] != '\0') { - Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n"); - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena); - return resp; + if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH)) + { + Seobeo_Client_Request_Destroy(request); + return NULL; + } + Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument); } - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); + if (request_body && request_body_length > 0) + Seobeo_Client_Request_Set_Body(request, request_body, request_body_length); - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - return resp; + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + Seobeo_Client_Request_Destroy(request); + return response; } -Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena) +static Seobeo_Request_Entry *forward_hg_response( + Seobeo_Client_Response *hg_response, + const char *default_content_type, + Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; + if (!hg_response) + return text_response( + arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}"); - void *path_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: rel_path='%s'\n", rel_path); - void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); - char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: graph_id='%s'\n", graph_id); - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - char *safe_path = sanitize_path(decoded_path, arena); + const char *upstream_content_type = + map_value_case_insensitive(hg_response->headers, "Content-Type"); + const char *upstream_or_default_content_type = + upstream_content_type ? upstream_content_type : default_content_type; + if (!upstream_or_default_content_type) + upstream_or_default_content_type = "application/octet-stream"; + char *content_type = arena_string(arena, upstream_or_default_content_type); - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path); + char *status = Dowa_Arena_Allocate(arena, 8); + snprintf(status, 8, "%d", hg_response->status_code); - if (strlen(safe_path) == 0) - { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); - return resp; - } + size_t body_length = hg_response->body ? hg_response->body_length : 0; + char *body = Dowa_Arena_Allocate(arena, body_length + 1); + if (body_length > 0) + memcpy(body, hg_response->body, body_length); + body[body_length] = '\0'; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", body_length); + + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", status, arena); + Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena); + Dowa_HashMap_Push_Arena(response, "body", body, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + Seobeo_Client_Response_Destroy(hg_response); + return response; +} - char hg_path[MAX_PATH]; - // void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); - // char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; - snprintf(hg_path, sizeof(hg_path), "/graph/%s?%s", graph_id, safe_path); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); +Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}"); - Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf( + hg_path, + sizeof(hg_path), + encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json", + encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}"); - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + arena); +} - if (!hg_response->body) - { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); - return resp; - } +Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) || + path[0] == '\0') + return text_response(arena, "400", "text/plain", "A valid file path is required"); + + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "File path is too long"); + + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"), + "application/octet-stream", + arena); +} - if (hg_response->status_code != 200) - { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: error hg_response: %s\n", hg_response->body); - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); - return resp; - } +Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *directory = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory)) + return text_response(arena, "400", "text/plain", "Invalid repository path"); + size_t readme_length = strlen(directory) + strlen("/README.md") + 1; + if (readme_length >= MAX_PATH_LENGTH) + return text_response(arena, "400", "text/plain", "README path is too long"); - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); + char *readme_path = Dowa_Arena_Allocate(arena, readme_length); + snprintf( + readme_path, + readme_length, + directory[0] ? "%s/README.md" : "README.md", + directory); + char *encoded = encode_repository_path(readme_path, arena); - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "README path is too long"); - return resp; + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"), + "text/markdown", + arena); } -Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; + const char *graph_id = map_value_case_insensitive(request, ":graph_id"); + if (!validate_revision(graph_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}"); - void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - char *safe_path = sanitize_path(decoded_path, arena); - - Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path); - - if (strlen(safe_path) == 0) + const char *encoded_graph_top = + map_value_case_insensitive(request, "query_graphtop"); + char *graph_top = NULL; + if (encoded_graph_top) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); - return resp; - } - - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); - - if (!hg_response->body) - { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); - return resp; + if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) || + !validate_revision(graph_top)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}"); } - if (hg_response->status_code != 200) - { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body); - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); - return resp; - } - + char hg_path[MAX_PATH_LENGTH]; + int length = graph_top + ? snprintf( + hg_path, + sizeof(hg_path), + "/graph/%s?graphtop=%s&style=json", + graph_id, + graph_top) + : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}"); - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + arena); } -Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) { - return ApiGetFile(req, arena); +Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *changeset_id = map_value_case_insensitive(request, ":changeset_id"); + if (!validate_revision(changeset_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}"); + + char hg_path[128]; + int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}"); + + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + arena); +} + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static size_t find_http_header_length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; } -// Streaming handler for hg wire protocol - pipes data directly without buffering -void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena) +static void send_proxy_error(Seobeo_Handle *client, int status, const char *message) { - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - - const char *hg_custom = req[7].value; + const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway"; + char response[512]; + int length = snprintf( + response, + sizeof(response), + "HTTP/1.1 %d %s\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n" + "\r\n" + "%s", + status, + reason, + strlen(message), + message); + if (length > 0 && (size_t)length < sizeof(response)) + { + Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length); + Seobeo_Handle_Flush(client); + } +} - Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string); +static boolean parse_content_length(const char *value, size_t *length_out) +{ + if (!value || !length_out || value[0] == '\0') + return FALSE; + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX) + return FALSE; + *length_out = (size_t)parsed; + return TRUE; +} - // THINKING: Connect to hg serve - // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in - // buffer and what not. - Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); - if (!p_upstream || p_upstream->socket < 0) +void StreamHgWireProtocol( + Seobeo_Handle *client, + Seobeo_Request_Entry *request, + Dowa_Arena *arena) +{ + (void)arena; + const char *method = map_value_case_insensitive(request, "HTTP_Method"); + const char *query = map_value_case_insensitive(request, "QueryString"); + const char *body = map_value_case_insensitive(request, "Body"); + const char *content_length_value = + map_value_case_insensitive(request, "Content-Length"); + const char *content_type = map_value_case_insensitive(request, "Content-Type"); + const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1"); + + if (!method || !query || + (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) || + !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) || + !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) || + !safe_header_value(content_type, 256)) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - if (p_upstream) - Seobeo_Handle_Destroy(p_upstream); + send_proxy_error(client, 502, "Invalid Mercurial proxy request"); return; } - // Create headers - // we only allow x-hgarg-1 and content-length - char request_buf[8192]; - int req_len = snprintf(request_buf, sizeof(request_buf), - "%s /?%s HTTP/1.1\r\n" - "Host: %s:%s\r\n" - "User-Agent: Seobeo/1.0\r\n" - "Connection: close\r\n", - method, query_string, HG_SERVE_HOST, HG_SERVE_PORT); + size_t body_length = 0; + if (content_length_value && + !parse_content_length(content_length_value, &body_length)) + { + send_proxy_error(client, 502, "Invalid Mercurial request length"); + return; + } + if (body_length > 0 && !body) + { + send_proxy_error(client, 502, "Missing Mercurial request body"); + return; + } + if (body_length > UINT32_MAX) + { + send_proxy_error(client, 502, "Mercurial request body is too large"); + return; + } - if (hg_custom && hg_custom[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom); + Seobeo_Handle *upstream = + Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); + if (!upstream || upstream->socket < 0) + { + if (upstream) + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend unavailable"); + return; + } - if (req_body && req_body[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body); - else - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n"); + char request_header[16384]; + int header_length = snprintf( + request_header, + sizeof(request_header), + "%s /?%s HTTP/1.1\r\n" + "Host: %s:%s\r\n" + "User-Agent: Seobeo/1.0\r\n" + "Connection: close\r\n", + method, + query, + HG_SERVE_HOST, + HG_SERVE_PORT); + if (header_length < 0 || (size_t)header_length >= sizeof(request_header)) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial request headers are too large"); + return; + } - Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len); - if (Seobeo_Handle_Flush(p_upstream) < 0) +#define APPEND_WIRE_HEADER(...) \ + do { \ + int appended = snprintf( \ + request_header + header_length, \ + sizeof(request_header) - (size_t)header_length, \ + __VA_ARGS__); \ + if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \ + Seobeo_Handle_Destroy(upstream); \ + send_proxy_error(client, 502, "Mercurial request headers are too large"); \ + return; \ + } \ + header_length += appended; \ + } while (0) + + if (hg_argument && hg_argument[0] != '\0') + APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument); + if (content_type && content_type[0] != '\0') + APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type); + if (body_length > 0) + APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length); + APPEND_WIRE_HEADER("\r\n"); +#undef APPEND_WIRE_HEADER + + if (Seobeo_Handle_Queue( + upstream, (const uint8 *)request_header, (uint32)header_length) != 0 || + (body_length > 0 && + Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) || + Seobeo_Handle_Flush(upstream) != 0) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend write failed"); return; } - // Responses - while (1) + boolean response_started = FALSE; + int64_t last_progress = monotonic_milliseconds(); + while (!response_started) { - int r = Seobeo_Handle_Read(p_upstream); - if (r < 0) + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2 || read_result < 0) { - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend closed before responding"); return; } - if (p_upstream->read_buffer_len >= 4 && - strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL) + if (read_result > 0) + last_progress = monotonic_milliseconds(); + + size_t header_size = + find_http_header_length(upstream->read_buffer, upstream->read_buffer_len); + if (header_size > 0) + { + (void)header_size; + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) + { + Seobeo_Handle_Destroy(upstream); + return; + } + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); + response_started = TRUE; break; - if (r == 0) - continue; + } + + if (read_result == 0) + { + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 504, "Mercurial backend response timed out"); + return; + } + usleep(1000); + } } - // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows. - char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n"); - if (!hdr_end) - { - Seobeo_Handle_Destroy(p_upstream); - return; - } - size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4; - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len); - Seobeo_Handle_Flush(p_client); - - // All body - size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len; - if (body_in_buffer > 0) + while (TRUE) { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer); - Seobeo_Handle_Flush(p_client); - } - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); - while (1) - { - int n = Seobeo_Handle_Read(p_upstream); - if (n > 0) + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2) + break; + if (read_result < 0) + break; + if (read_result == 0) { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n"); + break; + } + usleep(1000); + continue; } - else if (n == -2) + + last_progress = monotonic_milliseconds(); + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) break; - else if (n < 0) - break; + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); } - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); } -Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; - - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - size_t body_len = strlen(req_body); - - const char *hg_custom = req[7].value; - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len); - - Seobeo_Client_Response *hg_response; + (void)request; + size_t file_size = 0; + char *html = Seobeo_Web_LoadFile("/index.html", &file_size); + if (!html) + return text_response(arena, "500", "text/plain", "Application shell unavailable"); - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/?%s", query_string); - - hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length); - - Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type"); - - char *status = Dowa_Arena_Allocate(arena, 5); - snprintf(status, 4, "%i", hg_response->status_code); - - // Use binary-safe copy to handle null bytes in mercurial bundle data - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + Seobeo_Request_Entry *response = NULL; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", file_size); + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena); + Dowa_HashMap_Push_Arena(response, "body", html, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + return response; } -Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena) +int main(void) { - size_t file_size = 0; - char *html = Seobeo_Web_LoadFile("/index.html", &file_size); - - printf("%s", html); - Seobeo_Request_Entry *resp = NULL; - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/html", arena); - Dowa_HashMap_Push_Arena(resp, "body", html, arena); - return resp; -} - -int main(void) { Seobeo_Router_Init(); - Seobeo_Router_Register("GET", "/", GetReactHome); Seobeo_Router_Register("GET", "/directories", GetReactHome); + Seobeo_Router_Register("GET", "/directory", GetReactHome); Seobeo_Router_Register("GET", "/graph", GetReactHome); + Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome); Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory); Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile); + Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph); - Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); + Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset); - // Use streaming handler for hg wire protocol... Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol); Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol); printf("Starting on Port 6970...\n"); - - int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); - + int result = + Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); Seobeo_Router_Destroy(); - return result; }
--- a/hg-web/src/components/app.tsx Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/src/components/app.tsx Sun Aug 02 09:01:24 2026 -0700 @@ -5,15 +5,32 @@ import { Footer } from "hg-web/src/components/footer"; import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; -type Page = 'landing' | 'graph' | 'directory'; +type Page = 'landing' | 'graph' | 'directory' | 'changeset'; type RouteState = { page: Page; graphCommit?: string; graphTip?: string; dirPath?: string; + changesetId?: string; } +type ChangesetDetail = { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + parents: string[]; + files: string[]; + diff: Array<{ + blockno: number; + lines: Array<{ t: string; n: number; l: string }>; + }>; +}; + // Icons const ICONS = { folder: "/icons/folder.png", @@ -40,6 +57,18 @@ function parseRoute(): RouteState { const params = new URLSearchParams(window.location.search); const pathname = window.location.pathname; + const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/); + + if (changesetMatch) { + try { + const changesetId = decodeURIComponent(changesetMatch[1]); + if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) { + return { page: 'changeset', changesetId }; + } + } catch { + return { page: 'landing' }; + } + } if (pathname.startsWith('/graph') || params.has('graph')) { return { @@ -70,6 +99,8 @@ case 'directory': if (state.dirPath) params.set('path', state.dirPath); return `/directory${params.toString() ? '?' + params.toString() : ''}`; + case 'changeset': + return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph'; default: return '/'; } @@ -79,9 +110,11 @@ function LandingPage({ onNavigateToGraph, onNavigateToDirectory, + onNavigateToChangeset, }: { onNavigateToGraph: () => void; onNavigateToDirectory: (path?: string) => void; + onNavigateToChangeset: (node: string) => void; }) { const [directories, setDirectories] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]); @@ -128,9 +161,7 @@ <Graph data={graphData} maxRows={8} - onCommitClick={(node) => { - console.log('Clicked commit:', node); - }} + onCommitClick={onNavigateToChangeset} /> ) : ( <div className="empty-state">Failed to load commits</div> @@ -186,10 +217,12 @@ onBack, initialCommit, initialTip, + onOpenChangeset, }: { onBack: () => void; initialCommit?: string; initialTip?: string; + onOpenChangeset: (node: string) => void; }) { const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ initialCommit: initialCommit || null, @@ -239,14 +272,130 @@ loading={loading} hasMore={hasMore} onLoadMore={loadMore} - onCommitClick={(node) => { - console.log('Clicked commit:', node); - }} + onCommitClick={onOpenChangeset} /> </div> ); } +function ChangesetPage({ + changesetId, + onBack, + onOpenChangeset, +}: { + changesetId: string; + onBack: () => void; + onOpenChangeset: (node: string) => void; +}) { + const [changeset, setChangeset] = useState<ChangesetDetail | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const controller = new AbortController(); + + setLoading(true); + setError(null); + setChangeset(null); + + fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal }) + .then(async response => { + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Unable to load changeset (${response.status})`); + } + return response.json() as Promise<ChangesetDetail>; + }) + .then(setChangeset) + .catch(err => { + if (err.name !== 'AbortError') setError(err.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [changesetId]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack}> + ← Back to graph + </button> + <span className="page-title">Changeset</span> + </div> + + {loading && <div className="loading-state">Loading changeset...</div>} + {error && <div className="error-message">Error: {error}</div>} + + {changeset && ( + <article className="changeset-paper"> + <div className="changeset-heading"> + <code>{changeset.node}</code> + <span className="changeset-branch">{changeset.branch}</span> + </div> + <h2>{changeset.desc}</h2> + <div className="changeset-meta"> + <span>{changeset.user}</span> + <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}> + {new Date(changeset.date[0] * 1000).toLocaleString()} + </time> + </div> + + {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && ( + <div className="changeset-labels"> + {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)} + {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)} + </div> + )} + + {changeset.parents.length > 0 && ( + <div className="changeset-parents"> + <strong>Parents</strong> + {changeset.parents.map(parent => ( + <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}> + {parent.substring(0, 12)} + </button> + ))} + </div> + )} + + {changeset.files.length > 0 && ( + <div className="changeset-files"> + <strong>Files</strong> + {changeset.files.map(file => <code key={file}>{file}</code>)} + </div> + )} + + <section className="changeset-diff" aria-label="Changeset diff"> + <h3>Diff</h3> + {changeset.diff.length === 0 ? ( + <div className="empty-state">No textual changes in this changeset.</div> + ) : changeset.diff.map(block => ( + <pre key={block.blockno}> + {block.lines.map((line, index) => ( + <span + key={`${line.n}-${index}`} + className={ + line.t === '+' ? 'diff-add' : + line.t === '-' ? 'diff-remove' : + line.t === '@' ? 'diff-range' : 'diff-context' + } + > + <span className="diff-line-number">{line.n}</span> + <span>{line.l}</span> + </span> + ))} + </pre> + ))} + </section> + </article> + )} + </div> + ); +} + // Directory Page Component function DirectoryPage({ onBack, @@ -306,6 +455,10 @@ navigate({ page: 'directory', dirPath: path || '' }); }, [navigate]); + const navigateToChangeset = useCallback((changesetId: string) => { + navigate({ page: 'changeset', changesetId }); + }, [navigate]); + const handleDirectoryPathChange = useCallback((path: string) => { // Update URL without full navigation const params = new URLSearchParams(); @@ -333,7 +486,7 @@ Home </button> <button - className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} + className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`} onClick={() => navigateToGraph()} > <GraphIcon /> @@ -353,6 +506,7 @@ <LandingPage onNavigateToGraph={() => navigateToGraph()} onNavigateToDirectory={navigateToDirectory} + onNavigateToChangeset={navigateToChangeset} /> )} @@ -361,6 +515,7 @@ onBack={navigateToLanding} initialCommit={route.graphCommit} initialTip={route.graphTip} + onOpenChangeset={navigateToChangeset} /> )} @@ -372,6 +527,14 @@ /> )} + {route.page === 'changeset' && route.changesetId && ( + <ChangesetPage + changesetId={route.changesetId} + onBack={() => navigateToGraph()} + onOpenChangeset={navigateToChangeset} + /> + )} + <Footer /> </div> );
--- a/hg-web/src/components/graph.tsx Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/src/components/graph.tsx Sun Aug 02 09:01:24 2026 -0700 @@ -177,17 +177,11 @@ const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); + const [assetError, setAssetError] = useState<string | null>(null); const changesets = useMemo(() => maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); - let pencilPattern; - const img = new Image(); - img.src = "http://localhost:6970/pencil_lines.png"; - - const pandaImg = new Image(); - pandaImg.src = "http://localhost:6970/panda.png"; - useEffect(() => { const canvas = canvasRef.current; if (!canvas || !changesets.length) return; @@ -195,20 +189,11 @@ const ctx = canvas.getContext('2d'); if (!ctx) return; - // Grab colors from CSS variables or defaults - const getColors = () => { - const s = getComputedStyle(document.documentElement); - return [ - s.getPropertyValue('--graph-1').trim() || '#4dabf7', - s.getPropertyValue('--graph-2').trim() || '#63e6be', - s.getPropertyValue('--graph-3').trim() || '#ffbc42', - s.getPropertyValue('--graph-4').trim() || '#b197fc', - s.getPropertyValue('--graph-5').trim() || '#ff8787', - s.getPropertyValue('--graph-6').trim() || '#f06595', - ]; - }; - - const colors = getColors(); + let cancelled = false; + let pencilPattern: CanvasPattern | null = null; + let loadedAssets = 0; + const pencilImage = new Image(); + const pandaImage = new Image(); const dpr = window.devicePixelRatio || 1; const maxCol = Math.max(...changesets.map(cs => cs.col), 0); const canvasWidth = (maxCol + 2) * colWidth; @@ -241,13 +226,35 @@ // Pass 2: Draw Commit Nodes changesets.forEach((cs, i) => { const x = getX(cs.col), y = getY(i); - ctx.drawImage(pandaImg, x-10, y-10, 20, 20); + ctx.drawImage(pandaImage, x-10, y-10, 20, 20); }); }; - img.onload = () => { - pencilPattern = ctx.createPattern(img, "repeat")!; - renderCanvas(); + const handleAssetLoad = () => { + loadedAssets++; + if (loadedAssets !== 2 || cancelled) return; + pencilPattern = ctx.createPattern(pencilImage, "repeat"); + renderCanvas(); + }; + + const handleAssetError = () => { + if (!cancelled) setAssetError('Unable to load the graph artwork.'); + }; + + setAssetError(null); + pencilImage.onload = handleAssetLoad; + pencilImage.onerror = handleAssetError; + pandaImage.onload = handleAssetLoad; + pandaImage.onerror = handleAssetError; + pencilImage.src = "/pencil_lines.png"; + pandaImage.src = "/panda.png"; + + return () => { + cancelled = true; + pencilImage.onload = null; + pencilImage.onerror = null; + pandaImage.onload = null; + pandaImage.onerror = null; }; }, [changesets]); @@ -266,45 +273,38 @@ }, [onLoadMore, hasMore, loading]); return ( - <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}> + <div className="graph-container" style={{ backgroundImage: 'url("/hg-web-background.jpg")' }}> + {assetError && <div className="error-message">{assetError}</div>} <div ref={containerRef} - style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }} + className="graph-wrapper" > - {/* Graph Column - Sticky to keep lines aligned with text during scroll */} - <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}> + <div className="graph-canvas-column"> <canvas ref={canvasRef} style={{ display: 'block' }} /> </div> - {/* Details Column */} - <div style={{ flex: 1 }}> + <div className="graph-details-column"> {changesets.map((cs) => ( - <div - key={cs.node} - style={{ - height: rowHeight, - display: 'flex', - alignItems: 'center', - padding: '0 15px', - borderBottom: '1px solid #252525', - cursor: 'pointer', - fontSize: '13px', - whiteSpace: 'nowrap' - }} + <button + type="button" + key={cs.node} + className="graph-row" onClick={() => onCommitClick?.(cs.node)} - onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} > - <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span> - <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span> - <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span> - </div> + <span className="graph-row-meta"> + <span className="graph-hash">{cs.node.substring(0, 12)}</span> + <span className="graph-user">{cs.user.split(' <')[0]}</span> + {cs.branch && <span className="graph-branch">{cs.branch}</span>} + </span> + <span className="graph-desc">{cs.desc}</span> + </button> ))} <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> </div> </div> - {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>} + {loading && <div className="graph-loading-row">Loading repository history...</div>} </div> ); };
--- a/hg-web/src/index.css Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/src/index.css Sun Aug 02 09:01:24 2026 -0700 @@ -270,13 +270,15 @@ align-items: flex-start; max-height: 600px; overflow-y: auto; + position: relative; } .graph-canvas-column { flex-shrink: 0; - background: var(--bg); position: sticky; left: 0; + z-index: 1; + border-right: 1px solid var(--border); } .graph-details-column { @@ -285,11 +287,16 @@ } .graph-row { + width: 100%; height: 40px; display: flex; flex-direction: column; justify-content: center; padding: 0 12px; + background: transparent; + color: inherit; + text-align: left; + border: 0; border-bottom: 1px solid var(--border); font-size: 12px; cursor: pointer; @@ -300,6 +307,12 @@ background: var(--hover); } +.graph-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + background: var(--hover); +} + .graph-row-meta { display: flex; gap: 10px; @@ -365,6 +378,128 @@ } /* =========================================== + Changeset Detail + =========================================== */ +.changeset-paper { + background: var(--bg); + background-image: url("/pencil_texture.png"); + border: 1px solid var(--border); + border-radius: 6px; + padding: 24px; +} + +.changeset-heading, +.changeset-meta, +.changeset-parents, +.changeset-files, +.changeset-labels { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.changeset-heading { + justify-content: space-between; + margin-bottom: 12px; +} + +.changeset-heading code { + overflow-wrap: anywhere; +} + +.changeset-paper h2 { + margin-bottom: 8px; +} + +.changeset-meta { + color: var(--text-secondary); + justify-content: space-between; + margin-bottom: 16px; +} + +.changeset-branch, +.changeset-labels span { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; +} + +.changeset-labels, +.changeset-parents, +.changeset-files { + margin: 12px 0; +} + +.changeset-parents button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font-family: monospace; +} + +.changeset-parents button:hover { + text-decoration: underline; +} + +.changeset-parents button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.changeset-files code { + overflow-wrap: anywhere; +} + +.changeset-diff { + margin-top: 24px; +} + +.changeset-diff h3 { + margin-bottom: 10px; +} + +.changeset-diff pre { + margin: 0 0 16px; + padding: 0; + overflow-x: auto; + border: 1px solid var(--border); +} + +.changeset-diff pre > span { + display: flex; + min-width: max-content; + padding-right: 12px; +} + +.diff-line-number { + width: 54px; + flex-shrink: 0; + margin-right: 12px; + padding-right: 10px; + color: var(--text-secondary); + text-align: right; + user-select: none; + border-right: 1px solid var(--border); +} + +.diff-add { + background: color-mix(in srgb, var(--success) 18%, transparent); +} + +.diff-remove { + background: color-mix(in srgb, var(--danger) 18%, transparent); +} + +.diff-range { + color: var(--accent); + background: var(--bg-subtle); +} + +/* =========================================== Common States =========================================== */ .empty-state {
--- a/markdown_converter/markdown_to_html.c Sun Aug 02 08:34:54 2026 -0700 +++ b/markdown_converter/markdown_to_html.c Sun Aug 02 09:01:24 2026 -0700 @@ -61,14 +61,6 @@ buf->length += len; } -static void buffer_append_n(StringBuffer *buf, const char *str, size_t n) -{ - buffer_grow(buf, n); - memcpy(buf->data + buf->length, str, n); - buf->length += n; - buf->data[buf->length] = '\0'; -} - static void buffer_append_char(StringBuffer *buf, char c) { buffer_grow(buf, 1); @@ -76,6 +68,39 @@ buf->data[buf->length] = '\0'; } +static void buffer_append_html_escaped_n(StringBuffer *buf, const char *text, size_t len) +{ + for (size_t i = 0; i < len; i++) { + switch (text[i]) { + case '&': buffer_append(buf, "&"); break; + case '<': buffer_append(buf, "<"); break; + case '>': buffer_append(buf, ">"); break; + case '"': buffer_append(buf, """); break; + case '\'': buffer_append(buf, "'"); break; + default: buffer_append_char(buf, text[i]); break; + } + } +} + +static int is_safe_url(const char *url, size_t len, int is_image) +{ + if (len == 0) return 0; + + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)url[i]; + if (iscntrl(c) || isspace(c)) return 0; + } + + const char *colon = memchr(url, ':', len); + if (!colon) return 1; + + size_t scheme_len = (size_t)(colon - url); + if (scheme_len == 4 && strncasecmp(url, "http", scheme_len) == 0) return 1; + if (scheme_len == 5 && strncasecmp(url, "https", scheme_len) == 0) return 1; + if (!is_image && scheme_len == 6 && strncasecmp(url, "mailto", scheme_len) == 0) return 1; + return 0; +} + static void buffer_free(StringBuffer *buf) { if (buf) { @@ -160,28 +185,6 @@ return 1; } -// Check if line starts with a specific HTML tag (e.g., "script", "style") -static int is_html_tag(const char *line, const char *tag) -{ - line = skip_whitespace(line); - if (*line != '<') return 0; - line++; - - // Skip optional / - int is_closing = 0; - if (*line == '/') { - is_closing = 1; - line++; - } - - size_t tag_len = strlen(tag); - if (strncasecmp(line, tag, tag_len) != 0) return 0; - - char next = line[tag_len]; - // Tag must be followed by space, >, or end for closing tags - return next == '>' || next == ' ' || next == '\t' || next == '\n' || next == '\0'; -} - // Check if line is ordered list item static int is_ordered_list(const char *line) { @@ -359,11 +362,16 @@ while (url_end < len && text[url_end] != ')') url_end++; if (url_end < len) { - buffer_append(buf, "<a href=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\">"); - buffer_append_n(buf, text + link_start, link_end - link_start); - buffer_append(buf, "</a>"); + size_t url_len = url_end - url_start; + if (is_safe_url(text + url_start, url_len, 0)) { + buffer_append(buf, "<a href=\""); + buffer_append_html_escaped_n(buf, text + url_start, url_len); + buffer_append(buf, "\">"); + process_inline(buf, text + link_start, link_end - link_start); + buffer_append(buf, "</a>"); + } else { + process_inline(buf, text + link_start, link_end - link_start); + } i = url_end + 1; continue; } @@ -382,11 +390,16 @@ while (url_end < len && text[url_end] != ')') url_end++; if (url_end < len) { - buffer_append(buf, "<img src=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\" alt=\""); - buffer_append_n(buf, text + alt_start, alt_end - alt_start); - buffer_append(buf, "\">"); + size_t url_len = url_end - url_start; + if (is_safe_url(text + url_start, url_len, 1)) { + buffer_append(buf, "<img src=\""); + buffer_append_html_escaped_n(buf, text + url_start, url_len); + buffer_append(buf, "\" alt=\""); + buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start); + buffer_append(buf, "\">"); + } else { + buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start); + } i = url_end + 1; continue; } @@ -449,25 +462,14 @@ if (end < len) { buffer_append(buf, "<code>"); - buffer_append_n(buf, text + start, end - start); + buffer_append_html_escaped_n(buf, text + start, end - start); buffer_append(buf, "</code>"); i = end + 1; continue; } } - // This might not be needed for now. - // HTML escape special characters - // if (text[i] == '<') { - // buffer_append(buf, "<"); - // } else if (text[i] == '>') { - // buffer_append(buf, ">"); - // } else if (text[i] == '&') { - // buffer_append(buf, "&"); - // } else { - // buffer_append_char(buf, text[i]); - // } - buffer_append_char(buf, text[i]); + buffer_append_html_escaped_n(buf, text + i, 1); i++; } } @@ -759,48 +761,11 @@ } } - // HTML block - pass through unchanged + // Repository markdown is untrusted. Render raw HTML as text. if (is_html_block_start(line)) { - // Check if it's a script or style tag that needs special handling - int is_script = is_html_tag(line, "script"); - int is_style = is_html_tag(line, "style"); - - if (is_script || is_style) { - const char *end_tag = is_script ? "</script>" : "</style>"; - - // Output the opening line - buffer_append(buf, line); - buffer_append_char(buf, '\n'); - - free(line); - if (*ptr == '\n') ptr++; - - // Collect content until closing tag - while (*ptr) { - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - buffer_append(buf, line); - buffer_append_char(buf, '\n'); - - int found_end = (strstr(line, end_tag) != NULL); - free(line); - if (*ptr == '\n') ptr++; - - if (found_end) break; - } - continue; - } - - // Regular HTML tag - just pass through the line - buffer_append(buf, line); - buffer_append_char(buf, '\n'); + buffer_append(buf, "<p>"); + process_inline(buf, line, line_len); + buffer_append(buf, "</p>"); free(line); if (*ptr == '\n') ptr++; continue;
--- a/markdown_converter/tests/BUILD Sun Aug 02 08:34:54 2026 -0700 +++ b/markdown_converter/tests/BUILD Sun Aug 02 09:01:24 2026 -0700 @@ -1,3 +1,11 @@ +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_test( + name = "markdown_to_html_test", + srcs = ["markdown_to_html_test.c"], + deps = ["//markdown_converter:markdown_to_html_c"], +) + # load("//gui_ze:gui_ze.bzl", "bun_run", "move_files_into_dir") # # # Test for WASM module (run with: bazel test //markdown_converter:markdown_to_html_wasm_test)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/markdown_converter/tests/markdown_to_html_test.c Sun Aug 02 09:01:24 2026 -0700 @@ -0,0 +1,32 @@ +#include "markdown_converter/markdown_to_html.h" + +#include <stdio.h> +#include <string.h> + +static int failures = 0; + +static void expect_equal(const char *name, const char *markdown, const char *expected) +{ + char *actual = markdown_to_html(markdown); + if (!actual || strcmp(actual, expected) != 0) { + fprintf(stderr, "%s\nexpected: %s\nactual: %s\n", name, expected, actual ? actual : "(null)"); + failures++; + } + markdown_free(actual); +} + +int main(void) +{ + expect_equal("normal link", "[link](https://example.com)", + "<p><a href=\"https://example.com\">link</a></p>"); + expect_equal("escape paragraph", "a & <b>", "<p>a & <b></p>"); + expect_equal("escape raw script", "<script>alert('x')</script>", + "<p><script>alert('x')</script></p>"); + expect_equal("reject script link", "[open](javascript:alert)", "<p>open</p>"); + expect_equal("reject data image", "", "<p>preview</p>"); + expect_equal("escape link attribute", "[link](https://example.com/\"x)", + "<p><a href=\"https://example.com/"x\">link</a></p>"); + expect_equal("escape inline code", "`<script>`", "<p><code><script></code></p>"); + + return failures == 0 ? 0 : 1; +}
--- a/seobeo/s_http_client.c Sun Aug 02 08:34:54 2026 -0700 +++ b/seobeo/s_http_client.c Sun Aug 02 09:01:24 2026 -0700 @@ -1,5 +1,46 @@ #include "seobeo/seobeo.h" #include <ctype.h> +#include <time.h> + +static int64_t Seobeo_Client_Monotonic_Milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static boolean Seobeo_Client_Read_Timed_Out(int32 timeout_ms, int64_t last_progress_ms) +{ + return timeout_ms > 0 && + Seobeo_Client_Monotonic_Milliseconds() - last_progress_ms >= timeout_ms; +} + +static size_t Seobeo_Client_Find_Header_Length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; +} + +static const char *Seobeo_Client_Header_Value( + Seobeo_Request_Entry *headers, + const char *name) +{ + if (!headers || !name) + return NULL; + for (size_t i = 0; i < Dowa_Array_Length(headers); i++) + { + if (headers[i].key && strcasecmp(headers[i].key, name) == 0) + return headers[i].value; + } + return NULL; +} static void Seobeo_Client_Parse_Url(const char *url, char **p_host, char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena) @@ -87,6 +128,7 @@ p_req->follow_redirects = FALSE; p_req->max_redirects = 10; + p_req->timeout_ms = 0; return p_req; } @@ -149,6 +191,13 @@ p_req->max_redirects = max_redirects > 0 ? max_redirects : 10; } +void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms) +{ + if (!p_req) + return; + p_req->timeout_ms = timeout_ms > 0 ? timeout_ms : 0; +} + void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path) { if (!p_req || !path) @@ -223,7 +272,10 @@ return offset; } -static Seobeo_Client_Response *Seobeo_Client_Parse_Response(Seobeo_Handle *p_handle, const char *download_path) +static Seobeo_Client_Response *Seobeo_Client_Parse_Response( + Seobeo_Handle *p_handle, + const char *download_path, + int32 timeout_ms) { Seobeo_Client_Response *p_resp = malloc(sizeof(Seobeo_Client_Response)); if (!p_resp) @@ -238,27 +290,49 @@ return NULL; } + int64_t last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); while (TRUE) { int r = Seobeo_Handle_Read(p_handle); - if (r < 0) - return p_resp; if (r == -2) break; + if (r < 0) + { + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + if (r > 0) + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); - if (p_handle->read_buffer_len >= 4 && strstr((char*)p_handle->read_buffer, "\r\n\r\n") != NULL) + if (Seobeo_Client_Find_Header_Length( + p_handle->read_buffer, p_handle->read_buffer_len) > 0) break; if (r == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response header timed out\n"); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } } - char *buf = (char*)p_handle->read_buffer; - char *hdr_end = strstr(buf, "\r\n\r\n"); - if (!hdr_end) - return p_resp; + size_t hdr_len = Seobeo_Client_Find_Header_Length( + p_handle->read_buffer, p_handle->read_buffer_len); + if (hdr_len == 0) + { + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } - size_t hdr_len = hdr_end - buf + 4; + char *buf = Dowa_Arena_Allocate(p_resp->p_arena, hdr_len + 1); + memcpy(buf, p_handle->read_buffer, hdr_len); + buf[hdr_len] = '\0'; + char *hdr_end = buf + hdr_len - 4; char version[16]; int status_code; @@ -328,12 +402,12 @@ Seobeo_Handle_Consume(p_handle, (uint32)hdr_len); - void *p_cl_kv = Dowa_HashMap_Get_Ptr(p_resp->headers, "Content-Length"); size_t body_len = 0; - if (p_cl_kv) + const char *content_length = Seobeo_Client_Header_Value( + p_resp->headers, "Content-Length"); + if (content_length) { - const char *content_length_str = ((Seobeo_Request_Entry*)p_cl_kv)->value; - body_len = atoi(content_length_str); + body_len = (size_t)strtoull(content_length, NULL, 10); } FILE *p_file = NULL; @@ -366,15 +440,32 @@ total_read += to_copy; Seobeo_Handle_Consume(p_handle, (uint32)to_copy); + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); } if (total_read < body_len) { int r = Seobeo_Handle_Read(p_handle); - if (r < 0 || r == -2) - break; + if (r == -2 || r < 0) + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + if (r > 0) + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); if (r == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n"); + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } } } @@ -397,9 +488,12 @@ while (1) { - int n = Seobeo_Handle_Read(p_handle); + int n = p_handle->read_buffer_len > 0 + ? (int)p_handle->read_buffer_len + : Seobeo_Handle_Read(p_handle); if (n > 0) { + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); if (download_path) { fwrite(p_handle->read_buffer, 1, p_handle->read_buffer_len, p_file); @@ -420,9 +514,23 @@ else if (n == -2) break; else if (n == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n"); + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } else - break; + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } } if (!download_path) @@ -468,7 +576,8 @@ return NULL; } - Seobeo_Client_Response *p_resp = Seobeo_Client_Parse_Response(p_handle, p_req->download_path); + Seobeo_Client_Response *p_resp = + Seobeo_Client_Parse_Response(p_handle, p_req->download_path, p_req->timeout_ms); Seobeo_Handle_Destroy(p_handle);
--- a/seobeo/s_web.c Sun Aug 02 08:34:54 2026 -0700 +++ b/seobeo/s_web.c Sun Aug 02 09:01:24 2026 -0700 @@ -58,11 +58,13 @@ case HTTP_FORBIDDEN: status_text = "Forbidden"; break; case HTTP_NOT_FOUND: status_text = "Not Found"; break; case HTTP_INTERNAL_ERROR: status_text = "Internal Server Error"; break; + case 502: status_text = "Bad Gateway"; break; + case 504: status_text = "Gateway Timeout"; break; default: status_text = "Unknown"; break; } - sprintf( - buffer, + snprintf( + (char*)buffer, 1024, "HTTP/1.1 %d %s\r\n" "Content-Type: %s\r\n" "Content-Length: %d\r\n" @@ -774,6 +776,11 @@ Seobeo_Router_Send_Response_KeepAlive(p_handle, p_response_map, p_arena, FALSE); } +static boolean Seobeo_Response_Header_Value_Is_Safe(const char *value) +{ + return value && strchr(value, '\r') == NULL && strchr(value, '\n') == NULL; +} + void Seobeo_Router_Send_Response_KeepAlive( Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, @@ -819,26 +826,63 @@ else body_length = strlen(body); - char *header = Dowa_Arena_Allocate(p_arena, 4096); - Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); + size_t header_capacity = 1024; for (int i = 0; i < Dowa_Array_Length(p_response_map); i++) { + const char *key = p_response_map[i].key; + const char *value = p_response_map[i].value; if ( - strstr(p_response_map[i].key, "status") || - strstr(p_response_map[i].key, "body") || - strstr(p_response_map[i].key, "content-type") || - strstr(p_response_map[i].key, "content-length") + strcasecmp(key, "status") == 0 || + strcasecmp(key, "body") == 0 || + strcasecmp(key, "content-type") == 0 || + strcasecmp(key, "content-length") == 0 + ) + continue; + if (Seobeo_Response_Header_Value_Is_Safe(key) && + Seobeo_Response_Header_Value_Is_Safe(value)) + header_capacity += strlen(key) + strlen(value) + 4; + } + + char *header = Dowa_Arena_Allocate(p_arena, header_capacity); + Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); + size_t header_length = strlen(header); + if (header_length < 2) + return; + header_length -= 2; + + for (int i = 0; i < Dowa_Array_Length(p_response_map); i++) + { + const char *key = p_response_map[i].key; + const char *value = p_response_map[i].value; + if ( + strcasecmp(key, "status") == 0 || + strcasecmp(key, "body") == 0 || + strcasecmp(key, "content-type") == 0 || + strcasecmp(key, "content-length") == 0 ) continue; - int32 current_header_len = strlen(header); - char *temp = malloc(sizeof(char) * 1024); - sprintf(temp, "%s: %s\r\n\r\n", p_response_map[i].key, p_response_map[i].value); - memcpy(&header[current_header_len - 2 /* \r\n */], temp, strlen(temp)); - free(temp); + if (!Seobeo_Response_Header_Value_Is_Safe(key) || + !Seobeo_Response_Header_Value_Is_Safe(value)) + { + Seobeo_Log(SEOBEO_WARNING, "Skipping unsafe response header\n"); + continue; + } + + int written = snprintf( + header + header_length, + header_capacity - header_length, + "%s: %s\r\n", + key, + value); + if (written < 0 || (size_t)written >= header_capacity - header_length) + { + Seobeo_Log(SEOBEO_ERROR, "Response header exceeded allocated capacity\n"); + return; + } + header_length += (size_t)written; } - - printf("hEADER %s\n", header); + memcpy(header + header_length, "\r\n", 3); Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header)); Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
--- a/seobeo/seobeo.h Sun Aug 02 08:34:54 2026 -0700 +++ b/seobeo/seobeo.h Sun Aug 02 09:01:24 2026 -0700 @@ -82,6 +82,8 @@ extern void Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length); /* Enable/disable following redirects with max redirect count. Default is FALSE with 10 max. */ extern void Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects); +/* Set the maximum idle time for HTTP response reads. Zero disables the timeout. */ +extern void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms); /* Set download path to save response body to file instead of memory. */ extern void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path); /* Execute the HTTP request and return response. */
--- a/seobeo/seobeo_internal.h Sun Aug 02 08:34:54 2026 -0700 +++ b/seobeo/seobeo_internal.h Sun Aug 02 09:01:24 2026 -0700 @@ -131,6 +131,7 @@ boolean follow_redirects; int32 max_redirects; + int32 timeout_ms; char *download_path; @@ -158,6 +159,7 @@ extern void Seobeo_Client_Request_Add_Header_Array(Seobeo_Client_Request *p_req, const char *header); extern void Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length); extern void Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects); +extern void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms); extern void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path); extern Seobeo_Client_Response *Seobeo_Client_Request_Execute(Seobeo_Client_Request *p_req); extern void Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req);
--- a/seobeo/tests/BUILD Sun Aug 02 08:34:54 2026 -0700 +++ b/seobeo/tests/BUILD Sun Aug 02 09:01:24 2026 -0700 @@ -28,3 +28,39 @@ args = ["$(location //seobeo/examples:websocket_server_example)"], visibility = ["//visibility:public"], ) + +cc_test( + name = "seobeo_response_test", + srcs = ["seobeo_response_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_timeout_test", + srcs = ["seobeo_http_timeout_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_framing_test", + srcs = ["seobeo_http_framing_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_content_length_test", + srcs = ["seobeo_http_content_length_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_content_length_test.c Sun Aug 02 09:01:24 2026 -0700 @@ -0,0 +1,91 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +typedef struct { + int listener; +} Content_Length_Server; + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void *serve_lowercase_content_length(void *argument) +{ + Content_Length_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + const char response[] = + "HTTP/1.1 200 OK\r\n" + "content-length: 4\r\n" + "Connection: keep-alive\r\n" + "\r\n" + "done"; + (void)write(client, response, sizeof(response) - 1); + usleep(500000); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Content_Length_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_lowercase_content_length, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + int64_t started = monotonic_milliseconds(); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + int64_t elapsed = monotonic_milliseconds() - started; + + int failed = !response || + response->body_length != 4 || + memcmp(response->body, "done", 4) != 0 || + elapsed > 300; + if (failed) + fprintf(stderr, "Case-insensitive Content-Length was not honored (%lldms)\n", + (long long)elapsed); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_framing_test.c Sun Aug 02 09:01:24 2026 -0700 @@ -0,0 +1,79 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> + +typedef struct { + int listener; +} Framing_Server; + +static void *serve_close_delimited_response(void *argument) +{ + Framing_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + const char response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Connection: close\r\n" + "\r\n" + "buffered-body"; + (void)write(client, response, sizeof(response) - 1); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Framing_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_close_delimited_response, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + + int failed = !response || + response->status_code != 200 || + response->body_length != strlen("buffered-body") || + memcmp(response->body, "buffered-body", strlen("buffered-body")) != 0; + if (failed) + fprintf(stderr, "Close-delimited response body was not preserved\n"); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_timeout_test.c Sun Aug 02 09:01:24 2026 -0700 @@ -0,0 +1,82 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +typedef struct { + int listener; +} Timeout_Server; + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void *serve_stalled_response(void *argument) +{ + Timeout_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + usleep(250000); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Timeout_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_stalled_response, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 50); + + int64_t started = monotonic_milliseconds(); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + int64_t elapsed = monotonic_milliseconds() - started; + + int failed = response != NULL || elapsed < 40 || elapsed > 2000; + if (failed) + fprintf(stderr, "Expected a bounded timeout; response=%p elapsed=%lldms\n", + (void *)response, (long long)elapsed); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_response_test.c Sun Aug 02 09:01:24 2026 -0700 @@ -0,0 +1,49 @@ +#include "seobeo/seobeo.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> + +int main(void) +{ + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + Seobeo_Handle handle = {0}; + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + + Dowa_Arena *arena = Dowa_Arena_Create(4096); + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "hello", arena); + Dowa_HashMap_Push_Arena(response, "X-Test", "present", arena); + Dowa_HashMap_Push_Arena(response, "X-Unsafe", "bad\r\nInjected: yes", arena); + + Seobeo_Router_Send_Response(&handle, response, arena); + + char received[4096] = {0}; + ssize_t length = read(sockets[1], received, sizeof(received) - 1); + int failed = 0; + if (length <= 0) + failed = 1; + if (!strstr(received, "Content-Length: 5\r\n")) + failed = 1; + if (!strstr(received, "X-Test: present\r\n\r\nhello")) + failed = 1; + if (strstr(received, "Injected: yes")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); + return failed; +}