diff hg-web/main.c @ 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 9f4429c49733
children 0e7b9464248d
line wrap: on
line diff
--- 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;
 }