# HG changeset patch # User MrJuneJune # Date 1785706921 25200 # Node ID 3fa4bf481f425cd72bd0370e628bc80dd490d888 # Parent eb8b4230fdb9d5c99162f0528f3e121e3ee3cc1e# Parent 70de0c80d0932571933d9946cad21b472ecb22ce [merge] Merge hg-web into default diff -r eb8b4230fdb9 -r 3fa4bf481f42 AGENT.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/AGENT.md Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,21 @@ +# Agent instructions + +## Bazel is the project interface + +All project execution must happen through Bazel. Use existing `bazel build`, +`bazel test`, and `bazel run` targets instead of invoking compiled binaries, +language runtimes, package managers, generated scripts, or multiple services +manually. + +When a workflow needs several processes or commands, add a Bazel target that +owns their startup, runfiles, supervision, shutdown, and exit status. The user +should need one `bazel run` command. + +For hg-web development, run the complete local stack with: + +```bash +bazel run //hg-web:dev +``` + +Add or update the smallest relevant Bazel target whenever code cannot be built, +tested, run, bundled, or orchestrated through Bazel. diff -r eb8b4230fdb9 -r 3fa4bf481f42 MODULE.bazel --- a/MODULE.bazel Sun Aug 02 08:52:13 2026 -0700 +++ b/MODULE.bazel Sun Aug 02 14:42:01 2026 -0700 @@ -2,6 +2,8 @@ bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "rules_shell", version = "0.6.1") +bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True) +bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True) bazel_dep(name = "openssl", version = "3.3.1.bcr.7") bazel_dep(name = "rules_foreign_cc", version = "0.14.0") bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True) @@ -13,6 +15,36 @@ ) http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "playwright_chromium_linux", + urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"], + sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5", + strip_prefix = "chrome-linux", + build_file_content = """ +exports_files(["chrome"]) + +filegroup( + name = "chromium", + srcs = glob(["**"], exclude = ["BUILD.bazel"]), + visibility = ["//visibility:public"], +) +""", +) + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "20.18.0") +use_repo(node, "nodejs") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "hg_web_npm", + data = ["//hg-web/e2e:package.json"], + npm_package_lock = "//hg-web/e2e:package-lock.json", + pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml", +) +use_repo(npm, "hg_web_npm") # Bun http_file( diff -r eb8b4230fdb9 -r 3fa4bf481f42 MODULE.bazel.lock --- a/MODULE.bazel.lock Sun Aug 02 08:52:13 2026 -0700 +++ b/MODULE.bazel.lock Sun Aug 02 14:42:01 2026 -0700 @@ -230,6 +230,102 @@ }, "selectedYankedVersions": {}, "moduleExtensions": { + "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { + "general": { + "bzlTransitiveDigest": "A3wObJgLD5IcmyBKFyx9nfycX4VosrUq4UtGAHdyZ8M=", + "usagesDigest": "UCPGCdTkoflpzf0ShZ9hsk2zN6bbQlRvpRRZJnniDDc=", + "recordedInputs": [ + "REPO_MAPPING:aspect_bazel_lib+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:aspect_bazel_lib+,tar.bzl tar.bzl+", + "REPO_MAPPING:aspect_rules_js+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+", + "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools", + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:tar.bzl+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+" + ], + "generatedRepoSpecs": { + "pnpm": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "root_package": "", + "link_workspace": "", + "link_packages": {}, + "integrity": "sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==", + "url": "", + "commit": "", + "patch_args": [ + "-p0" + ], + "patches": [], + "custom_postinstall": "", + "npm_auth": "", + "npm_auth_basic": "", + "npm_auth_username": "", + "npm_auth_password": "", + "lifecycle_hooks": [], + "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])", + "extract_full_archive": true, + "exclude_package_contents": [] + } + }, + "pnpm__links": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "dev": false, + "root_package": "", + "link_packages": {}, + "deps": {}, + "transitive_closure": {}, + "lifecycle_build_target": false, + "lifecycle_hooks_env": [], + "lifecycle_hooks_execution_requirements": [ + "no-sandbox" + ], + "lifecycle_hooks_use_default_shell_env": false, + "bins": {}, + "package_visibility": [ + "//visibility:public" + ], + "exclude_package_contents": [] + } + } + } + } + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "general": { + "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=", + "usagesDigest": "TQSuPERI87Z4Alo1eOWeUR3NGo5f3txcCOd4tpsGXmw=", + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], + "generatedRepoSpecs": { + "aspect_tools_telemetry_report": { + "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", + "attributes": { + "deps": { + "aspect_rules_js": "2.9.2", + "aspect_tools_telemetry": "0.3.3" + } + } + } + } + } + }, "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": { "general": { "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=", @@ -446,6 +542,28 @@ } } }, + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + ], + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + } + } + }, "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": { "general": { "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=", @@ -879,7 +997,7 @@ "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=", - "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=", + "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=", "recordedInputs": [], "generatedRepoSpecs": { "nodejs_linux_amd64": { diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/BUILD --- a/hg-web/BUILD Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/BUILD Sun Aug 02 14:42:01 2026 -0700 @@ -1,4 +1,5 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle") # Source files @@ -58,6 +59,7 @@ srcs = ["main.c"], deps = ["//seobeo:seobeo"], data = [":all_assets"], + visibility = ["//hg-web:__subpackages__"], ) cc_binary( @@ -67,6 +69,20 @@ data = [":all_assets"], ) +sh_binary( + name = "dev", + srcs = ["dev.sh"], + data = [ + ":hg_web_server", + "@bazel_tools//tools/bash/runfiles", + ], +) + +test_suite( + name = "tests", + tests = ["//hg-web/e2e:app_e2e_test"], +) + bundle( name = "hg_web_server_bundle", binary = ":hg_web_server", diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/README.md --- a/hg-web/README.md Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/README.md Sun Aug 02 14:42:01 2026 -0700 @@ -1,34 +1,286 @@ # hg-web -A web-based Mercurial repository browser. Provides a GitHub-style interface for browsing files, viewing code with syntax highlighting, and reading markdown documentation. +A custom Mercurial forge and repository browser. The application keeps the +networking and HTTP stack in C with Seobeo, while the hand-drawn web interface +is implemented in React and TypeScript. + +## Wiki tree -## Features +```text +hg-web +├── Runtime +│ ├── hg_web_server :6970 +│ │ ├── React application and static assets +│ │ ├── repository browsing API +│ │ ├── commit graph API +│ │ └── Mercurial wire-protocol proxy +│ └── hg serve :4444 +│ ├── repository files and graph data +│ └── Mercurial pull/push protocol +├── Backend +│ ├── main.c +│ └── ../seobeo +│ ├── s_network.c +│ ├── s_web.c +│ ├── s_http_client.c +│ └── s_ssl.c +├── Frontend +│ └── src +│ ├── main.tsx +│ ├── components +│ │ ├── app.tsx +│ │ ├── directory-browser.tsx +│ │ ├── graph.tsx +│ │ ├── header.tsx +│ │ ├── footer.tsx +│ │ ├── theme.tsx +│ │ └── repo-browser.tsx +│ ├── index.html +│ ├── index.css +│ ├── base.css +│ └── custom pencil, panda, and icon assets +├── Build +│ ├── BUILD +│ ├── ../gui_ze/gui_ze.bzl +│ ├── ../markdown_converter +│ └── ../third_party/highlight +└── Operations + └── deploy.sh +``` + +`repo-browser.tsx` is an older standalone browser implementation. The active +entry point is `main.tsx`, which renders `app.tsx`; `app.tsx` embeds +`directory-browser.tsx`. + +## Runtime topology -- Browse repository files and directories -- View code files with syntax highlighting (highlight.js) -- Render markdown files with WASM-based converter -- Dark/light theme support with system preference detection -- Prefetch on hover for faster navigation +```text +Browser + | + | HTTPS + v +nginx + | + | HTTP :6970 + v +hg_web_server (main.c + Seobeo) + | + | HTTP :4444 + v +hg serve (Zenbu Mercurial repository) +``` + +The C server owns the public routes. It serves the application shell and +assets, translates browser API requests into `hg serve` requests, and streams +Mercurial wire-protocol traffic without routing bundle data through the normal +buffered HTTP response path. + +## Request flows + +### Application shell + +```text +GET /, /directories, or /graph + -> GetReactHome + -> hg-web/src/index.html + -> /page.js + -> src/main.tsx + -> components/app.tsx +``` + +### Repository browser -## Structure +```text +directory-browser.tsx + -> GET /api/repo/list?path=... + -> ApiListDirectory + -> GET hg-serve/file/tip/?style=json +file or README selection + -> GET /api/repo/file?path=... + -> ApiGetFile + -> GET hg-serve/raw-file/tip/ + -> highlight.js, markdown_converter WASM, or inline static preview ``` -hg-web/ -├── BUILD # Bazel build configuration -├── deploy.sh # Deployment script -├── main.c # C server handling API routes -└── src/ # Frontend source files + +Images, SVG, video, audio, and PDF files open in the forge preview modal. +Unknown binary files retain a download link. The raw file API supplies explicit +MIME types, `nosniff`, inline disposition for supported previews, and sandboxed +SVG responses. + +### Commit graph + +```text +graph.tsx + -> GET /api/graph/?graphtop=...&style=json + -> ApiGetGraph + -> GET hg-serve/graph/?... + -> React rows + custom pencil/panda canvas + +graph row selection + -> /changeset/ + -> GET /api/changeset/ + -> ApiGetChangeset + -> GET hg-serve/json-rev/ + -> changeset metadata + rendered diff +``` + +### Mercurial clone, pull, and push + +```text +Mercurial client + -> GET or POST /repo + -> StreamHgWireProtocol + -> hg serve /?cmd=... + -> binary-safe streamed response ``` -## Building +## Route map + +| Method | Route | Owner | Purpose | +| --- | --- | --- | --- | +| `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` | 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 + +| File | Responsibility | +| --- | --- | +| `src/components/app.tsx` | Client-side routes, landing page, tabs, and history | +| `src/components/directory-browser.tsx` | Breadcrumbs, listings, prefetch, file modals, README rendering | +| `src/components/graph.tsx` | Graph fetching, pagination, canvas edges, panda nodes | +| `src/components/theme.tsx` | Stored light/dark preference and system-theme integration | +| `src/components/header.tsx` | Forge identity and theme control | +| `src/components/footer.tsx` | Shared footer | +| `src/index.css`, `src/base.css` | Custom visual language and layout | + +Keep the custom assets and visual language in this package. New forge screens +should reuse the existing CSS variables, typography, textures, and components +instead of introducing a generic design system. + +## Build and local run + +Build the server and deployable bundle from the repository root: ```bash -bazel build //hg-web:hg_web +bazel build //hg-web:hg_web_server +bazel build //hg-web:hg_web_server_bundle +bazel test //hg-web:tests +bazel test //markdown_converter/tests:markdown_to_html_test +bazel test //seobeo/tests:all +``` + +`//hg-web:tests` creates a temporary two-commit Mercurial repository and runs +the application in pinned Chromium through Playwright. It covers shell routes, +files and README rendering, graph and changeset navigation, browser history, +console and page errors, API validation, Mercurial wire protocol, Markdown +script escaping, static assets, and backend outages. The test invokes the +workspace Mercurial CLI with an isolated configuration and home directory. + +Build and run the complete local stack with one Bazel command: + +```bash +bazel run //hg-web:dev +``` + +This starts `hg serve` on `127.0.0.1:4444` and `hg_web_server` on port `6970`. +Stopping the Bazel target stops both child processes. + +The bundle contains `hg_web_server` and `hg-web/src/`, including generated +`page.js`, markdown WASM, highlight.js, styles, and image assets. + +## Deployment contract + +The expected production layout is: + +```text +nginx + -> hg_web_server.service + -> /opt/hg_web_server_bundle_active/hg_web_server + -> working directory: /opt/hg_web_server_bundle_active + -> hg serve service on 127.0.0.1:4444 ``` -## API Endpoints +`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 + +```text +Zenbu Forge +├── Repository +│ ├── File and directory browsing available +│ ├── Syntax highlighting available +│ ├── README rendering available +│ ├── Commit graph available +│ ├── Changeset detail and diff available +│ ├── Branches, bookmarks, and tags planned +│ ├── File history and blame planned +│ └── Search planned +├── Collaboration +│ ├── Authentication and authorization planned +│ ├── Changeset review planned +│ ├── Issues planned +│ └── Releases and artifacts planned +└── Automation + ├── Mercurial incoming/changegroup hook planned + ├── Durable SQLite job queue planned + ├── Isolated Bazel runner planned + ├── Live logs and job status API planned + ├── Forge status and log screens planned + ├── Artifact retention planned + └── Atomic deploy, health check, rollback available +``` + +## Automation data flow -The C server (`main.c`) provides: +The Actions-like subsystem should extend the existing server rather than +replace it: + +```text +hg push + -> Mercurial hook records repository + revision + -> small enqueue command writes a SQLite job + -> runner service claims one queued job + -> isolated shared checkout updates to the exact revision + -> Bazel build and test steps stream logs + -> job result and artifacts are recorded + -> hg-web status API exposes the result + -> custom React screens render runs, steps, logs, and artifacts + -> successful protected jobs may call atomic deployment +``` -- `GET /api/repo/list?path=` - List directory contents -- `GET /api/repo/file?path=` - Fetch file contents +Start with one runner on the same host. Keep the hook fast, never execute build +steps inside the Mercurial request, and make the queue durable before adding +parallel or remote runners. + +## Safe extension order + +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 + +- Keep public application code on Seobeo APIs. +- Decode and validate every path before forwarding it to Mercurial. +- Keep `/repo` binary-safe; bundle data can contain null bytes. +- Do not buffer large wire-protocol responses through the regular HTTP client. +- Keep backend route names synchronized with frontend fetch and navigation code. +- Preserve accurate status codes, content lengths, and content types. +- Keep secrets in service-owned environment files, not in source or job logs. diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/deploy.sh --- a/hg-web/deploy.sh Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/deploy.sh Sun Aug 02 14:42:01 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" diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/dev.sh --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/dev.sh Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "${RUNFILES_DIR:-}" ]]; then + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" +elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" +elif [[ -d "$0.runfiles" ]]; then + RUNFILES_DIR="$0.runfiles" + export RUNFILES_DIR + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" +elif [[ -f "$0.runfiles_manifest" ]]; then + RUNFILES_MANIFEST_FILE="$0.runfiles_manifest" + export RUNFILES_MANIFEST_FILE + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" +else + echo "Bazel runfiles are unavailable." >&2 + exit 1 +fi + +workspace="${BUILD_WORKSPACE_DIRECTORY:-}" +if [[ -z "$workspace" || ! -d "$workspace/.hg" ]]; then + echo "Run this target from the Zenbu workspace." >&2 + exit 1 +fi + +server="$(rlocation _main/hg-web/hg_web_server)" +runfiles_workspace="$(dirname "$(dirname "$server")")" +hg_pid="" +server_pid="" + +cleanup() { + trap - EXIT INT TERM + for pid in "$server_pid" "$hg_pid"; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + for pid in "$server_pid" "$hg_pid"; do + if [[ -n "$pid" ]]; then + wait "$pid" 2>/dev/null || true + fi + done +} + +trap cleanup EXIT +trap 'exit 130' INT TERM + +hg --repository "$workspace" serve \ + --address 127.0.0.1 \ + --port 4444 \ + --accesslog - \ + --errorlog - & +hg_pid=$! + +( + cd "$runfiles_workspace" + exec "$server" +) & +server_pid=$! + +echo "hg-web: http://127.0.0.1:6970" +echo "Mercurial wire endpoint: http://127.0.0.1:6970/repo" +echo "Press Ctrl-C to stop both servers." + +set +e +wait -n "$hg_pid" "$server_pid" +status=$? +set -e +exit "$status" diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/e2e/BUILD --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/BUILD Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,31 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@hg_web_npm//:defs.bzl", "npm_link_all_packages") + +exports_files([ + "package-lock.json", + "package.json", + "pnpm-lock.yaml", +]) + +npm_link_all_packages(name = "node_modules") + +js_test( + name = "app_e2e_test", + entry_point = "app_e2e_test.js", + data = [ + ":node_modules/playwright-core", + "//hg-web:hg_web_server", + "@playwright_chromium_linux//:chromium", + "@playwright_chromium_linux//:chrome", + ], + env = { + "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)", + }, + no_copy_to_bin = ["@playwright_chromium_linux//:chromium"], + size = "large", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + timeout = "long", +) diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/e2e/app_e2e_test.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/app_e2e_test.js Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,417 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { chromium } = require('playwright-core'); + +const BASE_URL = 'http://127.0.0.1:6970'; +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`, + ); + } + return result.stdout.trim(); +} + +function mercurialEnvironment(home) { + return { + ...process.env, + HGPLAIN: '1', + HGRCPATH: '', + HOME: home, + }; +} + +function stopProcess(child) { + if (!child || child.exitCode !== null) return Promise.resolve(); + child.kill('SIGTERM'); + return new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForHttp(url, child, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`); +} + +function createFixtureRepository(root) { + const options = { env: mercurialEnvironment(root) }; + run('hg', ['init', root], options); + fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'README.md'), + '# Fixture Repository\n\n\n', + ); + fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n'); + fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n'); + fs.writeFileSync( + path.join(root, 'BUILD'), + 'cc_library(\n name = "fixture",\n srcs = ["src/main.c"],\n)\n', + ); + fs.writeFileSync( + path.join(root, 'pixel.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=', + 'base64', + ), + ); + fs.writeFileSync(path.join(root, 'movie.mp4'), Buffer.from('00000018667479706d703432', 'hex')); + fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3')); + fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n')); + fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3])); + run('hg', ['--repository', root, 'add'], options); + run('hg', ['--repository', root, 'commit', '-u', 'Test User ', '-m', 'Initial fixture'], options); + const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + + fs.writeFileSync( + path.join(root, 'README.md'), + '# Updated Fixture Repository\n\n\n\nSecond revision.\n', + ); + fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n'); + run('hg', ['add', 'new-file.txt'], { ...options, cwd: root }); + run('hg', ['--repository', root, 'commit', '-u', 'Test User ', '-m', 'Update fixture'], options); + const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + return { firstNode, tipNode }; +} + +async function assertJson(pathname, status = 200) { + const response = await fetch(`${BASE_URL}${pathname}`); + const body = await response.text(); + assert.equal(response.status, status, `${pathname} status: ${body}`); + return JSON.parse(body); +} + +async function assertPageHasNoBrowserErrors(browser, pathname, assertion) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`)); + page.on('console', message => { + if (message.type() === 'error') errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' }); + assert.equal(response.status(), 200, `${pathname} document status`); + await assertion(page); + if (errors.length > 0) { + throw new Error(`${pathname} browser errors\n${errors.join('\n')}`); + } + await page.close(); +} + +async function main() { + assert.ok(RUNFILES, 'rules_js runfiles path is required'); + assert.ok(WORKSPACE, 'rules_js workspace name is required'); + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-')); + const appLogs = []; + const hgLogs = []; + let hgServer; + let appServer; + let browser; + + try { + const { firstNode, tipNode } = createFixtureRepository(fixtureRoot); + const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); + const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server'); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + + assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`); + assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`); + + hgServer = spawn( + 'hg', + [ + '--repository', fixtureRoot, + 'serve', + '--address', '127.0.0.1', + '--port', '4444', + '--accesslog', '-', + '--errorlog', '-', + ], + { + env: mercurialEnvironment(fixtureRoot), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString())); + hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString())); + + appServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + stdio: ['ignore', 'pipe', 'pipe'], + }); + appServer.stdout.on('data', chunk => appLogs.push(chunk.toString())); + appServer.stderr.on('data', chunk => appLogs.push(chunk.toString())); + + await waitForHttp(`${BASE_URL}/`, appServer, appLogs); + if (hgServer.exitCode !== null) { + throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`); + } + await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs); + + for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} shell route`); + assert.match(await response.text(), /Zenbu Repository<\/title>/); + } + for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} static asset`); + assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`); + } + for (const pathname of ['/hg-web-background.jpg', '/pencil_texture.png']) { + assert.equal((await fetch(`${BASE_URL}${pathname}`)).status, 404); + } + + const rootList = await assertJson('/api/repo/list'); + assert.ok(rootList.directories.some(entry => entry.basename === 'docs')); + assert.ok(rootList.files.some(entry => entry.basename === 'README.md')); + + const nestedList = await assertJson('/api/repo/list?path=docs'); + assert.ok(nestedList.files.some(entry => entry.basename === 'README.md')); + + const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`); + assert.equal(fileResponse.status, 200); + assert.match(await fileResponse.text(), /int main/); + + for (const [filename, contentType, disposition] of [ + ['pixel.png', 'image/png', 'inline'], + ['movie.mp4', 'video/mp4', 'inline'], + ['sound.mp3', 'audio/mpeg', 'inline'], + ['document.pdf', 'application/pdf', 'inline'], + ['archive.bin', 'application/octet-stream', 'attachment'], + ]) { + const response = await fetch( + `${BASE_URL}/api/repo/file?path=${encodeURIComponent(filename)}`, + ); + assert.equal(response.status, 200, `${filename} response`); + assert.match(response.headers.get('content-type') || '', new RegExp(`^${contentType}`)); + assert.equal(response.headers.get('content-disposition'), disposition); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.ok((await response.arrayBuffer()).byteLength > 0); + } + const binaryResponse = await fetch(`${BASE_URL}/api/repo/file?path=archive.bin`); + assert.deepEqual( + Buffer.from(await binaryResponse.arrayBuffer()), + Buffer.from([0, 1, 2, 3]), + ); + + const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`); + assert.equal(readmeResponse.status, 200); + assert.match(await readmeResponse.text(), /Nested README/); + assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204); + + const graph = await assertJson('/api/graph/tip?style=json'); + assert.equal(graph.node, tipNode); + assert.ok(graph.changesets.length >= 2); + + const changeset = await assertJson(`/api/changeset/${tipNode}`); + assert.equal(changeset.node, tipNode); + assert.equal(changeset.desc, 'Update fixture'); + assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added')); + assert.ok(changeset.diff.length > 0); + + for (const pathname of [ + '/api/repo/list?path=..%2Fetc', + '/api/repo/file?path=..%2FREADME.md', + '/api/graph/not-a-node?style=json', + '/api/changeset/not-a-node', + ]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 400, `${pathname} rejects invalid input`); + } + assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404); + + const identify = run( + 'hg', + ['identify', `${BASE_URL}/repo`], + { env: mercurialEnvironment(fixtureRoot) }, + ); + assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`)); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + + await assertPageHasNoBrowserErrors(browser, '/', async page => { + await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor(); + await page.getByText('Recent Commits').waitFor(); + await page.getByText('Repository Files').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + assert.equal( + await page.locator('.graph-container').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.locator('.theme-toggle').click(); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory', async page => { + await page.getByText('Repository Files').waitFor(); + await page.getByRole('link', { name: 'README.md' }).first().click(); + await page.getByText('Updated Fixture Repository').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + await page.keyboard.press('Escape'); + + let delayedReadmeRequested = false; + let markReadmeRequested; + const readmeRequested = new Promise(resolve => { + markReadmeRequested = resolve; + }); + await page.route(/\/api\/repo\/readme\?path=/, async route => { + delayedReadmeRequested = true; + markReadmeRequested(); + await new Promise(resolve => setTimeout(resolve, 500)); + await route.continue(); + }); + await page.getByRole('link', { name: 'docs' }).click(); + await Promise.race([ + readmeRequested, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000); + }), + ]); + await page.getByRole('link', { name: 'root' }).click(); + await page.getByText('Updated Fixture Repository').waitFor(); + await page.waitForTimeout(600); + assert.equal(await page.getByText('Documentation').count(), 0); + assert.equal(delayedReadmeRequested, true); + + await page.getByRole('link', { name: 'pixel.png' }).click(); + const image = page.locator('.static-file-image'); + await image.waitFor(); + await image.evaluate(element => { + const imageElement = element; + if (imageElement.complete) return; + return new Promise((resolve, reject) => { + imageElement.addEventListener('load', resolve, { once: true }); + imageElement.addEventListener('error', reject, { once: true }); + }); + }); + assert.equal(await image.evaluate(element => element.naturalWidth), 1); + await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor(); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'BUILD' }).click(); + const buildCode = page.locator('code.language-python'); + await buildCode.waitFor(); + await page.getByText('cc_library').waitFor(); + assert.match(await buildCode.textContent(), /name = "fixture"/); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'src' }).click(); + await page.getByRole('link', { name: 'main.c' }).click(); + await page.getByText('int main(void)').waitFor(); + await page.keyboard.press('Escape'); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => { + await page.getByText('Documentation').waitFor(); + await page.getByRole('link', { name: 'README.md' }).click(); + await page.getByText('Nested README.').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/graph', async page => { + await page.getByText('Commit Graph').waitFor(); + await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip')); + const graphUrl = page.url(); + const rows = page.locator('.graph-row'); + await rows.first().click(); + await page.waitForURL(/\/changeset\/[0-9a-f]+$/); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.waitForFunction(expected => window.location.href === expected, graphUrl); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => { + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor(); + await page.getByText('added', { exact: true }).waitFor(); + await page.getByRole('region', { name: 'Changeset diff' }).waitFor(); + await page.locator('.diff-column-headings').getByText('Before').first().waitFor(); + await page.locator('.diff-column-headings').getByText('After').first().waitFor(); + await page.locator('.diff-left.diff-remove').filter({ hasText: '# Fixture Repository' }).waitFor(); + await page.locator('.diff-right.diff-add').filter({ hasText: '# Updated Fixture Repository' }).waitFor(); + assert.equal( + await page.locator('.diff-left.diff-context').filter({ hasText: '<script>' }).first().textContent(), + '<script>window.__hgWebXss = true</script>', + ); + assert.equal( + await page.locator('.changeset-paper').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.getByRole('button', { name: firstNode.slice(0, 12) }).click(); + await page.waitForFunction( + expectedPath => window.location.pathname === expectedPath, + `/changeset/${firstNode}`, + ); + await page.getByRole('heading', { name: 'Initial fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => { + await page.getByText('Recent Commits').waitFor(); + }); + + await stopProcess(hgServer); + const unavailable = await fetch(`${BASE_URL}/api/repo/list`); + assert.equal(unavailable.status, 502); + assert.equal((await fetch(`${BASE_URL}/`)).status, 200); + } finally { + if (browser) await browser.close(); + await stopProcess(appServer); + await stopProcess(hgServer); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/e2e/package-lock.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package-lock.json Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,24 @@ +{ + "name": "e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright-core": "1.48.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/e2e/package.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package.json Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "playwright-core": "1.48.2" + } +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/e2e/pnpm-lock.yaml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/pnpm-lock.yaml Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,18 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + playwright-core: + specifier: 1.48.2 + version: 1.48.2 + +packages: + + /playwright-core@1.48.2: + resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==} + engines: {node: '>=18'} + hasBin: true + dev: false diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/main.c --- a/hg-web/main.c Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/main.c Sun Aug 02 14:42:01 2026 -0700 @@ -1,450 +1,780 @@ #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 : ""; - - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); + 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 *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"); +static boolean extension_is(const char *extension, const char *expected) +{ + return extension && strcasecmp(extension, expected) == 0; +} + +static const char *repository_file_content_type( + const char *path, + boolean *inline_preview, + boolean *sandbox_content) +{ + const char *extension = strrchr(path, '.'); + *inline_preview = TRUE; + *sandbox_content = FALSE; - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - - Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - - if (hg_response->status_code != 200) + if (extension_is(extension, ".png")) return "image/png"; + if (extension_is(extension, ".jpg") || + extension_is(extension, ".jpeg")) return "image/jpeg"; + if (extension_is(extension, ".gif")) return "image/gif"; + if (extension_is(extension, ".webp")) return "image/webp"; + if (extension_is(extension, ".avif")) return "image/avif"; + if (extension_is(extension, ".bmp")) return "image/bmp"; + if (extension_is(extension, ".ico")) return "image/x-icon"; + if (extension_is(extension, ".svg")) { - 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; + *sandbox_content = TRUE; + return "image/svg+xml"; } + if (extension_is(extension, ".mp4") || + extension_is(extension, ".m4v")) return "video/mp4"; + if (extension_is(extension, ".webm")) return "video/webm"; + if (extension_is(extension, ".mov")) return "video/quicktime"; + if (extension_is(extension, ".ogv")) return "video/ogg"; + if (extension_is(extension, ".mp3")) return "audio/mpeg"; + if (extension_is(extension, ".wav")) return "audio/wav"; + if (extension_is(extension, ".ogg") || + extension_is(extension, ".oga")) return "audio/ogg"; + if (extension_is(extension, ".flac")) return "audio/flac"; + if (extension_is(extension, ".m4a")) return "audio/mp4"; + if (extension_is(extension, ".aac")) return "audio/aac"; + if (extension_is(extension, ".pdf")) return "application/pdf"; + if (extension_is(extension, ".wasm")) return "application/wasm"; - 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", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - return resp; + *inline_preview = FALSE; + if (extension_is(extension, ".md") || + extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8"; + if (extension_is(extension, ".txt") || + extension_is(extension, ".log") || + extension_is(extension, ".c") || + extension_is(extension, ".h") || + extension_is(extension, ".cc") || + extension_is(extension, ".cpp") || + extension_is(extension, ".js") || + extension_is(extension, ".jsx") || + extension_is(extension, ".ts") || + extension_is(extension, ".tsx") || + extension_is(extension, ".css") || + extension_is(extension, ".html") || + extension_is(extension, ".htm") || + extension_is(extension, ".xml") || + extension_is(extension, ".json") || + extension_is(extension, ".yaml") || + extension_is(extension, ".yml") || + extension_is(extension, ".toml") || + extension_is(extension, ".sh") || + extension_is(extension, ".py") || + extension_is(extension, ".rs") || + extension_is(extension, ".go")) + return "text/plain; charset=utf-8"; + return "application/octet-stream"; } -Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena) +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) { - Seobeo_Request_Entry *resp = NULL; + 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; - 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); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + if (!request) + return NULL; - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path); + 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 (strlen(safe_path) == 0) + if (hg_argument && hg_argument[0] != '\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; + 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 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); + 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; +} - Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); +static Seobeo_Request_Entry *forward_hg_response( + Seobeo_Client_Response *hg_response, + const char *default_content_type, + const char *override_content_type, + Dowa_Arena *arena) +{ + if (!hg_response) + return text_response( + arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}"); - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); + const char *upstream_content_type = + map_value_case_insensitive(hg_response->headers, "Content-Type"); + const char *upstream_or_default_content_type = + override_content_type + ? override_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); - 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; - } + 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; +} - 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 *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", + NULL, + arena); +} - 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); +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"); - 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); + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"); + if (!hg_response) + return forward_hg_response(NULL, "application/json", NULL, arena); - return resp; + boolean inline_preview = FALSE; + boolean sandbox_content = FALSE; + const char *content_type = + repository_file_content_type(path, &inline_preview, &sandbox_content); + Seobeo_Request_Entry *response = forward_hg_response( + hg_response, "application/octet-stream", content_type, arena); + Dowa_HashMap_Push_Arena( + response, + "Content-Disposition", + inline_preview ? "inline" : "attachment", + arena); + Dowa_HashMap_Push_Arena( + response, "X-Content-Type-Options", "nosniff", arena); + if (sandbox_content) + Dowa_HashMap_Push_Arena( + response, "Content-Security-Policy", "sandbox", arena); + return response; } -Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; + 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"); - 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); + 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); - Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path); + 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"); - if (strlen(safe_path) == 0) + 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) { - 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; + Seobeo_Client_Response_Destroy(hg_response); + return text_response(arena, "204", "text/markdown", ""); } - - 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); + return forward_hg_response( + hg_response, "text/markdown", "text/markdown; charset=utf-8", arena); +} - 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); +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\"}"); - if (!hg_response->body) + 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", "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", + NULL, + 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", + NULL, + 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; } diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/src/components/app.tsx --- a/hg-web/src/components/app.tsx Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/src/components/app.tsx Sun Aug 02 14:42:01 2026 -0700 @@ -5,13 +5,188 @@ 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; + returnDepth?: number; +} + +type ChangesetDetail = { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + parents: string[]; + files: Array<{ + file: string; + status: string; + }>; + diff: Array<{ + blockno: number; + lines: Array<{ t: string; n: number; l: string }>; + }>; +}; + +type DiffLine = ChangesetDetail['diff'][number]['lines'][number]; + +type DiffCell = { + lineNumber: number | null; + text: string; + kind: 'context' | 'add' | 'remove' | 'meta'; +}; + +type SideBySideRow = { + left?: DiffCell; + right?: DiffCell; + range?: string; +}; + +function trimDiffLine(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function contentDiffLine(line: DiffLine): string { + const text = trimDiffLine(line.l); + if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') && + text.startsWith(line.t || ' ')) { + return text.slice(1); + } + return text; +} + +function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] { + const rows: SideBySideRow[] = []; + let removals: DiffCell[] = []; + let additions: DiffCell[] = []; + let oldLine: number | null = null; + let newLine: number | null = null; + let inHunk = false; + + const flushChanges = () => { + const count = Math.max(removals.length, additions.length); + for (let index = 0; index < count; index++) { + rows.push({ left: removals[index], right: additions[index] }); + } + removals = []; + additions = []; + }; + + for (const line of lines) { + if (line.t === '@') { + flushChanges(); + const range = trimDiffLine(line.l); + const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + oldLine = match ? Number(match[1]) : null; + newLine = match ? Number(match[2]) : null; + inHunk = true; + rows.push({ range }); + continue; + } + + if (!inHunk && (line.t === '-' || line.t === '+')) { + const cell: DiffCell = { + lineNumber: null, + text: trimDiffLine(line.l), + kind: 'meta', + }; + if (line.t === '-') removals.push(cell); + else additions.push(cell); + continue; + } + + if (line.t === '-') { + removals.push({ + lineNumber: oldLine, + text: contentDiffLine(line), + kind: 'remove', + }); + if (oldLine !== null) oldLine++; + continue; + } + + if (line.t === '+') { + additions.push({ + lineNumber: newLine, + text: contentDiffLine(line), + kind: 'add', + }); + if (newLine !== null) newLine++; + continue; + } + + flushChanges(); + const text = contentDiffLine(line); + rows.push({ + left: { lineNumber: oldLine, text, kind: 'context' }, + right: { lineNumber: newLine, text, kind: 'context' }, + }); + if (oldLine !== null) oldLine++; + if (newLine !== null) newLine++; + } + + flushChanges(); + return rows; +} + +function diffBlockFilename( + block: ChangesetDetail['diff'][number], + fallback?: string, +): string { + if (fallback) return fallback; + const newFileHeader = block.lines.find( + line => line.t === '+' && line.l.startsWith('+++ '), + ); + if (!newFileHeader) return `Diff block ${block.blockno}`; + return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0]; +} + +function SideBySideDiff({ + block, + filename, +}: { + block: ChangesetDetail['diff'][number]; + filename: string; +}) { + const rows = buildSideBySideRows(block.lines); + return ( + <div className="side-by-side-diff"> + <div className="diff-file-header">{filename}</div> + <div className="diff-column-headings"> + <span>Before</span> + <span>After</span> + </div> + <div className="diff-grid"> + {rows.map((row, index) => ( + row.range ? ( + <div className="diff-range-row" key={`range-${index}`}>{row.range}</div> + ) : ( + <React.Fragment key={`row-${index}`}> + <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}> + {row.left?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}> + {row.left?.text ?? ''} + </code> + <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}> + {row.right?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}> + {row.right?.text ?? ''} + </code> + </React.Fragment> + ) + ))} + </div> + </div> + ); } // Icons @@ -40,6 +215,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,18 +257,29 @@ 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 '/'; } } +function isRouteState(value: unknown): value is RouteState { + if (!value || typeof value !== 'object' || !('page' in value)) return false; + return ['landing', 'graph', 'directory', 'changeset'].includes( + String((value as { page: unknown }).page), + ); +} + // Landing Page Component 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 +326,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 +382,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, @@ -209,7 +407,7 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> + <button className="back-button" onClick={onBack} aria-label="Back"> ← Back </button> <span className="page-title">Commit Graph</span> @@ -239,14 +437,127 @@ 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} aria-label="Back"> + ← Back + </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}> + <span className={`changeset-file-status status-${file.status}`}> + {file.status} + </span> + {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, index) => ( + <SideBySideDiff + key={block.blockno} + block={block} + filename={diffBlockFilename(block, changeset.files[index]?.file)} + /> + ))} + </section> + </article> + )} + </div> + ); +} + // Directory Page Component function DirectoryPage({ onBack, @@ -260,7 +571,7 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> + <button className="back-button" onClick={onBack} aria-label="Back"> ← Back </button> <span className="page-title">Repository Files</span> @@ -281,8 +592,8 @@ // Handle browser back/forward useEffect(() => { - const handlePopState = () => { - setRoute(parseRoute()); + const handlePopState = (event: PopStateEvent) => { + setRoute(isRouteState(event.state) ? event.state : parseRoute()); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); @@ -306,6 +617,21 @@ navigate({ page: 'directory', dirPath: path || '' }); }, [navigate]); + const navigateToChangeset = useCallback((changesetId: string) => { + const returnDepth = route.page === 'changeset' + ? (route.returnDepth ?? 0) + 1 + : 1; + navigate({ page: 'changeset', changesetId, returnDepth }); + }, [navigate, route.page, route.returnDepth]); + + const navigateBackFromChangeset = useCallback(() => { + if (route.returnDepth !== undefined && route.returnDepth > 0) { + window.history.go(-route.returnDepth); + return; + } + navigateToGraph(); + }, [navigateToGraph, route.returnDepth]); + const handleDirectoryPathChange = useCallback((path: string) => { // Update URL without full navigation const params = new URLSearchParams(); @@ -333,7 +659,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 +679,7 @@ <LandingPage onNavigateToGraph={() => navigateToGraph()} onNavigateToDirectory={navigateToDirectory} + onNavigateToChangeset={navigateToChangeset} /> )} @@ -361,6 +688,7 @@ onBack={navigateToLanding} initialCommit={route.graphCommit} initialTip={route.graphTip} + onOpenChangeset={navigateToChangeset} /> )} @@ -372,6 +700,14 @@ /> )} + {route.page === 'changeset' && route.changesetId && ( + <ChangesetPage + changesetId={route.changesetId} + onBack={navigateBackFromChangeset} + onOpenChangeset={navigateToChangeset} + /> + )} + <Footer /> </div> ); diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/src/components/directory-browser.tsx --- a/hg-web/src/components/directory-browser.tsx Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/src/components/directory-browser.tsx Sun Aug 02 14:42:01 2026 -0700 @@ -19,9 +19,22 @@ 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', - 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample', + 'bzl', 'bazel' +]); + +const BAZEL_FILENAMES = new Set([ + 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel' ]); +type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; + +const IMAGE_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg' +]); +const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']); + // Prefetch cache const prefetchCache = new Map<string, Promise<any>>(); @@ -30,6 +43,7 @@ const basename = filename.toLowerCase(); return CODE_EXTENSIONS.has(ext) || CODE_EXTENSIONS.has(basename) || + BAZEL_FILENAMES.has(basename) || basename === 'dockerfile' || basename === 'makefile' || basename.startsWith('.'); @@ -40,6 +54,15 @@ return ext === 'md' || ext === 'markdown'; } +function getStaticPreviewKind(filename: string): StaticPreviewKind | null { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + if (ext === 'pdf') return 'pdf'; + return null; +} + function prefetchDirectory(path: string): void { const cacheKey = `dir:${path}`; if (prefetchCache.has(cacheKey)) return; @@ -155,6 +178,10 @@ const getLanguage = () => { const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') { + return 'python'; + } const langMap: Record<string, string> = { js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', @@ -276,6 +303,81 @@ ); } +function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const filename = filePath.split('/').pop() || filePath; + const previewKind = getStaticPreviewKind(filename); + const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`; + const [failed, setFailed] = useState(false); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div + className="file-viewer static-file-viewer" + role="dialog" + aria-modal="true" + aria-label={`Preview ${filename}`} + onClick={(event) => event.stopPropagation()} + > + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <span className="file-viewer-actions"> + <a href={fileUrl} download={filename}>Download</a> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </span> + </div> + <div className="file-viewer-content static-file-preview"> + {failed && <div className="error-message">Unable to preview this file.</div>} + {!failed && previewKind === 'image' && ( + <img + className="static-file-image" + src={fileUrl} + alt={filename} + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'video' && ( + <video + className="static-file-video" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'audio' && ( + <audio + className="static-file-audio" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'pdf' && ( + <iframe + className="static-file-pdf" + src={fileUrl} + title={filename} + onError={() => setFailed(true)} + /> + )} + </div> + </div> + </div> + ); +} + /** * Component: FileList */ @@ -338,7 +440,7 @@ e.preventDefault(); if (isDir) { onNavigate(item.abspath); - } else if (isCodeFile(item.basename)) { + } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) { onOpenFile(item.abspath); } else { window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); @@ -429,6 +531,7 @@ const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [viewingFile, setViewingFile] = useState<string | null>(null); + const requestGeneration = useRef(0); // Sync with initialPath prop useEffect(() => { @@ -436,8 +539,9 @@ }, [initialPath]); useEffect(() => { - fetchDirectory(currentPath); - fetchReadme(currentPath); + const generation = ++requestGeneration.current; + fetchDirectory(currentPath, generation); + fetchReadme(currentPath, generation); }, [currentPath]); const navigate = useCallback((path: string) => { @@ -445,7 +549,7 @@ onPathChange?.(path); }, [onPathChange]); - const fetchDirectory = async (path: string) => { + const fetchDirectory = async (path: string, generation: number) => { setLoading(true); setError(null); try { @@ -468,26 +572,30 @@ throw new Error(data.error); } - setContent({ - files: data?.files || [], - directories: data?.directories || [] - }); + if (generation === requestGeneration.current) { + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } } catch (err: any) { console.error('Error loading directory:', err); - setError(err.message); + if (generation === requestGeneration.current) setError(err.message); } finally { - setLoading(false); + if (generation === requestGeneration.current) setLoading(false); } }; - const fetchReadme = async (path: string) => { - setReadme(null); - const readmePath = path ? `${path}/README.md` : 'README.md'; + const fetchReadme = async (path: string, generation: number) => { + if (generation === requestGeneration.current) setReadme(null); try { - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); + const url = path + ? `${API_BASE}/readme?path=${encodeURIComponent(path)}` + : `${API_BASE}/readme`; + const response = await fetch(url); if (response.ok) { const text = await response.text(); - setReadme(text); + if (generation === requestGeneration.current) setReadme(text || null); } } catch (err) { // Readme is optional @@ -528,6 +636,8 @@ {viewingFile && ( isMarkdownFile(viewingFile) ? ( <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : getStaticPreviewKind(viewingFile) ? ( + <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} /> ) : ( <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> ) diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/src/components/graph.tsx --- a/hg-web/src/components/graph.tsx Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/src/components/graph.tsx Sun Aug 02 14:42:01 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"> + {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> ); }; diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/src/hg-web-background.jpg Binary file hg-web/src/hg-web-background.jpg has changed diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/src/index.css --- a/hg-web/src/index.css Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/src/index.css Sun Aug 02 14:42:01 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,204 @@ } /* =========================================== + Changeset Detail + =========================================== */ +.changeset-paper { + background: var(--bg); + 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 { + display: inline-flex; + align-items: center; + gap: 6px; + overflow-wrap: anywhere; +} + +.changeset-file-status { + color: var(--text-secondary); + font-size: 10px; + text-transform: uppercase; +} + +.changeset-file-status.status-added { + color: var(--success); +} + +.changeset-file-status.status-removed { + color: var(--danger); +} + +.changeset-diff { + margin-top: 24px; +} + +.changeset-diff h3 { + margin-bottom: 10px; +} + +.side-by-side-diff { + margin-bottom: 18px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-code); +} + +.diff-file-header { + padding: 9px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-subtle); + font-family: monospace; + font-weight: 600; +} + +.diff-column-headings { + display: grid; + grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr); + min-width: 760px; + border-bottom: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.diff-column-headings span { + padding: 6px 12px; +} + +.diff-column-headings span + span { + border-left: 1px solid var(--border); +} + +.diff-grid { + display: grid; + grid-template-columns: 52px minmax(328px, 1fr) 52px minmax(328px, 1fr); + min-width: 760px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +.diff-side-number, +.diff-side-code { + min-height: 22px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + +.diff-side-number { + padding: 2px 8px; + color: var(--text-secondary); + text-align: right; + user-select: none; + border-right: 1px solid var(--border); +} + +.diff-side-code { + display: block; + margin: 0; + padding: 2px 10px; + border-radius: 0; + background: transparent; + white-space: pre; +} + +.diff-column-divider { + border-left: 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-empty { + background: color-mix(in srgb, var(--bg-subtle) 70%, transparent); +} + +.diff-meta { + color: var(--text-secondary); + background: var(--bg-subtle); +} + +.diff-range-row { + grid-column: 1 / -1; + padding: 4px 10px; + color: var(--accent); + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + white-space: pre; +} + +/* =========================================== Common States =========================================== */ .empty-state { @@ -603,11 +814,53 @@ opacity: 0.7; } +.file-viewer-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.file-viewer-actions a { + font-size: 13px; +} + .file-viewer-content { overflow: auto; flex: 1; } +.static-file-viewer { + max-width: 1100px; +} + +.static-file-preview { + min-height: 240px; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-subtle); +} + +.static-file-image, +.static-file-video { + display: block; + max-width: 100%; + max-height: 75vh; + object-fit: contain; +} + +.static-file-audio { + width: min(100%, 640px); +} + +.static-file-pdf { + width: 100%; + height: 75vh; + border: 0; + background: #fff; +} + .file-viewer-content pre { margin: 0; padding: 16px; diff -r eb8b4230fdb9 -r 3fa4bf481f42 hg-web/src/pencil_texture.png Binary file hg-web/src/pencil_texture.png has changed diff -r eb8b4230fdb9 -r 3fa4bf481f42 markdown_converter/markdown_to_html.c --- a/markdown_converter/markdown_to_html.c Sun Aug 02 08:52:13 2026 -0700 +++ b/markdown_converter/markdown_to_html.c Sun Aug 02 14:42:01 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; diff -r eb8b4230fdb9 -r 3fa4bf481f42 markdown_converter/tests/BUILD --- a/markdown_converter/tests/BUILD Sun Aug 02 08:52:13 2026 -0700 +++ b/markdown_converter/tests/BUILD Sun Aug 02 14:42:01 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) diff -r eb8b4230fdb9 -r 3fa4bf481f42 markdown_converter/tests/markdown_to_html_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/markdown_converter/tests/markdown_to_html_test.c Sun Aug 02 14:42:01 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", "![preview](data:image/svg+xml,test)", "<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; +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/s_http_client.c --- a/seobeo/s_http_client.c Sun Aug 02 08:52:13 2026 -0700 +++ b/seobeo/s_http_client.c Sun Aug 02 14:42:01 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); diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/s_network.c --- a/seobeo/s_network.c Sun Aug 02 08:52:13 2026 -0700 +++ b/seobeo/s_network.c Sun Aug 02 14:42:01 2026 -0700 @@ -1,8 +1,37 @@ #include "seobeo/seobeo.h" +static pthread_once_t g_sigpipe_once = PTHREAD_ONCE_INIT; + +static void Seobeo_Process_Ignore_Sigpipe(void) +{ + signal(SIGPIPE, SIG_IGN); +} + +static void Seobeo_Socket_Disable_Sigpipe(int socket_fd) +{ +#ifdef SO_NOSIGPIPE + int enabled = 1; + setsockopt(socket_fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); +#else + (void)socket_fd; +#endif +} + +static ssize_t Seobeo_Socket_Write( + int socket_fd, + const void *buffer, + size_t length) +{ +#ifdef MSG_NOSIGNAL + return send(socket_fd, buffer, length, MSG_NOSIGNAL); +#else + return send(socket_fd, buffer, length, 0); +#endif +} Seobeo_Handle *Seobeo_Stream_Handle_Server_Create(const char *host, const char* port) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; struct addrinfo hints, *server_infos, *free_server_info; int32 socket_fd, yes = 1; // Need this for setsockopt @@ -26,6 +55,7 @@ if((socket_fd = socket(free_server_info->ai_family, free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1) { perror("socket"); continue; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) { perror("setsockopt SO_REUSEADDR"); continue; } @@ -81,6 +111,7 @@ Seobeo_Handle *Seobeo_Stream_Handle_Client_Create(const char *host, const char* port, boolean use_tls) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; p_handle = malloc(sizeof(*p_handle)); @@ -98,6 +129,7 @@ if((socket_fd = socket(server_infos->ai_family, server_infos->ai_socktype, server_infos->ai_protocol)) == -1) { perror("socket"); return NULL; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0) { perror("connect"); return NULL; } @@ -154,6 +186,7 @@ Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr), client_inet_addr, sizeof client_inet_addr); if (client_fd == -1) return NULL; + Seobeo_Socket_Disable_Sigpipe(client_fd); // Set non blocking... int flags = fcntl(client_fd, F_GETFL, 0); @@ -236,7 +269,7 @@ }else { Seobeo_Log(SEOBEO_DEBUG, "Flushing socket: %d\n", p_handle->socket); - ssize_t n = write( + ssize_t n = Seobeo_Socket_Write( p_handle->socket, p_handle->write_buffer + sent, total - sent @@ -275,9 +308,10 @@ } else { - n = write(p_handle->socket, - data + offset, - data_size - offset); + n = Seobeo_Socket_Write( + p_handle->socket, + data + offset, + data_size - offset); } if (n==0) diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/s_web.c --- a/seobeo/s_web.c Sun Aug 02 08:52:13 2026 -0700 +++ b/seobeo/s_web.c Sun Aug 02 14:42:01 2026 -0700 @@ -51,6 +51,7 @@ { case HTTP_OK: status_text = "OK"; break; case HTTP_CREATED: status_text = "Created"; break; + case HTTP_NO_CONTENT: status_text = "No Content"; break; case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break; case HTTP_FOUND: status_text = "Found"; break; case HTTP_BAD_REQUEST: status_text = "Bad Request"; break; @@ -58,11 +59,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 +777,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 +827,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); diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/seobeo.h --- a/seobeo/seobeo.h Sun Aug 02 08:52:13 2026 -0700 +++ b/seobeo/seobeo.h Sun Aug 02 14:42:01 2026 -0700 @@ -30,6 +30,7 @@ // HTTP STATUS CODE #define HTTP_OK 200 #define HTTP_CREATED 201 +#define HTTP_NO_CONTENT 204 #define HTTP_MOVED_PERMANENTLY 301 #define HTTP_FOUND 302 #define HTTP_BAD_REQUEST 400 @@ -82,6 +83,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. */ diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/seobeo_internal.h --- a/seobeo/seobeo_internal.h Sun Aug 02 08:52:13 2026 -0700 +++ b/seobeo/seobeo_internal.h Sun Aug 02 14:42:01 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); diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/tests/BUILD --- a/seobeo/tests/BUILD Sun Aug 02 08:52:13 2026 -0700 +++ b/seobeo/tests/BUILD Sun Aug 02 14:42:01 2026 -0700 @@ -28,3 +28,48 @@ 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_sigpipe_test", + srcs = ["seobeo_sigpipe_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"], +) diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/tests/seobeo_http_content_length_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_content_length_test.c Sun Aug 02 14:42:01 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; +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/tests/seobeo_http_framing_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_framing_test.c Sun Aug 02 14:42:01 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; +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/tests/seobeo_http_timeout_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_timeout_test.c Sun Aug 02 14:42:01 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; +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/tests/seobeo_response_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_response_test.c Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,79 @@ +#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); + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + memset(&handle, 0, sizeof(handle)); + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + arena = Dowa_Arena_Create(4096); + response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "204", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "", arena); + Seobeo_Router_Send_Response(&handle, response, arena); + + memset(received, 0, sizeof(received)); + length = read(sockets[1], received, sizeof(received) - 1); + if (length <= 0 || + !strstr(received, "HTTP/1.1 204 No Content\r\n") || + !strstr(received, "Content-Length: 0\r\n") || + !strstr(received, "\r\n\r\n")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected no-content response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); + return failed; +} diff -r eb8b4230fdb9 -r 3fa4bf481f42 seobeo/tests/seobeo_sigpipe_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_sigpipe_test.c Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,36 @@ +#include "seobeo/seobeo.h" + +#include <signal.h> +#include <stdlib.h> +#include <sys/socket.h> + +int main(void) +{ + Seobeo_Handle *server = + Seobeo_Stream_Handle_Server_Create("127.0.0.1", "0"); + if (!server) + return 1; + + raise(SIGPIPE); + Seobeo_Handle_Destroy(server); + + 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 = 64; + handle.write_buffer = malloc(handle.write_buffer_capacity); + if (!handle.write_buffer) + return 1; + + close(sockets[1]); + if (Seobeo_Handle_Queue(&handle, (const uint8 *)"closed", 6) != 0) + return 1; + + int result = Seobeo_Handle_Flush(&handle); + close(sockets[0]); + free(handle.write_buffer); + return result < 0 ? 0 : 1; +}