Mercurial
view hg-web/main.c @ 223:0e7b9464248d hg-web
[hg-web] Add browser route regression coverage
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 10:07:40 -0700 |
| parents | ce7f4400c2de |
| children | 3007ef5fc0ed |
line wrap: on
line source
#include "seobeo/seobeo.h" #include "dowa/dowa.h" #include <ctype.h> #include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <strings.h> #include <time.h> #include <unistd.h> #define HG_SERVE_HOST "127.0.0.1" #define HG_SERVE_PORT "4444" #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 const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key) { if (!map || !key) return NULL; for (size_t i = 0; i < Dowa_Array_Length(map); i++) { 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; } 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; } 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++) { unsigned char value = (unsigned char)encoded[i]; if (encoded[i] == '%') { 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; } decoded[output_length++] = (char)value; } decoded[output_length] = '\0'; *decoded_out = decoded; if (decoded_length_out) *decoded_length_out = output_length; return TRUE; } static boolean normalize_repository_path( const char *encoded_path, Dowa_Arena *arena, char **normalized_out) { char *decoded = NULL; size_t decoded_length = 0; if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length)) return FALSE; size_t start = 0; size_t end = decoded_length; if (start < end && decoded[start] == '/') { start++; if (start < end && decoded[start] == '/') return FALSE; } if (end > start && decoded[end - 1] == '/') { if (end - 1 > start && decoded[end - 2] == '/') return FALSE; end--; } 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; } static char *encode_repository_path(const char *path, Dowa_Arena *arena) { 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; } 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; } 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_Client_Request *request = Seobeo_Client_Request_Create(url); if (!request) return NULL; 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') { 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); } if (request_body && request_body_length > 0) Seobeo_Client_Request_Set_Body(request, request_body, request_body_length); Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); Seobeo_Client_Request_Destroy(request); return response; } static Seobeo_Request_Entry *forward_hg_response( Seobeo_Client_Response *hg_response, const char *default_content_type, Dowa_Arena *arena) { if (!hg_response) return text_response( arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}"); 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); char *status = Dowa_Arena_Allocate(arena, 8); snprintf(status, 8, "%d", hg_response->status_code); 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; } 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\"}"); 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\"}"); return forward_hg_response( hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), "application/json", arena); } 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); } 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 *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); 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"); Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"); if (hg_response && hg_response->status_code == HTTP_NOT_FOUND) { Seobeo_Client_Response_Destroy(hg_response); return text_response(arena, "204", "text/markdown", ""); } return forward_hg_response(hg_response, "text/markdown", arena); } Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena) { 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\"}"); const char *encoded_graph_top = map_value_case_insensitive(request, "query_graphtop"); char *graph_top = NULL; if (encoded_graph_top) { 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\"}"); } 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\"}"); return forward_hg_response( hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), "application/json", 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; } static void send_proxy_error(Seobeo_Handle *client, int status, const char *message) { 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); } } 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; } 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)) { send_proxy_error(client, 502, "Invalid Mercurial proxy request"); return; } 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; } 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; } 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; } #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) { Seobeo_Handle_Destroy(upstream); send_proxy_error(client, 502, "Mercurial backend write failed"); return; } boolean response_started = FALSE; int64_t last_progress = monotonic_milliseconds(); while (!response_started) { int read_result = Seobeo_Handle_Read(upstream); if (read_result == -2 || read_result < 0) { Seobeo_Handle_Destroy(upstream); send_proxy_error(client, 502, "Mercurial backend closed before responding"); return; } 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 (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); } } while (TRUE) { int read_result = Seobeo_Handle_Read(upstream); if (read_result == -2) break; if (read_result < 0) break; if (read_result == 0) { if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) { Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n"); break; } usleep(1000); continue; } last_progress = monotonic_milliseconds(); if (Seobeo_Handle_Queue( client, upstream->read_buffer, upstream->read_buffer_len) != 0 || Seobeo_Handle_Flush(client) != 0) break; Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); } Seobeo_Handle_Destroy(upstream); } Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena) { (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"); 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; } 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/changeset/:changeset_id", ApiGetChangeset); 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); Seobeo_Router_Destroy(); return result; }