collin/anvil
Todo
-
pull mirror (maybe): a repo that virtually mirrors a GitHub repo
- just displays it here — periodically fetched, read-only on the anvil side
Done [x]
-
render
TODO.mdper the todo-md spec (~/Code/todo-md) as a kanban boardanvil-web/src/todomd.rs: spec parser (sections by heading,[x]-marked done sections, checkbox tasks, indented details, fence-aware) + board renderer; prose sections render as a collapsible notes area below the board- blob pages for any
TODO.mdget Board/Rendered/Source pills (board default, falls back to markdown when the file has no tasks); the repo page shows the board in a box below the README - this file is itself spec-compliant now; structured updates come later
-
browse source at any branch, tag, or commit
/{owner}/{repo}/tree/{rev}always accepted any rev; what was missing was UI. Added: branch/tag switcher dropdown on the repo and tree pages, "browse files" link on the commit page, percent-encoded ref names so branches with/work, and an unborn-HEAD fallback so a repo whose HEAD names a missing branch no longer renders as empty.
-
CI artifact system
- done per
docs/ci-artifacts.md:artifacts:in ci.yml with in-container meta extractors, broker collection viadownload_from_container, run-page list, downloads +/{owner}/{repo}/artifacts/{rev}/{name}alias +browse: truestatic sites, quota GC with branch-tip pinning, and a schema shim so the table appears on existing DBs. - verified end-to-end against real Docker incl. the GC path; rustdoc was the test case (big HTML subtree served like pages)
- repo-deletion cleanup deferred: repo deletion doesn't exist
- done per
-
repo mirroring to GitHub (push mirror)
- per-repo "Mirror push URL" in settings (
repositories.mirror_url, column shim for existing DBs); after every successful push over HTTP or SSH a background mirror push fires - pure gitoxide (design rule in CLAUDE.md — no git binary): gix can't
push yet, so
anvil-git/src/push.rsimplements the send-pack client itself — advertisement parse, mirror commands honoringdelete-refs,gitserver_core::pack::build_raw_pack, report-status — over smart HTTP(S) (reqwest + rustls/ring; no aws-lc, musl zigbuild verified) or a local path served by gitserver-core in-process - credentials ride in the URL
(
https://x-access-token:<token>@github.com/...), stored as-is and redacted from logs; verified e2e: anvil→anvil over real HTTP incl. branch-deletion propagation, cloned back with real git - building this surfaced and fixed three gitserver-core server bugs:
(1)
git cloneof any repo containing an annotated tag died mid-transfer on protocol v2 — tag-object wants weren't peeled into the pack walk (regression testtag_object_wants_are_peeled_and_packed); (2) the receive-pack advertisement peeled tag refs, so re-pushing an identical annotated tag was wrongly rejected (v0 advertisement now also emits proper^{}peeled lines); (3) ref deletion was prohibited outright — now advertised and allowed viadelete-refs, with the HEAD (default) branch protected
- per-repo "Mirror push URL" in settings (
-
per-repository issue tracker
Issue/IssueCommentmodels with schema shims; per-repo numbering (#1, #2, ...); list page with open/closed tabs, new-issue form, detail page with markdown bodies and comments, close/reopen (issue author or repo writer)- any logged-in reader can open issues and comment; visibility follows the repo; "Issues" link in the repo nav; CSRF on all forms; verified e2e through the login + form flow
- no labels/assignees/editing yet — deliberately minimal
-
render a root
README.mdbelow the file tree on the repo page- any case of
readme.mdat the root; reusesrender_markdown, links to the blob view from the box header
- any case of
-
push-to-create:
git pushto a repo that doesn't exist yet creates it- it did NOT already work — both transports 404'd. Policy in
repos::create_on_push: pusher must own the namespace or be admin; created repos are private. - over HTTP a missing repo now answers the receive-pack advertisement with a Basic challenge so git prompts; verified e2e over both HTTP and SSH, incl. the cross-namespace denial
- it did NOT already work — both transports 404'd. Policy in
-
make SSH the default clone selection, first in the pill buttons
- only when
[ssh] enabled; HTTP stays the lone pill otherwise
- only when
-
rename the
anvilcrate toanvil_cli- named it
anvil-clito match the workspace's hyphenated crate names; the binary is stillanvild, so deploy scripts and docs needed no changes
- named it
-
fix git push failing on thin packs (REF_DELTA)
- root cause: every push after the first sends a thin pack; gitserver-core
passed
Noneasthin_pack_base_object_lookuptogix_pack::Bundle::write_to_directory, so gix couldn't resolve the delta bases and aborted ("Ref delta objects are not supported..."). First-push-to-empty-repo worked because that pack is self-contained. - fix: pass the already-open
gix::Repositoryas the lookup (it implementsgix_object::Find). Regression testreceive_thin_pack_with_ref_deltasbuilds a real thin pack viagit pack-objects --thinand pushes it throughreceive_pack; verified the test fails without the fix.
- root cause: every push after the first sends a thin pack; gitserver-core
passed
Session notes / resume point
Last updated: 2026-06-10 (third session). Working state is clean: build,
clippy, fmt, sort-derives, and cargo test --workspace all pass (the
pre-commit hook runs all of these).
Done this session (2026-06-10, third session)
All six remaining TODO items — see # Done [x] above for details. Headlines:
- CI artifacts (
docs/ci-artifacts.mdis the canonical reference):artifacts:in.anvil/ci.yml; broker pulls them out of the stopped container viadownload_from_container(inverse of the checkout upload — still no mounts); meta extractors run inside the sandbox, one file per value under/tmp/anvil-meta;browse: truedirectories are served like pages (rustdoc-ready);/{owner}/{repo}/artifacts/{rev}/{name}is the latest-on-branch alias; per-repo quota GC pins branch tips. New tableci_artifacts+[ci] artifact_*_mbcaps. - Schema shims for existing DBs (
anvil-core/src/db.rs): Toasty still only pushes schema on a fresh file, so new tables/columns ship as idempotent DDL applied on connect (SCHEMA_SHIMS/COLUMN_SHIMS), with tests asserting fresh and migrated databases converge. New deps: rusqlite (pinned to toasty's), flate2. - Issues (
anvil-core/src/issues.rs,anvil-web/src/issues.rs): minimal GitHub-shaped tracker; tablesissues+issue_comments. - Push mirroring (
anvil-git/src/mirror.rs):repositories.mirror_url→ backgroundgit push --mirrorafter each push; Dockerfile now installs git (the one thing gix can't do yet is push). - Push-to-create (
repos::create_on_push+ both transports), README on repo page, SSH-first clone pills, rev-switcher/browse-at-rev UI (committed earlier this session, along with theanvil-clirename).
Done earlier (2026-06-10, second session)
- UI quick wins (
crates/anvil-web/src/ui.rs) — latest-commit bar on the repo page (sha + subject + author/time, attached above the file box, links to the commit); profile page no longer shows the email; repo header readsowner / repowithout the leadinganvil /. - (c) sandboxed CI broker — job containers now run with
cap_drop=ALL+no-new-privilegesunconditionally, plus config-drivenpids_limit(512),memory_mb+swap (2048),cpus(2), wall-clocktimeout_secs(1800, force-removed on expiry), optionalnetwork = false,run_as, and anallowed_imagesallowlist (empty = any; tagless entry allows all tags).CiConfigincrates/anvil-core/src/config.rs(withimage_allowedtest);execute()incrates/anvil-ci/src/lib.rs. Read-only rootfs deliberately skipped (workspace lives in the container fs; no volumes ever attached). Docs:DEPLOY.md§7,anvil.example.toml[ci]. - (b) threat model —
docs/untrusted-mode.md: severity-ranked analysis (CI containment, pages/stored-XSS origin, git resource exhaustion, registration anti-abuse, authz granularity, webhook SSRF), the already-right list, and the stance: single-tenant supported, untrusted gated on items 1–4. - Pages hosting —
crates/anvil-web/src/pages.rs: serves a repo'spagesbranch at/{owner}/{repo}/pages/...; top-level dirs are separate sites (rustdoc, book, …);index.htmlresolution with trailing-slash redirect so relative links work; extension→content-type map +nosniff; listing page with publish hint; "Pages" button on the repo header. Visibility follows the repo (private → 404). Publish withgit push origin <built-branch>:pages.
Done earlier (same day, first session)
- CI UI — runs list
/{owner}/{repo}/ci, run-detail (status/timing/log), per-commit status badges, "CI" nav link. (crates/anvil-web/src/ui.rs) - CD redeploy webhook — on a green run of
[ci] deploy_branchin the single[ci] deploy_repo, POST to[ci] deploy_webhook(X-Anvil-Deploy-Secretheader). Scoped to ONE repo.CiConfigincrates/anvil-core/src/config.rs;deploy()incrates/anvil-ci/src/lib.rs. Docs:DEPLOY.md§7,deploy/anvil.toml.reqwestadded with NO TLS feature (keeps musl cross-compile aws-lc-free). - Docker socket on hagrid —
deploy/run.shmounts it +--group-adds the gid for the non-root user; caveat inDEPLOY.md§4. - Toasty ORM cleanup —
ci.rslist_by_repo/latest_for_commit/queued_idsnow sort/limit/filter in SQL, not in memory. Verified by the newordering_and_limit_run_in_the_databasetest. (Sweep: these were the only real instances;repos::list_all_with_ownersorts by a joined username and needs all rows — intentionally left.) - (a) CSRF + cookie hardening —
- Cookie:
HttpOnly+SameSite=Lax+Secure(auto viaConfig::secure_cookies()when base_url is https). - Synchronizer token
HMAC-SHA256(server_secret, session); secret persisted atdata_dir/csrf_secret(App::csrf_tokenincrates/anvil-core/src/lib.rs). Depshmac,sha2. Csrfextractor + constant-timeverify_csrf(crates/anvil-web/src/auth.rs). Hiddencsrffield + verification on add/delete SSH key, new repo, repo settings. Login exempt; logout relies on SameSite.- htmx insurance:
auth::csrf_contextmiddleware → request-scoped task-local;layoutsends the token viahx-headerson every htmx request.
- Cookie:
The a/b/c plan — ALL DONE
(a) CSRF + cookie hardening, (b) docs/untrusted-mode.md threat model,
(c) sandboxed CI broker. Stronger isolation tiers (gVisor/Kata/Firecracker,
egress filtering, CI-minute quotas) are recorded in the threat model as the
gate for untrusted tenants, not planned work.
Loose ends
- CSRF header consumption:
hx-headerssends the token as acsrfheader, butverify_csrfonly reads the form field. When we add a tokenless htmx action (rawhx-post/hx-delete, no<form>), also read thecsrfheader. - Toasty migrations: still no real migration system; the shim approach in
db::connect(SCHEMA_SHIMSfor tables,COLUMN_SHIMSfor columns, both test-verified against a freshpush_schema) covers what we've needed so far. New columns must be declared last in the model. - Mirror URL secrecy:
repositories.mirror_urlmay embed a token and is stored plaintext in SQLite (logged redacted). Fine single-tenant; revisit if the DB ever leaves the box. - Pages caveats (single-tenant-acceptable): served from the forge origin —
move to a separate origin before untrusted users (threat model §2); whole
blobs load into memory per request (fine at our scale). The same applies to
browse: trueCI artifacts. - Issue tracker minimalism: no labels, assignees, milestones, or
editing/deleting of posts. Per-repo numbering assumes a single server
process (matches deployment; noted in
models.rs).
Remaining roadmap (plan milestones beyond a/b/c)
- Pull requests (gix merge) · 10. Webhooks (mind the SSRF item). (8. Issues shipped 2026-06-10.)