frostmirror-core 1.0.0

Core library for frostmirror: dependency resolution, bundle format, and diff logic
Documentation

Frostmirror

Dual Mode

Dual Mode

Update flow


Table of Contents


Quick Start

Step 1 -- Define your dependencies

Create a depends.toml file listing the crates your project needs:

[dependencies]

tokio = { version = "1", features = ["rt-multi-thread", "net", "macros"] }

serde = { version = "1", features = ["derive"] }

axum = "0.7"



[dependencies.2]

tower = "0.4"

axum = "0.6"



[platforms]

targets = ["x86_64-unknown-linux-gnu"]

toolchain = "stable"

You only need to list direct dependencies. frostmirror delegates to cargo generate-lockfile to resolve the entire transitive dependency tree -- features, optional deps, and platform-specific deps are all handled by cargo's own resolver.

Step 2 -- Fetch (online machine)

frostmirror fetch --config depends.toml --output ./releases/

This produces a file like 20260402-2130-crates.pkg in ./releases/. The bundle contains every .crate file, sparse index entries, rustup binaries, and a cargo config.

Step 3 -- Transfer across the air gap

cp ./releases/20260402-2130-crates.pkg /media/usb/

Step 4 -- Import (offline machine)

Drop the .pkg file into the incoming directory:

cp /media/usb/20260402-2130-crates.pkg ./incoming/

If the registry container is running with --watch-incoming, the import happens automatically. Otherwise, import manually:

frostmirror import 20260402-2130-crates.pkg --mirror /mirror

Step 5 -- Build your project offline

# ~/.cargo/config.toml

[http]

check-revoke = false # may be needed if you have a self sign ssl https server



[source.frostmirror]

registry = "sparse+http://frostmirror.internal:8080/index/"



[source.crates-io]

replace-with = "frostmirror"

cargo build  # resolves everything from frostmirror


Installation

From source

git clone https://github.com/pillisan42/frostmirror.git

cd frostmirror

cargo install --path crates/frostmirror

With cargo

cargo install frostmirror

Docker

docker build -t frostmirror:latest -f docker/Dockerfile .


Core Concepts

depends.toml

The single source of truth for what gets mirrored. Three formats are supported and can be mixed freely:

[dependencies]

# Simple -- just a version string

anyhow = "1"



# Extended -- version + features (same syntax as Cargo.toml)

tokio = { version = "1", features = ["rt-multi-thread", "net", "macros"] }

serde = { version = "1", features = ["derive"] }

uuid = { version = "1", features = ["v4"] }



# Extended -- disable default features

reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }



# Multiple versions of the same crate -- use an array

# Useful when mirroring for multiple projects with conflicting requirements

serde_json = ["1.0.60", "1.0.120"]



[platforms]

targets = ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]

toolchain = "stable"

Version strings follow semver (e.g. "1", "1.50.0", ">=0.12, <0.13").

Features are passed directly to cargo's resolver. If a crate has optional dependencies activated through features (like ratatui activating ratatui-crossterm via its crossterm default feature), they are automatically included.

Multiple versions of the same crate are supported via the array syntax. Each version is resolved independently so conflicting requirements across projects don't cause errors.

Supported targets and toolchains

The [platforms] section accepts any value the upstream Rust dist server publishes at https://static.rust-lang.org. frostmirror downloads rustup-init and the channel components for each listed target.

toolchain — pick one channel string. All map to dist/channel-rust-<value>.toml:

Value Resolves to
stable current stable release
beta current beta
nightly current nightly
1.86.0, 1.75.0, ... a pinned Rust version (any released MAJOR.MINOR.PATCH)

Dated nightlies (nightly-2026-04-25) are not supported by the URL scheme — pin a stable version instead.

targets — list one or more Rust target triples. The full taxonomy lives at https://doc.rust-lang.org/nightly/rustc/platform-support.html. The values that ship pre-built rustup-init plus a complete host toolchain (and therefore work end-to-end through the air gap) are:

Tier 1 — host toolchain available, fully supported:

Triple Platform
aarch64-apple-darwin ARM64 macOS (11.0+)
aarch64-pc-windows-msvc ARM64 Windows MSVC
aarch64-unknown-linux-gnu ARM64 Linux (glibc 2.17+)
i686-pc-windows-msvc 32-bit Windows MSVC
i686-unknown-linux-gnu 32-bit Linux
x86_64-pc-windows-gnu 64-bit Windows MinGW
x86_64-pc-windows-msvc 64-bit Windows MSVC
x86_64-unknown-linux-gnu 64-bit Linux (glibc 2.17+)

Tier 2 — host toolchain available, guaranteed to build:

Triple Platform
aarch64-pc-windows-gnullvm ARM64 MinGW (LLVM ABI)
aarch64-unknown-linux-musl ARM64 Linux musl
aarch64-unknown-linux-ohos ARM64 OpenHarmony
arm-unknown-linux-gnueabi Armv6 Linux
arm-unknown-linux-gnueabihf Armv6 Linux hardfloat
armv7-unknown-linux-gnueabihf Armv7-A Linux hardfloat
armv7-unknown-linux-ohos Armv7-A OpenHarmony
i686-pc-windows-gnu 32-bit Windows MinGW
loongarch64-unknown-linux-gnu LoongArch64 Linux glibc
loongarch64-unknown-linux-musl LoongArch64 Linux musl
powerpc-unknown-linux-gnu PowerPC Linux
powerpc64-unknown-linux-gnu PPC64 Linux
powerpc64-unknown-linux-musl PPC64 Linux musl
powerpc64le-unknown-linux-gnu PPC64LE Linux
powerpc64le-unknown-linux-musl PPC64LE Linux musl
riscv64gc-unknown-linux-gnu RISC-V Linux
s390x-unknown-linux-gnu S390x Linux
sparcv9-sun-solaris SPARC V9 Solaris 11.4
x86_64-apple-darwin 64-bit macOS (10.12+)
x86_64-pc-solaris 64-bit x86 Solaris 11.4
x86_64-pc-windows-gnullvm 64-bit Windows MinGW (LLVM ABI)
x86_64-unknown-freebsd 64-bit FreeBSD
x86_64-unknown-illumos 64-bit illumos
x86_64-unknown-linux-musl 64-bit Linux musl
x86_64-unknown-linux-ohos 64-bit OpenHarmony
x86_64-unknown-netbsd 64-bit NetBSD

Cross-compile-only targets (Tier 2 without host tools, Tier 3 — e.g. wasm32-unknown-unknown, aarch64-apple-ios, thumbv7em-none-eabihf) ship rust-std only, not rustup-init. Listing one in targets will fail the rustup-init download step. Install rustup for your host triple and add the cross-compile target on the offline machine via rustup target add <triple> once the toolchain is installed.

Dependency resolution

frostmirror delegates resolution entirely to cargo itself. For each dependency in depends.toml, frostmirror:

  1. Creates a temporary Cargo project with that dependency
  2. Runs cargo generate-lockfile to produce an exact Cargo.lock
  3. Parses the lock file to extract all (name, version) pairs
  4. Merges results across all dependencies, deduplicating by (name, version)

This two-pass strategy ensures complete coverage:

Pass What it does What it catches
Combined All deps in one Cargo.toml Unified transitive versions (version unification)
Per-dependency Each dep in its own Cargo.toml Conflict-specific versions, multi-version entries

The result is the union of both passes. Because cargo does the resolution, all features, optional deps, platform-specific deps, and version unification are handled exactly as they would be in a real cargo build.

.pkg bundle format

Each bundle is a brotli-compressed archive with a custom binary format:

Section Contents
manifest.json Resolved dep graph, SHA-256 hashes, parent .pkg reference
rustup/ rustup-init binaries for declared target platforms
crates/ .crate files for all resolved packages
index/ Sparse index entries for all resolved crates
config.toml Ready-to-use cargo source replacement config

Filename scheme: YYYYMMDD-HHMM-crates.pkg (e.g. 20260402-2130-crates.pkg)

Incremental updates

After the initial full bundle, subsequent fetches only download new crates:

# First time -- full bundle (may be large)

frostmirror fetch --output ./releases/


# After editing depends.toml -- delta only

frostmirror fetch --incremental --output ./releases/

Incremental bundles include:

  • New .crate files -- only crates not in the previous manifest
  • Full index entries -- for all resolved crates (not just new ones), so cargo can resolve correctly on the air-gap side
  • Rustup binaries -- for any new target platforms added since the last bundle

If no history exists, --incremental automatically falls back to a full fetch with a warning.

Incoming watcher

On the offline machine, the serve command can watch for new .pkg files:

./incoming/
    (drop .pkg files here)
    done/      <-- successfully imported bundles
    failed/    <-- bundles that failed verification

When a .pkg file appears in ./incoming/:

  1. Waits for the file write to complete (size stability check)
  2. SHA-256 manifest check and per-crate hash verification
  3. If valid: merge into mirror atomically, move .pkg to done/
  4. If invalid: move to failed/, log the error, mirror is untouched

The watcher runs on a dedicated thread so it never blocks the HTTP server.


CLI Reference

frostmirror fetch

Resolve dependencies and produce a .pkg bundle.

frostmirror fetch [OPTIONS]

Option Default Description
-c, --config <PATH> depends.toml Path to the depends.toml file
-o, --output <DIR> ./output Output directory for .pkg files
--incremental off Only download crates not in the previous bundle

Examples:

# Full fetch with default config

frostmirror fetch


# Full fetch with custom paths

frostmirror fetch --config /path/to/depends.toml --output /path/to/output/


# Incremental fetch (delta only)

frostmirror fetch --incremental --output ./releases/

Note: cargo must be installed on the machine running fetch, since frostmirror uses cargo generate-lockfile for dependency resolution.

frostmirror import

Import a .pkg bundle into the local mirror store.

frostmirror import <FILE> [OPTIONS]

Option Default Description
--mirror <DIR> /mirror Mirror directory
--config-dir <DIR> /config Where to restore frostmirror.toml / depends.toml from a snapshot bundle (no effect on regular fetch bundles)

Examples:

frostmirror import 20260402-2130-crates.pkg --mirror /data/mirror


# Importing a full snapshot (mirror + config)

frostmirror import snapshot-20260428-1530.pkg --mirror /data/mirror --config-dir /data/config

frostmirror serve

Start the HTTP registry server with optional file-drop auto-import.

frostmirror serve [OPTIONS]

Option Default Description
--bind <ADDR> 0.0.0.0:8080 HTTP bind address
--base-url <URL> http://localhost:8080 Base URL for generated configs
--mirror <DIR> /mirror Mirror directory
--incoming <DIR> /incoming Incoming directory for .pkg files
--watch-incoming off Auto-import .pkg files dropped into incoming/

Examples:

# Serve with auto-import and custom URL

frostmirror serve \

  --bind 0.0.0.0:3000 \

  --base-url http://mirrors.corp.internal:3000 \

  --mirror /data/mirror \

  --incoming /data/incoming \

  --watch-incoming

frostmirror verify

Check the integrity of a .pkg bundle before transporting or importing it.

frostmirror verify <FILE>
frostmirror verify 20260402-2130-crates.pkg

# OK -- 183 crates, 2 rustup artifacts

frostmirror status

Display current mirror state.

frostmirror status --mirror /data/mirror

# Crate count:  183

# Total size:   45231872 bytes

# Last import:  2026-04-02T21:30:00+00:00

frostmirror gc

Garbage collect crates no longer referenced by the current manifest.

frostmirror gc --mirror /data/mirror

# Removed 3 crates, freed 1248576 bytes

Removed dependencies are never pruned automatically. You must run gc explicitly.


Docker Usage

Development

docker compose -f compose.dev.yml up dev    # hot-reload

docker compose -f compose.dev.yml run --rm test  # tests

Online machine -- Docker fetch

FROSTMIRROR_MODE=full docker compose -f compose.fetch.yml run --rm fetch

FROSTMIRROR_MODE=incremental docker compose -f compose.fetch.yml run --rm fetch

Offline machine -- Air-gapped registry

docker compose -f compose.airgap.yml up -d

curl http://localhost:8080/api/status

The container uses network_mode: none -- no outbound network at all. Drop .pkg files into ./incoming/ and they are imported automatically.


Docker Image Scripts

Three helper scripts in scripts/ handle the full lifecycle of Docker images.

Script Purpose Run on
scripts/build.sh Build all Docker images from source Online machine
scripts/export.sh Save images to a compressed .tar.gz archive Online machine
scripts/import.sh Load images from the archive into Docker Offline machine

scripts/build.sh

./scripts/build.sh                # all images

./scripts/build.sh --production   # only frostmirror:latest

./scripts/build.sh --no-cache     # clean rebuild

The production image uses a multi-stage build: rust:1.86-slim for compilation, debian:bookworm-slim for the final runtime (~15 MB).

scripts/export.sh

./scripts/export.sh                          # all images -> ./export/

./scripts/export.sh --production             # only production image

./scripts/export.sh --output /media/usb/     # write to USB drive

scripts/import.sh

./scripts/import.sh /media/usb/frostmirror-images-20260402-2130.tar.gz

docker compose -f compose.airgap.yml up -d


Air-Gap Workflow

Complete example: from zero to offline builds

On the online machine:

# 1. Build and export the Docker image

./scripts/build.sh --production

./scripts/export.sh --production --output /media/usb/


# 2. Create depends.toml

cat > depends.toml << 'EOF'

[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }

[platforms]
targets = ["x86_64-unknown-linux-gnu"]
toolchain = "stable"
EOF


# 3. Fetch everything

frostmirror fetch --output ./releases/


# 4. Verify before transport

frostmirror verify ./releases/20260402-2130-crates.pkg

Transfer:

cp ./releases/20260402-2130-crates.pkg /media/usb/

On the offline machine:

# 5. Load the Docker image (first time, or when updating)

./scripts/import.sh /media/usb/frostmirror-images-20260402-2130.tar.gz


# 6. Start the registry (first time only)

docker compose -f compose.airgap.yml up -d


# 7. Drop the bundle

cp /media/usb/20260402-2130-crates.pkg ./incoming/


# 8. Verify the import

curl http://localhost:8080/api/status


# 9. Configure cargo on each developer machine

cat > ~/.cargo/config.toml << 'EOF'

[source.frostmirror]
registry = "sparse+http://frostmirror.internal:8080/index/"

[source.crates-io]
replace-with = "frostmirror"
EOF


# 10. Build your project

cargo build

Subsequent updates

# Online: delta only

frostmirror fetch --incremental --output ./releases/


# Transfer just the new .pkg

cp ./releases/20260403-1000-crates.pkg /media/usb/


# Offline: drop it in

cp /media/usb/20260403-1000-crates.pkg ./incoming/

# Auto-imported, old crates preserved, new targets included

Mirroring for multiple projects

If different projects on the air-gap need conflicting dependency versions, list them all in depends.toml:

[dependencies]

# Project A uses an older serde

serde = ["=1.0.100", { version = "1", features = ["derive"] }]

# Project B uses ratatui

ratatui = "0.30.0"

# Both share tokio

tokio = { version = "1", features = ["full"] }

Each entry is resolved independently, so conflicting requirements don't cause errors. The mirror contains all required versions.


Live-Mirror Mode

Frostmirror's default workflow is strictly offline: an online machine produces .pkg bundles, the offline machine imports them, and the registry serves only what was bundled. When your registry server does have internet access — typical for a shared developer cache or a region-local CI mirror — you can enable live-mirror mode so missing crates are fetched from upstream on demand and cached locally for everyone else.

How it works

With proxy_mode = true, every registry handler (sparse index, crate downloads, rustup-init binaries, toolchain dist files) serves from disk on a cache hit and falls back to upstream on a cache miss. Fetched bytes are written atomically to the mirror, served to the requesting client, and — for crates — recorded in manifest.json so garbage collection treats them as first-class entries (and won't sweep them away).

With proxy_mode = false (the default), a cache miss returns 404. The offline workflow is unchanged.

Enable from the web UI

  1. Open http://your-mirror:8080/config
  2. Find the Live-mirror (proxy upstream) fieldset
  3. Tick Enable live-mirror
  4. Optionally adjust the upstream URLs (defaults shown below)
  5. Click Save Configuration
  6. Restart the server for the change to take effect

Or edit frostmirror.toml directly

proxy_mode = true

proxy_index_url = "https://index.crates.io"

proxy_dl_url   = "https://static.crates.io/crates"

proxy_dist_url = "https://static.rust-lang.org"

Then restart the server.

When to use it

Scenario Why live-mirror helps
Shared developer cache A team points cargo at the same frostmirror; each crate is downloaded from crates.io exactly once and reused by everyone after that.
Bootstrap an air-gap mirror Run with proxy on while your team works normally — the mirror fills with exactly the crates actually used. Disable proxy, run gc, snapshot the result, and ship that to the offline site.
Region-local CI cache A nearby frostmirror with proxy on shaves seconds off every cold-cache build and reduces crates.io load.

When to keep it off

Strict air-gap deployments, regulated networks, or any environment where the registry must never make outbound HTTP. Live-mirror is opt-in by design — you have to flip it on.

Caveats

  • The registry needs outbound HTTPS to the configured upstream hosts.
  • The first request for any crate, index entry, or toolchain file pays the upstream latency. Subsequent requests are local-disk speed.
  • Rustup channel manifests are cached as ordinary dist files; if upstream publishes a new stable, your mirror keeps serving the cached manifest until something invalidates it (currently: delete mirror/dist/channel-rust-stable.toml and let the next request re-pull).
  • Garbage collection still uses manifest.json. Proxy-cached crates are appended automatically, but if you bypass the registry handler (e.g. drop files into mirror/crates/ manually), GC won't know about them.

Snapshot Export & Redeployment

Once a frostmirror instance is populated — whether by importing .pkg bundles or by accumulating crates via live-mirror mode — you can package the entire instance (mirror + configuration) into a single .pkg and redeploy it on a fresh server.

Create a snapshot from the web UI

  1. Open http://your-mirror:8080/packages
  2. Click Create Snapshot
  3. Wait for the build to finish (large mirrors take a moment — the server walks every file and computes SHA-256)
  4. Click the Download link in the snapshots table

The snapshot includes:

  • All .crate files under mirror/crates/
  • All sparse index entries under mirror/index/
  • All rustup-init binaries under mirror/rustup/dist/
  • All toolchain dist files under mirror/dist/
  • frostmirror.toml and depends.toml from /config/

Redeploy on another server

Drop the downloaded snapshot-*.pkg into the new server's incoming/ directory. The watcher imports it automatically — mirror data lands in /mirror/, and frostmirror.toml + depends.toml are restored to /config/.

For manual import:

frostmirror import snapshot-20260428-1530.pkg --mirror /mirror --config-dir /config

After import, frostmirror serve on the new host serves the same registry as the source.

Use cases

  • Hardware migration. Move a frostmirror instance to new hardware without re-running every historical bundle.
  • Replication. Stand up a hot-spare in another data center.
  • Bootstrap from live-mirror. Run live-mirror mode at HQ to accumulate exactly what your team uses, snapshot it, then ship the snapshot to an air-gapped site that runs strictly offline.

Web UI

The web UI is served at the root URL (http://frostmirror.internal:8080/). No extra service or port required.

Page URL Description
Dashboard / Crate count, mirror size, last import, watcher state, failed count
Dependencies /deps Edit depends.toml with a table UI, live TOML preview
Configuration /config Registry URL, bind address, targets, behavior toggles, live-mirror proxy settings
Packages /packages Import history, bundle sizes, GC button, Create Snapshot
Client Setup /setup Generated shell commands, downloadable config files, SSL revocation tip

The Dashboard auto-refreshes every 30 seconds. The failed count is the primary operational alert -- if non-zero, inspect ./incoming/failed/.


API Reference

All API endpoints are served by the same process as the registry.

Method Endpoint Description
GET /api/status Mirror health, crate count, last import time
GET /api/packages Import history list
GET /api/config Current frostmirror.toml as JSON
POST /api/config Write new config (triggers in-process reload)
GET /api/deps Current depends.toml as JSON
POST /api/deps Write new depends.toml
GET /api/incoming Watcher state, done/failed counts
POST /api/gc Trigger garbage collection
POST /api/export Build a snapshot of the running instance (mirror + config)
GET /api/export List previously-built snapshots in incoming/exports/
GET /api/export/download/{filename} Download a snapshot .pkg
GET /api/setup/cargo-config Download cargo config.toml
GET /api/setup/cargo-config?check_revoke=false Same, with [http] check-revoke = false appended
GET /api/setup/rustup-env.sh Download shell env script
GET /api/setup/rustup-env.ps1 Download PowerShell env script

Examples

# Check mirror status

curl -s http://localhost:8080/api/status | python3 -m json.tool


# Update dependencies (supports simple, extended, and array formats)

curl -X POST http://localhost:8080/api/deps \

  -H "Content-Type: application/json" \

  -d '{
    "dependencies": {
      "tokio": {"version": "1", "features": ["full"]},
      "serde": ["1.0.100", {"version": "1", "features": ["derive"]}],
      "anyhow": "1"
    }
  }'


# Trigger garbage collection

curl -X POST http://localhost:8080/api/gc


Client Configuration

Cargo

Add to ~/.cargo/config.toml on each developer machine:

[source.frostmirror]

registry = "sparse+http://frostmirror.internal:8080/index/"



[source.crates-io]

replace-with = "frostmirror"

The sparse+ prefix is required -- it tells cargo to use the HTTP sparse protocol instead of trying to git-clone the URL.

Or download the ready-made file:

curl http://frostmirror.internal:8080/api/setup/cargo-config > ~/.cargo/config.toml

Rustup

export RUSTUP_DIST_SERVER=http://frostmirror.internal:8080

export RUSTUP_UPDATE_ROOT=http://frostmirror.internal:8080/rustup

Install rustup from the mirror:

# Linux/macOS

curl http://frostmirror.internal:8080/rustup/dist/x86_64-unknown-linux-gnu/rustup-init \

  -o rustup-init

chmod +x rustup-init && ./rustup-init


# Windows (PowerShell)

Invoke-WebRequest http://frostmirror.internal:8080/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe `
  -OutFile rustup-init.exe

.\rustup-init.exe

Note: Windows targets use rustup-init.exe, Linux/macOS targets use rustup-init.

PowerShell (Windows)

$env:RUSTUP_DIST_SERVER = "http://frostmirror.internal:8080"
$env:RUSTUP_UPDATE_ROOT = "http://frostmirror.internal:8080/rustup"

Environment Variables

Variable Used by Default Description
FROSTMIRROR_MODE fetch full full or incremental
FROSTMIRROR_PLATFORMS fetch x86_64-unknown-linux-gnu Comma-separated target triples
FROSTMIRROR_TOOLCHAIN fetch stable Rust toolchain channel
FROSTMIRROR_OUTPUT fetch ./output Where to write .pkg files
FROSTMIRROR_HOME fetch ~/.frostmirror State directory (history, config)
FROSTMIRROR_REGISTRY_URL fetch crates.io Override sparse index URL
FROSTMIRROR_DL_URL fetch static.crates.io Override crate download URL
FROSTMIRROR_DIST_URL fetch static.rust-lang.org Override rustup dist URL
FROSTMIRROR_HISTORY fetch ~/.frostmirror/history Manifest history directory
FROSTMIRROR_BASE_URL serve http://localhost:8080 Base URL embedded in client configs
FROSTMIRROR_BIND serve 0.0.0.0:8080 HTTP bind address
FROSTMIRROR_MIRROR serve /mirror Mirror data directory
FROSTMIRROR_INCOMING serve /incoming Incoming .pkg drop directory
RUST_LOG all info Log verbosity (debug, info, warn, error)

Project Architecture

frostmirror/
├── crates/
│   ├── frostmirror-core/       # Library: bundle format, manifest, diff logic
│   ├── frostmirror-fetch/      # Library: cargo-based resolver, crate/rustup downloaders
│   ├── frostmirror-import/     # Library: .pkg extraction, atomic mirror merge, GC
│   ├── frostmirror-serve/      # Library: HTTP server, sparse registry, web UI, watcher
│   └── frostmirror/            # Binary: CLI entrypoint
├── docker/                     # Dockerfiles and entrypoint
├── scripts/                    # Build, export, and import Docker images
├── tests/integration/          # Docker-based integration tests
└── depends.toml                # Self-hosted: frostmirror mirrors its own deps
Crate Role
frostmirror-core Bundle format (brotli + custom binary archive), manifest with SHA-256 integrity, depends.toml parser with features/multi-version support
frostmirror-fetch Delegates to cargo generate-lockfile for resolution, downloads .crate files and index entries, produces .pkg bundles
frostmirror-import Decompresses and verifies .pkg bundles, atomically merges contents into the mirror store
frostmirror-serve Axum-based HTTP server implementing the Cargo sparse registry protocol (nest + fallback routing), rustup dist serving, REST API, embedded web UI, incoming watcher on dedicated thread
frostmirror CLI binary wiring everything together via clap

Development

Prerequisites

  • Rust 1.86+ (required for Cargo.lock v4 format)
  • Docker and Docker Compose (for integration tests and production images)

Build

cargo build --workspace

Run unit tests

cargo test --workspace

Run with verbose logging

RUST_LOG=debug cargo run -p frostmirror -- fetch --config depends.toml

Integration tests

./scripts/build.sh

docker compose -f compose.test.yml run --rm test-runner

Test What it validates
T1 -- Full fetch End-to-end: fetch, bundle, auto-import, crates served
T2 -- Incremental Delta bundle is smaller, parent chain valid, merge is additive
T3 -- Corruption Corrupted .pkg rejected, mirror state unchanged
T4 -- Version conflict Both versions of a crate included when graph requires them
T5 -- Cargo offline cargo build succeeds using only the frostmirror registry
T6 -- Rustup offline rustup-init installs a toolchain from the mirror

Troubleshooting

cargo generate-lockfile fails during fetch

frostmirror requires cargo to be installed on the machine running fetch. The fetcher creates temporary Cargo projects and runs cargo generate-lockfile to resolve dependencies. If cargo is not in PATH, the fetch will fail.

"no matching package found" on the air-gap

Check that:

  1. The crate and version are in your depends.toml (or are transitive deps of something that is)
  2. A .pkg containing that crate has been imported
  3. Your ~/.cargo/config.toml uses the sparse+ prefix: registry = "sparse+http://..."

Without sparse+, cargo tries to git-clone the URL instead of using the HTTP sparse protocol, which will always fail.

Run with debug logging on the server to see what cargo is requesting:

RUST_LOG=debug frostmirror serve --mirror /data/mirror

"failed to download" -- crate file returns 404

The index entry exists but the .crate file is missing from the mirror. This can happen if:

  • The crate was resolved in a previous depends.toml but the .pkg containing it was never imported
  • The crate version was unified differently by cargo (e.g. your project resolves aho-corasick 1.1.4 but the mirror only has 1.1.3)

Fix: re-run frostmirror fetch (full, not incremental) to rebuild the bundle with the current resolution, then re-import.

Incremental fetch falls back to full

This happens when no previous manifest is found in the history directory (~/.frostmirror/history/). Normal on the first run. The warning is informational.

Bundle verification fails on import

The .pkg file may be corrupted (truncated transfer, bad disk). It is moved to ./incoming/failed/. Re-transfer the original and try again.

frostmirror verify 20260402-2130-crates.pkg

Web UI shows failed count > 0

Inspect ./incoming/failed/. Common causes: corrupted transfer, disk full. Fix the issue, then re-drop a valid .pkg.

Mirror taking too much disk space

frostmirror gc --mirror /data/mirror

# Or via API:

curl -X POST http://localhost:8080/api/gc

Windows rustup-init returns 404

Windows targets use rustup-init.exe (not rustup-init). Make sure your depends.toml lists the Windows target:

[platforms]

targets = ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]

frostmirror automatically uses the correct filename per platform.