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") + } +}