Pack Hugging Face models into chunked, hashed, self-verifying bundles for air-gapped transfer.
Project description
modelferry
Pack a Hugging Face model into a chunked, hashed, self-verifying bundle on the connected side. Verify and reassemble it on the disconnected side with no network and no third-party dependencies.
I thought air-gapped just meant no internet. Same stack, cut the outbound route. How hard could it be.
Then I had to get a model in. You don't pull 40GB from Hugging Face and go. It moves through whatever channel the client approves, checksums get verified by hand, and the file usually gets chopped up because the transfer media won't take a single object that size. I've watched senior engineers lose an entire afternoon to this. At the end of the afternoon, the only thing you can hand the security officer is "trust me, it's the same file."
modelferry does that in one command and gives you the paperwork at the end.
The manifest is the product. It doubles as the approval document that gets signed off before the bundle crosses the air gap, and as the checklist the receiving side runs against on arrival.
What it does
modelferry has two sides.
On the connected side, pack downloads a model repo pinned to a commit, splits
files that are too big for the transfer media into parts, hashes everything with
sha256, and writes a bundle: the payload, a machine-readable manifest.json, a
human-readable MANIFEST.md for review, and a copy of the offline verifier
itself.
On the disconnected side, verify recomputes every hash and checks it against
the manifest, and unpack reassembles the original file tree so vLLM or
transformers can load it directly. Both run from a single standard-library
Python file that ships inside every bundle, so the receiving environment needs
nothing installed and no network. Python 3.9 or newer is the only requirement
there.
Install
The connected side is a command-line tool, so install it in its own isolated environment. Nothing imports modelferry, and an isolated install keeps its dependency versions from colliding with whatever your projects have pinned.
uv tool install modelferry
or
pipx install modelferry
Plain pip install modelferry works too if you would rather put it in an
existing environment.
The disconnected side installs nothing. The verifier travels inside the bundle
at tools/modelferry_offline.py and runs against the system Python.
Five-minute quickstart
This uses a tiny public test repo so you can see the whole round trip in a couple of minutes without downloading real weights.
That repo ships one model in three serialization formats: model.safetensors,
pytorch_model.bin (PyTorch), and tf_model.h5 (TensorFlow). You only need one,
so exclude the other two. The safetensors file that remains is only a few hundred
KiB, so pass a small --chunk-size to force it to split into parts and see
chunking work:
modelferry pack hf-internal-testing/tiny-random-gpt2 --dest ./bundles \
--chunk-size 200K --exclude "*.bin" --exclude "*.h5"
That writes ./bundles/tiny-random-gpt2__<sha>/. pack prints the exact bundle
path on its last line, so use whatever it printed in place of <sha> below.
Now pretend you have carried the bundle across the air gap. Everything from here
runs from inside the bundle with the bundled tool, exactly as the receiving side
would, with no modelferry install. This is the same three-command sequence
MANIFEST.md prints and scripts/e2e_airgap.sh proves under --network none.
cd ./bundles/tiny-random-gpt2__<sha>
Look at what a reviewer would see, then check every hash against the manifest:
python3 tools/modelferry_offline.py inspect .
python3 tools/modelferry_offline.py verify .
inspect prints the recomputed manifest_sha256 to compare against the copy
you approved before transfer, and verify prints "verify OK" only when every
object matches. Then reassemble the model tree:
python3 tools/modelferry_offline.py unpack . ../model
../model now holds the original repo tree, and ../model/UNPACK_RECEIPT.json
records that it was verified on the way in.
Packing a real model
modelferry pack Qwen/Qwen2.5-7B-Instruct --dest /media/usb --revision main
Useful options:
--revisionpins the download. It defaults tomainand is resolved to a commit SHA before anything is fetched. The manifest records both the requested revision and the resolved SHA.--chunk-sizesets the maximum part size. It defaults to3900M, which fits under FAT32's 4 GiB file limit with margin. Pass16Gfor larger media, ornoneto store every file whole.--includeand--excludeare fnmatch patterns against repo-relative paths. Exclude wins. The common case is--exclude "*.bin"when safetensors already cover the weights.--stagingis where the download lands before packing. It defaults to~/.cache/modelferry/. Re-runningpackafter an interruption resumes the download.
Hugging Face auth is the HF_TOKEN environment variable, and only that. It is
never a flag and never written into a bundle, manifest, receipt, or log. Custom
hub endpoints work through the standard HF_ENDPOINT variable, and the endpoint
used is recorded in the manifest.
The manifest
manifest.json is deterministic: the same inputs produce a byte-identical file,
so it diffs cleanly and can be compared across sites. It records the source repo
and resolved commit, the license and gated flag, every file with its size and
whole-file sha256, the parts each chunked file was split into with their own
hashes, and the sha256 of the bundled verifier.
MANIFEST.md is the same information rendered for a human reviewer. It names two
moments: before transfer, approve and keep a copy of it; on arrival, compare the
manifest.json checksum to the copy you kept, then run verify. If the license
could not be determined from repo metadata it is recorded as UNKNOWN and
flagged at the top of the document.
Trust model
Stated honestly, because this is the part a security review turns on.
v1 protects against accidental corruption, incomplete transfers, media errors, and casual tampering with payload files. Any of those shows up as a hash mismatch, a missing part, or an extra file, and verify exits non-zero and names what failed.
v1 does not protect against an adversary who can modify the payload, the manifest, and the bundled verifier together. Defending against that requires signature verification with a key held out-of-band, which is planned for v1.1 (minisign).
The mitigation available today is out-of-band verification of the verifier.
manifest.json records the sha256 of the bundled offline.py, and every
release publishes the canonical offline.py hash in its release notes. A
receiving site can check the bundled verifier against that published hash before
trusting it, or ignore the bundled copy and bring its own known-good verifier.
Exit codes
0 success
1 integrity failure (verify mismatch, missing or extra file, unpack hash
failure, path-safety violation)
2 usage error (bad arguments, unknown manifest version, malformed manifest)
3 source error (network failure, HF auth, 404, gated without token)
4 local filesystem error (permissions, disk full, destination exists
without --force)
Development
uv sync
uv run pytest -m "not network" # default, offline
uv run pytest # includes the network integration test
uv run ruff check . && uv run ruff format .
bash scripts/e2e_airgap.sh # docker, runs verify/unpack with --network none
# Resolve every dependency to its declared floor and run the suite, so the
# lower bounds in pyproject are tested claims. The network test is included so
# the huggingface_hub floor is exercised for real.
uv sync --resolution lowest-direct && uv run pytest -m "not network" && uv run pytest -m network
# Build the wheel and round-trip pack/verify/unpack from a clean venv with the
# repo off the path, so the installed package (not the checkout) is exercised.
uv build && uv venv /tmp/wenv && uv pip install --python /tmp/wenv/bin/python dist/*.whl
SPEC.md is the contract. src/modelferry/offline.py is the trust surface: it
is standard library only, runs on CPython 3.9, imports nothing from the
modelferry package, and is copied verbatim into every bundle. A test enforces
all of that.
License
Apache-2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file modelferry-0.1.0.tar.gz.
File metadata
- Download URL: modelferry-0.1.0.tar.gz
- Upload date:
- Size: 1.7 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7bd81c72d8a5e9d486d97526e6bd7f21b2438a3c399283fb5cb70a73a1d03b41
|
|
| MD5 |
b8f9f35857e12cf5247c0156bcf98101
|
|
| BLAKE2b-256 |
7c95014f1c1002fde8ba9d2d223a9684b44dd90a454278d98de01e44872f9d3f
|
Provenance
The following attestation bundles were made for modelferry-0.1.0.tar.gz:
Publisher:
release.yml on HamzaAhmedWajeeh/modelferry
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
modelferry-0.1.0.tar.gz -
Subject digest:
7bd81c72d8a5e9d486d97526e6bd7f21b2438a3c399283fb5cb70a73a1d03b41 - Sigstore transparency entry: 2211126965
- Sigstore integration time:
-
Permalink:
HamzaAhmedWajeeh/modelferry@2fd6f6e37c48c36f797a03e6f7aa1d8cf190898c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/HamzaAhmedWajeeh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2fd6f6e37c48c36f797a03e6f7aa1d8cf190898c -
Trigger Event:
push
-
Statement type:
File details
Details for the file modelferry-0.1.0-py3-none-any.whl.
File metadata
- Download URL: modelferry-0.1.0-py3-none-any.whl
- Upload date:
- Size: 28.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4ce17aa94ad15e2e236a7e1dc60929601bd1afd239830903dc5a70369ea0f98a
|
|
| MD5 |
3e2974ce08bb4ed05e0faacfc5703e33
|
|
| BLAKE2b-256 |
2f9be20dcc22cda7b79f294ed10a6c186ea8751b9bb508d62ed0b17a79b4038c
|
Provenance
The following attestation bundles were made for modelferry-0.1.0-py3-none-any.whl:
Publisher:
release.yml on HamzaAhmedWajeeh/modelferry
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
modelferry-0.1.0-py3-none-any.whl -
Subject digest:
4ce17aa94ad15e2e236a7e1dc60929601bd1afd239830903dc5a70369ea0f98a - Sigstore transparency entry: 2211126998
- Sigstore integration time:
-
Permalink:
HamzaAhmedWajeeh/modelferry@2fd6f6e37c48c36f797a03e6f7aa1d8cf190898c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/HamzaAhmedWajeeh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2fd6f6e37c48c36f797a03e6f7aa1d8cf190898c -
Trigger Event:
push
-
Statement type: