Chunk objects, shard the store, and make filler configurable

File bodies are now cut into 4 MiB objects, the last padded to its
power-of-two bucket, so every object is 64 KiB to 4 MiB whatever the file
size. Object IDs are HMAC'd from a secret per-record seed, so nothing on
disk links a file's pieces, and each object still leads with its own ID,
binding it to its record and position. Chunks are encrypted and synced by
a small worker pool so their fsyncs overlap.

records/ and objects/ are sharded into 256 subdirectories by ID prefix,
all created at init, with every shard's mtime pinned to the epoch.

vault init --filler SIZE writes at least SIZE of filler. Filler entries
are now real entries for a throwaway key, of log-uniform random sizes, so
they carry multi-chunk files like a real collection.

The marker becomes VAULT 2; earlier vaults are not supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andy Dixon
2026-09-26 18:20:51 +01:00
co-authored by Claude Opus 5.5
parent 8f3a755bf9
commit eea16c9076
17 changed files with 556 additions and 175 deletions
+9 -5
View File
@@ -12,7 +12,9 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second
slot is either a decoy identity or random bytes; slot order is random.
- `store.go` - on-disk vault: `records/` holds fixed-size encrypted catalogue
records, `objects/` holds padded encrypted file bodies. Records a key cannot
records, `objects/` holds file bodies cut into 4 MiB chunks (last one
bucket-padded) whose IDs are HMAC'd from a per-record seed. Both are
sharded into 256 `xx/` subdirectories by ID prefix. Records a key cannot
open are skipped, not treated as errors.
- `lock*.go` - vault lock on `DIR/.lock`: fcntl on unix, LockFileEx on
Windows, an exclusive lock file elsewhere. Writers lock exclusively,
@@ -46,10 +48,12 @@ export VAULT_PASSPHRASE=test VAULT_KEY=$PWD/k VAULT_DIR=$PWD/s
- Never write a seed or private key unencrypted.
- Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable.
- Every ciphertext's mtime is pinned to the epoch; records stay one fixed
size; objects stay bucket-padded. These hide metadata from the storage host.
- An object's plaintext starts with its own ID, binding it to its record.
- `get` publishes output only after the entire object has authenticated.
- Every ciphertext's and shard directory's mtime is pinned to the epoch;
records stay one fixed size; objects stay at most 4 MiB and bucket-padded.
These hide metadata from the storage host.
- An object's plaintext starts with its own ID, binding it to its record
and its position in the file.
- `get` publishes output only after every object has authenticated.
- Decoy deniability: both key slots stay the same size and are both always
tried; `init` always writes filler; foreign records are skipped silently.
- Every command that changes the vault holds the exclusive lock.
+20 -4
View File
@@ -5,8 +5,11 @@ A post-quantum encrypted file vault in a single binary.
Files are encrypted with a hybrid FrodoKEM-640 + ML-KEM-768 key encapsulation
and ChaCha20-Poly1305. The vault directory holds only randomly named
ciphertexts. Names, sizes, modes and timestamps live inside encrypted
catalogue records of one fixed size. File bodies are padded to power-of-two
buckets.
catalogue records of one fixed size. File bodies are cut into 4 MiB objects,
the last padded to a power-of-two bucket, so every object is 64 KiB to 4 MiB
whatever the file's size. Object names derive from a secret seed in the
record, so nothing on disk links a file's pieces. Records and objects are
spread over 256 subfolders (`records/3f/3fa9…`) to keep directories small.
## Build
@@ -32,6 +35,7 @@ A full illustrated guide lives in `docs/guide`. Build the PDF with
```sh
vault genkey # create ~/.vault/key, protected by a passphrase
vault init # create ~/.vault/store
vault init --filler 2G # the same, with at least 2 GiB of filler
vault add report.pdf # stored as report.pdf
vault add report.pdf work/2026 # stored as work/2026/report.pdf
vault add ./photos # every regular file under photos/
@@ -84,10 +88,22 @@ Two measures keep the real files deniable:
A decoy can only be set when the key is created. Someone who watches the
vault over time can still see it grow when real files are added.
Default filler is only a few tens of megabytes, so someone holding the decoy
passphrase can see when far more data than that is unopenable. To hide more,
create the vault with `vault init --filler SIZE` (`500M`, `2G`, `1T`), at
least as large as what you will hide. Filler entries are written by the
same code as real files, with a spread of small and multi-chunk sizes, so
they look like a real collection. Filler can only be added at `init`;
added later it would stand out as a burst of new entries.
## Limits
- The storage host can see how many files exist, their size buckets and when
the vault changes.
- The storage host can see how many records and objects exist, so roughly
how much is stored, and when the vault changes. Chunking hides a file's
size in any single snapshot, but objects written together can be grouped
by anyone watching the folder or a sync service's upload log.
- Padding still costs space: small files round up to a 64 KiB-or-larger
bucket and a big file's last piece to its bucket, at most 4 MiB per file.
- The key file is the only way in. Losing it or its passphrase loses the data.
## Licence
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+3 -3
View File
@@ -232,7 +232,7 @@ def main():
t = Term(box)
t.cmd("vault init")
t.cmd("ls -A ~/.vault/store/")
t.cmd("ls ~/.vault/store/records | wc -l")
t.cmd("find ~/.vault/store/records -type f | wc -l")
shoot("init", t)
t = Term(box)
@@ -261,7 +261,7 @@ def main():
shoot("mv-rm", t)
t = Term(box, title="sam@laptop: ~/.vault/store")
t.cmd("cd .vault/store && ls -l records | head -5", cwd="~/.vault/store", shown="ls -l records | head -5")
t.cmd("cd .vault/store && ls -l records/*/* | head -5", cwd="~/.vault/store", shown="ls -l records/*/* | head -5")
shoot("on-disk", t)
# decoy: fill the decoy side, then show both views side by side
@@ -275,7 +275,7 @@ def main():
t = Term(box)
t.cmd("vault get ideas/notes.md -o /tmp/x.md", D, highlight=("not in the vault",))
t.cmd("ls ~/.vault/store/records | wc -l")
t.cmd("find ~/.vault/store/records -type f | wc -l")
shoot("decoy-hidden", t)
# errors
+115 -35
View File
@@ -93,9 +93,11 @@ decrypts it. That's the core loop. Put in, take out.
The interesting part is what the folder looks like from the outside. Nothing.
Well, not \emph{nothing}. A pile of randomly named files, all dated 1 January
1970, in a handful of suspiciously round sizes. No names. No real sizes. No
dates. No folders. Someone who steals the folder learns roughly how much stuff
you have, and that's about it.
1970, all in a handful of suspiciously round sizes, none bigger than 4\,MiB,
scattered across folders named \file{00} to \file{ff}. No names. No real
sizes. No dates. None of \emph{your} folders. Big files are cut into pieces
that look like everything else. Someone who steals the folder learns roughly
how much stuff you have, and that's about it.
\begin{center}
\begin{tikzpicture}[
@@ -436,6 +438,12 @@ protect yet.
\shot{init}{A fresh vault. Two sub-folders, a marker, and\ldots\ records already?!}
Inside \file{records/} and \file{objects/} are 256 folders each, named
\file{00} to \file{ff}. Every encrypted file lives in the one matching the
first two characters of its name. That just keeps any one folder from
getting huge, which some file systems and sync services dislike. All 256
exist from the start, so the layout itself gives nothing away.
\section{Wait, why is there already stuff in it?}
Good catch. A brand new vault holds somewhere between 8 and 32 entries of
@@ -448,10 +456,33 @@ with your decoy passphrase sees unopenable entries, that's completely
unremarkable. It's just filler. (Some of it might not be. They can't tell.)
\begin{headsup}
The filler takes a few tens of megabytes. That's the price of plausible
By default the filler takes a few tens of megabytes. That's the price of plausible
deniability, and it's paid once.
\end{headsup}
\section{More filler, for bigger secrets}
A few tens of megabytes of filler can only cover for a few files. If you
plan to hide more than that behind a decoy, ask for more when you create the
vault:
\begin{shell}
$ vault init --filler 2G
\end{shell}
vault keeps writing filler until there's at least that much. Sizes take
\cmd{K}, \cmd{M}, \cmd{G} or \cmd{T}. The filler entries are built exactly
like real ones, from a mix of small and large random ``files'', so a big
file of yours sits among big files of filler. It costs the disk space and a
few seconds per gigabyte, once. It can only be chosen at \cmd{init}: added
later, the filler would show up as a burst of new entries, which is exactly
what it's meant to hide.
\begin{quickwin}
Rule of thumb: set \cmd{--filler} to at least as much as you ever expect to
hide from the decoy passphrase. Without a decoy, you don't need it.
\end{quickwin}
\section{More than one vault}
Want a separate vault for work stuff? Point \cmd{--vault} anywhere:
@@ -473,6 +504,7 @@ type \cmd{--vault} again.
\begin{pocketlist}
\item \cmd{vault init} makes \file{\tld/.vault/store}. \cmd{--vault DIR} to put it elsewhere.
\item Filler entries are normal. Nobody can open them. That's the point.
\item Hiding a lot behind a decoy? \cmd{vault init --filler 2G} (or however much you'll hide).
\item One key, as many vaults as you like.
\end{pocketlist}
\end{pocket}
@@ -677,7 +709,8 @@ The decoy is chosen when the key is made, and only then:
\begin{steps}
\item Run \cmd{vault genkey}. Type your \emph{real} passphrase, twice.
\item At \hl{Enter decoy passphrase (empty for none)}, type the decoy. Twice.
\item Run \cmd{vault init} as normal.
\item Run \cmd{vault init}, adding \cmd{--filler} with at least as much as
you plan to hide, say \cmd{--filler 5G}.
\item Add some decoy files, typing the \emph{decoy} passphrase when asked.
\item Add your real files, typing the \emph{real} passphrase.
\end{steps}
@@ -704,8 +737,9 @@ there's nothing to safely ``upgrade''.
\item \textbf{The vault can't tell.} Real and decoy entries are sealed to
different keys, but they're the same size, the same shape, and all
dated 1970. Nothing on disk says which key an entry belongs to.
\item \textbf{The filler covers for you.} Every vault starts with 8 to 32
entries nobody can open. So ``some entries I can't open'' is true of
\item \textbf{The filler covers for you.} Every vault starts with at
least 8 to 32 entries nobody can open, and as much more as you ask
for with \cmd{--filler}. So ``some entries I can't open'' is true of
\emph{every} vault, decoy or not.
\item \textbf{The errors are identical.} A real file asked for by name with
the decoy passphrase gives exactly the same ``not in the vault'' as
@@ -734,10 +768,14 @@ one with two files called \file{test.txt}, is a neon sign.
\section{The honest limits}
\begin{headsup}
\textbf{Numbers can give you away.} Every file is one entry (one file in
\file{records/}), and filler adds at most 32. If the decoy shows 3 files but
\file{records/} holds 400, the filler can't explain the difference. Keep the decoy collection
large enough that the leftovers look like filler.
\textbf{Numbers can give you away.} Someone holding the decoy passphrase
can count the records and pieces they can't open. Without \cmd{--filler}
there are only a few tens of megabytes of filler, so if \file{records/} holds
400 entries, or \file{objects/} holds a 300\,MB video's 72 pieces, the
filler can't explain the difference. They can't see \emph{what} the
leftovers are, but they can see there's more than filler. Create the vault
with \cmd{--filler} at least as large as what you'll hide, and keep the
decoy collection believable next to it.
\end{headsup}
\begin{headsup}
@@ -950,8 +988,9 @@ point \cmd{--vault} at the backup copy and run \cmd{vault list} now and then.
\begin{tldr}
Each file gets its own random 256-bit key, wrapped with a hybrid of FrodoKEM
and ML-KEM, and is encrypted in authenticated 64\,KiB chunks. Catalogue
entries are padded to one fixed size, file contents to power-of-two buckets,
and every timestamp on disk is set to 1970. An attacker learns roughly how
entries are padded to one fixed size, file contents are cut into 4\,MiB
pieces with the last padded to a power-of-two bucket, and every timestamp on
disk is set to 1970. An attacker learns roughly how
much you store. They can delete or roll back entries, but not read or forge them.
\end{tldr}
@@ -983,26 +1022,60 @@ either one alone isn't enough.
note/.style={font=\footnotesize\color{vslate},anchor=west},
tree/.style={draw=vslate!45,line width=0.8pt}]
\node[dir] (root) at (0,0) {\tld/.vault/store/};
\node[f] (m) at (0.6,-0.7) {.vault}; \node[note] at (4.2,-0.7) {marker: ``VAULT 1''};
\node[f] (m) at (0.6,-0.7) {.vault}; \node[note] at (4.2,-0.7) {marker: ``VAULT 2''};
\node[f] (l) at (0.6,-1.3) {.lock}; \node[note] at (4.2,-1.3) {lock file, always empty};
\node[dir] (r) at (0.6,-1.9) {records/}; \node[note] at (4.2,-1.9) {one encrypted catalogue entry per file};
\node[dir] (r) at (0.6,-1.9) {records/0f/}; \node[note] at (4.2,-1.9) {one encrypted catalogue entry per file};
\node[f] (r1) at (1.2,-2.5) {0f1170a6\ldots}; \node[note] at (4.2,-2.5) {every one exactly 18,704 bytes};
\node[dir] (o) at (0.6,-3.1) {objects/}; \node[note] at (4.2,-3.1) {one encrypted file body per file};
\node[f] (o1) at (1.2,-3.7) {3e78275e\ldots}; \node[note] at (4.2,-3.7) {64\,KiB, 128\,KiB, 256\,KiB\ldots\ never in between};
\node[dir] (o) at (0.6,-3.1) {objects/3e/}; \node[note] at (4.2,-3.1) {file contents, in 4\,MiB pieces};
\node[f] (o1) at (1.2,-3.7) {3e78275e\ldots}; \node[note] at (4.2,-3.7) {64\,KiB, 128\,KiB\ldots\ 4\,MiB, never in between};
\draw[tree] (0.3,-0.25) |- (m.west); \draw[tree] (0.3,-0.7) |- (l.west); \draw[tree] (0.3,-1.3) |- (r.west);
\draw[tree] (0.3,-1.9) |- (o.west);
\draw[tree] (0.9,-2.15) |- (r1.west); \draw[tree] (0.9,-3.35) |- (o1.west);
\end{tikzpicture}
\end{center}
A \hl{record} holds one file's name, size, permissions, dates and the ID of
its object. It's padded to 4096 bytes before encryption, so every record is
the same size whatever the name. An \hl{object} holds the file's contents,
prefixed with its own ID and padded with random bytes up to the next power
of two, at least 64\,KiB.
A \hl{record} holds one file's name, size, permissions, dates and a secret
random \hl{seed}. It's padded to 4096 bytes before encryption, so every
record is the same size whatever the name. The file's contents are cut into
\hl{objects} of just under 4\,MiB each. Every object starts with its own ID,
the last one is padded with random bytes up to the next power of two (at
least 64\,KiB), and each is encrypted separately. The objects' names are
worked out from the seed, so only someone who can open the record can tell
which objects belong together.
\shot{on-disk}{Random names, identical sizes, and all ``modified'' at the dawn of Unix time}
\section{Why big files are cut up}
With one object per file, a 300\,MB video would sit in the folder as a
single 512\,MiB blob. Everyone could see there's one large file, roughly how
large, and, since filler is never that big, that it can't be filler. Cut
into pieces, the same video is 72 objects of exactly the same size as the
largest filler and as every other big file's pieces. Nothing on disk says
which pieces belong together, or where one file ends and the next begins.
It's not magic, and some padding and fluff is still doing the work:
\begin{itemize}
\item \textbf{Small files are still bucketed.} A file under 4\,MiB is one
object padded to 64\,KiB, 128\,KiB, 256\,KiB and so on, exactly as
before. The last piece of a big file is padded the same way, so no
file wastes more than 4\,MiB, where a single padded blob could
waste nearly half its size.
\item \textbf{Filler is still the cover story.} The random entries
\cmd{init} writes are the reason ``objects I can't open'' is
unremarkable. By default there are only a few tens of megabytes of
them, enough to hide a few files, not gigabytes. \cmd{vault init
--filler SIZE} writes more, shaped like a real collection of
files, at the cost of that much disk.
\item \textbf{Totals still show.} The number of objects times 4\,MiB is
a fair estimate of how much you store.
\item \textbf{Timing still shows.} Someone who watches the folder change,
or reads a sync service's upload log, sees 72 objects appear together
and can guess they're one file. The pieces hide a file's size from a
snapshot, not from someone watching you add it.
\end{itemize}
\section{What an attacker learns}
\begin{vtable}{Y>{\centering\arraybackslash}p{22mm}}
@@ -1014,7 +1087,8 @@ Learn when a file was created or changed & \textcolor{vgreendeep}{\faIcon{times}
Tell real entries from decoy or filler & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
Alter a file without it being detected & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
Swap one file's contents for another's & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
Count entries, and see rough size buckets & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
Tell which pieces make up one file, from a single copy & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
Count files, and see roughly how much you store & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
See that the folder changed between two copies & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
Delete entries & \textcolor{vcoraldeep}{\faIcon{check}\ yes} \\
Put back an older copy of an entry (roll back) & \textcolor{vcoraldeep}{\faIcon{check}\ yes} \\
@@ -1029,15 +1103,17 @@ If the storage isn't under your control, keep an independent backup.
\section{What happens during \texttt{add}}
\begin{steps}
\item Read your file and encrypt its contents, plus random padding, to a
hidden temporary file in \file{objects/}.
\item Give it its random name. It's now safely stored, but nothing refers to it.
\item Encrypt a record pointing at it, the same way, into \file{records/}.
\item Pick a random seed. Read your file 4\,MiB at a time and encrypt each
piece, the last with random padding, to a hidden temporary file in
its \file{objects/} folder. Several pieces are written at once.
\item Give each piece the name the seed dictates. They're now safely
stored, but nothing refers to them.
\item Encrypt a record holding the seed, the same way, into \file{records/}.
\item Only then, delete any older version of the same name.
\end{steps}
Interrupt it anywhere and the worst case is an orphaned encrypted object that
nothing points to. You never end up with a record pointing at nothing, or
Interrupt it anywhere and the worst case is some orphaned encrypted objects
that nothing points to. You never end up with a record pointing at nothing, or
without either version.
\begin{squirrel}
@@ -1051,7 +1127,8 @@ is how bugs get in.
\begin{pocket}
\begin{pocketlist}
\item Hybrid post-quantum key wrapping, per-file keys, authenticated streaming encryption.
\item Fixed-size records, power-of-two objects, 1970 timestamps: metadata stays private.
\item Fixed-size records, 4\,MiB pieces, power-of-two tails, 1970 timestamps: metadata stays private.
\item Pieces hide a file's size in any one copy of the folder, not from someone watching it change.
\item Tampering is caught. Deletion and rollback are not, so keep backups.
\end{pocketlist}
\end{pocket}
@@ -1137,7 +1214,7 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
\begin{tcbraster}[raster columns=2,raster equal height=rows,raster column skip=8pt,raster row skip=8pt,
enhanced,colback=vmist,colframe=vline,boxrule=0.4pt,arc=4pt,left=9pt,right=9pt,top=7pt,bottom=7pt]
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault genkey}\\[2pt]\small Make a key. Asks for a passphrase and an optional decoy.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault init}\\[2pt]\small Make an empty vault, with filler.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault init}\\[2pt]\small Make an empty vault, with filler. \texttt{--filler 2G} for more.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault add FILE|DIR [FOLDER]}\\[2pt]\small Encrypt in, keeping the name. Replaces an existing name.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox}
@@ -1154,6 +1231,7 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
\textrm{(prompt)} & VAULT\_PASSPHRASE & asked on the terminal \\
\textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\
-o OUT & \textrm{---} & the file's own name, here \\
--filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\
\end{vtable}
\section*{Numbers worth knowing}
@@ -1162,7 +1240,9 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
Largest file & 1\,TiB \\
Longest stored name & 1024 bytes \\
Smallest object on disk & 64\,KiB \\
Filler entries in a new vault & 8 to 32 \\
Largest object on disk & 4\,MiB \\
Folders under \file{records/} and \file{objects/} & 256 each \\
Filler entries in a new vault & 8 to 32, or more with \cmd{--filler} \\
Key file size & 319 bytes \\
Exit codes & 0 ok, 1 error, 2 no command \\
\end{vtable}
@@ -1177,13 +1257,13 @@ Exit codes & 0 ok, 1 error, 2 no command \\
\item[Argon2id] A deliberately slow, memory-hard function that turns your passphrase into a key, making guessing expensive.
\item[Catalogue] The encrypted list of what's in your vault, stored as one record per file.
\item[Decoy passphrase] A second passphrase that opens a separate, harmless set of files.
\item[Entry] One record plus its object. Real, decoy and filler entries all look identical on disk.
\item[Entry] One record plus its objects. Real, decoy and filler entries all look identical on disk.
\item[Filler] Entries sealed to a key that was immediately destroyed. Nobody can open them. They make the decoy plausible.
\item[FrodoKEM, ML-KEM] Post-quantum key-encapsulation algorithms. vault uses both at once.
\item[Key file] The small file holding your identity, encrypted under your passphrase (and decoy).
\item[Object] The encrypted contents of one file, padded to a power-of-two size.
\item[Object] One encrypted piece of a file, 4\,MiB, or padded to a smaller power of two if it's the last piece.
\item[Post-quantum] Designed to stay secure against attackers with large quantum computers.
\item[Record] The encrypted catalogue entry for one file: its name, size, dates and object ID.
\item[Record] The encrypted catalogue entry for one file: its name, size, dates and the seed its objects' names come from.
\item[Vault] The folder where your encrypted files live.
\end{description}
+23 -2
View File
@@ -13,6 +13,7 @@ import (
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/andydixon/vault/internal/term"
@@ -20,7 +21,8 @@ import (
const usage = `Usage:
vault genkey create a new passphrase-protected key
vault init create a new, empty vault
vault init [--filler SIZE] create a new, empty vault, with at least
SIZE of filler (e.g. 500M, 2G)
vault add FILE|DIR [FOLDER] encrypt a file or directory into the vault,
optionally under a folder inside the vault
vault list [PREFIX] list stored files
@@ -62,6 +64,7 @@ func main() {
keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "")
vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "")
output := fset.String("o", "", "")
filler := fset.String("filler", "0", "")
pos, err := parseInterleaved(fset, args)
if err != nil {
fail("%v\n\n%s", err, usage)
@@ -87,7 +90,9 @@ func main() {
fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath)
case "init":
want(0, 0)
check(initStore(*vaultDir))
size, err := parseSize(*filler)
check(err)
check(initStore(*vaultDir, size))
fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir)
case "add":
want(1, 2)
@@ -144,6 +149,22 @@ func parseInterleaved(fset *flag.FlagSet, args []string) ([]string, error) {
}
}
// parseSize reads a byte count with an optional K, M, G or T suffix, in
// powers of 1024.
func parseSize(s string) (int64, error) {
num, shift := strings.TrimSuffix(strings.ToUpper(s), "B"), 0
for i, unit := range "KMGT" {
if strings.HasSuffix(num, string(unit)) {
num, shift = strings.TrimSuffix(num, string(unit)), 10*(i+1)
}
}
n, err := strconv.ParseInt(num, 10, 64)
if err != nil || n < 0 || n > fileLimit>>shift {
return 0, fmt.Errorf("invalid size %q", s)
}
return n << shift, nil
}
func defaultPath(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
+258 -118
View File
@@ -5,18 +5,23 @@ package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
mrand "math/rand/v2"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
@@ -26,31 +31,40 @@ import (
// On disk a vault is:
//
// DIR/.vault marker
// DIR/records/<id> one encrypted catalogue record per file
// DIR/objects/<id> one encrypted file body per record
// DIR/.vault marker
// DIR/records/<xx>/<id> one encrypted catalogue record per file
// DIR/objects/<xx>/<id> the encrypted body of a file, in one or more chunks
//
// IDs are random. Records are padded to one fixed size and objects to
// power-of-two buckets, so the storage reveals neither names, exact sizes nor
// timestamps. Every ciphertext's mtime is pinned to the epoch.
// <xx> is the first two hex digits of <id>, spreading entries over 256
// shards. IDs look random: record IDs are, and a file's object IDs derive
// from a secret seed in its record, so nothing on disk links a file's
// chunks. Records are padded to one fixed size. A body is cut into
// chunkSize objects and the last is padded to a power-of-two bucket, so
// every object is 64 KiB to 4 MiB whatever the file size. Every ciphertext's
// and shard's mtime is pinned to the epoch.
const (
markerName = ".vault"
markerContent = "VAULT 1\n"
recordsDir = "records"
objectsDir = "objects"
recordSize = 4096
nameLimit = 1024
fileLimit = int64(1) << 40
minPadding = int64(64 << 10)
idSize = 16
fillerMin = 8
fillerMax = 32
markerName = ".vault"
markerContent = "VAULT 2\n"
recordsDir = "records"
objectsDir = "objects"
recordSize = 4096
nameLimit = 1024
fileLimit = int64(1) << 40
minPadding = int64(64 << 10)
chunkSize = minPadding << 6 // 4 MiB, the largest bucket
chunkData = chunkSize - idSize
idSize = 16
objectSeedSize = 32
fillerMin = 8
fillerMax = 32
)
var epoch = time.Unix(0, 0)
type record struct {
Name string `json:"name"`
Object string `json:"object"`
Seed string `json:"seed"` // derives the file's object IDs
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
Modified time.Time `json:"modified"`
@@ -65,57 +79,68 @@ type store struct {
noSync bool // filler needs no per-file durability
}
func initStore(root string) error {
// initStore creates a vault at root holding at least filler bytes of filler.
func initStore(root string, filler int64) error {
if _, err := os.Stat(filepath.Join(root, markerName)); err == nil {
return fmt.Errorf("%s is already a vault", root)
}
for _, dir := range []string{root, filepath.Join(root, recordsDir), filepath.Join(root, objectsDir)} {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
// Every shard exists from the start, so the tree's shape says nothing.
for _, dir := range []string{recordsDir, objectsDir} {
for i := range 256 {
if err := os.MkdirAll(filepath.Join(root, dir, fmt.Sprintf("%02x", i)), 0o700); err != nil {
return err
}
}
}
if err := writeFiller(root); err != nil {
if err := writeFiller(root, filler); err != nil {
return err
}
for _, dir := range []string{recordsDir, objectsDir} {
shards, _ := filepath.Glob(filepath.Join(root, dir, "??"))
for _, p := range append(shards, filepath.Join(root, dir)) {
if err := os.Chtimes(p, epoch, epoch); err != nil {
return err
}
}
}
return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600)
}
// writeFiller stores a random number of record/object pairs sealed to a key
// that is thrown away. Every vault therefore holds entries nobody can open,
// so a file hidden from a decoy passphrase is indistinguishable from filler.
func writeFiller(root string) error {
// writeFiller stores random entries sealed to a key that is thrown away.
// Every vault therefore holds entries nobody can open, so a file hidden from
// a decoy passphrase is indistinguishable from filler. Filler entries are
// real entries, written by the same code as add, of log-uniformly random
// sizes: fillerMin to fillerMax of them, or more until they total target
// bytes.
func writeFiller(root string, target int64) error {
throwaway, err := pqcrypt.GeneratePQIdentity()
if err != nil {
return err
}
defer throwaway.Close()
s := &store{root: root, id: throwaway, noSync: true}
var b [2]byte
if _, err := rand.Read(b[:1]); err != nil {
return err
}
for range fillerMin + int(b[0])%(fillerMax-fillerMin+1) {
if _, err := rand.Read(b[:]); err != nil {
// Bigger targets allow bigger files, so the filler holds multi-chunk
// files in about the proportion a real collection would.
upper := float64(min(max(target/8, chunkSize), 1<<30))
count := fillerMin + mrand.IntN(fillerMax-fillerMin+1)
for i, total := 0, int64(0); i < count || total < target; i++ {
size := int64(1024 * math.Exp(mrand.Float64()*math.Log(upper/1024)))
seed, err := randomHex(objectSeedSize)
if err != nil {
return err
}
// Object buckets from 64 KiB to 4 MiB, weighted towards small ones.
objSize := minPadding << (min(b[0]%8, b[1]%8) % 7)
for _, part := range []struct {
dir string
size int64
}{{recordsDir, recordSize}, {objectsDir, objSize}} {
id, err := randomID()
if err != nil {
return err
}
err = s.writeCiphertext(part.dir, id, func(w io.Writer) error {
_, err := io.CopyN(w, rand.Reader, part.size)
return err
})
if err != nil {
return err
}
if err := s.writeObjects(rand.Reader, seed, size); err != nil {
return err
}
name, err := randomHex(8)
if err != nil {
return err
}
now := time.Now().UTC()
if err := s.writeRecord(&record{Name: name, Seed: seed, Size: size, Mode: 0o600, Modified: now, Added: now}); err != nil {
return err
}
total += size
}
return nil
}
@@ -128,17 +153,47 @@ func openStore(root string, id *pqcrypt.PQIdentity) (*store, error) {
return &store{root: root, id: id}, nil
}
func randomID() (string, error) {
b := make([]byte, idSize)
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func validID(s string) bool {
func validHex(s string, n int) bool {
b, err := hex.DecodeString(s)
return err == nil && len(b) == idSize && hex.EncodeToString(b) == s
return err == nil && len(b) == n && hex.EncodeToString(b) == s
}
func validID(s string) bool { return validHex(s, idSize) }
// objectID derives the ID of a file's i-th object from its record's seed.
// Without the seed the IDs are unlinkable, so a file's chunks can't be told
// apart from any other object.
func objectID(seed string, i int64) string {
key, _ := hex.DecodeString(seed)
m := hmac.New(sha256.New, key)
binary.Write(m, binary.BigEndian, i)
return hex.EncodeToString(m.Sum(nil)[:idSize])
}
// objectCount is how many objects hold a file of size bytes.
func objectCount(size int64) int64 {
return max(1, (size+chunkData-1)/chunkData)
}
func (s *store) path(dir, id string) string {
return filepath.Join(s.root, dir, id[:2], id)
}
// removeFile deletes dir/id and re-pins its shard's mtime.
func (s *store) removeFile(dir, id string) error {
p := s.path(dir, id)
if err := os.Remove(p); err != nil {
return err
}
return os.Chtimes(filepath.Dir(p), epoch, epoch)
}
func validName(name string) bool {
@@ -166,7 +221,8 @@ func paddedSize(size int64) int64 {
// writeCiphertext encrypts to dir/id via a temporary file, so a partial
// ciphertext never appears under a real ID.
func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) error {
dir = filepath.Join(s.root, dir)
dst := s.path(dir, id)
dir = filepath.Dir(dst)
tmp, err := os.CreateTemp(dir, ".pending-")
if err != nil {
return err
@@ -191,11 +247,13 @@ func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) err
if err := tmp.Close(); err != nil {
return err
}
epoch := time.Unix(0, 0)
if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil {
return err
}
if err := os.Rename(tmp.Name(), filepath.Join(dir, id)); err != nil || s.noSync {
if err := os.Rename(tmp.Name(), dst); err != nil {
return err
}
if err := os.Chtimes(dir, epoch, epoch); err != nil || s.noSync {
return err
}
return syncDir(dir)
@@ -224,7 +282,7 @@ func (s *store) writeRecord(r *record) error {
if _, err := rand.Read(plain[4+len(data):]); err != nil {
return err
}
if r.id, err = randomID(); err != nil {
if r.id, err = randomHex(idSize); err != nil {
return err
}
return s.writeCiphertext(recordsDir, r.id, func(w io.Writer) error {
@@ -236,25 +294,34 @@ func (s *store) writeRecord(r *record) error {
// catalogue decrypts every record this identity can open. Records sealed to
// a different key are skipped silently: they are simply not ours.
func (s *store) catalogue() (map[string][]*record, error) {
entries, err := os.ReadDir(filepath.Join(s.root, recordsDir))
shards, err := os.ReadDir(filepath.Join(s.root, recordsDir))
if err != nil {
return nil, err
}
byName := map[string][]*record{}
for _, e := range entries {
if !validID(e.Name()) {
continue
}
r, err := s.readRecord(e.Name())
var noMatch *pqcrypt.NoIdentityMatchError
if errors.As(err, &noMatch) {
for _, shard := range shards {
if !shard.IsDir() {
continue
}
entries, err := os.ReadDir(filepath.Join(s.root, recordsDir, shard.Name()))
if err != nil {
fmt.Fprintf(os.Stderr, "vault: skipping unreadable record %s: %v\n", e.Name(), err)
continue
return nil, err
}
for _, e := range entries {
if !validID(e.Name()) || e.Name()[:2] != shard.Name() {
continue
}
r, err := s.readRecord(e.Name())
var noMatch *pqcrypt.NoIdentityMatchError
if errors.As(err, &noMatch) {
continue
}
if err != nil {
fmt.Fprintf(os.Stderr, "vault: skipping unreadable record %s: %v\n", e.Name(), err)
continue
}
byName[r.Name] = append(byName[r.Name], r)
}
byName[r.Name] = append(byName[r.Name], r)
}
// Newest first, so [0] is current. Duplicates only exist after an
// interrupted replace; the next write of that name cleans them up.
@@ -265,7 +332,7 @@ func (s *store) catalogue() (map[string][]*record, error) {
}
func (s *store) readRecord(id string) (*record, error) {
f, err := os.Open(filepath.Join(s.root, recordsDir, id))
f, err := os.Open(s.path(recordsDir, id))
if err != nil {
return nil, err
}
@@ -291,7 +358,7 @@ func (s *store) readRecord(id string) (*record, error) {
if err := dec.Decode(r); err != nil {
return nil, err
}
if !validName(r.Name) || !validID(r.Object) || r.Size < 0 || r.Size > fileLimit {
if !validName(r.Name) || !validHex(r.Seed, objectSeedSize) || r.Size < 0 || r.Size > fileLimit {
return nil, errors.New("malformed record")
}
return r, nil
@@ -315,36 +382,23 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
if !info.Mode().IsRegular() || info.Size() > fileLimit {
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
}
objID, err := randomID()
seed, err := randomHex(objectSeedSize)
if err != nil {
return false, err
}
err = s.writeCiphertext(objectsDir, objID, func(w io.Writer) error {
// The object ID leads the plaintext so a record can only ever
// open the object it names, not one swapped in under its ID.
raw, _ := hex.DecodeString(objID)
if _, err := w.Write(raw); err != nil {
return err
if err := s.writeObjects(f, seed, info.Size()); err != nil {
s.dropObjects(seed, info.Size())
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
n, err := io.Copy(w, io.LimitReader(f, info.Size()))
if err != nil {
return err
}
if n != info.Size() {
return fmt.Errorf("%s changed while being added", src)
}
_, err = io.CopyN(w, rand.Reader, paddedSize(idSize+n)-idSize-n)
return err
})
if err != nil {
return false, err
}
r := &record{
Name: name, Object: objID, Size: info.Size(), Mode: uint32(info.Mode().Perm()),
Name: name, Seed: seed, Size: info.Size(), Mode: uint32(info.Mode().Perm()),
Modified: info.ModTime().UTC(), Added: time.Now().UTC(),
}
if err := s.writeRecord(r); err != nil {
os.Remove(filepath.Join(s.root, objectsDir, objID))
s.dropObjects(seed, info.Size())
return false, err
}
replaced := len(cat[name]) > 0
@@ -355,6 +409,67 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
return replaced, nil
}
// writeObjects reads size bytes from f into the objects derived from seed.
// Chunks are read in order but encrypted, written and synced by a few
// workers at once, so their fsyncs overlap instead of queueing.
func (s *store) writeObjects(f io.Reader, seed string, size int64) error {
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
fail := func(err error) {
mu.Lock()
defer mu.Unlock()
if firstErr == nil {
firstErr = err
}
}
failed := func() bool {
mu.Lock()
defer mu.Unlock()
return firstErr != nil
}
slots := make(chan struct{}, 8) // at most 8 chunks, 32 MiB, in memory
remaining := size
for i := range objectCount(size) {
if failed() {
break
}
// The object ID leads the plaintext so a record can only ever
// open the objects it derives, each in its place, not one
// swapped in under its ID.
id := objectID(seed, i)
part := min(remaining, chunkData)
remaining -= part
slots <- struct{}{}
buf := make([]byte, idSize+part)
hex.Decode(buf, []byte(id))
if _, err := io.ReadFull(f, buf[idSize:]); err != nil {
<-slots
fail(err)
break
}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-slots }()
err := s.writeCiphertext(objectsDir, id, func(w io.Writer) error {
if _, err := w.Write(buf); err != nil {
return err
}
_, err := io.CopyN(w, rand.Reader, paddedSize(int64(len(buf)))-int64(len(buf)))
return err
})
if err != nil {
fail(err)
}
}()
}
wg.Wait()
return firstErr
}
// remove deletes every record for name and, when dropObjects is set, any of
// their objects no other record still uses. Records go first, so an
// interruption only ever leaves an orphaned object.
@@ -363,16 +478,16 @@ func (s *store) remove(cat map[string][]*record, name string, dropObjects bool)
for n, rs := range cat {
for _, r := range rs {
if n != name {
inUse[r.Object] = true
inUse[r.Seed] = true
}
}
}
for _, r := range cat[name] {
if err := os.Remove(filepath.Join(s.root, recordsDir, r.id)); err != nil {
if err := s.removeFile(recordsDir, r.id); err != nil {
return err
}
if dropObjects && !inUse[r.Object] {
if err := os.Remove(filepath.Join(s.root, objectsDir, r.Object)); err != nil && !os.IsNotExist(err) {
if dropObjects && !inUse[r.Seed] {
if err := s.dropObjects(r.Seed, r.Size); err != nil {
return err
}
}
@@ -380,6 +495,17 @@ func (s *store) remove(cat map[string][]*record, name string, dropObjects bool)
return nil
}
// dropObjects deletes the objects holding a file of size bytes, skipping any
// already gone.
func (s *store) dropObjects(seed string, size int64) error {
for i := range objectCount(size) {
if err := s.removeFile(objectsDir, objectID(seed, i)); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func (s *store) lookup(name string) (map[string][]*record, *record, error) {
cat, err := s.catalogue()
if err != nil {
@@ -419,7 +545,7 @@ func (s *store) rename(oldName, newName string) error {
return s.remove(cat, oldName, false)
}
// get decrypts name to out. Nothing appears at out until the entire object,
// get decrypts name to out. Nothing appears at out until every object,
// padding included, has authenticated.
func (s *store) get(name, out string) error {
_, r, err := s.lookup(name)
@@ -429,34 +555,19 @@ func (s *store) get(name, out string) error {
if _, err := os.Lstat(out); err == nil {
return fmt.Errorf("%s already exists", out)
}
f, err := os.Open(filepath.Join(s.root, objectsDir, r.Object))
if err != nil {
return err
}
defer f.Close()
pr, err := pqcrypt.Decrypt(f, s.id)
if err != nil {
return err
}
head := make([]byte, idSize)
if _, err := io.ReadFull(pr, head); err != nil || hex.EncodeToString(head) != r.Object {
return errors.New("object does not match its catalogue record")
}
tmp, err := os.CreateTemp(filepath.Dir(out), ".vault-get-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.CopyN(tmp, pr, r.Size); err != nil {
return err
}
pad, err := io.Copy(io.Discard, pr)
if err != nil {
return err
}
if idSize+r.Size+pad != paddedSize(idSize+r.Size) {
return errors.New("object size does not match its catalogue record")
remaining := r.Size
for i := range objectCount(r.Size) {
part := min(remaining, chunkData)
if err := s.readObject(tmp, objectID(r.Seed, i), part); err != nil {
return err
}
remaining -= part
}
if err := tmp.Chmod(os.FileMode(r.Mode) & 0o777); err != nil {
return err
@@ -472,3 +583,32 @@ func (s *store) get(name, out string) error {
}
return os.Rename(tmp.Name(), out)
}
// readObject decrypts object id to w, checking it leads with its own ID and
// holds part bytes padded to their bucket.
func (s *store) readObject(w io.Writer, id string, part int64) error {
f, err := os.Open(s.path(objectsDir, id))
if err != nil {
return err
}
defer f.Close()
pr, err := pqcrypt.Decrypt(f, s.id)
if err != nil {
return err
}
head := make([]byte, idSize)
if _, err := io.ReadFull(pr, head); err != nil || hex.EncodeToString(head) != id {
return errors.New("object does not match its catalogue record")
}
if _, err := io.CopyN(w, pr, part); err != nil {
return err
}
pad, err := io.Copy(io.Discard, pr)
if err != nil {
return err
}
if idSize+part+pad != paddedSize(idSize+part) {
return errors.New("object size does not match its catalogue record")
}
return nil
}
+128 -8
View File
@@ -6,7 +6,9 @@ package main
import (
"bufio"
"bytes"
"crypto/rand"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -46,7 +48,7 @@ func TestStoreLifecycle(t *testing.T) {
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store")); err != nil {
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
t.Fatal(err)
}
s := newTestStore(t, keyPath, "pass")
@@ -84,9 +86,8 @@ func TestStoreLifecycle(t *testing.T) {
// Swapping one object for another must be detected.
cat, _ = s.catalogue()
d, c := cat["d"][0], cat["c"][0]
objects := filepath.Join(s.root, objectsDir)
data, _ := os.ReadFile(filepath.Join(objects, c.Object))
os.WriteFile(filepath.Join(objects, d.Object), data, 0o600)
data, _ := os.ReadFile(s.path(objectsDir, objectID(c.Seed, 0)))
os.WriteFile(s.path(objectsDir, objectID(d.Seed, 0)), data, 0o600)
if err := s.get("d", filepath.Join(dir, "swapped")); err == nil {
t.Fatal("swapped object was accepted")
}
@@ -105,7 +106,7 @@ func TestDecoy(t *testing.T) {
if err := writeKeyFile(keyPath, "real", "decoy"); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store")); err != nil {
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "in")
@@ -128,9 +129,96 @@ func TestDecoy(t *testing.T) {
if _, err := unlockKeyFile(keyPath, "neither"); err == nil {
t.Fatal("wrong passphrase unlocked the key")
}
entries, _ := os.ReadDir(filepath.Join(dir, "store", recordsDir))
if len(entries) < fillerMin+2 {
t.Fatalf("only %d records; filler missing", len(entries))
if n := len(ciphertexts(t, filepath.Join(dir, "store", recordsDir))); n < fillerMin+2 {
t.Fatalf("only %d records; filler missing", n)
}
}
// ciphertexts maps every file under dir to its size.
func ciphertexts(t *testing.T, dir string) map[string]int64 {
t.Helper()
sizes := map[string]int64{}
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
info, err := d.Info()
sizes[p] = info.Size()
return err
})
if err != nil {
t.Fatal(err)
}
return sizes
}
// TestChunks checks a file bigger than one chunk becomes unlinkable 4 MiB
// objects plus a bucket-sized tail, all in their shards, and that reordering
// the chunks is caught.
func TestChunks(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
t.Fatal(err)
}
s := newTestStore(t, keyPath, "pass")
objects := filepath.Join(s.root, objectsDir)
before := ciphertexts(t, objects)
src := filepath.Join(dir, "in")
want := make([]byte, 2*chunkData+100)
rand.Read(want)
os.WriteFile(src, want, 0o600)
cat, _ := s.catalogue()
if _, err := s.add(cat, src, "big"); err != nil {
t.Fatal(err)
}
cat, _ = s.catalogue()
r := cat["big"][0]
var sizes []int64
for i := range objectCount(r.Size) {
p := s.path(objectsDir, objectID(r.Seed, i))
if filepath.Base(filepath.Dir(p)) != filepath.Base(p)[:2] {
t.Fatalf("%s is in the wrong shard", p)
}
sizes = append(sizes, ciphertexts(t, p)[p])
}
if len(sizes) != 3 || sizes[0] != sizes[1] || sizes[2] >= sizes[0] {
t.Fatalf("object sizes = %v, want two full chunks and a smaller tail", sizes)
}
if n := len(ciphertexts(t, objects)) - len(before); n != 3 {
t.Fatalf("add wrote %d objects, want 3", n)
}
for p, size := range ciphertexts(t, objects) {
if size > sizes[0] {
t.Fatalf("%s is %d bytes, larger than a chunk", p, size)
}
}
out := filepath.Join(dir, "out")
if err := s.get("big", out); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(out); !bytes.Equal(got, want) {
t.Fatal("round trip mismatch")
}
a, b := s.path(objectsDir, objectID(r.Seed, 0)), s.path(objectsDir, objectID(r.Seed, 1))
os.Rename(a, a+".x")
os.Rename(b, a)
os.Rename(a+".x", b)
if err := s.get("big", filepath.Join(dir, "swapped")); err == nil {
t.Fatal("reordered chunks were accepted")
}
if err := s.delete("big"); err != nil {
t.Fatal(err)
}
if n := len(ciphertexts(t, objects)); n != len(before) {
t.Fatalf("%d objects after rm, want %d", n, len(before))
}
}
@@ -175,3 +263,35 @@ func TestLockExcludesOtherProcess(t *testing.T) {
}
}
}
func TestParseSize(t *testing.T) {
for in, want := range map[string]int64{"0": 0, "512": 512, "2k": 2 << 10, "500M": 500 << 20, "2G": 2 << 30, "1TB": 1 << 40} {
if got, err := parseSize(in); err != nil || got != want {
t.Errorf("parseSize(%q) = %d, %v; want %d", in, got, err, want)
}
}
for _, in := range []string{"", "G", "-1", "2X", "1.5G", "2T"} {
if _, err := parseSize(in); err == nil {
t.Errorf("parseSize(%q) accepted", in)
}
}
}
// TestFiller checks a filler target is met with entries shaped like real
// files: fixed-size records and objects no bigger than a chunk.
func TestFiller(t *testing.T) {
root := filepath.Join(t.TempDir(), "store")
if err := initStore(root, 24<<20); err != nil {
t.Fatal(err)
}
if n := len(ciphertexts(t, filepath.Join(root, recordsDir))); n < fillerMin {
t.Fatalf("%d filler records, want at least %d", n, fillerMin)
}
var total, largest int64
for _, size := range ciphertexts(t, filepath.Join(root, objectsDir)) {
total, largest = total+size, max(largest, size)
}
if total < 24<<20 || largest > chunkSize+chunkSize/64 {
t.Fatalf("filler objects total %d, largest %d", total, largest)
}
}