diff --git a/CLAUDE.md b/CLAUDE.md index 759f6d1..b3c10f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,8 @@ vault is a post-quantum encrypted file vault: one `package main` at the repo root that builds a single `vault` binary. Module `github.com/andydixon/vault`. - `main.go` - CLI: `genkey`, `init`, `add` (file, directory - or URL), `migrate` (add, then delete originals), `list`, `get`, `mv`, `rm`. + or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar + on stdin, `absorb.go`), `list`, `get`, `mv`, `rm`. - `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 9644238..abbcfdb 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ vault add ./photos # every regular file under photos/ vault add https://example.com/a.pdf # download and store a URL vault migrate ./photos # like add, then delete the originals vault mirror https://example.com/ # crawl a site into example.com/, links rewritten +tar -cz photos | vault absorb backup # every file in a tar (or .tar.gz) under backup/ vault list # everything vault list work # everything under work/ vault get work/2026/report.pdf # writes ./report.pdf diff --git a/absorb.go b/absorb.go new file mode 100644 index 0000000..0a5acee --- /dev/null +++ b/absorb.go @@ -0,0 +1,75 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "strings" +) + +// absorb reads a tar archive, gzipped or not, from r and adds every regular +// file in it beneath dest, streaming each straight into the vault so no +// plaintext touches the disk. Other entries, and names that would escape +// dest, are reported and skipped. +func absorb(s *store, cat map[string][]*record, r io.Reader, dest string) error { + br := bufio.NewReader(r) + if magic, _ := br.Peek(2); bytes.Equal(magic, []byte{0x1f, 0x8b}) { + zr, err := gzip.NewReader(br) + if err != nil { + return err + } + defer zr.Close() + r = zr + } else { + r = br + } + tr := tar.NewReader(r) + n := 0 + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading archive: %w", err) + } + if hdr.Typeflag != tar.TypeReg { + if hdr.Typeflag != tar.TypeDir { + fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", hdr.Name) + } + continue + } + name := strings.TrimPrefix(path.Clean("/"+hdr.Name), "/") + if strings.Contains(hdr.Name, "\\") || name == "" || name != path.Clean(strings.TrimPrefix(strings.TrimPrefix(hdr.Name, "./"), "/")) { + fmt.Fprintf(os.Stderr, "skipping %s: unsafe name\n", hdr.Name) + continue + } + name = path.Join(dest, name) + replaced, err := s.addReader(cat, tr, hdr.Size, hdr.FileInfo().Mode(), hdr.ModTime, name) + if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { + err = fmt.Errorf("%s: archive ends early", hdr.Name) + } + if err != nil { + return err + } + verb := "added" + if replaced { + verb = "replaced" + } + fmt.Fprintf(os.Stderr, "%s %s\n", verb, name) + n++ + } + if n == 0 { + return errors.New("no files in archive") + } + return nil +} diff --git a/docs/guide/img/genkey.png b/docs/guide/img/genkey.png index 68080ec..1d4786c 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 0825e65..45d79c8 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 581262c..b99ec09 100644 Binary files a/docs/guide/img/init.png and b/docs/guide/img/init.png differ diff --git a/docs/guide/shots.py b/docs/guide/shots.py index 95d7361..edac702 100755 --- a/docs/guide/shots.py +++ b/docs/guide/shots.py @@ -315,6 +315,35 @@ def main(): t.cmd("ls Scans") shoot("migrate", t) + # absorb: a tar archive piped in, here pulled from a gzipped tarball + box.write("Projects/website/index.html", "
',
+ "style.css": "body{background:url(/img/bg.png)}",
+ "img/me.png": "png", "img/bg.png": "png"}.items():
+ os.makedirs(os.path.dirname(os.path.join(site, rel)), exist_ok=True)
+ with open(os.path.join(site, rel), "w") as f:
+ f.write(body)
+ srv = subprocess.Popen([sys.executable, "-m", "http.server", "8000", "--bind", "127.0.0.1"],
+ cwd=site, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ time.sleep(1)
+ t = Term(box)
+ t.cmd("vault mirror http://localhost:8000/ sites", P)
+ t.cmd("vault get sites/localhost:8000/about.html && cat about.html", P)
+ shoot("mirror", t)
+ srv.terminate()
+
# errors
t = Term(box)
t.cmd("vault list", "hunter2", highlight=("incorrect",))
diff --git a/docs/guide/vault-guide.tex b/docs/guide/vault-guide.tex
index 011a192..3d231dc 100644
--- a/docs/guide/vault-guide.tex
+++ b/docs/guide/vault-guide.tex
@@ -567,6 +567,22 @@ the whole site, one request at a time, into a folder named after its host.
Links inside pages and stylesheets are rewritten to relative paths, so the
copy still browses once you get it out. Only the same host is followed.
+\subsection{From a tar archive: \texttt{absorb}}
+\cmd{absorb} reads a tar archive, gzipped or not, from standard input and
+stores every plain file in it, optionally under a folder:
+
+\begin{verbatim}
+tar -cz photos | vault absorb backup
+ssh server 'tar -c /etc' | vault absorb server1
+vault absorb old < archive.tar.gz
+\end{verbatim}
+
+Each file is encrypted straight from the stream, so nothing is unpacked to
+disk first. Directories need no entry of their own; links, devices and any
+name that tries to climb out with \file{..} are skipped with a note. If the
+archive stops halfway through a file, that file isn't stored and vault
+stops; everything before it is already safely in.
+
\subsection{Moving in: \texttt{migrate}}
\cmd{migrate} works exactly like \cmd{add}, but deletes each original once
it's safely stored, then tidies away any folders left empty. It won't take a
diff --git a/docs/vault-absorb.1 b/docs/vault-absorb.1
new file mode 100644
index 0000000..15837cd
--- /dev/null
+++ b/docs/vault-absorb.1
@@ -0,0 +1,73 @@
+.TH VAULT-ABSORB 1 "October 2026" "vault" "User Commands"
+.SH NAME
+vault-absorb \- add the files in a tar archive to an encrypted vault
+.SH SYNOPSIS
+.B vault absorb
+.RB [ \-\-key
+.IR PATH ]
+.RB [ \-\-vault
+.IR DIR ]
+.RI [ FOLDER ]
+.B <
+.I ARCHIVE
+.SH DESCRIPTION
+.B vault absorb
+reads a tar archive from standard input and stores every regular file in
+it, beneath
+.I FOLDER
+if one is given, keeping the paths, permissions and modification times
+recorded in the archive. A gzip-compressed archive is detected and
+unpacked automatically.
+.PP
+Each file is encrypted straight from the stream; nothing is unpacked to
+disk. Anything already stored under the same name is replaced.
+.PP
+Directory entries are not stored (directories exist implicitly). Symbolic
+and hard links, devices and other special entries are reported on standard
+error and skipped, as is any entry whose name would escape
+.I FOLDER
+through
+.BR .. .
+A leading
+.B ./
+or
+.B /
+is dropped.
+.PP
+The passphrase prompt reads from the terminal, not standard input, so
+it still works with an archive piped in. The vault is locked exclusively
+for the whole run.
+.SH OPTIONS
+.TP
+.BI \-\-key " PATH"
+Key file. Default: $VAULT_KEY, else ~/.vault/key.
+.TP
+.BI \-\-vault " DIR"
+Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
+.SH ENVIRONMENT
+.TP
+.B VAULT_PASSPHRASE
+Passphrase, instead of prompting on the terminal.
+.SH EXIT STATUS
+0 if at least one file was stored and the archive was read to the end.
+1 if the archive is malformed or ends early, holds no regular files, or
+anything fails; files stored before the failure stay in the vault.
+.SH EXAMPLES
+Store a directory under
+.BR backup/photos/ :
+.PP
+.RS
+tar \-cz photos | vault absorb backup
+.RE
+.PP
+Pull a remote directory straight into the vault:
+.PP
+.RS
+ssh server 'tar \-C /etc \-c .' | vault absorb server1/etc
+.RE
+.SH SEE ALSO
+.BR vault (1),
+.BR vault\-add (1),
+.BR tar (1)
+.SH AUTHOR
+Andy Dixon