Skip to content

Latest commit

 

History

30 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

smolvault

python dependencies license version

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

Why

  • 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.

Quick start

python3 smolvault.py                    # creates vault.vault + opens the hub

Press 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.

What it looks like

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    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.

Features

  • 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 DIR and password-holding clients can browse that directory remotely and seal server-local files into the vault (traversal-locked, additive).
  • Space insight β€” --du breaks 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; --check scrubs the whole vault. Crash-tested: kill -9 mid-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). Press m: 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) via prompt_for_port, persisted per-vault; change anytime with P in wizard or --port / $SMOLVAULT_PORT.
  • Tiny vault file manager β€” [b]rowse vault in play/get/library/info/copy (and remote watch/get) β€” same raw-mode keys (↑↓ β†’ ← Enter i info), live filter, backed by VaultBrowseState (prefix tree from the listing, with the row carried through so the renderer stays O(1) per visible row). get and library open it in multi-select: Space to check, :s to act on everything checked.

Requirements

Runtime Python 3.9+ (uses str.removeprefix)
Dependencies none β€” standard library only
Install copy smolvault.py anywhere
Platforms Linux Β· macOS Β· Windows Β· Android/Termux

Usage

Wizard (default)

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.py

Sealing from scripts

python3 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.

Remote access

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 --connect

Full 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

Vault sync

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 β†’ mine

Additive-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.

Configuration

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)

HTTP API

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.

Benchmarks

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

On a 1 GB box

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=-64000 are 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=1G leaves 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.

Resilience & scale

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.urandom data, 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.

Troubleshooting

"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

  1. Firewall: sudo ufw allow 8100/tcp && sudo ufw allow 8100/udp
  2. 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.

Security model

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.

Design notes

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.

How it compares

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.

Contributing

Issues and PRs welcome β€” it's one file on purpose; keep additions honest about that budget. Reproduce before/after with python3 benchmark.py.

License

MIT β€” see also the header of smolvault.py.

About

Single-file, zero-dependency Python vault: write-once (WORM) storage with content-defined dedup, AES-256-GCM at rest, and 4K streaming from a Pi-sized RAM budget

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages