smolvault is an immutable, content-addressed vault for everything you have β that speaks just enough HTTP to be mistaken for a local disk.
One file, zero dependencies, no install step. Drop files into the terminal to seal them write-once (WORM), deduplicated by content and verified on every read β then stream any of it to mpv, your phone, or a browser with instant seeking. Media is the headline act; documents, archives and disk images are equal citizens.
git clone https://github.com/smolfiddle/smolvault.git
cd smolvault
python3 smolvault.py # that's the whole install- Backups you can't corrupt by accident. Files are sealed forever:
overwrite β
409, delete β403. Bit-rot fails loudly instead of silently. - Storage that shrinks. Content-defined chunking + dedup means identical content costs zero extra bytes, across files and folders.
- Remote without setup. One command serves the vault on your LAN; phones, TVs and other machines get full read/write parity β discovery included, IPs optional.
- It gets out of the way. Single-file stdlib Python: copy it anywhere, run it, done.
python3 smolvault.py # creates vault.vault + opens the hubPress a, drag any files from your file manager into the terminal, Enter:
β sealed /movie.mkv 8.4 GB Β· 9 chunks
ββ 1 added Β· 0 skipped Β· 0 failed Β· 8.4 GB of 8.4 GB newly stored (0% saved)
Point it at a folder to ingest a whole tree β or [b] to open the
browse navigator: a tiny file manager that takes over the screen,
starting at your current directory and locked to it (you can
descend into subdirectories, never climb out). ββ move Β· β descend
Β· β up Β· Space toggles (on a folder = its whole subtree, β n/m
while partial) Β· :a select-all Β· :u clear Β· :s seal β every
other printable key types into the filter. Vault internals (*.vault,
__pycache__) never appear. Then press p and start typing β results
filter as you type:
watch β― dune Β· 2
β― /movies/dune.2021.mkv (8.4 GB)
/movies/dune.part.two.mkv (9.1 GB)
Enter plays in mpv. When an episode ends, [Enter]=next chains the season in
real numeric order (E2 β E10). The banner shows a LAN URL β open it on your
phone and the same library streams there.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
smolvault 0.4.1
vault vault.vault Β· 12 files Β· 22.3 GB logical Β· 11 GB stored
local http://127.0.0.1:8100/
network http://192.168.1.14:8100/ β running β phone/TV ready
auth password protected Β· AES-256-GCM at rest
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
a add drop files Β· path Β· [b]rowse a folder
s search find something
p play search β mpv (w) Β· [b]rowse vault
l library browse everything Β· [b]vault browser
d du space by folder Β· find double-sealed files
m board this vault's messages Β· clients & server post here
i info details about a file Β· [b]browse
g get export a copy Β· [b]browse vault
c copy stream link β clipboard Β· [b]browse vault
y sync mirror another smolvault
S server start/stop network sharing
P port change port
v verify check every chunk's hash
q quit
After each action the hub collapses to a one-line prompt β
β― vault.vault Β· 12f β :8100 β― β so long sessions stay calm. The menu is one
h away.
- WORM immutability β sealed means sealed; perfect for media libraries, evidence, datasets, anything that must not quietly change. Race-tested: 12 parallel same-path PUTs yield exactly one seal.
- Streaming-grade HTTP β HTTP/1.1 keep-alive, full RFC 7233 byte ranges
(
N-M,N-,-N),If-Range, 416s,ETag= content hash,Cache-Control: immutable. Players seek as if the file were local. - Content-defined chunking β gear-hash CDC (64 KBβ1 MB chunks) makes dedup work across shifted/ressembled files, not just identical copies: a mid-file edit still dedups roughly half or more of the file on re-seal (up to ~98%).
- Entropy-adaptive storage β high-entropy media stored raw and scanned on a coarse fast stride (~2Γ seal speed); compressible text is compressed.
- Folder-aware ingest β recursive walks keep structure
(
s01/e1.mkv β /movies/s01/e1.mkv), with a live Space-toggle multi-picker. - Vault sync β additive gap-filling replication between instances; nothing ever deleted, safe on a schedule.
- Remote ingest (opt-in) β run the server with
--share-root DIRand password-holding clients can browse that directory remotely and seal server-local files into the vault (traversal-locked, additive). - Space insight β
--dubreaks storage down by folder and surfaces byte-identical files sealed under different paths (WORM keeps them all, so knowing matters). - Integrity on every read β per-chunk BLAKE2b-256 verified end-to-end;
--checkscrubs the whole vault. Crash-tested:kill -9mid-ingest recovers clean (--gc+--check). - At-rest encryption (opt-in) β every chunk sealed with AES-256-GCM via the system's OpenSSL; key derived from your vault password (scrypt). Zero PyPI dependencies. Dedup fully preserved.
- Message board, live β each vault carries a small public board
(MMO-style): clients and the server post notes and system events
(
sealed 12 files,sync pushed X). Pressm: new messages stream in as they arrive while you type. - Optional password β PBKDF2-HMAC-SHA256 over HTTP Basic; doubles as
the encryption key wrapper, and the network gate it controls is an
independent switch (
--auth on|off) so trusted LANs can stream freely. - Port picker at vault creation β
port [8100](Enter=auto next free) viaprompt_for_port, persisted per-vault; change anytime withPin wizard or--port/$SMOLVAULT_PORT. - Tiny vault file manager β
[b]rowse vaultinplay/get/library/info/copy(and remotewatch/get) β same raw-mode keys (ββ β β Enter i info), live filter, backed byVaultBrowseState(prefix tree from the listing, with the row carried through so the renderer stays O(1) per visible row).getandlibraryopen it in multi-select: Space to check,:sto act on everything checked.
| Runtime | Python 3.9+ (uses str.removeprefix) |
| Dependencies | none β standard library only |
| Install | copy smolvault.py anywhere |
| Platforms | Linux Β· macOS Β· Windows Β· Android/Termux |
python3 smolvault.py # hub, as shown above
# on first vault creation you'll be asked: port [8100] (Enter=auto next free)
# or set upfront: python3 smolvault.py my.vault --port 9000
# or env: SMOLVAULT_PORT=9000 python3 smolvault.pypython3 smolvault.py lib.vault --add /data/movies # whole folder, structure kept
python3 smolvault.py lib.vault --add a.mkv notes.txt --into /docs/
python3 smolvault.py lib.vault --list
python3 smolvault.py lib.vault --du
python3 smolvault.py lib.vault --search marvel
python3 smolvault.py lib.vault --play dune [--player mpv]
python3 smolvault.py lib.vault --info dune
python3 smolvault.py lib.vault --get dune -o copy.mkv
python3 smolvault.py lib.vault --check # scrub all chunks
python3 smolvault.py lib.vault --gc # prune orphaned chunks
python3 smolvault.py lib.vault --encrypt # at-rest AES-256-GCM (resumable)
python3 smolvault.py lib.vault --decrypt # strip encryption--du output at a glance:
space by folder:
/movies 12 files Β· 14.2 GB
/docs 210 files Β· 1.8 GB
ββ vault: 16.0 GB logical Β· 10.3 GB stored Β· 36% deduped
byte-identical sets (WORM keeps them all):
8.4 GB Γ2 /movies/cut.mkv
= /movies/old/cut.mkv
ββ 1 set Β· 8.4 GB sealed twice under different names
Exit codes: 0 ok Β· 1 no match/refused Β· 2 usage error β pipe-friendly.
The same file is client and server. Copy it anywhere:
python3 smolvault.py lib.vault --serve # plain server + live feed
python3 smolvault.py --connect # discover vaults on the LAN
python3 smolvault.py --connect 192.168.1.14:8100 # or aim directly
SMOLVAULT_SERVER=host:port python3 smolvault.py --connectFull parity over the network: list, live search, watch in your local player
(streams go straight from the vault β never proxied), export, even remote
drag-paste uploads. Discovery runs over UDP broadcast (--no-discover
silences it).
| Platform | Playback |
|---|---|
| Linux / Windows / macOS | mpv (default) Β· VLC via SMOLVAULT_PLAYER |
| Android Termux | video auto-opens in mpv-android via intent (CLI mpv there is audio-only); falls back to any video app |
| iPhone / TV | browser at the banner URL, or VLC network-stream |
Because vaults are immutable and content-addressed, syncing is pure gap-filling β zero conflict logic:
python3 smolvault.py lib.vault --sync-to 192.168.1.20:8100 # push mine β theirs
python3 smolvault.py lib.vault --sync-from 192.168.1.20:8100 # pull theirs β mineAdditive-only: fills gaps, skips already-sealed paths, deletes nothing.
Every pulled file is chunk-hash-verified on arrival. A transfer plan prints
first; in the wizard, y sync adds a LAN discovery picker.
No config files β flags and environment only. A few things are remembered
per vault in its own SQLite config table (the last LAN port, whether the
network gate is on, and an exposed --share-root); there is no separate
config file to edit.
| Env var | Purpose |
|---|---|
SMOLVAULT_VAULT |
default vault path when none given |
SMOLVAULT_PLAYER |
player binary (default mpv; mpvapp = mpv-android intent) |
SMOLVAULT_SERVER |
default host:port for client mode |
SMOLVAULT_DEBUG |
verbose debug output + SIGUSR1 stack dumps |
SMOLVAULT_NAME |
node name on the message board (else hostname) |
SMOLVAULT_PORT |
default port at vault creation (else auto next free) |
NO_COLOR |
disable ANSI color everywhere |
| Flag | Effect |
|---|---|
--serve |
plain server mode (no wizard) |
--host / --port |
bind address (default 127.0.0.1:8100, remembered per-vault; --port validated 1-65535) |
-p/--password |
set/verify vault password (also unlocks encrypted vault) |
-v/--verbose |
verbose logging |
-i/--wizard |
force the interactive wizard (even with a vault) |
--no-discover |
do not answer LAN discovery probes |
--connect [HOST[:PORT]] |
client mode: auto discovers LAN, or SMOLVAULT_SERVER |
--add PATH⦠[--into DIR] |
seal files/folders (folders walk recursively) |
--list |
print library table |
--search Q |
search the library |
--play Q [--player BIN] |
search + play in mpv |
--info PATH_OR_Q |
file details |
--get PATH_OR_Q [-o FILE] |
export a file |
--du |
space by folder + byte-identical duplicate report |
--encrypt / --decrypt |
toggle at-rest encryption in place (resumable) |
--name NAME |
node name on the message board (default hostname / $SMOLVAULT_NAME) |
--auth on|off |
require the vault password over HTTP β independent of encryption: off keeps files sealed at rest while streaming openly on trusted LANs |
--share-root DIR |
expose DIR to password-holding clients for remote browse + ingest (traversal-locked, symlink-blocked, additive; persisted in vault config β re-run without flag stays exposed) |
--check / --gc |
verify all chunks (--check needs vault) / reclaim orphaned chunks (locked, VACUUM under write lock) |
--sync-to HOST / --sync-from HOST |
push/pull additive gap-filling sync (hash/size verified) |
| Endpoint | Behaviour |
|---|---|
GET/HEAD /path |
full file or RFC 7233 range; canonical path (resolves ..); ETag/304; 423 if vault locked |
PUT /path |
seal a new file (409 if exists β WORM; 400 on truncated Content-Length; 0-byte allowed; 423 locked); answers 201 with Location |
DELETE /path |
always 403 β WORM |
GET /__api/list |
JSON listing (path, size, mime, created_at, root_hash) |
POST /__api/msg |
post to the vault's message board {"body": β¦} β 201 (max 2000 chars, control chars stripped) |
GET /__api/msg?since=N&limit=M |
board messages after id N (max 500) |
GET /__api/browse?dir= |
listing under --share-root (403 when off / escaping) |
GET /__api/browse?dir=&recursive=1 |
internal recursive listing, capped at 2000 files ("truncated": true when capped) |
POST /__api/ingest |
seal server-local files {paths:[...max 200], into} (max 2000 files, 409 WORM, 403 traversal/symlink) |
GET /__api/auth |
{"auth": bool, "share_root": bool} β password gate + share-root presence |
A locked vault answers 423 to every endpoint, /__api/* included β no
metadata leaks through the listing endpoints.
Framing is strict: a request carrying Transfer-Encoding, or two
conflicting Content-Length headers, is rejected before any handler runs,
and every error response either drains its request body or closes the
connection. Ambiguous framing is how HTTP request smuggling starts.
Auth (if set): HTTP Basic, PBKDF2-HMAC-SHA256, 100k iterations.
Disable it for trusted LANs with --auth off β encryption stays on.
Loopback Β· NVMe Β· 6 cores Β· Python 3.12 (full sheet,
regenerate with python3 benchmark.py, ~7 min). Every figure below comes
from that one committed run:
| Metric | Result |
|---|---|
| Ingest (700 MB seal) | 20.5 s Β· 34.1 MB/s β media scans a hot stride |
| Dedup | identical content β +0 bytes; WORM reject < 1 ms |
| Full read 700 MB | 2.06 s Β· 339 MB/s hash-verified |
| Concurrent reads | 388 MB/s aggregate (4Γ64 MB) |
| Range read p50/p95 | 1.8 ms / 5.3 ms (256 KB, keep-alive) |
| Playback vs local disk | startup +0.08 s Β· deep seek +0.01 s |
| Vault sync | push 732 MB @ 13.7 MB/s Β· pull 192 MB @ 34.3 MB/s Β· no-op re-sync 24 ms |
The server itself runs inside a hard 1 GB RAM / no-swap / 4-core cgroup
envelope (systemd-run --user -p MemoryMax=1G) while a 700 MB movie is
sealed and streamed back out of it:
| Metric | Result |
|---|---|
| PUT 700 MB into the constrained server | 201 Β· ~48β68 MB/s |
| GET full + sha256 verify | byte-exact |
| Seek p50/p95 | 5 ms / 21 ms |
| Server peak memory | ~220β880 MB of 1024 (incl. reclaimable mmap 512 MB + cache 64 MB) β latest run ~226 MB |
Note:
PRAGMA mmap_size=512M+cache_size=-64000are tunable. The page-cache figure is a cap, not a reservation β SQLite grows it lazily, so a connection only holds the pages it actually touches. Lowering it to 2 MB was measured and reverted: with a chunks index past the cap (~300k chunks), range reads degrade p50 1.6 β 4.9 ms and p95 7.1 β 12.7 ms (fresh process per setting, identical warm-up). Seeks are the whole point of a media server, so leave it alone unless you have measured a problem.MemoryMax=1Gleaves headroom; a real Pi will ingest slower, while the memory profile holds.
Memory stays flat because files stream in chunks β the ceiling is your disk,
not your RAM. Reproduce with python3 benchmark.py --lowmem (Linux +
user systemd session). Caveat: this is x86 under a memory ceiling, not ARM
silicon β a real Pi will ingest slower, but the memory profile holds.
Every promise the docs make, measured, from the same committed full sheet:
| Promise | Measured |
|---|---|
| CDC survives edits β insert 1 MB mid-file into 192 MB, re-seal | 88.6% deduped (varies with edit position/content) Β· naive copy 59.0 MB/s β 89.2 MB/s effective |
| Small-file reality β 1500 mixed 0β64 KB files | 315 files/s Β· seal p50 0.8 ms Β· p95 2.0 ms |
| Library scale β 10k / 50k entries | --list 30 / 140 ms Β· search 37 / 201 ms Β· du 9 / 41 ms |
| Reads don't block on writes β 6 range-readers during a 700 MB PUT | 3123 reads Β· p50 29 ms / p95 93 ms while the writer ran |
| WORM is race-safe β 12 parallel PUTs, same path | exactly 1Γ201 + 11Γ409 in 26 ms, file serves intact after |
Crash consistency β kill -9 mid-ingest, restart |
pre-crash file byte-exact Β· half-written file invisible (404) Β· --gc reclaimed the dead ingest's lease + 35 orphans Β· --check PASS |
Concurrent gc vs ingest β --gc while a seal runs |
gc sees the live ingest lease and defers; a lease whose owning pid is gone is reclaimed immediately, so kill -9 self-heals. Costs 2 extra SQL statements per seal: β8% on 1500 small files (704 vs 768 files/s), below the noise floor on media |
| Multi-viewer storm β 3 simultaneous decoders + seek noise | β67 fps aggregate Β· seek-noise p50 7.3 ms under load |
| Write endurance β 3.5 GB sustained in 45 s | 78.3 MB/s mean Β· commit latency stable (389β424 ms p50) Β· WAL capped at 32 MB |
| At-rest encryption cost β AES-256-GCM on/off | seal +37% time Β· reads 148 vs 266 MB/s Β· range seeks 8.5 ms p50 Β· unlock 88 ms |
| Message board β post β readable | 13.1 ms roundtrip |
Remote ingest β --share-root, 96 MB via one POST |
36.0 MB/s server-side Β· 20-file batch in 298 ms Β· traversal/symlink β 403 |
The WORM race test earned its keep: it exposed a real bug (losing writers
stalled 60 s on SQLite lock timeouts, then died without a response) which is
now fixed with explicit single-writer discipline β contenders get instant
409s instead.
Reading these numbers. Several sections seal
os.urandomdata, so chunk counts and throughput move run to run β the CDC-resync dedup figure alone has ranged 49β95% across runs purely on where the edit landed, and the 700 MB seal has landed anywhere from 34 to 49 MB/s. On this hardware the media-seal path has a pass-to-pass spread exceeding 2Γ, so treat single-run deltas under ~20% as noise. The numbers above are one full run, not a curated best-of; if a figure here looks unflattering, it unflattered the run that is committed.
"Could not connect to socket" (Termux intents) β your Termux app build
predates the AM socket server (needed on Android 12+/14+ where raw am is
blocked). Fix: update the Termux app itself from
github.com/termux/termux-app/releases
(β₯ 0.119), fully close & reopen, then pkg reinstall termux-am. smolvault
also auto-falls back to termux-open, which bypasses am entirely.
LAN devices can't reach the vault
- Firewall:
sudo ufw allow 8100/tcp && sudo ufw allow 8100/udp - Router AP/Wireless Client Isolation enabled (common on ISP routers) β
disable it, or use a hotspot:
nmcli dev wifi hotspot ssid vaultnet password β¦
Port already in use β smolvault remembers the last port per vault and suggests the next free one.
What encryption protects: a stolen disk, SD card, laptop or leaked
backup of vault.vault. Chunks are unreadable without your password.
What it does not protect:
- the running server (keys live in RAM while unlocked),
- network traffic β smolvault speaks plain HTTP; put it behind Tailscale, WireGuard or an SSH tunnel for remote access,
- content equality: nonces derive from chunk hashes so dedup keeps working, which means an attacker who already knows a file's exact bytes can confirm it exists in your vault.
Auth vs encryption are independent: encryption always protects the
stored bytes; whether network requests need the password is a separate
switch (--auth on|off, default on). Media-server on a trusted LAN?
--auth off gives players password-free streaming while everything stays
sealed on disk.
Passphrase strength matters: the key is only as strong as the password
wrapping it (scrypt, n=2ΒΉβ΅). Password changes re-wrap the master key in
milliseconds β data is never re-encrypted. --decrypt refuses to drop key
material while any chunk is still sealed, so a half-finished migration can
never look like successful one. The message board is visible to anyone
holding the vault password and is not replicated by sync. --share-root
grants password holders read+seal access to that one directory
(traversal- and symlink-locked, additive-only, persisted in vault
config) β point it at a downloads folder, never at / (/ is now
refused). Paths are canonicalized (/a/../b β /b), truncated uploads are
rejected (400) and rolled back, and gc holds a write lock plus an
ingest lease to avoid orphans.
smolvault is the distilled successor of DenseVault. Deliberately absent: delta encoding, WebDAV lock theater, hidden system collections, compression pipelines, config files, worker-thread ingest pipelines (measured slower on boost-heavy consumer CPUs β the GIL-bound chunker runs fastest alone). Deliberately kept verbatim: the gear-hash chunker and hash-verified reads; the entropy gate now also picks each file's sealing stride.
One invariant worth knowing if you ever touch the ingest path. The
chunker scans only as far as its buffer reaches, so cut positions depend on
read sizes as well as content. Ingest always feeds it a 256 KiB entropy
probe followed by the reader's own 4 MiB reads, which is what makes chunking
deterministic and dedup/resync work. Swapping in a buffered or whole-file
reader would silently move every boundary β and --check would still pass,
because each chunk stays internally consistent. If you change how _put
builds its reader, re-measure the CDC-resync dedup row in
BENCHMARKS.md before trusting it.
No single pillar here is new β CDC chunking comes from the backup world (borg/restic/FastCDC), WORM-over-HTTP from stores like verm and S3 Object Lock, and stdlib-Python range-streaming servers are practically a genre. What we could not find elsewhere is all of it at once in one dependency-free file: write-once + content-defined dedup + verified reads + RFC-7233 streaming + LAN discovery + client/server parity. The CAS engines (casq, Kloset, farchive) stop before the serving layer β several advertise "no network" as a feature; the tiny Python servers (servery, neev, pi-media-server) serve plain filesystems with none of the storage semantics. smolvault lives in the gap between those two camps.
Issues and PRs welcome β it's one file on purpose; keep additions honest
about that budget. Reproduce before/after with python3 benchmark.py.
MIT β see also the header of smolvault.py.