10 Commits
Author SHA1 Message Date
Andy DixonandClaude Opus 5.5 0d45ecea59 Add rm -r for folders, and make ls list one folder level
rm -r FOLDER deletes a folder and everything under it, records and
objects, so the space is freed. It lists what will go (the first 20
names, then a count) with the total size and asks on the terminal; no
answer or no terminal deletes nothing. -f skips the question and makes a
missing name a no-op, -rf and -fr combine them, and rm -r / is refused.
Objects still in use are worked out once per delete, not once per file.

ls now shows one level, like ls(1): sub-folders once with a trailing
slash, their total size and newest date, then the files, with names
relative to the folder. list keeps the recursive listing of full names
that scripts pipe through cut -c31-, and ls -r is the same.

Docs, man pages, the guide (new rm-r screenshot) and README follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:15:28 +01:00
Andy DixonandClaude Opus 5.5 730877823e Absorb: ask for the passphrase once the archive starts arriving
With gpg --decrypt x.tar.gpg | vault absorb, vault printed its prompt
while gpg was still writing to the terminal, which buried it, so the
command looked hung. Peek at standard input first: the upstream program
has finished with the terminal by the time data arrives, and an empty
pipe now fails without asking at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:27:07 +01:00
Andy DixonandClaude Opus 5.5 1403be8b1c Absorb: example of unpacking an existing archive
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:58:18 +01:00
Andy DixonandClaude Opus 5.5 fc5a856487 Guide: screenshots for absorb and mirror, new commands in the cheat sheet
shots.py now shoots absorb (a gzipped tarball and a tar pipe) and mirror
(a small site served on localhost). The locking section and the cheat
sheet list migrate, mirror and absorb, and the man pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:57:38 +01:00
Andy Dixon 55af82f95f Absorb functionality 2026-10-02 16:43:19 +01:00
Andy DixonandClaude Sonnet 5.5 92ff8546fd Document mirror, migrate and the man pages in the README
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 18:43:16 +01:00
Andy DixonandClaude Sonnet 5.5 0b3dcf08f2 Add man pages for vault and every command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:04:28 +01:00
Andy DixonandClaude Sonnet 5.5 10f2a88493 Add a mirror command that crawls a site into the vault
Single-threaded, same host only, Chrome user agent. Links in HTML and
CSS are rewritten to relative paths. Includes a man page and a guide note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:02:12 +01:00
Andy DixonandClaude Opus 5.5 e40a803367 Re-shoot the guide's screenshots and add one for migrate
shots.py gains a pseudo-terminal runner so it can answer migrate's
overwrite question, and the guide shows the resulting screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:36:12 +01:00
Andy DixonandClaude Opus 5.5 eb138b5524 Add URLs to add, and a migrate command that deletes originals
add now downloads http(s) URLs (with a Chrome user agent) through a
private temporary file, and treats ".", ".." and "/" as "this folder's
contents" so the destination folder is no longer dropped for "..".

migrate is add followed by deleting each original and any directories
left empty. It asks on the terminal before overwriting anything already
in the vault, refusing when there is no terminal, unless --yes is given.

Adding or migrating a directory skips the vault and key file if they
live inside it, so migrate can never ingest or delete them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:59:09 +01:00
36 changed files with 1812 additions and 68 deletions
+4 -1
View File
@@ -7,7 +7,10 @@ This file provides guidance to Claude Code when working with this repository.
vault is a post-quantum encrypted file vault: one `package main` at the repo vault is a post-quantum encrypted file vault: one `package main` at the repo
root that builds a single `vault` binary. Module `github.com/andydixon/vault`. root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
- `main.go` - CLI: `genkey`, `init`, `add`, `list`, `get`, `mv`, `rm`. - `main.go` - CLI: `genkey`, `init`, `add` (file, directory
or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar
on stdin, `absorb.go`), `ls` (one folder level), `list` (recursive),
`get`, `mv`, `rm` (`-r` for a folder, asks unless `-f`).
- `keyfile.go` - key file with two passphrase slots (Argon2id + - `keyfile.go` - key file with two passphrase slots (Argon2id +
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second 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. slot is either a decoy identity or random bytes; slot order is random.
+16 -1
View File
@@ -30,6 +30,12 @@ A full illustrated guide lives in `docs/guide`. Build the PDF with
`docs/guide/build-pdf.sh`, which fetches Tectonic if it isn't installed. `docs/guide/build-pdf.sh`, which fetches Tectonic if it isn't installed.
`docs/guide/shots.py` regenerates its screenshots from the real binary. `docs/guide/shots.py` regenerates its screenshots from the real binary.
## Man pages
`docs/*.1` holds `vault(1)` and one page per command (`vault-add(1)`,
`vault-mirror(1)` and so on). Install them with
`cp docs/*.1 ~/.local/share/man/man1/`.
## Use ## Use
```sh ```sh
@@ -39,12 +45,21 @@ 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 # stored as report.pdf
vault add report.pdf work/2026 # stored as work/2026/report.pdf vault add report.pdf work/2026 # stored as work/2026/report.pdf
vault add ./photos # every regular file under photos/ vault add ./photos # every regular file under photos/
vault list # everything vault add https://example.com/a.pdf # download and store a URL
vault migrate ./photos # like add, then delete the originals
vault mirror https://example.com/ # crawl a site into example.com/, links rewritten
tar -cz photos | vault absorb backup # every file in a tar (or .tar.gz) under backup/
vault absorb backup < photos.tar.gz # same, from an existing archive
vault ls # the top level: folders (ending in /) and files
vault ls work # one level down, inside work/
vault list # every file, all the way down, full names
vault list work # everything under work/ vault list work # everything under work/
vault get work/2026/report.pdf # writes ./report.pdf vault get work/2026/report.pdf # writes ./report.pdf
vault get report.pdf -o copy.pdf vault get report.pdf -o copy.pdf
vault mv report.pdf old/report.pdf vault mv report.pdf old/report.pdf
vault rm old/report.pdf vault rm old/report.pdf
vault rm -r old # a whole folder; lists it and asks first
vault rm -rf old # the same, without asking
``` ```
Commands that change the vault take an exclusive lock on `DIR/.lock`, and Commands that change the vault take an exclusive lock on `DIR/.lock`, and
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
)
// absorb reads a tar archive, gzipped or not, from r and adds every regular
// file in it beneath dest, streaming each straight into the vault so no
// plaintext touches the disk. Other entries, and names that would escape
// dest, are reported and skipped.
func absorb(s *store, cat map[string][]*record, r io.Reader, dest string) error {
br := bufio.NewReader(r)
if magic, _ := br.Peek(2); bytes.Equal(magic, []byte{0x1f, 0x8b}) {
zr, err := gzip.NewReader(br)
if err != nil {
return err
}
defer zr.Close()
r = zr
} else {
r = br
}
tr := tar.NewReader(r)
n := 0
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("reading archive: %w", err)
}
if hdr.Typeflag != tar.TypeReg {
if hdr.Typeflag != tar.TypeDir {
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", hdr.Name)
}
continue
}
name := strings.TrimPrefix(path.Clean("/"+hdr.Name), "/")
if strings.Contains(hdr.Name, "\\") || name == "" || name != path.Clean(strings.TrimPrefix(strings.TrimPrefix(hdr.Name, "./"), "/")) {
fmt.Fprintf(os.Stderr, "skipping %s: unsafe name\n", hdr.Name)
continue
}
name = path.Join(dest, name)
replaced, err := s.addReader(cat, tr, hdr.Size, hdr.FileInfo().Mode(), hdr.ModTime, name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s: archive ends early", hdr.Name)
}
if err != nil {
return err
}
verb := "added"
if replaced {
verb = "replaced"
}
fmt.Fprintf(os.Stderr, "%s %s\n", verb, name)
n++
}
if n == 0 {
return errors.New("no files in archive")
}
return nil
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+80 -1
View File
@@ -16,6 +16,7 @@ import glob
import grp import grp
import html import html
import os import os
import pty
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -94,6 +95,28 @@ class Sandbox:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
return self.tidy(p.stdout) return self.tidy(p.stdout)
def run_tty(self, command, passphrase, answer):
"""Run on a pseudo-terminal, typing answer at vault's [y/N] question."""
env = self.env({"VAULT_PASSPHRASE": passphrase})
pid, fd = pty.fork()
if pid == 0:
os.chdir(self.home)
os.execve("/bin/bash", ["bash", "-c", command], env)
out = b""
while b"[y/N]" not in out:
out += os.read(fd, 1024)
os.write(fd, answer.encode() + b"\n")
try:
while True:
chunk = os.read(fd, 1024)
if not chunk:
break
out += chunk
except OSError:
pass
os.waitpid(pid, 0)
return self.tidy(out.decode().replace("\r\n", "\n"))
def tidy(self, text): def tidy(self, text):
owner = " %s %s " % (getpass.getuser(), grp.getgrgid(os.getgid()).gr_name) owner = " %s %s " % (getpass.getuser(), grp.getgrgid(os.getgid()).gr_name)
return text.replace(self.home, SHOWN_HOME).replace(owner, " sam sam ").rstrip("\n") return text.replace(self.home, SHOWN_HOME).replace(owner, " sam sam ").rstrip("\n")
@@ -243,8 +266,9 @@ def main():
shoot("add", t) shoot("add", t)
t = Term(box) t = Term(box)
t.cmd("vault ls", P)
t.cmd("vault ls letters", P)
t.cmd("vault list", P) t.cmd("vault list", P)
t.cmd("vault list letters", P)
shoot("list", t) shoot("list", t)
t = Term(box) t = Term(box)
@@ -278,6 +302,61 @@ def main():
t.cmd("find ~/.vault/store/records -type f | wc -l") t.cmd("find ~/.vault/store/records -type f | wc -l")
shoot("decoy-hidden", t) shoot("decoy-hidden", t)
# migrate: one file is already in the vault, so it asks first
box.write("Scans/receipts/march.pdf", os.urandom(48_113))
box.write("Scans/receipts/april.pdf", os.urandom(51_870))
box.run("vault add Scans/receipts/march.pdf receipts", P)
t = Term(box)
t.lines.append(prompt() + '<span class="c">vault migrate Scans/receipts</span>')
for line in box.run_tty("vault migrate Scans/receipts", P, "y").split("\n"):
esc = html.escape(line)
if "[y/N]" in line:
esc = '<span class="hl">%s</span>' % esc
t.lines.append(esc)
t.cmd("ls Scans")
shoot("migrate", t)
# rm -r: lists the folder, asks, then frees the space
t = Term(box)
t.cmd("vault ls", P)
t.lines.append(prompt() + '<span class="c">vault rm -r receipts</span>')
for line in box.run_tty("vault rm -r receipts", P, "y").split("\n"):
esc = html.escape(line)
if "[y/N]" in line:
esc = '<span class="hl">%s</span>' % esc
t.lines.append(esc)
t.cmd("vault ls", P)
shoot("rm-r", t)
# absorb: a tar archive piped in, here pulled from a gzipped tarball
box.write("Projects/website/index.html", "<h1>hello</h1>\n")
box.write("Projects/website/css/site.css", "body{}\n")
box.write("Projects/website/img/logo.png", os.urandom(24_311))
t = Term(box)
t.cmd("tar -czf website.tar.gz -C Projects website")
t.cmd("vault absorb backups < website.tar.gz", P)
t.cmd("tar -c recipes | vault absorb old", P)
t.cmd("vault list backups", P)
shoot("absorb", t)
# mirror: a tiny local site, served on localhost
site = os.path.join(box.root, "site")
for rel, body in {"index.html": '<a href="/about.html">About</a> <link href="/style.css">',
"about.html": '<a href="/">Home</a> <img src="/img/me.png">',
"style.css": "body{background:url(/img/bg.png)}",
"img/me.png": "png", "img/bg.png": "png"}.items():
os.makedirs(os.path.dirname(os.path.join(site, rel)), exist_ok=True)
with open(os.path.join(site, rel), "w") as f:
f.write(body)
srv = subprocess.Popen([sys.executable, "-m", "http.server", "8000", "--bind", "127.0.0.1"],
cwd=site, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1)
t = Term(box)
t.cmd("vault mirror http://localhost:8000/ sites", P)
t.cmd("vault get sites/localhost:8000/about.html && cat about.html", P)
shoot("mirror", t)
srv.terminate()
# errors # errors
t = Term(box) t = Term(box)
t.cmd("vault list", "hunter2", highlight=("incorrect",)) t.cmd("vault list", "hunter2", highlight=("incorrect",))
+108 -28
View File
@@ -63,7 +63,7 @@ Got two minutes? Here's all of it:
$ vault genkey # make your key (pick a good passphrase) $ vault genkey # make your key (pick a good passphrase)
$ vault init # make your vault $ vault init # make your vault
$ vault add taxes.pdf # put something in $ vault add taxes.pdf # put something in
$ vault list # see what's in there $ vault ls # see what's in there
$ vault get taxes.pdf # get it back out $ vault get taxes.pdf # get it back out
\end{shell} \end{shell}
@@ -511,18 +511,19 @@ type \cmd{--vault} again.
% ====================================================================== % ======================================================================
\chapter{Everyday moves} \chapter{Everyday moves}
\tagline{add, list, get, mv, rm: the five commands you'll actually use} \tagline{add, ls, get, mv, rm: the five commands you'll actually use}
\begin{tldr} \begin{tldr}
\cmd{add} puts files in (optionally into a folder), \cmd{list} shows them, \cmd{add} puts files in (optionally into a folder), \cmd{ls} shows them a
\cmd{get} takes one back out, \cmd{mv} renames, \cmd{rm} deletes. Each asks folder at a time, \cmd{get} takes one back out, \cmd{mv} renames, \cmd{rm}
deletes (a whole folder with \cmd{-r}). Each asks
for your passphrase. That's the whole daily workflow. for your passphrase. That's the whole daily workflow.
\end{tldr} \end{tldr}
\section{Putting things in: \texttt{add}} \section{Putting things in: \texttt{add}}
\begin{shell} \begin{shell}
$ vault add FILE-OR-FOLDER [FOLDER-IN-VAULT] $ vault add FILE-OR-FOLDER-OR-URL [FOLDER-IN-VAULT]
\end{shell} \end{shell}
Give it a file, and it goes in under its own name. Give it a second argument, Give it a file, and it goes in under its own name. Give it a second argument,
@@ -540,6 +541,8 @@ Notice what happened there:
\file{id/passport-scan.jpg}. \file{id/passport-scan.jpg}.
\item Adding the folder \file{Documents/letters} kept its own name and \item Adding the folder \file{Documents/letters} kept its own name and
structure: \file{letters/bank.txt}, \file{letters/landlord.txt}. structure: \file{letters/bank.txt}, \file{letters/landlord.txt}.
\item Adding \file{.} (or \file{..}) brings in that folder's
\emph{contents}, subfolders and all, without a folder name of its own.
\end{itemize} \end{itemize}
\begin{headsup} \begin{headsup}
@@ -553,22 +556,81 @@ Adding a name that already exists \hl{replaces} it, and vault tells you so.
The old version is deleted \emph{after} the new one is safely stored, so a The old version is deleted \emph{after} the new one is safely stored, so a
crash halfway through never leaves you with neither. crash halfway through never leaves you with neither.
\subsection{Straight from the web}
Give \cmd{add} an \file{http://} or \file{https://} address and vault
downloads it (as Chrome, so fussy sites play along) and stores it under the
last part of the address, such as \file{report.pdf}. The download sits in a
private temporary file only until it's encrypted.
\subsection{Copying a website: \texttt{mirror}}
\cmd{mirror} takes an \file{http://} or \file{https://} address and copies
the whole site, one request at a time, into a folder named after its host.
Links inside pages and stylesheets are rewritten to relative paths, so the
copy still browses once you get it out. Only the same host is followed.
\shot{mirror}{Mirroring a small site. The links in each page now point at the copy.}
\subsection{From a tar archive: \texttt{absorb}}
\cmd{absorb} reads a tar archive, gzipped or not, from standard input and
stores every plain file in it, optionally under a folder:
\begin{verbatim}
tar -cz photos | vault absorb backup
ssh server 'tar -c /etc' | vault absorb server1
vault absorb old < archive.tar.gz
\end{verbatim}
Each file is encrypted straight from the stream, so nothing is unpacked to
disk first. Directories need no entry of their own; links, devices and any
name that tries to climb out with \file{..} are skipped with a note.
vault asks for your passphrase only once the archive starts arriving, so
when it comes out of something that asks first, like
\cmd{gpg --decrypt secrets.tar.gpg | vault absorb}, gpg's prompt comes
and goes before vault's appears. If the
archive stops halfway through a file, that file isn't stored and vault
stops; everything before it is already safely in.
\shot{absorb}{A gzipped tarball and a plain tar stream, both absorbed.}
\subsection{Moving in: \texttt{migrate}}
\cmd{migrate} works exactly like \cmd{add}, but deletes each original once
it's safely stored, then tidies away any folders left empty. It won't take a
URL; there's no original to delete.
Because the originals go, \cmd{migrate} checks first: if anything would
\hl{replace} a file already in the vault, it lists those names and asks
before touching anything. Answer anything but \texttt{y} and nothing changes.
With no terminal to ask on, the answer is no. For scripts, \texttt{--yes}
answers yes up front.
\shot{migrate}{Moving a folder in. One file was already there, so vault asked first.}
\subsection{What gets skipped} \subsection{What gets skipped}
When adding a folder, anything that isn't a plain file, such as symbolic When adding a folder, anything that isn't a plain file, such as symbolic
links, sockets or devices, is skipped with a note. Files larger than 1\,TiB are links, sockets or devices, is skipped with a note. So are the vault and key
themselves, should they live inside the folder, so \cmd{migrate} can never
eat them. Files larger than 1\,TiB are
refused. (If you have a single 1\,TiB file, we have questions.) refused. (If you have a single 1\,TiB file, we have questions.)
\section{Seeing what's inside: \texttt{list}} \section{Seeing what's inside: \texttt{ls} and \texttt{list}}
\begin{shell} \begin{shell}
$ vault list # everything $ vault ls # the top level: folders (ending in /) and files
$ vault list letters # just what's inside letters/ $ vault ls letters # one level down, inside letters/
$ vault list # every file, all the way down, full names
\end{shell} \end{shell}
Each line shows the size, the file's \emph{own} last-modified time from when \cmd{ls} works like the shell's: it shows one folder at a time. Each
you added it, and its name, sorted alphabetically. sub-folder appears once, ending in \file{/}, with the total size of
everything inside it and its newest date. Files show their size and their
\emph{own} last-modified time from when you added them. Folders come first,
then files. Keep going down with \cmd{vault ls letters/2025}.
\shot{list}{The full catalogue, then just one folder} \cmd{list} is the whole catalogue in one go: every file under a folder,
with its full name, sorted. It's the one to pipe into other commands, and
\cmd{vault ls -r} does the same.
\shot{list}{One level at a time with \cmd{ls}, then everything with \cmd{list}}
\begin{whyso} \begin{whyso}
\textbf{Why does \texttt{list} need my passphrase?} Because the names are \textbf{Why does \texttt{list} need my passphrase?} Because the names are
@@ -612,25 +674,31 @@ and it doesn't re-encrypt the file, so it's instant however big the file is.
\section{Deleting: \texttt{rm}} \section{Deleting: \texttt{rm}}
\begin{shell} \begin{shell}
$ vault rm NAME $ vault rm NAME # one file
$ vault rm -r FOLDER # a folder and everything in it; asks first
$ vault rm -rf FOLDER # the same, without asking
\end{shell} \end{shell}
Removes the file's catalogue entry and its encrypted contents. One file per Removes the file's catalogue entry and every encrypted piece of it, so the
command, on purpose. There's no recursive delete to fat-finger. space comes back straight away.
\shot{mv-rm}{Replace, move, delete, and the result} \shot{mv-rm}{Replace, move, delete, and the result}
\begin{danger} \begin{danger}
\textbf{\texttt{rm} is immediate and there's no undo.} No recycle bin, no \textbf{\texttt{rm} is immediate and there's no undo.} No recycle bin. If it
``are you sure?''. If it matters, make sure it's backed up somewhere first. matters, make sure it's backed up somewhere first.
\end{danger} \end{danger}
\begin{squirrel} \subsection{Deleting a whole folder}
Need to delete a whole folder? Loop over \cmd{list}. The name starts at \cmd{rm -r} deletes a folder and everything under it. Because that can be a
character 31 of each line, so:\\ lot, it first lists what's going (the first 20 names, then a count) with
\cmd{vault list old | cut -c31- | while read -r f; do vault rm "\$f"; done}\\ the total size, and asks. Anything but \texttt{y} deletes nothing, and so
With \env{VAULT\_PASSPHRASE} set, or it'll ask every time. See chapter 7. does having no terminal to ask on. \cmd{-f} skips the question, and also
\end{squirrel} means ``don't complain if there's nothing there'', so \cmd{rm -rf} is the
one for scripts. \cmd{vault rm -r /} is refused rather than emptying the
vault.
\shot{rm-r}{A folder deleted: vault lists it, asks, then frees the space}
\section{Flags work anywhere} \section{Flags work anywhere}
@@ -647,7 +715,8 @@ $ vault add report.pdf --vault=~/work
\begin{pocketlist} \begin{pocketlist}
\item \cmd{add FILE [FOLDER]}: keeps the file's name, optionally in a folder. Folders add recursively. \item \cmd{add FILE [FOLDER]}: keeps the file's name, optionally in a folder. Folders add recursively.
\item Adding an existing name replaces it, safely. \item Adding an existing name replaces it, safely.
\item \cmd{list [FOLDER]}, \cmd{get NAME [-o OUT]}, \cmd{mv OLD NEW}, \cmd{rm NAME}. \item \cmd{ls [FOLDER]} one level at a time, \cmd{list [FOLDER]} everything.
\item \cmd{get NAME [-o OUT]}, \cmd{mv OLD NEW}, \cmd{rm NAME}, \cmd{rm -r FOLDER}.
\item \cmd{get} never overwrites and never publishes a half-checked file. \item \cmd{get} never overwrites and never publishes a half-checked file.
\item \cmd{rm} has no undo. \item \cmd{rm} has no undo.
\end{pocketlist} \end{pocketlist}
@@ -911,7 +980,8 @@ covered.
\section{Locking is automatic} \section{Locking is automatic}
Commands that change the vault (\cmd{add}, \cmd{mv}, \cmd{rm}) take an Commands that change the vault (\cmd{add}, \cmd{migrate}, \cmd{mirror},
\cmd{absorb}, \cmd{mv}, \cmd{rm}) take an
\hl{exclusive} lock. Commands that only read (\cmd{list}, \cmd{get}) take a \hl{exclusive} lock. Commands that only read (\cmd{list}, \cmd{get}) take a
\hl{shared} one, so several can read at once. If a command has to wait, it \hl{shared} one, so several can read at once. If a command has to wait, it
says so, then carries on by itself: says so, then carries on by itself:
@@ -1159,6 +1229,8 @@ NAME: not in the vault & Check the exact name with \cmd{vault list}: it's case-s
invalid vault path "\ldots" & Names can't start with \cmd{/} or \cmd{../}, end in \cmd{/}, or contain backslashes or control characters. \\ invalid vault path "\ldots" & Names can't start with \cmd{/} or \cmd{../}, end in \cmd{/}, or contain backslashes or control characters. \\
\ldots: not a regular file of at most 1\,TiB & You tried to add a device, a socket or a gigantic file. \\ \ldots: not a regular file of at most 1\,TiB & You tried to add a device, a socket or a gigantic file. \\
\ldots\ changed while being added & The file was being written while vault read it. Try again once it's finished. \\ \ldots\ changed while being added & The file was being written while vault read it. Try again once it's finished. \\
refusing to remove the whole vault & \cmd{rm -r /} (or an empty folder name). Name the folder you mean. \\
rm cancelled; nothing changed & You didn't answer \texttt{y} to \cmd{rm -r}, or there was no terminal to ask on. \cmd{-f} skips the question. \\
skipping \ldots: not a regular file & Adding a folder: a symlink or special file was skipped. Everything else went in. \\ skipping \ldots: not a regular file & Adding a folder: a symlink or special file was skipped. Everything else went in. \\
passphrases do not match & The two entries differed. Nothing was created; run it again. \\ passphrases do not match & The two entries differed. Nothing was created; run it again. \\
passphrase must not be empty & Blank passphrases aren't allowed, for good reason. \\ passphrase must not be empty & Blank passphrases aren't allowed, for good reason. \\
@@ -1215,12 +1287,17 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
enhanced,colback=vmist,colframe=vline,boxrule=0.4pt,arc=4pt,left=9pt,right=9pt,top=7pt,bottom=7pt] 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 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. \texttt{--filler 2G} for more.\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 add FILE|DIR|URL [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 migrate FILE|DIR [FOLDER]}\\[2pt]\small Like add, then delete the originals. Asks before replacing.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mirror URL [FOLDER]}\\[2pt]\small Crawl a website in, links rewritten to work offline.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault absorb [FOLDER] < TAR}\\[2pt]\small Every file in a tar or .tar.gz from standard input.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault ls [FOLDER]}\\[2pt]\small One folder: sub-folders (ending in /) and files.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Every file under it, full names. Pipes cleanly.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm NAME}\\[2pt]\small Delete one file. No undo.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm [-r] [-f] NAME}\\[2pt]\small Delete a file, or a folder with -r. Frees the space. No undo.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox}
\end{tcbraster} \end{tcbraster}
\section*{Options and variables} \section*{Options and variables}
@@ -1232,6 +1309,9 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
\textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\ \textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\
-o OUT & \textrm{---} & the file's own name, here \\ -o OUT & \textrm{---} & the file's own name, here \\
--filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\ --filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\
--yes & \textrm{---} & ask, for \cmd{migrate} \\
-r & \textrm{---} & \cmd{rm}: one file; \cmd{ls}: one level \\
-f & \textrm{---} & \cmd{rm}: ask before \cmd{-r}, fail if missing \\
\end{vtable} \end{vtable}
\section*{Numbers worth knowing} \section*{Numbers worth knowing}
+84
View File
@@ -0,0 +1,84 @@
.TH VAULT-ABSORB 1 "October 2026" "vault" "User Commands"
.SH NAME
vault-absorb \- add the files in a tar archive to an encrypted vault
.SH SYNOPSIS
.B vault absorb
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ FOLDER ]
.B <
.I ARCHIVE
.SH DESCRIPTION
.B vault absorb
reads a tar archive from standard input and stores every regular file in
it, beneath
.I FOLDER
if one is given, keeping the paths, permissions and modification times
recorded in the archive. A gzip-compressed archive is detected and
unpacked automatically.
.PP
Each file is encrypted straight from the stream; nothing is unpacked to
disk. Anything already stored under the same name is replaced.
.PP
Directory entries are not stored (directories exist implicitly). Symbolic
and hard links, devices and other special entries are reported on standard
error and skipped, as is any entry whose name would escape
.I FOLDER
through
.BR .. .
A leading
.B ./
or
.B /
is dropped.
.PP
The passphrase prompt reads from the terminal, not standard input, so
it still works with an archive piped in. It waits until the archive starts
arriving, so a program feeding the pipe, such as
.BR gpg (1)
asking for its own passphrase, has finished with the terminal first.
An empty pipe fails without asking at all.
The vault is locked exclusively for the whole run.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXIT STATUS
0 if at least one file was stored and the archive was read to the end.
1 if the archive is malformed or ends early, holds no regular files, or
anything fails; files stored before the failure stay in the vault.
.SH EXAMPLES
Unpack an existing archive into the vault under
.BR backup/ :
.PP
.RS
vault absorb backup < photos.tar.gz
.RE
.PP
Store a directory under
.BR backup/photos/ :
.PP
.RS
tar \-cz photos | vault absorb backup
.RE
.PP
Pull a remote directory straight into the vault:
.PP
.RS
ssh server 'tar \-C /etc \-c .' | vault absorb server1/etc
.RE
.SH SEE ALSO
.BR vault (1),
.BR vault\-add (1),
.BR tar (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+47
View File
@@ -0,0 +1,47 @@
.TH VAULT-ADD 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-add \- encrypt files, directories or URLs into the vault
.SH SYNOPSIS
.B vault add
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.IR FILE | DIR | URL
.RI [ FOLDER ]
.SH DESCRIPTION
Encrypts a file into the vault, named after its base name. A directory is
added recursively, keeping its structure beneath its own name; a directory
named "." or ".." adds its contents directly. Non-regular files, the vault
and the key are skipped. A stored name that already exists is replaced.
.PP
An \fBhttp://\fR or \fBhttps://\fR \fIURL\fR is downloaded with a Chrome user
agent and stored under the last element of its final (post-redirect) path, or
the host name if there is none. Only a 200 response is accepted.
.PP
\fIFOLDER\fR places the result inside a folder in the vault. File contents
are cut into 4 MiB chunks, the last bucket-padded.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXAMPLES
.nf
vault add report.pdf work/2026
vault add ./photos
vault add https://example.com/paper.pdf papers
.fi
.SH SEE ALSO
.BR vault (1),
.BR vault\-migrate (1),
.BR vault\-mirror (1),
.BR vault\-absorb (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+32
View File
@@ -0,0 +1,32 @@
.TH VAULT-GENKEY 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-genkey \- create a new vault key
.SH SYNOPSIS
.B vault genkey
.RB [ \-\-key
.IR PATH ]
.SH DESCRIPTION
Creates the key file, prompting twice for a passphrase, then for an optional
decoy passphrase (empty for none). Both passphrase slots are sealed with
Argon2id and XChaCha20-Poly1305, are the same size, and are stored in random
order; the second holds either a decoy identity or random bytes. It refuses to
overwrite an existing file. No seed or private key is ever written unencrypted.
.PP
When \fBVAULT_PASSPHRASE\fR is set, no prompts are made and the decoy comes
from \fBVAULT_DECOY_PASSPHRASE\fR, or is omitted.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Where to write the key. Default: $VAULT_KEY, else ~/.vault/key.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.TP
.B VAULT_DECOY_PASSPHRASE
Decoy passphrase; must differ from the passphrase.
.SH SEE ALSO
.BR vault (1),
.BR vault\-init (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+32
View File
@@ -0,0 +1,32 @@
.TH VAULT-GET 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-get \- decrypt a stored file
.SH SYNOPSIS
.B vault get
.RB [ \-o
.IR OUTPUT ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I NAME
.SH DESCRIPTION
Decrypts \fINAME\fR. Nothing appears at the output path until every object has
authenticated. The stored mode and modification time are restored. It refuses
to overwrite an existing file. Takes a shared lock.
.SH OPTIONS
.TP
.BI \-o " OUTPUT"
Output path. Default: the base name of \fINAME\fR in the current directory.
.PP
See also \fB\-\-key\fR and \fB\-\-vault\fR in
.BR vault (1).
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH SEE ALSO
.BR vault (1),
.BR vault\-list (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+27
View File
@@ -0,0 +1,27 @@
.TH VAULT-INIT 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-init \- create an empty vault
.SH SYNOPSIS
.B vault init
.RB [ \-\-vault
.IR DIR ]
.RB [ \-\-filler
.IR SIZE ]
.SH DESCRIPTION
Creates the vault directory, with \fBrecords/\fR and \fBobjects/\fR each
sharded into 256 subdirectories. It writes at least \fISIZE\fR bytes of
unreadable filler so that real files cannot be told apart from it by anyone
lacking the right passphrase. Filler is always written, even when \fISIZE\fR is 0.
.SH OPTIONS
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.TP
.BI \-\-filler " SIZE"
Minimum filler, in bytes, with an optional K, M, G or T suffix (powers of
1024), e.g. 500M or 2G. Default: 0.
.SH SEE ALSO
.BR vault (1),
.BR vault\-genkey (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+91
View File
@@ -0,0 +1,91 @@
.TH VAULT-LIST 1 "October 2026" "vault" "User Commands"
.SH NAME
vault-list, vault-ls \- list files and folders in the vault
.SH SYNOPSIS
.B vault ls
.RB [ \-r ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ FOLDER ]
.br
.B vault list
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ FOLDER ]
.SH DESCRIPTION
Both print one line per entry: human-readable size, modification time
(local, minute resolution) and name, in fixed-width columns so the name
always starts at character 31.
.PP
.B vault ls
shows one level, like
.BR ls (1):
each sub-folder of
.I FOLDER
(the top of the vault if none is given) once, with a trailing
.BR / ,
and the files directly in it.
A folder's size is the total of everything under it and its time is the
newest among them. Names are relative to
.IR FOLDER ,
so
.B vault ls photos
then
.B vault ls photos/2025
walks down the tree. Folders are listed first, then files, each sorted by
name. If
.I FOLDER
is a file's name, that file alone is shown.
.PP
.B vault list
shows every file at or under
.IR FOLDER ,
recursively, with full names, sorted. It is the form to pipe into other
commands, and
.B vault ls \-r
is the same.
.PP
Records the passphrase cannot open are skipped silently. Takes a shared lock.
.SH OPTIONS
.TP
.B \-r
.BR ls :
list everything under
.I FOLDER
with full names, as
.B vault list
does.
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXIT STATUS
0 on success. 1 if
.B vault ls
is given a
.I FOLDER
with nothing in or under it, or on any other error.
.SH EXAMPLES
.RS
vault ls
.br
vault ls photos/2025
.br
vault list | cut \-c31\- # every name, one per line
.RE
.SH SEE ALSO
.BR vault (1),
.BR vault\-get (1),
.BR vault\-rm (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+41
View File
@@ -0,0 +1,41 @@
.TH VAULT-MIGRATE 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-migrate \- move files into the vault, deleting the originals
.SH SYNOPSIS
.B vault migrate
.RB [ \-\-yes ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.IR FILE | DIR
.RI [ FOLDER ]
.SH DESCRIPTION
Works like
.BR vault\-add (1)
but deletes each original once it is stored, then any directories left empty.
Directories still holding skipped files stay. A URL cannot be migrated.
.PP
Before storing anything it lists the vault names that would be overwritten and
asks for confirmation on the terminal. With no terminal the answer is no, and
nothing changes.
.SH OPTIONS
.TP
.B \-\-yes
Overwrite without asking.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH SEE ALSO
.BR vault (1),
.BR vault\-add (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+86
View File
@@ -0,0 +1,86 @@
.TH VAULT-MIRROR 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-mirror \- mirror a website into an encrypted vault
.SH SYNOPSIS
.B vault mirror
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I URL
.RI [ FOLDER ]
.SH DESCRIPTION
.B vault mirror
crawls the website at
.I URL
and stores every page and asset it finds in the vault, beneath
.IR FOLDER / HOST
(or just
.I HOST
if no folder is given).
.PP
The crawl is single threaded: one request at a time, in breadth-first order.
It follows only links on the same host as
.IR URL ,
and requests are sent with a Chrome user agent string.
.PP
Links in HTML
.RB ( href ,
.BR src ,
.BR action
and
.B poster
attributes) and in CSS
.RB ( url(...) )
that point into the mirror are rewritten to relative paths, so the copy
browses correctly once fetched out of the vault. Links to other hosts, and
fragments, are left as they were.
.PP
Pages are named after their URL path. A path that is empty or ends in
.B /
becomes
.BR index.html ;
a query string is appended as
.BI _ query
(URL-escaped). Anything already stored under the same name is replaced.
.PP
A page that fails to download, or answers with anything but
.BR "200 OK" ,
is reported on standard error and skipped; the crawl carries on.
The vault is locked exclusively for the whole run.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXAMPLES
Mirror a site into the vault under
.BR example.com/ :
.PP
.RS
vault mirror https://example.com/
.RE
.PP
Mirror it under a folder, then fetch the front page:
.PP
.RS
vault mirror https://example.com/ archive/2026
.br
vault get archive/2026/example.com/index.html
.RE
.SH LIMITATIONS
Links are found by pattern matching, not by parsing HTML: URLs assembled by
scripts, and
.B srcset
lists, are neither followed nor rewritten. There is no depth limit, rate
limit or robots.txt handling; only mirror sites you may copy.
.SH SEE ALSO
.BR vault (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+33
View File
@@ -0,0 +1,33 @@
.TH VAULT-MV 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-mv \- rename a stored file
.SH SYNOPSIS
.B vault mv
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I OLD NEW
.br
.B vault rename
\&...
.SH DESCRIPTION
Renames \fIOLD\fR to \fINEW\fR, which may be in another folder. Fails if
\fINEW\fR exists or is not a valid vault path. File contents are not
re-encrypted.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH SEE ALSO
.BR vault (1),
.BR vault\-rm (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+90
View File
@@ -0,0 +1,90 @@
.TH VAULT-RM 1 "October 2026" "vault" "User Commands"
.SH NAME
vault-rm \- delete stored files or folders
.SH SYNOPSIS
.B vault rm
.RB [ \-f ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I NAME
.br
.B vault rm \-r
.RB [ \-f ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I FOLDER
.br
.B vault delete
\&...
.SH DESCRIPTION
Deletes the stored file
.I NAME
from the vault: its catalogue record and every encrypted object holding
its contents, so the space is freed on disk straight away.
.PP
With
.BR \-r ,
deletes
.I FOLDER
and everything at or under it. Because there is no undo, it first lists
what it will delete (the first 20 names, then a count) with the total size,
and asks on the terminal. Anything but
.B y
or
.B yes
deletes nothing, and so does having no terminal to ask on. A trailing
.B /
on
.I FOLDER
is ignored, and
.B vault rm \-r /
is refused rather than emptying the vault.
.PP
Each deleted file is reported on standard error, and
.B \-r
ends with the number of files and their total size. The vault is locked
exclusively for the whole run. A key sees only its own files, so the
decoy passphrase never deletes real files, or the other way round.
.SH OPTIONS
.TP
.B \-r
Delete a folder and everything under it.
.TP
.B \-f
Do not ask before a recursive delete, and do not fail when there is
nothing to delete.
.TP
.BR \-rf ", " \-fr
Both of the above.
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXIT STATUS
0 if everything asked for was deleted, or there was nothing to delete with
.BR \-f .
1 if the name is not in the vault, the delete was declined, or anything
fails; files already deleted stay deleted.
.SH EXAMPLES
.RS
vault rm notes.md
.br
vault rm \-r old/2024 # asks first
.br
vault rm \-rf tmp # no questions
.RE
.SH SEE ALSO
.BR vault (1),
.BR vault\-list (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+113
View File
@@ -0,0 +1,113 @@
.TH VAULT 1 "September 2026" "vault" "User Commands"
.SH NAME
vault \- post-quantum encrypted file vault
.SH SYNOPSIS
.B vault
.I command
.RI [ options ]
.RI [ arguments ]
.SH DESCRIPTION
.B vault
stores files in a directory of encrypted, fixed-size records and chunks.
Encryption is a hybrid of FrodoKEM-640 and ML-KEM-768 with ChaCha20-Poly1305.
The key file is protected by an Argon2id-derived passphrase key.
File names, sizes and contents are only visible to a holder of the key and
passphrase. Ciphertext sizes and timestamps are normalised to hide
metadata from the storage host.
.PP
Options may appear before, between or after the arguments.
.SH COMMANDS
.TP
.BR vault\-genkey (1)
Create a new passphrase-protected key file.
.TP
.BR vault\-init (1)
Create a new, empty vault.
.TP
.BR vault\-add (1)
Encrypt a file, directory or downloaded URL into the vault.
.TP
.BR vault\-migrate (1)
Like add, then delete each original.
.TP
.BR vault\-mirror (1)
Crawl a website into the vault.
.TP
.BR vault\-absorb (1)
Add every file in a tar archive read from standard input.
.TP
.BR vault\-list (1)
.B ls
lists one folder, with sub-folders marked;
.B list
lists every file under a folder.
.TP
.BR vault\-get (1)
Decrypt a stored file.
.TP
.BR vault\-mv (1)
Rename a stored file (alias:
.BR rename ).
.TP
.BR vault\-rm (1)
Delete a stored file, or a whole folder with
.B \-r
(alias:
.BR delete ).
.TP
.B help
Print usage.
.SH OPTIONS
These apply to every command.
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_KEY
Default key file.
.TP
.B VAULT_DIR
Default vault directory.
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.TP
.B VAULT_DECOY_PASSPHRASE
Decoy passphrase used by
.BR "vault genkey" .
.SH LOCKING
Commands that change the vault hold an exclusive lock on
.IR DIR/.lock ;
readers hold a shared lock. The lock is taken after the passphrase prompt.
.SH DECOY PASSPHRASE
.B genkey
can add a second passphrase. It unlocks a separate identity, so every
command run with it sees and changes only the decoy files, and the real
files stay invisible. The key file does not reveal whether a decoy exists,
and
.B init
fills each vault with unreadable filler, so hidden files look like filler to
anyone holding only the decoy passphrase.
.SH EXIT STATUS
0 on success; 1 on error (diagnostics on standard error); 2 when run with no command.
.SH EXAMPLES
.nf
vault genkey
vault init \-\-filler 500M
vault add report.pdf work/2026
vault list work
vault get work/2026/report.pdf \-o /tmp/report.pdf
.fi
.SH FILES
.TP
.I ~/.vault/key
Default key file.
.TP
.I ~/.vault/store
Default vault directory.
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+19
View File
@@ -1,9 +1,11 @@
package term package term
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
"strings"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -72,6 +74,23 @@ func ReadSecret(prompt string) (s []byte, err error) {
return return
} }
// Confirm asks a yes/no question on the terminal. Only "y" or "yes" is yes.
func Confirm(prompt string) (yes bool, err error) {
err = WithTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && line == "" {
return err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
yes = true
}
return nil
})
return
}
// ReadPassphrase returns the passphrase from the environment variable env if // ReadPassphrase returns the passphrase from the environment variable env if
// it is set, otherwise prompts on the terminal. // it is set, otherwise prompts on the terminal.
func ReadPassphrase(prompt, env string) (string, error) { func ReadPassphrase(prompt, env string) (string, error) {
+332 -25
View File
@@ -5,16 +5,20 @@
package main package main
import ( import (
"bufio"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"net/http"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/andydixon/vault/internal/term" "github.com/andydixon/vault/internal/term"
) )
@@ -23,12 +27,29 @@ const usage = `Usage:
vault genkey create a new passphrase-protected key vault genkey create a new passphrase-protected key
vault init [--filler SIZE] create a new, empty vault, with at least vault init [--filler SIZE] create a new, empty vault, with at least
SIZE of filler (e.g. 500M, 2G) SIZE of filler (e.g. 500M, 2G)
vault add FILE|DIR [FOLDER] encrypt a file or directory into the vault, vault add FILE|DIR|URL [FOLDER] encrypt a file, a directory (recursively)
or a downloaded URL into the vault,
optionally under a folder inside the vault optionally under a folder inside the vault
vault list [PREFIX] list stored files vault migrate FILE|DIR [FOLDER] like add, then delete each original once
[--yes] it is stored; asks before overwriting
anything already in the vault, unless
--yes is given
vault mirror URL [FOLDER] crawl a website (same host only, one request
at a time) into FOLDER/HOST, rewriting its
links to relative paths
vault absorb [FOLDER] read a tar archive (optionally gzipped) from
standard input and add every file in it,
optionally under a folder inside the vault
vault ls [FOLDER] list one folder: its files, and its
sub-folders (ending in /) with their
total size; -r lists everything under it
vault list [FOLDER] list every stored file under FOLDER,
with full names
vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name) vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name)
vault mv OLD NEW rename a stored file vault mv OLD NEW rename a stored file
vault rm NAME delete a stored file vault rm NAME delete a stored file
vault rm -r FOLDER [-f] delete a folder and everything under it;
asks first unless -f (or -rf) is given
Options (any command): Options (any command):
--key PATH key file (default $VAULT_KEY, else ~/.vault/key) --key PATH key file (default $VAULT_KEY, else ~/.vault/key)
@@ -65,6 +86,11 @@ func main() {
vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "") vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "")
output := fset.String("o", "", "") output := fset.String("o", "", "")
filler := fset.String("filler", "0", "") filler := fset.String("filler", "0", "")
yes := fset.Bool("yes", false, "")
// rm's -r and -f; Go's flag package has no combined short flags, so
// -rf and -fr are spelled out.
recurse, force := fset.Bool("r", false, ""), fset.Bool("f", false, "")
rf, fr := fset.Bool("rf", false, ""), fset.Bool("fr", false, "")
pos, err := parseInterleaved(fset, args) pos, err := parseInterleaved(fset, args)
if err != nil { if err != nil {
fail("%v\n\n%s", err, usage) fail("%v\n\n%s", err, usage)
@@ -94,7 +120,7 @@ func main() {
check(err) check(err)
check(initStore(*vaultDir, size)) check(initStore(*vaultDir, size))
fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir) fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir)
case "add": case "add", "migrate":
want(1, 2) want(1, 2)
dest := "" dest := ""
if len(pos) == 2 { if len(pos) == 2 {
@@ -103,7 +129,58 @@ func main() {
s := open(*keyPath, *vaultDir, true) s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue() cat, err := s.catalogue()
check(err) check(err)
check(addPath(s, cat, pos[0], dest)) if isURL(pos[0]) {
if cmd == "migrate" {
fail("cannot migrate a URL; use add")
}
check(addURL(s, cat, pos[0], dest))
break
}
// Never ingest, or delete, the vault or its key.
var skip []os.FileInfo
for _, p := range []string{*vaultDir, *keyPath} {
if fi, err := os.Stat(p); err == nil {
skip = append(skip, fi)
}
}
overwrite := confirmOverwrite
if *yes {
overwrite = func([]string) bool { return true }
}
check(addPath(s, cat, pos[0], dest, cmd == "migrate", skip, overwrite))
case "mirror":
want(1, 2)
if !isURL(pos[0]) {
fail("mirror needs an http:// or https:// URL")
}
dest := ""
if len(pos) == 2 {
dest = strings.Trim(pos[1], "/")
}
s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue()
check(err)
check(mirror(s, cat, pos[0], dest))
case "absorb":
want(0, 1)
if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
fail("absorb reads a tar archive from standard input; pipe one in")
}
dest := ""
if len(pos) == 1 {
dest = strings.Trim(pos[0], "/")
}
// Wait for the archive to start arriving before asking for the
// passphrase, so a program feeding the pipe (gpg, ssh) has finished
// with the terminal and cannot bury the prompt.
in := bufio.NewReader(os.Stdin)
if _, err := in.Peek(1); err != nil {
fail("nothing to absorb on standard input")
}
s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue()
check(err)
check(absorb(s, cat, in, dest))
case "list", "ls": case "list", "ls":
want(0, 1) want(0, 1)
prefix := "" prefix := ""
@@ -112,6 +189,10 @@ func main() {
} }
cat, err := open(*keyPath, *vaultDir, false).catalogue() cat, err := open(*keyPath, *vaultDir, false).catalogue()
check(err) check(err)
if cmd == "ls" && !*recurse {
check(ls(os.Stdout, cat, prefix))
break
}
list(cat, prefix) list(cat, prefix)
case "get": case "get":
want(1, 1) want(1, 1)
@@ -125,7 +206,41 @@ func main() {
check(open(*keyPath, *vaultDir, true).rename(pos[0], pos[1])) check(open(*keyPath, *vaultDir, true).rename(pos[0], pos[1]))
case "rm", "delete": case "rm", "delete":
want(1, 1) want(1, 1)
check(open(*keyPath, *vaultDir, true).delete(pos[0])) recursive, forced := *recurse || *rf || *fr, *force || *rf || *fr
name := pos[0]
if recursive {
name = strings.Trim(name, "/")
if name == "" {
fail("refusing to remove the whole vault")
}
}
s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue()
check(err)
var names []string
if recursive {
names = under(cat, name)
} else if len(cat[name]) > 0 {
names = []string{name}
}
if len(names) == 0 {
if forced {
break
}
fail("%s: not in the vault", name)
}
var size int64
for _, n := range names {
size += cat[n][0].Size
}
if recursive && !forced && !confirm("these files will be deleted, with no undo:", names,
fmt.Sprintf("delete %d file(s), %s? [y/N]", len(names), humanSize(size))) {
fail("rm cancelled; nothing changed (-f skips the question)")
}
check(s.removeAll(cat, names, func(n string) { fmt.Fprintf(os.Stderr, "removed %s\n", n) }))
if recursive {
fmt.Fprintf(os.Stderr, "removed %d file(s), %s\n", len(names), humanSize(size))
}
case "help", "-h", "--help": case "help", "-h", "--help":
fmt.Println(usage) fmt.Println(usage)
default: default:
@@ -228,33 +343,160 @@ func decoyPassphrase() string {
return string(p) return string(p)
} }
// addPath adds a file, or every regular file under a directory, beneath dest. // addPath adds a file, or every regular file under a directory, beneath dest,
func addPath(s *store, cat map[string][]*record, src, dest string) error { // keeping the directory structure. A directory named "." or ".." adds its
// contents directly beneath dest. With migrate, each original is deleted
// once stored, then any directories left empty, and nothing is stored
// unless overwrite approves the vault names it would replace, if any.
func addPath(s *store, cat map[string][]*record, src, dest string, migrate bool, skip []os.FileInfo, overwrite func([]string) bool) error {
info, err := os.Stat(src) info, err := os.Stat(src)
if err != nil { if err != nil {
return err return err
} }
base := filepath.Base(filepath.Clean(src)) base := filepath.Base(filepath.Clean(src))
if !info.IsDir() { if base == "." || base == ".." || base == string(filepath.Separator) {
return addOne(s, cat, src, path.Join(dest, base)) base = ""
} }
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { skipped := func(fi os.FileInfo) bool {
if err != nil || d.IsDir() { for _, s := range skip {
return err if os.SameFile(fi, s) {
return true
}
} }
if !d.Type().IsRegular() { return false
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", p) }
if skipped(info) {
return fmt.Errorf("%s is the vault or its key", src)
}
type item struct{ src, name string }
var items []item
var dirs []string
if !info.IsDir() {
items = append(items, item{src, path.Join(dest, filepath.Base(src))})
} else {
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if fi, err := d.Info(); err == nil && skipped(fi) {
fmt.Fprintf(os.Stderr, "skipping %s: the vault or its key\n", p)
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
dirs = append(dirs, p)
return nil
}
if !d.Type().IsRegular() {
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", p)
return nil
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
items = append(items, item{p, path.Join(dest, base, filepath.ToSlash(rel))})
return nil return nil
} })
rel, err := filepath.Rel(src, p)
if err != nil { if err != nil {
return err return err
} }
return addOne(s, cat, p, path.Join(dest, base, filepath.ToSlash(rel))) }
}) if migrate {
var existing []string
for _, it := range items {
if len(cat[it.name]) > 0 {
existing = append(existing, it.name)
}
}
if len(existing) > 0 && !overwrite(existing) {
return errors.New("migrate cancelled; nothing changed")
}
}
for _, it := range items {
if err := addOne(s, cat, it.src, it.name, migrate); err != nil {
return err
}
}
if migrate {
// Children come after their parents in walk order, so go backwards.
// Directories still holding skipped files stay.
for i := len(dirs) - 1; i >= 0; i-- {
os.Remove(dirs[i])
}
}
return nil
} }
func addOne(s *store, cat map[string][]*record, src, name string) error { // confirmOverwrite lists the vault names a migrate would replace and asks
// on the terminal whether to go ahead. No terminal means no.
func confirmOverwrite(names []string) bool {
return confirm("these files are already in the vault and would be overwritten:", names,
fmt.Sprintf("overwrite %d file(s)? [y/N]", len(names)))
}
// confirm prints intro and names, the first 20 of them, then asks question
// on the terminal. No terminal means no.
func confirm(intro string, names []string, question string) bool {
fmt.Fprintln(os.Stderr, intro)
for i, name := range names {
if i == 20 {
fmt.Fprintf(os.Stderr, " … and %d more\n", len(names)-i)
break
}
fmt.Fprintf(os.Stderr, " %s\n", name)
}
ok, err := term.Confirm(question)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot ask for confirmation: %v\n", err)
}
return ok
}
func isURL(s string) bool {
return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://")
}
const chromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
// addURL downloads rawURL to a private temporary file and adds it beneath
// dest, named after the last element of the final (post-redirect) URL path.
func addURL(s *store, cat map[string][]*record, rawURL, dest string) error {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", chromeUA)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", rawURL, resp.Status)
}
name := path.Base(resp.Request.URL.Path)
if name == "/" || name == "." {
name = resp.Request.URL.Hostname()
}
tmp, err := os.CreateTemp("", "vault-download-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, resp.Body); err != nil {
return fmt.Errorf("%s: %w", rawURL, err)
}
if t, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil {
os.Chtimes(tmp.Name(), t, t)
}
return addOne(s, cat, tmp.Name(), path.Join(dest, name), false)
}
func addOne(s *store, cat map[string][]*record, src, name string, migrate bool) error {
replaced, err := s.add(cat, src, name) replaced, err := s.add(cat, src, name)
if err != nil { if err != nil {
return err return err
@@ -263,18 +505,83 @@ func addOne(s *store, cat map[string][]*record, src, name string) error {
if replaced { if replaced {
verb = "replaced" verb = "replaced"
} }
if migrate {
if err := os.Remove(src); err != nil {
return err
}
verb = "migrated"
}
fmt.Fprintf(os.Stderr, "%s %s\n", verb, name) fmt.Fprintf(os.Stderr, "%s %s\n", verb, name)
return nil return nil
} }
func list(cat map[string][]*record, prefix string) { // ls prints one level of the vault, like ls(1): the files directly in
var names []string // folder, and each sub-folder once, marked with a trailing slash and shown
for name := range cat { // with the total size and newest date of everything under it. Folders come
if prefix == "" || name == prefix || strings.HasPrefix(name, prefix+"/") { // first. A folder that is a file's name lists just that file.
names = append(names, name) func ls(w io.Writer, cat map[string][]*record, folder string) error {
type entry struct {
size int64
modified time.Time
}
entries := map[string]*entry{}
for name, rs := range cat {
rel := name
switch {
case folder == "":
case name == folder:
rel = path.Base(name)
case strings.HasPrefix(name, folder+"/"):
rel = name[len(folder)+1:]
default:
continue
}
if i := strings.IndexByte(rel, '/'); i >= 0 {
rel = rel[:i+1]
}
e := entries[rel]
if e == nil {
e = &entry{}
entries[rel] = e
}
r := rs[0]
e.size += r.Size
if r.Modified.After(e.modified) {
e.modified = r.Modified
} }
} }
sort.Strings(names) if len(entries) == 0 && folder != "" {
return fmt.Errorf("%s: not in the vault", folder)
}
names := make([]string, 0, len(entries))
for name := range entries {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
di, dj := strings.HasSuffix(names[i], "/"), strings.HasSuffix(names[j], "/")
if di != dj {
return di
}
return names[i] < names[j]
})
for _, name := range names {
e := entries[name]
fmt.Fprintf(w, "%10s %s %s\n", humanSize(e.size), e.modified.Local().Format("2006-01-02 15:04"), name)
}
return nil
}
// list prints every file at or under prefix, with its full name.
func list(cat map[string][]*record, prefix string) {
var names []string
if prefix == "" {
for name := range cat {
names = append(names, name)
}
sort.Strings(names)
} else {
names = under(cat, prefix)
}
for _, name := range names { for _, name := range names {
r := cat[name][0] r := cat[name][0]
fmt.Printf("%10s %s %s\n", humanSize(r.Size), r.Modified.Local().Format("2006-01-02 15:04"), name) fmt.Printf("%10s %s %s\n", humanSize(r.Size), r.Modified.Local().Format("2006-01-02 15:04"), name)
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strings"
)
var (
// Attribute values in HTML, and url(...) in CSS: group 1 is everything
// up to the URL, group 2 is the URL.
htmlURLs = regexp.MustCompile(`(?i)(\b(?:href|src|action|poster)\s*=\s*["'])([^"']*)`)
cssURLs = regexp.MustCompile(`(?i)(url\(\s*["']?)([^"')\s]+)`)
)
// mirror crawls the site at rawURL, one request at a time, staying on its
// host, and adds every page and asset beneath dest/host. Links in HTML and
// CSS that point into the mirror are rewritten to relative paths, so the
// copy browses correctly once fetched out of the vault. A page that fails
// is reported and skipped.
// ponytail: regex rewriting, so links built by scripts or in srcset are left
// as they were; swap in golang.org/x/net/html if that matters.
func mirror(s *store, cat map[string][]*record, rawURL, dest string) error {
start, err := url.Parse(rawURL)
if err != nil {
return err
}
host := strings.ToLower(start.Host)
root := path.Join(dest, host)
seen := map[string]bool{}
queue := []*url.URL{start}
seen[key(start)] = true
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
err := mirrorOne(s, cat, u, host, root, func(l *url.URL) {
if k := key(l); !seen[k] {
seen[k] = true
queue = append(queue, l)
}
})
if err != nil {
fmt.Fprintf(os.Stderr, "skipping %s: %v\n", u, err)
}
}
return nil
}
// key identifies a page independent of its fragment.
func key(u *url.URL) string {
c := *u
c.Fragment = ""
return c.String()
}
// localPath is where u lives inside the mirror, relative to its root.
func localPath(u *url.URL) string {
p := strings.TrimPrefix(u.Path, "/")
if p == "" || strings.HasSuffix(p, "/") {
p += "index.html"
}
if u.RawQuery != "" {
p += "_" + url.QueryEscape(u.RawQuery)
}
return p
}
// relative gives the link from the file "from" to the file "to", both
// slash-separated paths relative to the same root.
func relative(from, to string) string {
f := strings.Split(path.Dir(from), "/")
t := strings.Split(to, "/")
if f[0] == "." {
f = nil
}
for len(f) > 0 && len(t) > 1 && f[0] == t[0] {
f, t = f[1:], t[1:]
}
return strings.Repeat("../", len(f)) + strings.Join(t, "/")
}
func mirrorOne(s *store, cat map[string][]*record, u *url.URL, host, root string, enqueue func(*url.URL)) error {
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", chromeUA)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
self := localPath(u)
// rewrite maps a link found in the page to a relative one, queueing it.
rewrite := func(re *regexp.Regexp, text string) string {
return re.ReplaceAllStringFunc(text, func(m string) string {
sub := re.FindStringSubmatch(m)
l, err := u.Parse(html.UnescapeString(sub[2]))
if err != nil || (l.Scheme != "http" && l.Scheme != "https") || strings.ToLower(l.Host) != host {
return m
}
frag := l.Fragment
l.Fragment = ""
if l.Path == "" && l.RawQuery == "" {
l.Path = "/"
}
enqueue(l)
r := relative(self, localPath(l))
if frag != "" {
r += "#" + frag
}
return sub[1] + r
})
}
switch ct := strings.ToLower(resp.Header.Get("Content-Type")); {
case strings.HasPrefix(ct, "text/html"):
body = []byte(rewrite(cssURLs, rewrite(htmlURLs, string(body))))
case strings.HasPrefix(ct, "text/css"):
body = []byte(rewrite(cssURLs, string(body)))
}
tmp, err := os.CreateTemp("", "vault-mirror-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := tmp.Write(body); err != nil {
return err
}
return addOne(s, cat, tmp.Name(), path.Join(root, self), false)
}
+72 -12
View File
@@ -14,6 +14,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"math" "math"
mrand "math/rand/v2" mrand "math/rand/v2"
"os" "os"
@@ -382,30 +383,43 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
if !info.Mode().IsRegular() || info.Size() > fileLimit { if !info.Mode().IsRegular() || info.Size() > fileLimit {
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src) return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
} }
replaced, err := s.addReader(cat, f, info.Size(), info.Mode().Perm(), info.ModTime(), name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return replaced, err
}
// addReader stores size bytes from r as name, like add. Reading fewer than
// size bytes stores nothing and fails with io.ErrUnexpectedEOF or io.EOF.
func (s *store) addReader(cat map[string][]*record, r io.Reader, size int64, mode fs.FileMode, mtime time.Time, name string) (bool, error) {
if !validName(name) {
return false, fmt.Errorf("invalid vault path %q", name)
}
if size < 0 || size > fileLimit {
return false, fmt.Errorf("%s: larger than 1 TiB", name)
}
seed, err := randomHex(objectSeedSize) seed, err := randomHex(objectSeedSize)
if err != nil { if err != nil {
return false, err return false, err
} }
if err := s.writeObjects(f, seed, info.Size()); err != nil { if err := s.writeObjects(r, seed, size); err != nil {
s.dropObjects(seed, info.Size()) s.dropObjects(seed, size)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return false, err return false, err
} }
r := &record{ rec := &record{
Name: name, Seed: seed, Size: info.Size(), Mode: uint32(info.Mode().Perm()), Name: name, Seed: seed, Size: size, Mode: uint32(mode.Perm()),
Modified: info.ModTime().UTC(), Added: time.Now().UTC(), Modified: mtime.UTC(), Added: time.Now().UTC(),
} }
if err := s.writeRecord(r); err != nil { if err := s.writeRecord(rec); err != nil {
s.dropObjects(seed, info.Size()) s.dropObjects(seed, size)
return false, err return false, err
} }
replaced := len(cat[name]) > 0 replaced := len(cat[name]) > 0
if err := s.remove(cat, name, true); err != nil { if err := s.remove(cat, name, true); err != nil {
return replaced, err return replaced, err
} }
cat[name] = []*record{r} cat[name] = []*record{rec}
return replaced, nil return replaced, nil
} }
@@ -523,7 +537,53 @@ func (s *store) delete(name string) error {
if err != nil { if err != nil {
return err return err
} }
return s.remove(cat, name, true) return s.removeAll(cat, []string{name}, func(string) {})
}
// under returns the names in cat at or beneath the folder prefix, sorted.
func under(cat map[string][]*record, prefix string) []string {
var names []string
for name := range cat {
if name == prefix || strings.HasPrefix(name, prefix+"/") {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// removeAll deletes every record for names and the objects they hold, then
// drops them from cat, calling done after each name. Like remove, but it
// works out once which objects other records still use, so deleting a large
// folder stays linear.
func (s *store) removeAll(cat map[string][]*record, names []string, done func(name string)) error {
gone := map[string]bool{}
for _, name := range names {
gone[name] = true
}
inUse := map[string]bool{}
for name, rs := range cat {
if !gone[name] {
for _, r := range rs {
inUse[r.Seed] = true
}
}
}
for _, name := range names {
for _, r := range cat[name] {
if err := s.removeFile(recordsDir, r.id); err != nil {
return err
}
if !inUse[r.Seed] {
if err := s.dropObjects(r.Seed, r.Size); err != nil {
return err
}
}
}
delete(cat, name)
done(name)
}
return nil
} }
func (s *store) rename(oldName, newName string) error { func (s *store) rename(oldName, newName string) error {
+280
View File
@@ -4,14 +4,20 @@
package main package main
import ( import (
"archive/tar"
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"crypto/rand" "crypto/rand"
"io" "io"
"io/fs" "io/fs"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -295,3 +301,277 @@ func TestFiller(t *testing.T) {
t.Fatalf("filler objects total %d, largest %d", total, largest) t.Fatalf("filler objects total %d, largest %d", total, largest)
} }
} }
// TestAddTree migrates "." holding the vault and key: structure is kept,
// originals and emptied directories go, the vault and key are left alone.
// A URL download is then added under a folder.
func TestAddTree(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")
os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755)
os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0o600)
os.WriteFile(filepath.Join(dir, "a", "b", "y"), []byte("y"), 0o600)
t.Chdir(dir)
var skip []os.FileInfo
for _, p := range []string{"store", "key"} {
fi, _ := os.Stat(p)
skip = append(skip, fi)
}
cat, _ := s.catalogue()
if err := addPath(s, cat, "x", "d", false, skip, nil); err != nil {
t.Fatal(err)
}
// Migrating over d/x must ask first, and a no must leave everything.
var asked []string
no := func(names []string) bool { asked = names; return false }
if err := addPath(s, cat, ".", "d", true, skip, no); err == nil || !slices.Equal(asked, []string{"d/x"}) {
t.Fatalf("migrate over d/x: err %v, asked %v", err, asked)
}
if _, err := os.Stat("x"); err != nil || len(cat) != 1 {
t.Fatalf("declined migrate changed things: %v, %d stored", err, len(cat))
}
if err := addPath(s, cat, ".", "d", true, skip, func([]string) bool { return true }); err != nil {
t.Fatal(err)
}
var got []string
for name := range cat {
got = append(got, name)
}
slices.Sort(got)
if !slices.Equal(got, []string{"d/a/b/y", "d/x"}) {
t.Fatalf("stored %v", got)
}
var left []string
entries, _ := os.ReadDir(".")
for _, e := range entries {
left = append(left, e.Name())
}
if !slices.Equal(left, []string{"key", "store"}) {
t.Fatalf("left behind %v", left)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.UserAgent() != chromeUA {
http.Error(w, "bad agent", http.StatusForbidden)
return
}
io.WriteString(w, "body")
}))
defer srv.Close()
if err := addURL(s, cat, srv.URL+"/files/page.html", "web"); err != nil {
t.Fatal(err)
}
if err := s.get("web/page.html", "out"); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile("out"); string(b) != "body" {
t.Fatalf("downloaded %q", b)
}
}
// TestMirror crawls a two-page site: links in HTML and CSS become relative,
// off-host links stay, and everything is stored under host/.
func TestMirror(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")
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.UserAgent() != chromeUA {
http.Error(w, "bad agent", http.StatusForbidden)
return
}
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, `<a href="`+srv.URL+`/sub/p.html#x">p</a> <a href="https://elsewhere.example/">e</a> <link href='/s.css'>`)
case "/sub/p.html":
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, `<a href="/">home</a> <img src="../img/a.png">`)
case "/s.css":
w.Header().Set("Content-Type", "text/css")
io.WriteString(w, `body{background:url(/img/a.png)}`)
case "/img/a.png":
io.WriteString(w, "png")
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cat, _ := s.catalogue()
if err := mirror(s, cat, srv.URL, "m"); err != nil {
t.Fatal(err)
}
host := srv.Listener.Addr().String()
for name, want := range map[string]string{
"m/" + host + "/index.html": `<a href="sub/p.html#x">p</a> <a href="https://elsewhere.example/">e</a> <link href='s.css'>`,
"m/" + host + "/sub/p.html": `<a href="../index.html">home</a> <img src="../img/a.png">`,
"m/" + host + "/s.css": `body{background:url(img/a.png)}`,
"m/" + host + "/img/a.png": "png",
} {
out := filepath.Join(dir, "out")
if err := s.get(name, out); err != nil {
t.Fatal(name, err)
}
if b, _ := os.ReadFile(out); string(b) != want {
t.Errorf("%s = %q, want %q", name, b, want)
}
os.Remove(out)
}
}
func TestAbsorb(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")
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
mtime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
for _, h := range []*tar.Header{
{Name: "./d/", Typeflag: tar.TypeDir, Mode: 0o755},
{Name: "./d/a.txt", Size: 5, Mode: 0o600, ModTime: mtime},
{Name: "../escape", Size: 5, Mode: 0o600},
{Name: "./d/link", Typeflag: tar.TypeSymlink, Linkname: "a.txt"},
} {
tw.WriteHeader(h)
if h.Size > 0 {
io.WriteString(tw, "hello")
}
}
tw.Close()
zw.Close()
cat, _ := s.catalogue()
if err := absorb(s, cat, &buf, "in"); err != nil {
t.Fatal(err)
}
if len(cat) != 1 || len(cat["in/d/a.txt"]) != 1 {
t.Fatalf("catalogue = %v, want only in/d/a.txt", cat)
}
if r := cat["in/d/a.txt"][0]; !r.Modified.Equal(mtime) || r.Mode != 0o600 {
t.Errorf("record = %v, %o", r.Modified, r.Mode)
}
out := filepath.Join(dir, "out")
if err := s.get("in/d/a.txt", out); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(out); string(b) != "hello" {
t.Errorf("got %q", b)
}
}
func TestRemoveTree(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(dir, "store", "objects")
before := len(ciphertexts(t, objects))
cat, _ := s.catalogue()
src := filepath.Join(dir, "in")
big := make([]byte, chunkData+1) // two objects
rand.Read(big)
for name, body := range map[string][]byte{"a/x": big, "a/b/y": []byte("y"), "ab/z": []byte("z"), "b/w": []byte("w")} {
os.WriteFile(src, body, 0o600)
if _, err := s.add(cat, src, name); err != nil {
t.Fatal(err)
}
}
if n := len(ciphertexts(t, objects)) - before; n != 5 {
t.Fatalf("%d objects added, want 5", n)
}
names := under(cat, "a")
if !slices.Equal(names, []string{"a/b/y", "a/x"}) {
t.Fatalf("under a: %v", names)
}
var done []string
if err := s.removeAll(cat, names, func(n string) { done = append(done, n) }); err != nil {
t.Fatal(err)
}
if !slices.Equal(done, names) || len(cat) != 2 || len(cat["ab/z"]) != 1 || len(cat["b/w"]) != 1 {
t.Fatalf("done %v, left %v", done, cat)
}
if n := len(ciphertexts(t, objects)) - before; n != 2 {
t.Fatalf("%d objects left, want 2: the space was not freed", n)
}
fresh, _ := s.catalogue()
if len(fresh) != 2 {
t.Fatalf("on disk: %v", fresh)
}
out := filepath.Join(dir, "out")
if err := s.get("ab/z", out); err != nil {
t.Fatal(err)
}
}
func TestLs(t *testing.T) {
at := func(m int) time.Time { return time.Date(2026, 1, 1, 12, m, 0, 0, time.UTC) }
cat := map[string][]*record{
"notes.md": {{Size: 10, Modified: at(1)}},
"a/x": {{Size: 100, Modified: at(2)}},
"a/b/y": {{Size: 1000, Modified: at(5)}},
"a/b/c/z": {{Size: 1, Modified: at(3)}},
"ab/z": {{Size: 7, Modified: at(4)}},
"photos/2025/img1": {{Size: 2048, Modified: at(6)}},
}
names := func(folder string) []string {
t.Helper()
var b bytes.Buffer
if err := ls(&b, cat, folder); err != nil {
t.Fatal(err)
}
var got []string
for _, line := range strings.Split(strings.TrimRight(b.String(), "\n"), "\n") {
got = append(got, strings.TrimSpace(line[10:]))
}
return got
}
day := func(m int) string { return at(m).Local().Format("2006-01-02 15:04") + " " }
if got, want := names(""), []string{day(5) + "a/", day(4) + "ab/", day(6) + "photos/", day(1) + "notes.md"}; !slices.Equal(got, want) {
t.Fatalf("ls: %q, want %q", got, want)
}
if got, want := names("a"), []string{day(5) + "b/", day(2) + "x"}; !slices.Equal(got, want) {
t.Fatalf("ls a: %q, want %q", got, want)
}
if got, want := names("a/b"), []string{day(3) + "c/", day(5) + "y"}; !slices.Equal(got, want) {
t.Fatalf("ls a/b: %q, want %q", got, want)
}
if got, want := names("a/x"), []string{day(2) + "x"}; !slices.Equal(got, want) {
t.Fatalf("ls a/x: %q, want %q", got, want)
}
var b bytes.Buffer
ls(&b, cat, "")
if !strings.HasPrefix(b.String(), " 1.1 KiB ") {
t.Fatalf("folder a/ should total 1101 bytes: %q", b.String())
}
if err := ls(&b, cat, "nope"); err == nil {
t.Fatal("ls of a missing folder succeeded")
}
}