# Bundled Binary Distribution — Multi-Platform Plan ## Context Users have asked for a way to run OxiCloud without Docker — a plain binary. Today `release.yml` only creates a GitHub Release with notes; no binary is attached. The Docker workflow (`docker-publish.yml`) ships multi-arch images, but that's a separate audience. The blocker for a "just download and run" experience is that the `oxicloud` binary depends on the SvelteKit build output (`static-dist/` under `/static-dist/`, resolved by `src/interfaces/web/mod.rs::resolve_static_path` at boot). Two files to distribute per platform is friction; a single self-contained binary is what users actually want. The ask has two parts: 1. Ship **single-file binaries with frontend assets embedded**, for the common Linux targets and macOS. 2. Audit the current binary set — the crate produces 6+ binaries today, some of which are test-only. Strip anything that shouldn't ship to end users. The intended outcome: a `v0.9.0` release attaches **4 musl-static tarballs** (Linux amd64/arm64 + macOS Intel/Apple Silicon), each ~15-30 MB, containing a **single `oxicloud` binary** with assets + operator tools + one-off migrations all baked in. User extracts the tarball, sets `DATABASE_URL`, runs `./oxicloud` — server up. Subcommands (`oxicloud opaque setup`, `oxicloud migrate nfc-filenames --dry-run`) provide operator access to the same tools currently split across `oxicloud-cli` and `migrate-nfc-filenames`. Design shape (confirmed 2026-08-27): - **musl-only Linux** — parity with the existing Docker image (Alpine base), no glibc-version fragmentation - **Assets embedded via `rust-embed` with compile-time deflate compression** — smaller binary - **`bundled-assets` is opt-in** — default `cargo build` unchanged; `just dev` still uses the filesystem `ServeDir` with Vite HMR - **Single unified binary** — `oxicloud`, `oxicloud-cli`, and `migrate-nfc-filenames` collapse into one clap-driven executable with implicit-server default (backwards compat with existing Docker CMD / systemd units) ## Current binary inventory From `Cargo.toml` + `src/bin/`: | Binary | Path | Purpose | Ship to end users? | |---|---|---|---| | `oxicloud` | `src/main.rs` (implicit) | Server | **YES** | | `oxicloud-cli` | `src/bin/oxicloud-cli.rs` | Operator toolbox (`opaque setup/reset`) | **MERGED** — absorbed into `oxicloud` per Deliverable 1b | | `migrate-nfc-filenames` | `src/bin/migrate-nfc-filenames.rs` | One-off filename migration (historical, June 2026 fix) | **MERGED** — absorbed into `oxicloud migrate nfc-filenames` per Deliverable 1a→1b | | `generate-openapi` | `src/bin/generate-openapi.rs` | Regenerate `resources/gen/openapi.json` | NO — dev tool, gate behind `dev_tools` feature | | `opaque-hurl-helper` | `src/bin/opaque-hurl-helper.rs` | Hurl test companion (OPRF client) | NO — gate behind `test_utils` feature | | `dpop-hurl-helper` | `src/bin/dpop-hurl-helper.rs` | Hurl test companion (ES256 DPoP proof) | NO — gate behind `test_utils` feature | | `load-seed` | `src/bin/load-seed.rs` | Test fixture seeder | Already gated behind `load_seed_bin` feature ✅ | After Deliverables 1 + 1a + 1b, `cargo build --release --bins` produces exactly ONE binary: `oxicloud`. That single binary ships in the tarball and in the Docker image. ## Deliverables ### 1. Squash test/dev binaries with `required-features` Cargo respects `required-features` per `[[bin]]` — a binary is only built when its listed features are active. This gates test helpers out of `cargo build --release --bins` cleanly without needing custom Cargo commands or shell trimming. Edits to `Cargo.toml`: ```toml [features] # ... existing features ... dev_tools = [] # NEW: gates ops tooling that shouldn't ship [[bin]] name = "opaque-hurl-helper" path = "src/bin/opaque-hurl-helper.rs" required-features = ["test_utils"] # NEW gate [[bin]] name = "dpop-hurl-helper" path = "src/bin/dpop-hurl-helper.rs" required-features = ["test_utils"] # NEW gate [[bin]] name = "generate-openapi" path = "src/bin/generate-openapi.rs" required-features = ["dev_tools"] # NEW gate — `just openapi` flips it # [[bin]] name = "migrate-nfc-filenames" ← DELETED per Deliverable 1a # [[bin]] name = "oxicloud-cli" ← DELETED per Deliverable 1b ``` Existing invocations that need adjustment: - `just openapi` recipe → add `--features dev_tools` to the underlying `cargo run --bin generate-openapi` call (currently `cargo run --bin generate-openapi` per justfile) - `tests/api/run.sh` → add `--features test_utils` when building the two hurl helpers (shape confirmed: `cargo build [--release] --bin opaque-hurl-helper` / same for dpop in each helper's build-if-missing branch) After these edits + Deliverables 1a + 1b: `cargo build --release --bins` produces exactly ONE binary — `oxicloud`. Everything else falls out of the default build set. ### 1a. Merge `migrate-nfc-filenames` into `oxicloud-cli` The standalone `migrate-nfc-filenames` binary is a June-2026 one-off: it cleans up NFD/NFC filename collisions in databases populated before the write-time fix (`normalize_storage_name()` at `src/domain/services/path_service.rs:36`, called from `src/infrastructure/repositories/pg/file_blob_read_repository.rs:1062`). New installs never need it; only pre-June 2026 databases do. `oxicloud-cli`'s header docstring (`src/bin/oxicloud-cli.rs:20-23`) already documents the growth pattern for absorbing tools like this: > *"each new domain gets its own module below (e.g. `mod opaque`) > with a `#[derive(Subcommand)]` enum for its actions and a > `run(args) -> ExitCode` entrypoint. Keep each module self-contained > so a future extraction is a file move."* Note: this Deliverable is an intermediate step. Deliverable 1b then absorbs `oxicloud-cli` itself into `oxicloud`, so the final CLI form becomes `oxicloud migrate nfc-filenames --dry-run` — but 1a lands first so the migration logic is proven inside the clap subcommand tree before the main-binary merge. Edits: - **New `mod migrate` in `src/bin/oxicloud-cli.rs`** — moves the ~149 non-boilerplate lines from `migrate-nfc-filenames.rs::main()` into a `run_nfc_filenames(dry_run: bool) -> ExitCode` function. `env::args()` parsing goes away; clap handles it. - **Delete `src/bin/migrate-nfc-filenames.rs`**. - **Delete the `[[bin]]` entry** in `Cargo.toml`. - **Update `Dockerfile`** — 6 references to `migrate-nfc-filenames` (build commands at :46, :49, :89, `cp` steps at :130, :143, doc comment at :170, `COPY --chmod=755 --from=app` at :173). - **Update `docs/plan/benchmake-and-performance-tracking.md`** — 2 references to `migrate-nfc-filenames` at lines :44 and :161. Reword to reference `oxicloud-cli migrate nfc-filenames` (or, after 1b, `oxicloud migrate nfc-filenames`) and update the Cargo.toml placement example. - **Any operator runbook** that documents `docker exec migrate-nfc-filenames --dry-run` becomes `docker exec oxicloud-cli migrate nfc-filenames --dry-run` (intermediate) then `docker exec oxicloud migrate nfc-filenames --dry-run` after 1b. Effort: ~1.5 hours mechanical. Extracts the "should the tarball ship migrate-nfc-filenames?" question entirely — everything now ships as one operator toolbox binary that also happens to include the historical migration. Future v1.0 removal path (deferred): delete `mod migrate` block + one enum variant + docs. Much cleaner than removing a whole `.rs` file + Cargo entry + Dockerfile refs. ### 1b. Merge `oxicloud-cli` into `oxicloud` Single binary — server + operator tools + migrations — with an **implicit-server** subcommand tree. `oxicloud` with no arguments starts the server (backwards compat with existing Docker CMD / systemd units / user configs). Subcommands add operator actions on top. After merge, the CLI shape is: ``` $ oxicloud --help Usage: oxicloud [OPTIONS] [COMMAND] Commands: opaque OPAQUE aPAKE substrate management migrate One-time data migrations If no command is given, oxicloud starts the server (see docs/config). ``` Concrete forms: - `oxicloud` — start server (unchanged) - `oxicloud opaque setup` — was `oxicloud-cli opaque setup` - `oxicloud opaque reset --user alice --dry-run` — was `oxicloud-cli opaque reset ...` - `oxicloud migrate nfc-filenames --dry-run` — was `migrate-nfc-filenames --dry-run` (via Deliverable 1a) **Backwards-compat guarantee**: `oxicloud` with no args continues to start the server. Every existing `CMD ["oxicloud"]`, `ExecStart=/usr/local/bin/oxicloud`, docker-compose entry, and k8s Deployment keeps working unchanged. Users updating to v0.9.0 see no surprise. **Migration impact**: the user-visible break is that `oxicloud-cli opaque setup` (etc.) no longer exists as a separate binary. Given the current audience for `oxicloud-cli` is very small (essentially only the maintainer), the migration cost is trivial. Any user who had scripted it can adapt with a one-line find/replace. Edits: - **`src/main.rs`** — top of `main()`, before the current server init, parse args via clap. If a subcommand is provided, dispatch to it and exit; otherwise fall through to the existing server-init path. Zero-arg startup cost stays ≤ microseconds (clap parse of empty args). - **`src/cli/mod.rs`** — NEW module. Contains the `Domain` enum + the `opaque` and `migrate` submodules moved from `src/bin/oxicloud-cli.rs`. Each subcommand module keeps its self-contained shape per the growth pattern documented in the old `oxicloud-cli.rs` header. - **Delete `src/bin/oxicloud-cli.rs`** entirely. - **Delete the `[[bin]] name = "oxicloud-cli"` block** in `Cargo.toml`. - **`Dockerfile`** — drop all 4 references to `oxicloud-cli` (build target lines + COPY steps). Simplified build command becomes `cargo build --release --bin oxicloud` — single-binary. - **Docs** — all `docker exec oxicloud-cli ` become `docker exec oxicloud `. Same shape, one fewer word. Effort: ~2 hours mechanical. Comparable to Deliverable 1a but with slightly more care at the `main.rs` entry point for the args-vs-server branch. **Tarball layout simplification** — the tarball now ships exactly ONE binary: ``` oxicloud-0.9.0-/ ├── oxicloud (single file, server + tools + embedded assets) ├── example.env ├── LICENSE └── README-install.md ``` That's the "just download and run" ethos in physical form: one file, one command, done. ### 2. Add `bundled-assets` cargo feature Purpose: at compile time, choose between filesystem-served static assets (current behaviour — filesystem `ServeDir`) and embedded-into-binary assets (via `rust-embed`). Feature is **opt-in** — the default `cargo build --release` still produces a filesystem-based binary, matching the current Docker image behaviour (where assets are separate volume layers). Release tarballs are built with `--features bundled-assets`. **Dev mode is untouched.** `just dev` runs `PROFILE=dev cargo run` + `npm run dev`, neither of which activates `bundled-assets`. The dev workflow continues to: - Serve from `frontend/` via Vite's dev server with HMR - Backend reads static assets from `/static-dist/` via the usual `ServeDir` (or falls back to `frontend/static/` when the build hasn't been run) - No rebuild required to change locales, styles, or vendor JS The `bundled-assets` code paths only compile when the feature is explicitly enabled — under a `#[cfg(feature = "bundled-assets")]` gate. The non-feature build's binary shape, ergonomics, and dev loop stay identical to today. Measured footprint (2026-08-27): | Slice | Size | Notes | |---|---|---| | Total `static-dist/` uncompressed | **9.8 MB** | 499 files | | `_app/` (SvelteKit bundle) | 3.3 MB | JS + CSS chunks | | `vendors/` | 3.6 MB | maplibre-gl 1.0 MB, pdf.worker 1.0 MB, others | | `locales/` | 2.2 MB | 16 locales, ru.json + hi.json largest at ~116-140 KB | | `logo/`, `geo/`, `basemaps/`, `workers/`, misc | ~600 KB | | | **`.tar.gz` compressed** | **4.65 MB** | realistic embed cost after brotli/gzip inside binary | | **`.tar.xz` compressed** | **4.22 MB** | not what rust-embed uses; reference only | Expected release-binary size with embed: `oxicloud` today ships in the 30-60 MB range (stripped, LTO). Add ~5-10 MB for embedded static-dist. Tarball compression on top → ~20-30 MB shipped per platform. Four platforms × ~25 MB = ~100 MB per release. Well within GitHub Releases limits. Cargo.toml additions: ```toml [features] bundled-assets = ["dep:rust-embed", "dep:mime_guess"] [dependencies] rust-embed = { version = "8", features = ["compression"], optional = true } mime_guess = { version = "2", optional = true } ``` Runtime shape — a new module `src/interfaces/web/embedded.rs`: ```rust #[cfg(feature = "bundled-assets")] #[derive(rust_embed::RustEmbed)] #[folder = "static-dist/"] // ← repo-root, matches SvelteKit adapter-static output #[include = "*"] #[exclude = "*.br"] // Vite's precompressed sibling — response compression handles on wire #[exclude = "*.gz"] // ditto pub struct EmbeddedAssets; ``` The `#[folder]` path is relative to Cargo.toml (repo root), where the SvelteKit adapter-static config in `frontend/svelte.config.js` emits: ```js adapter: adapter({ pages: '../static-dist', assets: '../static-dist', ... }) ``` The current filesystem shape (at `src/interfaces/web/mod.rs:47-106`) is more than one `ServeDir` — the embed swap replaces FOUR sites, all downstream of `resolve_static_path()`: 1. **`spa` ServeDir** (`mod.rs:60-63`) — root fallback with `precompressed_br().precompressed_gzip()` and SPA-shell fallback pointing at `/index.html`. Under embed: an axum handler that resolves the request path against `EmbeddedAssets::get()`, 200 with correct MIME (via `mime_guess`) if hit, otherwise return the embedded `index.html` bytes with `text/html` for SPA client-routing. 2. **`app_immutable` ServeDir** (`mod.rs:66-77`) — nested at `/_app/immutable` with `Cache-Control: public, max-age=31536000, immutable`. Under embed: same handler shape as (1), scoped to the `_app/immutable/` prefix, plus a `.layer()` that stamps the immutable cache header. 3. **`ServeFile::new(index.html)`** SPA fallback (`mod.rs:63`) — folds into (1)'s not-found path. 4. **CSP inline-script scan** (`mod.rs:163-233`) — currently reads every `.html` file in the resolved static dir via `std::fs::read_dir` + `std::fs::read_to_string` at boot to compute SHA-256 CSP source expressions for every inline `