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>
This commit is contained in:
Andy Dixon
2026-10-02 18:15:28 +01:00
co-authored by Claude Opus 5.5
parent 730877823e
commit 0d45ecea59
22 changed files with 476 additions and 57 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
- `main.go` - CLI: `genkey`, `init`, `add` (file, directory - `main.go` - CLI: `genkey`, `init`, `add` (file, directory
or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar 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 + - `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.
+5 -1
View File
@@ -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 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/ 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 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 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

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: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 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: 124 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 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: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+14 -1
View File
@@ -266,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)
@@ -315,6 +316,18 @@ def main():
t.cmd("ls Scans") t.cmd("ls Scans")
shoot("migrate", t) 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 # 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/index.html", "<h1>hello</h1>\n")
box.write("Projects/website/css/site.css", "body{}\n") box.write("Projects/website/css/site.css", "body{}\n")
+45 -24
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,11 +511,12 @@ 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}
@@ -611,17 +612,25 @@ themselves, should they live inside the folder, so \cmd{migrate} can never
eat them. Files larger than 1\,TiB are 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
@@ -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}} \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}
@@ -700,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}
@@ -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. \\ 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. \\
@@ -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 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 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 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 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} \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}
@@ -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 \\ -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} \\ --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}
+68 -11
View File
@@ -1,23 +1,65 @@
.TH VAULT-LIST 1 "September 2026" "vault" "User Commands" .TH VAULT-LIST 1 "October 2026" "vault" "User Commands"
.SH NAME .SH NAME
vault-list \- list files in the vault vault-list, vault-ls \- list files and folders in the vault
.SH SYNOPSIS .SH SYNOPSIS
.B vault ls
.RB [ \-r ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ FOLDER ]
.br
.B vault list .B vault list
.RB [ \-\-key .RB [ \-\-key
.IR PATH ] .IR PATH ]
.RB [ \-\-vault .RB [ \-\-vault
.IR DIR ] .IR DIR ]
.RI [ PREFIX ] .RI [ FOLDER ]
.br
.B vault ls
\&...
.SH DESCRIPTION .SH DESCRIPTION
Prints one line per stored file, sorted by name: human-readable size, Both print one line per entry: human-readable size, modification time
modification time (local, minute resolution) and name. With \fIPREFIX\fR only (local, minute resolution) and name, in fixed-width columns so the name
that file, or files under that folder, are shown. Records the passphrase always starts at character 31.
cannot open are skipped silently. Takes a shared lock. .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 .SH OPTIONS
.TP .TP
.B \-r
.BR ls :
list everything under
.I FOLDER
with full names, as
.B vault list
does.
.TP
.BI \-\-key " PATH" .BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key. Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP .TP
@@ -27,8 +69,23 @@ Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.TP .TP
.B VAULT_PASSPHRASE .B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal. 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 .SH SEE ALSO
.BR vault (1), .BR vault (1),
.BR vault\-get (1) .BR vault\-get (1),
.BR vault\-rm (1)
.SH AUTHOR .SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+62 -4
View File
@@ -1,21 +1,66 @@
.TH VAULT-RM 1 "September 2026" "vault" "User Commands" .TH VAULT-RM 1 "October 2026" "vault" "User Commands"
.SH NAME .SH NAME
vault-rm \- delete a stored file vault-rm \- delete stored files or folders
.SH SYNOPSIS .SH SYNOPSIS
.B vault rm .B vault rm
.RB [ \-f ]
.RB [ \-\-key .RB [ \-\-key
.IR PATH ] .IR PATH ]
.RB [ \-\-vault .RB [ \-\-vault
.IR DIR ] .IR DIR ]
.I NAME .I NAME
.br .br
.B vault rm \-r
.RB [ \-f ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I FOLDER
.br
.B vault delete .B vault delete
\&... \&...
.SH DESCRIPTION .SH DESCRIPTION
Deletes the stored file \fINAME\fR from the vault. It does not ask for Deletes the stored file
confirmation. .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 .SH OPTIONS
.TP .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" .BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key. Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP .TP
@@ -25,6 +70,19 @@ Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.TP .TP
.B VAULT_PASSPHRASE .B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal. 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 .SH SEE ALSO
.BR vault (1), .BR vault (1),
.BR vault\-list (1) .BR vault\-list (1)
+7 -3
View File
@@ -37,8 +37,10 @@ Crawl a website into the vault.
Add every file in a tar archive read from standard input. Add every file in a tar archive read from standard input.
.TP .TP
.BR vault\-list (1) .BR vault\-list (1)
List stored files (alias: .B ls
.BR ls ). lists one folder, with sub-folders marked;
.B list
lists every file under a folder.
.TP .TP
.BR vault\-get (1) .BR vault\-get (1)
Decrypt a stored file. Decrypt a stored file.
@@ -48,7 +50,9 @@ Rename a stored file (alias:
.BR rename ). .BR rename ).
.TP .TP
.BR vault\-rm (1) .BR vault\-rm (1)
Delete a stored file (alias: Delete a stored file, or a whole folder with
.B \-r
(alias:
.BR delete ). .BR delete ).
.TP .TP
.B help .B help
+130 -11
View File
@@ -18,6 +18,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/andydixon/vault/internal/term" "github.com/andydixon/vault/internal/term"
) )
@@ -39,10 +40,16 @@ const usage = `Usage:
vault absorb [FOLDER] read a tar archive (optionally gzipped) from vault absorb [FOLDER] read a tar archive (optionally gzipped) from
standard input and add every file in it, standard input and add every file in it,
optionally under a folder inside the vault 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 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)
@@ -80,6 +87,10 @@ func main() {
output := fset.String("o", "", "") output := fset.String("o", "", "")
filler := fset.String("filler", "0", "") filler := fset.String("filler", "0", "")
yes := fset.Bool("yes", false, "") 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)
@@ -178,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)
@@ -191,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:
@@ -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 // confirmOverwrite lists the vault names a migrate would replace and asks
// on the terminal whether to go ahead. No terminal means no. // on the terminal whether to go ahead. No terminal means no.
func confirmOverwrite(names []string) bool { func confirmOverwrite(names []string) bool {
fmt.Fprintln(os.Stderr, "these files are already in the vault and would be overwritten:") return confirm("these files are already in the vault and would be overwritten:", names,
for _, name := range 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) 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 { if err != nil {
fmt.Fprintf(os.Stderr, "cannot ask for confirmation: %v\n", err) 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 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)
+47 -1
View File
@@ -537,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 {
+96
View File
@@ -17,6 +17,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -479,3 +480,98 @@ func TestAbsorb(t *testing.T) {
t.Errorf("got %q", b) 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")
}
}