.forge/prs/71c03ee0-ba11-40ea-b705-dfd5de88cbbf/patch.diff 19.6 KiB 562 lines Raw
1 From ee8c2ea4ec64ec0625a2de400c10f4c1e897893b Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:09:02 +01004 Subject: [PATCH] m5/docker: Dockerfile, entrypoint, 404, empty states, polish5  (106 tests)6 7 ---8  .dockerignore   |  11 ++9  Dockerfile      |  35 ++++++10  deploy.DOC.md   |  80 ++++++++++++++11  entrypoint.sh   |  56 ++++++++++12  src/serve.rs    |  23 ++--13  tests/polish.rs | 279 ++++++++++++++++++++++++++++++++++++++++++++++++14  6 files changed, 478 insertions(+), 6 deletions(-)15  create mode 100644 .dockerignore16  create mode 100644 Dockerfile17  create mode 100644 deploy.DOC.md18  create mode 100755 entrypoint.sh19  create mode 100644 tests/polish.rs20 21 diff --git a/.dockerignore b/.dockerignore22 new file mode 10064423 index 0000000..e00508724 --- /dev/null25 +++ b/.dockerignore26 @@ -0,0 +1,11 @@27 +# Keep the docker build context small: sources only.28 +# (The Dockerfile copies Cargo.toml, Cargo.lock, src/, static/ explicitly.)29 +target/30 +dist/31 +32 +# Runtime / local data: never part of the image context.33 +/data/34 +*.tmp35 +36 +# VCS metadata: not needed to build.37 +/.git/38 diff --git a/Dockerfile b/Dockerfile39 new file mode 10064440 index 0000000..31b427c41 --- /dev/null42 +++ b/Dockerfile43 @@ -0,0 +1,35 @@44 +# hu13 — multi-stage build (f0rge issue #11).45 +#46 +#   docker build -t hu13 .47 +#   docker run --rm -p 39313:39313 \48 +#     -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \49 +#     [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \50 +#     hu1351 +#52 +# Stage 1: pinned Rust builder. Only the files cargo needs are copied so53 +# the layer cache survives unrelated edits. `--locked` keeps Cargo.lock54 +# authoritative.55 +56 +FROM rust:1.98-bookworm AS builder57 +WORKDIR /src58 +COPY Cargo.toml Cargo.lock ./59 +COPY src/ ./src/60 +COPY static/ ./static/61 +RUN cargo build --locked --release62 +63 +# Stage 2: minimal runtime. ca-certificates for https clone URLs, git64 +# (hu13 shells out to `git -C` for every read — no git, no build),65 +# nonroot `hu13` (uid 10000), the static binary + entrypoint only.66 +FROM debian:bookworm-slim67 +RUN apt-get update \68 +    && apt-get install -y --no-install-recommends ca-certificates git \69 +    && rm -rf /var/lib/apt/lists \70 +    && useradd --uid 10000 --create-home --shell /usr/sbin/nologin hu13 \71 +    && mkdir -p /srv/www /data/inbox \72 +    && chown -R hu13:hu13 /srv/www /data/inbox73 +COPY --from=builder /src/target/release/hu13 /usr/local/bin/hu1374 +COPY entrypoint.sh /usr/local/bin/entrypoint.sh75 +RUN chmod +x /usr/local/bin/entrypoint.sh76 +USER hu1377 +EXPOSE 3931378 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]79 diff --git a/deploy.DOC.md b/deploy.DOC.md80 new file mode 10064481 index 0000000..15b953e82 --- /dev/null83 +++ b/deploy.DOC.md84 @@ -0,0 +1,80 @@85 +# Deploy hu13 with Docker86 +87 +> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the88 +> branch stays free of `.forge/` writes. Supervisor: move it to the wiki89 +> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md`90 +> via the CLI) and delete this file.91 +92 +## Build the image93 +94 +From the repo root (build context is sources only — see `.dockerignore`):95 +96 +```sh97 +docker build -t hu13 .98 +```99 +100 +Multi-stage: `rust:1.98-bookworm` compiles with101 +`cargo build --locked --release` (only `Cargo.toml`, `Cargo.lock`, `src/`,102 +`static/` are copied, so edits elsewhere don't bust the cache);103 +`debian:bookworm-slim` runtime adds `ca-certificates`, a nonroot user104 +`hu13` (uid 10000), the binary, and `entrypoint.sh`. No toolchain, no105 +sources, no `.git` in the final image.106 +107 +## Run it108 +109 +```sh110 +docker run --rm -p 39313:39313 \111 +  -v "$PWD:/data/repo:ro" \112 +  -v hu13-inbox:/data/inbox \113 +  -e HU13_BRANCH=main \114 +  -e HU13_CLONE_URL=https://example.com/mirror.git \115 +  hu13116 +```117 +118 +Then open `http://localhost:39313/`.119 +120 +What happens inside (`entrypoint.sh`):121 +122 +1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url`123 +   only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure —124 +   bad branch, unreadable repo — aborts LOUD before serving starts125 +   (`set -eu`; serve never runs on a stale/empty site).126 +2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox127 +   --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`,128 +   `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching129 +   `HU13_*` env var is set; otherwise the serve defaults apply).130 +   `exec` keeps `serve` on PID 1.131 +132 +## Volumes / env reference133 +134 +| Mount / var            | Default            | Notes                                              |135 +| ---------------------- | ------------------ | -------------------------------------------------- |136 +| `/data/repo` (ro)      | —                  | repo checkout to render (required)                 |137 +| `/data/inbox`          | —                  | inbox storage; MUST sit outside `--www` (serve refuses otherwise) |138 +| `HU13_BRANCH`          | `main`, else `master` | branch to build; wrong name fails the container |139 +| `HU13_CLONE_URL`       | git origin remote  | clone URL advertised on the index                  |140 +| `HU13_LISTEN`          | `0.0.0.0:39313`    | serve address                                      |141 +| `HU13_MAX_TITLE`       | `300`              | inbox title cap (chars)                            |142 +| `HU13_MAX_BODY`        | `20000`            | inbox body/prompt cap (chars)                      |143 +| `HU13_MAX_BYTES`       | `32768`            | whole-POST cap (bytes)                             |144 +| `HU13_RATE_PER_HOUR`   | `2`                | per-IP POSTs/hour                                  |145 +| `HU13_QUOTA`           | `500`              | global inbox file quota                            |146 +147 +## Notes148 +149 +- Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.150 +  If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`151 +  (libyaml) with the hand-rolled frontmatter split already used for152 +  parsing shapes, hand-roll the fixed-order `api/*.json` emitter instead153 +  of `serde_json`, enable `strip = true` + `opt-level = "z"` + LTO in the154 +  release profile. Do not gold-plate before then.155 +- `HU13_WWW` / `HU13_INBOX_DIR` also work for bare-metal156 +  `hu13 serve` (flags win over env); the container always passes157 +  explicit `--www` / `--inbox-dir`.158 +- Inbox files are read locally only: `hu13 inbox list|show --inbox-dir159 +  /data/inbox` (or `docker exec` into the volume). Never served over HTTP.160 +- Mount a plain checkout, not a git worktree: a worktree's `.git` is a161 +  pointer file (`gitdir: ...`) whose target lives outside the mount, so162 +  git inside the container cannot resolve anything. `git clone` the163 +  branch (or copy the repo) and mount that. (Host/container uid mismatch164 +  on the mount is already handled via `safe.directory` in entrypoint.sh.)165 diff --git a/entrypoint.sh b/entrypoint.sh166 new file mode 100755167 index 0000000..3cc51e5168 --- /dev/null169 +++ b/entrypoint.sh170 @@ -0,0 +1,56 @@171 +#!/bin/sh172 +# hu13 container entrypoint (f0rge issue #11): build once, then serve.173 +#174 +# Layout: the repo is mounted read-only at /data/repo, the inbox lives at175 +# /data/inbox (OUTSIDE the served tree — serve refuses to start otherwise),176 +# the built site goes to /srv/www.177 +#178 +# Env:179 +#   HU13_BRANCH     branch to build (default: main/master auto-detect)180 +#   HU13_CLONE_URL  clone URL to advertise (default: repo origin remote)181 +#   HU13_LISTEN     serve address (default: 0.0.0.0:39313)182 +#   HU13_MAX_TITLE / HU13_MAX_BODY / HU13_MAX_BYTES /183 +#   HU13_RATE_PER_HOUR / HU13_QUOTA184 +#                   inbox caps (serve defaults when unset; passed through185 +#                   as flags only when set, env is the fallback otherwise)186 +#187 +# Fail loud: any build failure (bad branch, bad repo, missing dir)188 +# aborts before serve starts. `serve` is exec'd so it keeps PID 1.189 +set -eu190 +191 +# Mounted checkouts belong to the host uid, but we run as uid 10000:192 +# without this git refuses every read ("dubious ownership") and the193 +# build below would misreport "branch not found".194 +git config --global --add safe.directory /data/repo195 +196 +BUILD_ARGS=""197 +if [ -n "${HU13_BRANCH:-}" ]; then198 +    BUILD_ARGS="$BUILD_ARGS --branch $HU13_BRANCH"199 +fi200 +if [ -n "${HU13_CLONE_URL:-}" ]; then201 +    BUILD_ARGS="$BUILD_ARGS --clone-url $HU13_CLONE_URL"202 +fi203 +204 +# shellcheck disable=SC2086205 +hu13 build /data/repo -o /srv/www $BUILD_ARGS206 +207 +SERVE_ARGS=""208 +if [ -n "${HU13_MAX_TITLE:-}" ]; then209 +    SERVE_ARGS="$SERVE_ARGS --max-title $HU13_MAX_TITLE"210 +fi211 +if [ -n "${HU13_MAX_BODY:-}" ]; then212 +    SERVE_ARGS="$SERVE_ARGS --max-body $HU13_MAX_BODY"213 +fi214 +if [ -n "${HU13_MAX_BYTES:-}" ]; then215 +    SERVE_ARGS="$SERVE_ARGS --max-bytes $HU13_MAX_BYTES"216 +fi217 +if [ -n "${HU13_RATE_PER_HOUR:-}" ]; then218 +    SERVE_ARGS="$SERVE_ARGS --rate-per-hour $HU13_RATE_PER_HOUR"219 +fi220 +if [ -n "${HU13_QUOTA:-}" ]; then221 +    SERVE_ARGS="$SERVE_ARGS --quota $HU13_QUOTA"222 +fi223 +224 +# shellcheck disable=SC2086225 +exec hu13 serve --www /srv/www --inbox-dir /data/inbox \226 +    --listen "${HU13_LISTEN:-0.0.0.0:39313}" $SERVE_ARGS227 diff --git a/src/serve.rs b/src/serve.rs228 index bd878f0..ed60894 100644229 --- a/src/serve.rs230 +++ b/src/serve.rs231 @@ -309,7 +309,16 @@ fn static_error(stream: &mut TcpStream, code: u16) {232          405 => "method not allowed",233          _ => "error",234      };235 -    let body = crate::layout::page(msg, "", &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));236 +    // Error pages keep the top-level nav so a lost visitor can click out.237 +    let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \238 +         <a href=\"/issues/index.html\">issues</a> · \239 +         <a href=\"/pulls/index.html\">pulls</a> · \240 +         <a href=\"/tasks/index.html\">tasks</a> · \241 +         <a href=\"/ci/index.html\">ci</a> · \242 +         <a href=\"/releases/index.html\">releases</a> · \243 +         <a href=\"/wiki/index.html\">wiki</a> · \244 +         <a href=\"/agents.html\">agents</a>";245 +    let body = crate::layout::page(msg, nav, &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));246      let extra = if code == 405 {247          &[("Allow", "GET, HEAD")][..]248      } else {249 @@ -460,11 +469,9 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {250          handle_inbox(&mut stream, &head, rest, kind, shared);251          return;252      }253 -    // Static: GET/HEAD only; never a listing (dirs → 404, files only).254 -    if head.method != "GET" && head.method != "HEAD" {255 -        static_error(&mut stream, 405);256 -        return;257 -    }258 +    // Static: files only, never a listing (dirs → 404). Unknown paths259 +    // are 404 for ANY method (including POST); a known file with the260 +    // wrong method is 405. Fail closed, echo nothing.261      let rel = path.strip_prefix('/').unwrap_or(&path);262      if rel.contains('\0')263          || rel.split('/').any(|s| s == "..")264 @@ -490,6 +497,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {265              return;266          }267      };268 +    if head.method != "GET" && head.method != "HEAD" {269 +        static_error(&mut stream, 405);270 +        return;271 +    }272      let ctype = content_type_for(&file_rel);273      if head.method == "HEAD" {274          let out = format!(275 diff --git a/tests/polish.rs b/tests/polish.rs276 new file mode 100644277 index 0000000..e71a09f278 --- /dev/null279 +++ b/tests/polish.rs280 @@ -0,0 +1,279 @@281 +//! Polish suite (m5/docker, f0rge issue #11):282 +//! CSS budget, serve 404s, empty-forge build, entrypoint fail-loudness.283 +//!284 +//! No docker daemon needed: the entrypoint is tested through flag parsing285 +//! (`hu13 build --branch <bad>` exits non-zero, naming the branch) plus a286 +//! `bash -n` syntax check of `entrypoint.sh` itself.287 +288 +use std::io::{BufRead, BufReader, Read, Write};289 +use std::net::TcpStream;290 +use std::path::{Path, PathBuf};291 +use std::process::{Child, Command, Stdio};292 +use std::time::Duration;293 +294 +fn bin() -> &'static str {295 +    env!("CARGO_BIN_EXE_hu13")296 +}297 +298 +fn manifest() -> PathBuf {299 +    PathBuf::from(env!("CARGO_MANIFEST_DIR"))300 +}301 +302 +fn fresh(prefix: &str) -> PathBuf {303 +    let dir = std::env::temp_dir().join(format!(304 +        "hu13-polish-{prefix}-{}-{}",305 +        std::process::id(),306 +        std::time::SystemTime::now()307 +            .duration_since(std::time::UNIX_EPOCH)308 +            .map(|d| d.as_nanos())309 +            .unwrap_or(0)310 +    ));311 +    std::fs::create_dir_all(&dir).unwrap();312 +    dir313 +}314 +315 +fn git(repo: &Path, args: &[&str]) {316 +    let st = Command::new("git")317 +        .arg("-C")318 +        .arg(repo)319 +        .args(args)320 +        .status()321 +        .expect("spawn git");322 +    assert!(st.success(), "git {args:?} failed");323 +}324 +325 +/// Minimal repo: 1 commit, NO `.forge/` at all.326 +fn bare_repo() -> PathBuf {327 +    let dir = fresh("repo");328 +    git(&dir, &["init", "-b", "main"]);329 +    git(&dir, &["config", "user.name", "hu13-test"]);330 +    git(&dir, &["config", "user.email", "[email protected]"]);331 +    git(&dir, &["config", "commit.gpgsign", "false"]);332 +    std::fs::write(dir.join("README.md"), "# polish fixture\n").unwrap();333 +    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();334 +    git(&dir, &["add", "-A"]);335 +    git(&dir, &["commit", "-m", "init"]);336 +    assert!(!dir.join(".forge").exists());337 +    dir338 +}339 +340 +struct Server {341 +    child: Child,342 +    addr: String,343 +}344 +345 +impl Drop for Server {346 +    fn drop(&mut self) {347 +        self.child.kill().ok();348 +        self.child.wait().ok();349 +    }350 +}351 +352 +fn start_server(www: &Path, inbox: &Path) -> Server {353 +    let mut child = Command::new(bin())354 +        .arg("serve")355 +        .arg("--www")356 +        .arg(www)357 +        .arg("--inbox-dir")358 +        .arg(inbox)359 +        .arg("--listen")360 +        .arg("127.0.0.1:0")361 +        .stdout(Stdio::piped())362 +        .stderr(Stdio::piped())363 +        .spawn()364 +        .expect("spawn hu13 serve");365 +    let stdout = child.stdout.take().expect("piped stdout");366 +    let mut lines = BufReader::new(stdout).lines();367 +    let mut addr = String::new();368 +    for _ in 0..50 {369 +        match lines.next() {370 +            Some(Ok(line)) if line.contains("listening on ") => {371 +                addr = line372 +                    .rsplit("listening on ")373 +                    .next()374 +                    .unwrap_or("")375 +                    .trim()376 +                    .to_string();377 +                break;378 +            }379 +            Some(Ok(_)) => continue,380 +            _ => break,381 +        }382 +    }383 +    assert!(!addr.is_empty(), "serve printed no `listening on` line");384 +    std::thread::sleep(Duration::from_millis(100));385 +    Server { child, addr }386 +}387 +388 +fn transact(addr: &str, req: &[u8]) -> String {389 +    let mut s = TcpStream::connect(addr).expect("connect");390 +    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();391 +    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();392 +    s.write_all(req).expect("write");393 +    let mut out = Vec::new();394 +    s.read_to_end(&mut out).expect("read");395 +    String::from_utf8_lossy(&out).into_owned()396 +}397 +398 +fn status(resp: &str) -> &str {399 +    resp.split("\r\n").next().unwrap_or("")400 +}401 +402 +#[test]403 +fn css_budget_under_15k() {404 +    let css = manifest().join("static").join("style.css");405 +    let len = std::fs::metadata(&css).expect("style.css exists").len();406 +    assert!(407 +        len < 15360,408 +        "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"409 +    );410 +}411 +412 +#[test]413 +fn unknown_path_is_404_html_for_get_and_post() {414 +    let www = fresh("www");415 +    std::fs::write(www.join("index.html"), "<!DOCTYPE html><p>hi</p>").unwrap();416 +    let inbox = fresh("inbox");417 +    let srv = start_server(&www, &inbox);418 +419 +    let get = transact(420 +        &srv.addr,421 +        b"GET /no-such-page.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",422 +    );423 +    assert!(424 +        status(&get).contains("404"),425 +        "GET unknown -> 404, got: {get}"426 +    );427 +    assert!(get.contains("text/html"), "404 body is html, got: {get}");428 +    assert!(429 +        get.contains("X-Content-Type-Options: nosniff"),430 +        "404 carries nosniff"431 +    );432 +433 +    // POST to an unknown (non-inbox) path is 404, not 405 and not 200.434 +    let body = "a=b";435 +    let post = transact(436 +        &srv.addr,437 +        format!(438 +            "POST /no-such-page.html HTTP/1.1\r\nHost: x\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",439 +            body.len()440 +        )441 +        .as_bytes(),442 +    );443 +    assert!(444 +        status(&post).contains("404"),445 +        "POST unknown -> 404, got: {post}"446 +    );447 +    assert!(post.contains("text/html"), "404 body is html");448 +449 +    // A KNOWN file with the wrong method is still 405 (no regression).450 +    let put = transact(451 +        &srv.addr,452 +        b"PUT /index.html HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",453 +    );454 +    assert!(455 +        status(&put).contains("405"),456 +        "wrong method on real file -> 405, got: {put}"457 +    );458 +}459 +460 +#[test]461 +fn empty_forge_build_all_tabs_none_yet() {462 +    let repo = bare_repo();463 +    let out = fresh("out");464 +    let build = Command::new(bin())465 +        .arg("build")466 +        .arg(&repo)467 +        .arg("-o")468 +        .arg(&out)469 +        .output()470 +        .expect("spawn hu13 build");471 +    assert!(472 +        build.status.success(),473 +        "no-forge build exits 0: {}",474 +        String::from_utf8_lossy(&build.stderr)475 +    );476 +    for (page, marker) in [477 +        ("issues/index.html", "No issues yet"),478 +        ("pulls/index.html", "No pull requests yet"),479 +        ("tasks/index.html", "No tasks yet"),480 +        ("ci/index.html", "No CI runs yet"),481 +        ("releases/index.html", "No releases yet"),482 +        ("wiki/index.html", "No wiki pages yet"),483 +    ] {484 +        let html = std::fs::read_to_string(out.join(page)).expect(page);485 +        assert!(html.contains(marker), "{page} says {marker:?}");486 +    }487 +}488 +489 +#[test]490 +fn bad_branch_fails_loud_naming_branch() {491 +    let repo = bare_repo();492 +    let out = fresh("out-bad");493 +    // Flag path (what entrypoint.sh passes from $HU13_BRANCH).494 +    let build = Command::new(bin())495 +        .arg("build")496 +        .arg(&repo)497 +        .arg("-o")498 +        .arg(&out)499 +        .arg("--branch")500 +        .arg("does-not-exist")501 +        .output()502 +        .expect("spawn hu13 build");503 +    assert!(!build.status.success(), "bad branch exits non-zero");504 +    let err = String::from_utf8_lossy(&build.stderr).into_owned();505 +    assert!(506 +        err.contains("does-not-exist"),507 +        "stderr names the bad branch, got: {err}"508 +    );509 +    // Env path (HU13_BRANCH fallback inside gitx::resolve_branch).510 +    let out2 = fresh("out-bad-env");511 +    let build2 = Command::new(bin())512 +        .arg("build")513 +        .arg(&repo)514 +        .arg("-o")515 +        .arg(&out2)516 +        .env("HU13_BRANCH", "also-missing")517 +        .output()518 +        .expect("spawn hu13 build");519 +    assert!(!build2.status.success(), "bad HU13_BRANCH exits non-zero");520 +    let err2 = String::from_utf8_lossy(&build2.stderr).into_owned();521 +    assert!(522 +        err2.contains("also-missing"),523 +        "stderr names the bad env branch, got: {err2}"524 +    );525 +    // Missing repo dir is loud too.526 +    let out3 = fresh("out-bad-repo");527 +    let build3 = Command::new(bin())528 +        .arg("build")529 +        .arg("/does/not/exist-at-all")530 +        .arg("-o")531 +        .arg(&out3)532 +        .output()533 +        .expect("spawn hu13 build");534 +    assert!(!build3.status.success(), "missing repo exits non-zero");535 +}536 +537 +#[test]538 +fn entrypoint_syntax_checks() {539 +    let ep = manifest().join("entrypoint.sh");540 +    assert!(ep.exists(), "entrypoint.sh exists");541 +    let st = Command::new("bash")542 +        .arg("-n")543 +        .arg(&ep)544 +        .status()545 +        .expect("spawn bash -n");546 +    assert!(st.success(), "entrypoint.sh parses");547 +    let text = std::fs::read_to_string(&ep).expect("read entrypoint");548 +    for needle in [549 +        "set -eu",550 +        "hu13 build /data/repo -o /srv/www",551 +        "HU13_BRANCH",552 +        "HU13_CLONE_URL",553 +        "exec hu13 serve --www /srv/www --inbox-dir /data/inbox",554 +        "0.0.0.0:39313",555 +        "safe.directory",556 +    ] {557 +        assert!(text.contains(needle), "entrypoint mentions {needle}");558 +    }559 +}560 -- 561 2.55.0562