diff --git a/CLAUDE.md b/CLAUDE.md
index b3c10f5..d723e5f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,8 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
- `main.go` - CLI: `genkey`, `init`, `add` (file, directory
or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar
- on stdin, `absorb.go`), `list`, `get`, `mv`, `rm`.
+ 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 +
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.
diff --git a/README.md b/README.md
index 982e77f..d1cfd28 100644
--- a/README.md
+++ b/README.md
@@ -50,12 +50,16 @@ 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 list # everything
+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 get work/2026/report.pdf # writes ./report.pdf
vault get report.pdf -o copy.pdf
vault mv report.pdf 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
diff --git a/docs/guide/img/absorb.png b/docs/guide/img/absorb.png
index 9adb17d..08b5403 100644
Binary files a/docs/guide/img/absorb.png and b/docs/guide/img/absorb.png differ
diff --git a/docs/guide/img/build.png b/docs/guide/img/build.png
index 9ad15c6..472cd78 100644
Binary files a/docs/guide/img/build.png and b/docs/guide/img/build.png differ
diff --git a/docs/guide/img/decoy-hidden.png b/docs/guide/img/decoy-hidden.png
index 5f08977..6e204a5 100644
Binary files a/docs/guide/img/decoy-hidden.png and b/docs/guide/img/decoy-hidden.png differ
diff --git a/docs/guide/img/decoy-views.png b/docs/guide/img/decoy-views.png
index bb109b6..2694dd1 100644
Binary files a/docs/guide/img/decoy-views.png and b/docs/guide/img/decoy-views.png differ
diff --git a/docs/guide/img/genkey.png b/docs/guide/img/genkey.png
index 1d4786c..3a9d5e0 100644
Binary files a/docs/guide/img/genkey.png and b/docs/guide/img/genkey.png differ
diff --git a/docs/guide/img/help.png b/docs/guide/img/help.png
index 45d79c8..278a23b 100644
Binary files a/docs/guide/img/help.png and b/docs/guide/img/help.png differ
diff --git a/docs/guide/img/init.png b/docs/guide/img/init.png
index b99ec09..37d1838 100644
Binary files a/docs/guide/img/init.png and b/docs/guide/img/init.png differ
diff --git a/docs/guide/img/list.png b/docs/guide/img/list.png
index 365d513..95a3e0b 100644
Binary files a/docs/guide/img/list.png and b/docs/guide/img/list.png differ
diff --git a/docs/guide/img/mv-rm.png b/docs/guide/img/mv-rm.png
index ca41f1a..dedae05 100644
Binary files a/docs/guide/img/mv-rm.png and b/docs/guide/img/mv-rm.png differ
diff --git a/docs/guide/img/on-disk.png b/docs/guide/img/on-disk.png
index 52b7430..3b921a4 100644
Binary files a/docs/guide/img/on-disk.png and b/docs/guide/img/on-disk.png differ
diff --git a/docs/guide/img/rm-r.png b/docs/guide/img/rm-r.png
new file mode 100644
index 0000000..132b6fe
Binary files /dev/null and b/docs/guide/img/rm-r.png differ
diff --git a/docs/guide/img/scripting.png b/docs/guide/img/scripting.png
index 6896ea9..1596fc9 100644
Binary files a/docs/guide/img/scripting.png and b/docs/guide/img/scripting.png differ
diff --git a/docs/guide/shots.py b/docs/guide/shots.py
index edac702..81eba78 100755
--- a/docs/guide/shots.py
+++ b/docs/guide/shots.py
@@ -266,8 +266,9 @@ def main():
shoot("add", t)
t = Term(box)
+ t.cmd("vault ls", P)
+ t.cmd("vault ls letters", P)
t.cmd("vault list", P)
- t.cmd("vault list letters", P)
shoot("list", t)
t = Term(box)
@@ -315,6 +316,18 @@ def main():
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() + 'vault rm -r receipts')
+ for line in box.run_tty("vault rm -r receipts", P, "y").split("\n"):
+ esc = html.escape(line)
+ if "[y/N]" in line:
+ esc = '%s' % 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", "
hello
\n")
box.write("Projects/website/css/site.css", "body{}\n")
diff --git a/docs/guide/vault-guide.tex b/docs/guide/vault-guide.tex
index ff0a780..ee9ee76 100644
--- a/docs/guide/vault-guide.tex
+++ b/docs/guide/vault-guide.tex
@@ -63,7 +63,7 @@ Got two minutes? Here's all of it:
$ vault genkey # make your key (pick a good passphrase)
$ vault init # make your vault
$ 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
\end{shell}
@@ -511,11 +511,12 @@ type \cmd{--vault} again.
% ======================================================================
\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}
-\cmd{add} puts files in (optionally into a folder), \cmd{list} shows them,
-\cmd{get} takes one back out, \cmd{mv} renames, \cmd{rm} deletes. Each asks
+\cmd{add} puts files in (optionally into a folder), \cmd{ls} shows them a
+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.
\end{tldr}
@@ -611,17 +612,25 @@ 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.)
-\section{Seeing what's inside: \texttt{list}}
+\section{Seeing what's inside: \texttt{ls} and \texttt{list}}
\begin{shell}
-$ vault list # everything
-$ vault list letters # just what's inside letters/
+$ vault ls # the top level: folders (ending in /) and files
+$ vault ls letters # one level down, inside letters/
+$ vault list # every file, all the way down, full names
\end{shell}
-Each line shows the size, the file's \emph{own} last-modified time from when
-you added it, and its name, sorted alphabetically.
+\cmd{ls} works like the shell's: it shows one folder at a time. Each
+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}
\textbf{Why does \texttt{list} need my passphrase?} Because the names are
@@ -665,25 +674,31 @@ and it doesn't re-encrypt the file, so it's instant however big the file is.
\section{Deleting: \texttt{rm}}
\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}
-Removes the file's catalogue entry and its encrypted contents. One file per
-command, on purpose. There's no recursive delete to fat-finger.
+Removes the file's catalogue entry and every encrypted piece of it, so the
+space comes back straight away.
\shot{mv-rm}{Replace, move, delete, and the result}
\begin{danger}
-\textbf{\texttt{rm} is immediate and there's no undo.} No recycle bin, no
-``are you sure?''. If it matters, make sure it's backed up somewhere first.
+\textbf{\texttt{rm} is immediate and there's no undo.} No recycle bin. If it
+matters, make sure it's backed up somewhere first.
\end{danger}
-\begin{squirrel}
-Need to delete a whole folder? Loop over \cmd{list}. The name starts at
-character 31 of each line, so:\\
-\cmd{vault list old | cut -c31- | while read -r f; do vault rm "\$f"; done}\\
-With \env{VAULT\_PASSPHRASE} set, or it'll ask every time. See chapter 7.
-\end{squirrel}
+\subsection{Deleting a whole folder}
+\cmd{rm -r} deletes a folder and everything under it. Because that can be a
+lot, it first lists what's going (the first 20 names, then a count) with
+the total size, and asks. Anything but \texttt{y} deletes nothing, and so
+does having no terminal to ask on. \cmd{-f} skips the question, and also
+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}
@@ -700,7 +715,8 @@ $ vault add report.pdf --vault=~/work
\begin{pocketlist}
\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 \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{rm} has no undo.
\end{pocketlist}
@@ -1213,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. \\
\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. \\
+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. \\
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. \\
@@ -1273,10 +1291,11 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
\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 list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\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 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 man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox}
\end{tcbraster}
@@ -1291,6 +1310,8 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
-o OUT & \textrm{---} & the file's own name, here \\
--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}
\section*{Numbers worth knowing}
diff --git a/docs/vault-list.1 b/docs/vault-list.1
index 3e757c2..c7d4a10 100644
--- a/docs/vault-list.1
+++ b/docs/vault-list.1
@@ -1,23 +1,65 @@
-.TH VAULT-LIST 1 "September 2026" "vault" "User Commands"
+.TH VAULT-LIST 1 "October 2026" "vault" "User Commands"
.SH NAME
-vault-list \- list files in the vault
+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 [ PREFIX ]
-.br
-.B vault ls
-\&...
+.RI [ FOLDER ]
.SH DESCRIPTION
-Prints one line per stored file, sorted by name: human-readable size,
-modification time (local, minute resolution) and name. With \fIPREFIX\fR only
-that file, or files under that folder, are shown. Records the passphrase
-cannot open are skipped silently. Takes a shared lock.
+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
@@ -27,8 +69,23 @@ Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.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\-get (1),
+.BR vault\-rm (1)
.SH AUTHOR
Andy Dixon , https://www.dixon.cx
diff --git a/docs/vault-rm.1 b/docs/vault-rm.1
index 50779d7..501e822 100644
--- a/docs/vault-rm.1
+++ b/docs/vault-rm.1
@@ -1,21 +1,66 @@
-.TH VAULT-RM 1 "September 2026" "vault" "User Commands"
+.TH VAULT-RM 1 "October 2026" "vault" "User Commands"
.SH NAME
-vault-rm \- delete a stored file
+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 \fINAME\fR from the vault. It does not ask for
-confirmation.
+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
@@ -25,6 +70,19 @@ Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.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)
diff --git a/docs/vault.1 b/docs/vault.1
index fe3f381..67bc40e 100644
--- a/docs/vault.1
+++ b/docs/vault.1
@@ -37,8 +37,10 @@ Crawl a website into the vault.
Add every file in a tar archive read from standard input.
.TP
.BR vault\-list (1)
-List stored files (alias:
-.BR ls ).
+.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.
@@ -48,7 +50,9 @@ Rename a stored file (alias:
.BR rename ).
.TP
.BR vault\-rm (1)
-Delete a stored file (alias:
+Delete a stored file, or a whole folder with
+.B \-r
+(alias:
.BR delete ).
.TP
.B help
diff --git a/main.go b/main.go
index 2e14587..e5f5929 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,7 @@ import (
"sort"
"strconv"
"strings"
+ "time"
"github.com/andydixon/vault/internal/term"
)
@@ -39,10 +40,16 @@ const usage = `Usage:
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 list [PREFIX] list stored files
+ 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 mv OLD NEW rename 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):
--key PATH key file (default $VAULT_KEY, else ~/.vault/key)
@@ -80,6 +87,10 @@ func main() {
output := fset.String("o", "", "")
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)
if err != nil {
fail("%v\n\n%s", err, usage)
@@ -178,6 +189,10 @@ func main() {
}
cat, err := open(*keyPath, *vaultDir, false).catalogue()
check(err)
+ if cmd == "ls" && !*recurse {
+ check(ls(os.Stdout, cat, prefix))
+ break
+ }
list(cat, prefix)
case "get":
want(1, 1)
@@ -191,7 +206,41 @@ func main() {
check(open(*keyPath, *vaultDir, true).rename(pos[0], pos[1]))
case "rm", "delete":
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":
fmt.Println(usage)
default:
@@ -384,11 +433,22 @@ func addPath(s *store, cat map[string][]*record, src, dest string, migrate bool,
// 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 {
- fmt.Fprintln(os.Stderr, "these files are already in the vault and would be overwritten:")
- for _, name := range names {
+ 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(fmt.Sprintf("overwrite %d file(s)? [y/N]", len(names)))
+ ok, err := term.Confirm(question)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot ask for confirmation: %v\n", err)
}
@@ -455,14 +515,73 @@ func addOne(s *store, cat map[string][]*record, src, name string, migrate bool)
return nil
}
-func list(cat map[string][]*record, prefix string) {
- var names []string
- for name := range cat {
- if prefix == "" || name == prefix || strings.HasPrefix(name, prefix+"/") {
- names = append(names, name)
+// ls prints one level of the vault, like ls(1): the files directly in
+// folder, and each sub-folder once, marked with a trailing slash and shown
+// with the total size and newest date of everything under it. Folders come
+// first. A folder that is a file's name lists just that file.
+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 {
r := cat[name][0]
fmt.Printf("%10s %s %s\n", humanSize(r.Size), r.Modified.Local().Format("2006-01-02 15:04"), name)
diff --git a/store.go b/store.go
index 907c114..80fa894 100644
--- a/store.go
+++ b/store.go
@@ -537,7 +537,53 @@ func (s *store) delete(name string) error {
if err != nil {
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 {
diff --git a/vault_test.go b/vault_test.go
index 146a3a1..f7ef83f 100644
--- a/vault_test.go
+++ b/vault_test.go
@@ -17,6 +17,7 @@ import (
"os/exec"
"path/filepath"
"slices"
+ "strings"
"testing"
"time"
)
@@ -479,3 +480,98 @@ func TestAbsorb(t *testing.T) {
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")
+ }
+}