grenadeandClaude Opus 5 acd47a31c8 feat(web): add the operator UI and the /v1 inventory API it reads
Three routes, per architecture/generic.md §4 (Vite + React + SWC + TS,
static, served by nginx): / renders the repository readme, /models is a
paged and filterable listing of what the bucket holds, and
/{namespace}/{name} shows one repository -- its pinned refs, the files
actually stored, and its model card.

The API it reads is /v1/, deliberately not /api/. That surface is the
Hub's, recorded and replayed verbatim, and adding routes of our own to
it risks a client mistaking one for the real thing. /v1/ answers a
question the Hub has no equivalent for -- what is in this bucket --
which is inventory, not the model search the spec rules out.

The listing shows only what has actually been fetched, never upstream's
siblings. A repository pulled one file at a time shows one file, which
is the honest answer to "what can I get from here offline".

Model cards are third-party content, so their HTML is parsed and then
sanitised against GitHub's allowlist. The plugin order is load-bearing
and commented as such. Verified against a card crafted with <script>,
onerror, a javascript: href, an <iframe>, an SVG-embedded script and an
inline handler: none execute and ordinary markdown still renders. Card
images are not loaded at all -- fetching them would leak the viewer's
address to a third party and make an offline registry's pages depend on
the internet.

Routing: rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is
both a page and the prefix of a file. The vhost splits them the way the
Hub does -- /resolve/ anywhere in the path, plus /api/ and /v1/, go to
the service; everything else is the app. A repo legitimately named
v1/repos is handed back to the resolve path by the router, and there is
a test for it. The /resolve/ test is on the repo type rather than the
path substring, because a repo may contain a directory called resolve
and /api/models/a/b/tree/main/resolve/f must stay a tree request.

Deployment: the frontend ships to hanzalova:/var/www/rustingface, so
that host now gets its own scoped gitea_ci drop-in -- narrower than the
service host's: a webroot rsync, a relabel, nginx -t and a reload. The
health check probes rf.internal from the proxy rather than from the
runner, because a runner is a plain Fedora container with no internal
root CA (verified: fedora:43 gets 000, the proxy gets 200).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 12:37:26 +03:00
2026-08-31 09:47:23 +03:00

rustingface

A sovereign, HuggingFace-compatible model registry.

Single Rust binary. One config file. S3-compatible storage. Anything a client fetches through it lives in your bucket until you explicitly remove it.

Set HF_ENDPOINT and nothing else changes:

export HF_ENDPOINT=https://rf.internal
python -c "from transformers import AutoModel; AutoModel.from_pretrained('Qwen/Qwen3-0.6B')"

The design is in doc/spec.md. Architectural conventions this project inherits are in ~/git/architecture — workspace layout, deployment, systemd hardening, firewalld, SELinux, port allocation.


What it guarantees

  1. Fetch-once retention. Any blob served through rustingface is durably stored before the response completes.
  2. No eviction. No TTL, no LRU, no background cleaner. Storage growth is the operator's problem, by design.
  3. Offline sufficiency. Once a (repo, revision, file) triple has been served, it is servable forever with no network path to any upstream.
  4. Reference stability. main resolves to the same commit on every later request until an operator repins it.
  5. State portability. The bucket is the entire system state.

It does not reimplement the Hub's semantics. It records the exact HTTP metadata upstream returned at first fetch — ETag, X-Repo-Commit, X-Linked-Etag, X-Linked-Size — and replays it byte-identically, so a ~/.cache/huggingface populated through rustingface is interchangeable with one populated directly from the Hub.


Layout

crates/
  rustingface-entities/  manifest, ref and repo schemas; bucket key layout;
                         configuration; the X-Error-Code taxonomy. No I/O.
  rustingface-core/      resolver, freeze pinning, single-flight, the
                         streaming tee, policy, gc/verify/refresh. Defines the
                         Store and Upstream ports.
  rustingface-data/      adapters: object_store (S3 and local) and reqwest.
  rustingface-api/       the axum surface and the header contract.
  rustingface/           the binary: serve, plus the admin subcommands.
web/                     Vite + React + SWC + TS; the operator-facing UI
asset/                   systemd, firewalld, nginx, config template
script/infra-setup.sh    one-time host provisioning
test/conformance/        the suite that drives a real huggingface_hub

Two departures from architecture/generic.md, both deliberate:

  • One binary, not -api plus -cli. doc/spec.md §9 specifies a single binary with subcommands so the CLI and the service cannot drift. The library crate split is unchanged.
  • rustingface-api is a library, consumed by the binary, rather than being the binary itself. Same reason.

The web UI

web/ is a static single-page app served from the nginx host. Three routes: / renders this readme, /models is a paged, filterable listing of what the bucket holds, and /{namespace}/{name} shows one repository — its pinned refs, the files actually stored, and its model card.

It reads a /v1/ API that is deliberately separate from /api/. That surface belongs to the Hub: it is recorded and replayed verbatim, and adding routes of our own to it risks a client mistaking one for the real thing. /v1/ answers a question the Hub has no equivalent for — what is in this bucket — which is inventory, not the model search the spec rules out.

GET /v1/status                       what this instance is; whether it is sealed
GET /v1/repos?page=&per_page=&type=&q=   paged inventory
GET /v1/repos/{type}/{repo_id}       one repository, with its stored files

Two things the UI is careful about, because model cards are third-party content:

  • Card HTML is parsed and then sanitised against GitHub's allowlist. Verified against a card crafted with <script>, onerror, a javascript: href, an <iframe>, an SVG-embedded script and an inline handler: none execute, and ordinary markdown still renders.
  • Images referenced by a card are not loaded; the alt text stands in. Fetching them would leak the viewer's address to a third party and make an offline registry's pages depend on the internet.
cd web && pnpm install
pnpm dev            # proxies /v1, /api and the resolve paths to a local service
VITE_DEV_API=http://bob.hanzalova.internal:20482 pnpm dev

Routing

rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is both a page a person reads and the prefix of a file a client downloads. The vhost splits them the same way the Hub does — anything containing /resolve/, plus /api/ and /v1/, goes to the service; everything else is the app, with client-side routes falling back to index.html.

The one consequence worth knowing: a repository named v1/repos or api/… collides with those prefixes. /v1/repos/… is disambiguated in the router (its first segment is always a repo type, so anything else is handed back to the resolve path), and the Hub itself cannot host a repo at api/models either.


Build and run

cargo build --release
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings

Locally, against a directory instead of a bucket:

cargo run -- --config asset/config/config.toml.example serve
HF_ENDPOINT=http://127.0.0.1:20482 hf download Qwen/Qwen3-0.6B config.json

storage.local_path forfeits guarantee 5 — a directory is not a bucket you can re-point a new host at — and the service says so at startup. It exists for tests and trials.

Configuration

One TOML file, /etc/rustingface/config.toml by default. Every key is overridable by environment (RUSTINGFACE__SERVER__LISTEN). Secrets are named by path, never inline, and a path that does not exist is retried under $CREDENTIALS_DIRECTORY so one file works both under systemd's LoadCredential= and when run by hand. See asset/config/config.toml.tmpl.

The two settings worth understanding:

  • policy.ref_resolution — freeze (default) records the first resolution of a mutable ref and reuses it indefinitely. follow re-resolves upstream, which breaks guarantee 4 and warns at startup.
  • upstream.enabled — false seals the registry. No upstream client is constructed at all, so there is no code path that could reach the network.

Admin CLI

Same binary, subcommands.

rustingface serve                          # default
rustingface fetch <repo>[@rev] [--files …] # warm the bucket ahead of need
rustingface pin <repo> <ref> <commit>      # set a ref explicitly
rustingface refresh <repo> [<ref>]         # re-resolve upstream and repin
rustingface list [--repo-type models]      # stored repos, revisions, sizes
rustingface show <repo>[@rev]              # manifest contents
rustingface rm <repo>[@rev] --yes          # remove refs/manifests (NOT blobs)
rustingface gc [--dry-run]                 # delete blobs no manifest references
rustingface verify [<repo>]                # re-read blobs, check digests
rustingface doctor                         # config, S3 reachability, permissions

rm and gc are separate on purpose. Removal detaches metadata; reclamation is a second, explicit, dry-runnable step. Nothing deletes bytes without an operator typing gc.


Deployment

Concern Where
Service bob.hanzalova.internal:20482
Object storage MinIO on caveman.kosherinata.internal:9000, bucket rustingface
Ingress rf.internal, via the hanzalova edge proxy (mesh-only, no public record)
Frontend /var/www/rustingface on hanzalova, served by the same vhost
Port 20482, derived from the service name per architecture/port-allocations.md §3

Deployment is CI-driven (.gitea/workflows/deploy.yml): push to main, the workflow runs the lint/test gate, builds a static musl binary and the frontend, ships both as gitea_ci over scoped sudo, and health-checks. The service is probed with /healthz and then rustingface doctor, which proves the bucket is actually writable rather than merely that a socket opened; the frontend is probed through rf.internal from the proxy, since a CI runner is a plain container that does not carry the internal root CA.

The frontend deploys to hanzalova (/var/www/rustingface) and the service to bob, so both hosts carry a scoped gitea_ci sudoers drop-in. The proxy's is the narrower of the two: a webroot rsync, a relabel, nginx -t and a reload.

One-time host provisioning is script/infra-setup.sh, run by an operator from a workstation with full sudo. It is idempotent and skips unreachable hosts.

Required Gitea repo secrets: RSYNC_SSH_KEY, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, HF_TOKEN.

The service holds no durable state, which is what makes the systemd hardening easy: ProtectSystem=strict with no ReadWritePaths and no StateDirectory at all.


Testing

cargo test --workspace                    # unit + the sovereignty suite
python3 -m venv .venv && .venv/bin/pip install -r test/conformance/requirements.txt
.venv/bin/python test/conformance/conformance.py --binary target/release/rustingface

crates/rustingface-api/tests/sovereignty.rs runs the whole service against a fake Hub that speaks the real header protocol and counts what it was asked for: cold fetch, sealed replay, single-flight (eight clients, one upstream fetch), client disconnect mid-transfer, range resume, freeze stability across an upstream commit move, gc after rm, digest mismatch, deduplication, policy, bearer auth, and state portability.

test/conformance/conformance.py drives a pinned, unmodified huggingface_hub through rustingface against the real Hub, then seals it and re-runs. It also re-checks the X-Error-Code strings against the installed client, because those are client-internal constants rather than a stable API — a client upgrade is a spec-review trigger.

Run the real offline test by hand before trusting a deployment: null-route huggingface.co at the site router and verify there, not by trusting the service.


Xet

The Hub is migrating to Xet-backed storage. rustingface implements none of it: it never advertises Xet capability downstream and never negotiates it upstream, so files arrive whole through the Git LFS bridge. The cost is fetch-time bandwidth on first pull. The benefit is that the on-disk format stays "files with names" and no future maintainer can strand your data behind a chunk-reconstruction format. upstream.disable_xet = false is refused at startup.


Licence

GPL-3.0-or-later.

S
Description
a sovereign, huggingface-compatible model registry.
https://rustingface.com
Readme
842 KiB
Languages
Rust 81.3%
TypeScript 5.9%
Python 5.6%
Shell 5.4%
CSS 1.6%
Other 0.1%