6 Commits
Author SHA1 Message Date
Andy Dixon ec204f0ea2 Add cp, folder mv, get -r, and unlock/lock to remember the key
Filesystem basics: mv now moves folders as well as files, and like mv(1)
a NEW ending in / or naming an existing folder means into it. cp copies a
file, or a folder with -r, by writing new records that share the
original's objects, so it is instant and takes no space; rm already keeps
objects another record still uses. New records go first, so an
interrupted move leaves both names, never neither. get -r extracts a
folder, keeping its structure.

vault unlock [--for 5m] checks the passphrase and starts the binary again
as a hidden __agent process, handing it the suite ID and seed over a pipe.
The agent keeps them in mlocked memory, with core dumps off and (on Linux)
PR_SET_DUMPABLE cleared, serves them on a Unix socket in a 0700 per-user
directory whose owner is checked on every use, and wipes them and exits
at a hard deadline or on vault lock. Commands ask it first unless
VAULT_PASSPHRASE is set, and genkey stops any agent left for that path.

Guide, man pages (new vault-cp, vault-unlock, vault-lock), README and
screenshots updated; the guide's error table is split so it fits.
2026-10-02 22:13:06 +01:00
Andy DixonandClaude Opus 5.5 0d45ecea59 Add rm -r for folders, and make ls list one folder level
rm -r FOLDER deletes a folder and everything under it, records and
objects, so the space is freed. It lists what will go (the first 20
names, then a count) with the total size and asks on the terminal; no
answer or no terminal deletes nothing. -f skips the question and makes a
missing name a no-op, -rf and -fr combine them, and rm -r / is refused.
Objects still in use are worked out once per delete, not once per file.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:57:38 +01:00
Andy Dixon 55af82f95f Absorb functionality 2026-10-02 16:43:19 +01:00
38 changed files with 1601 additions and 114 deletions
+10 -2
View File
@@ -8,7 +8,15 @@ vault is a post-quantum encrypted file vault: one `package main` at the repo
root that builds a single `vault` binary. Module `github.com/andydixon/vault`. root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
- `main.go` - CLI: `genkey`, `init`, `add` (file, directory - `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`), `ls` (one folder level), `list` (recursive),
`get` (`-r` for a folder), `mv`/`cp` (files or folders; `transfers`
resolves names like mv(1), copies share objects), `rm` (`-r` for a
folder, asks unless `-f`), `unlock`/`lock`.
- `agent*.go` - `vault unlock` re-executes itself as a hidden `__agent`
process holding the suite ID and seed (pipe in, mlocked, undumpable) and
serving them on a Unix socket in a 0700 per-user dir until a hard
deadline. `keyIdentity` asks it first unless `$VAULT_PASSPHRASE` is set.
- `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.
@@ -47,7 +55,7 @@ export VAULT_PASSPHRASE=test VAULT_KEY=$PWD/k VAULT_DIR=$PWD/s
## Invariants ## Invariants
- Never write a seed or private key unencrypted. - Never write a seed or private key unencrypted (the agent gets it by pipe).
- Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable. - Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable.
- Every ciphertext's and shard directory's mtime is pinned to the epoch; - Every ciphertext's and shard directory's mtime is pinned to the epoch;
records stay one fixed size; objects stay at most 4 MiB and bucket-padded. records stay one fixed size; objects stay at most 4 MiB and bucket-padded.
+23 -1
View File
@@ -48,12 +48,24 @@ vault add ./photos # every regular file under photos/
vault add https://example.com/a.pdf # download and store a URL vault add https://example.com/a.pdf # download and store a URL
vault migrate ./photos # like add, then delete the originals 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
vault list # everything tar -cz photos | vault absorb backup # every file in a tar (or .tar.gz) under backup/
vault absorb backup < photos.tar.gz # same, from an existing archive
vault ls # the top level: folders (ending in /) and files
vault ls work # one level down, inside work/
vault list # every file, all the way down, full names
vault list work # everything under work/ vault list work # everything under work/
vault get work/2026/report.pdf # writes ./report.pdf vault get work/2026/report.pdf # writes ./report.pdf
vault get report.pdf -o copy.pdf vault get report.pdf -o copy.pdf
vault get -r work # a whole folder, into ./work
vault mv report.pdf old/report.pdf vault mv report.pdf old/report.pdf
vault mv report.pdf old/ # the same: into a folder, like mv(1)
vault mv work archive/work # folders move too
vault cp -r work work-backup # instant; copies share their encrypted data
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
vault unlock # remember the key for 5 minutes (--for 30m)
vault lock # forget it now
``` ```
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
@@ -71,6 +83,16 @@ existing file and writes nothing until the whole object has authenticated.
The passphrase is read from the terminal, or from `$VAULT_PASSPHRASE` for The passphrase is read from the terminal, or from `$VAULT_PASSPHRASE` for
scripts. scripts.
`vault unlock` asks once, then starts a helper process, like `ssh-agent`,
that holds the unlocked key in memory until a hard deadline (5 minutes by
default) or `vault lock`. Until then commands skip the prompt, unless
`$VAULT_PASSPHRASE` is set, which always wins. The key reaches the helper
over a pipe and never touches the disk; the helper's memory is locked
against swapping, it cannot be core-dumped (or, on Linux, debugged), and it
answers only on a socket in a directory private to your user. While it is
unlocked, anything running as you can use the vault, decoy or not, so lock
before handing the machine over.
## Decoy passphrase ## Decoy passphrase
`vault genkey` offers an optional second passphrase. In scripts, set `vault genkey` offers an optional second passphrase. In scripts, set
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
)
// absorb reads a tar archive, gzipped or not, from r and adds every regular
// file in it beneath dest, streaming each straight into the vault so no
// plaintext touches the disk. Other entries, and names that would escape
// dest, are reported and skipped.
func absorb(s *store, cat map[string][]*record, r io.Reader, dest string) error {
br := bufio.NewReader(r)
if magic, _ := br.Peek(2); bytes.Equal(magic, []byte{0x1f, 0x8b}) {
zr, err := gzip.NewReader(br)
if err != nil {
return err
}
defer zr.Close()
r = zr
} else {
r = br
}
tr := tar.NewReader(r)
n := 0
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("reading archive: %w", err)
}
if hdr.Typeflag != tar.TypeReg {
if hdr.Typeflag != tar.TypeDir {
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", hdr.Name)
}
continue
}
name := strings.TrimPrefix(path.Clean("/"+hdr.Name), "/")
if strings.Contains(hdr.Name, "\\") || name == "" || name != path.Clean(strings.TrimPrefix(strings.TrimPrefix(hdr.Name, "./"), "/")) {
fmt.Fprintf(os.Stderr, "skipping %s: unsafe name\n", hdr.Name)
continue
}
name = path.Join(dest, name)
replaced, err := s.addReader(cat, tr, hdr.Size, hdr.FileInfo().Mode(), hdr.ModTime, name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s: archive ends early", hdr.Name)
}
if err != nil {
return err
}
verb := "added"
if replaced {
verb = "replaced"
}
fmt.Fprintf(os.Stderr, "%s %s\n", verb, name)
n++
}
if n == 0 {
return errors.New("no files in archive")
}
return nil
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/andydixon/vault/internal/pqcrypt"
"github.com/andydixon/vault/internal/term"
)
// The agent remembers an unlocked key for a while, like ssh-agent, so
// commands skip the passphrase. `vault unlock` checks the passphrase, then
// starts a copy of itself (agentCmd) and hands it the suite ID and master
// seed over a pipe; it never touches the disk. The agent serves them on a
// Unix socket in a directory only this user can enter, keeps them in locked,
// undumpable memory where the platform allows, and wipes them and exits at
// a fixed deadline, or on `vault lock`. Using the agent never extends it.
const (
agentCmd = "__agent" // hidden: the agent process itself
agentGet = 'g'
agentQuit = 'q'
agentReply = 2 + seedSize
agentTimeout = 2 * time.Second
)
// agentSocket is the socket of the agent for the key file at keyPath, one
// per key file, in a private per-user directory it creates if needed.
func agentSocket(keyPath string) (string, error) {
abs, err := filepath.Abs(keyPath)
if err != nil {
return "", err
}
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" {
base = os.TempDir()
}
dir := filepath.Join(base, fmt.Sprintf("vault-%d", os.Getuid()))
if err := os.Mkdir(dir, 0o700); err != nil && !os.IsExist(err) {
return "", err
}
if err := privateDir(dir); err != nil {
return "", err
}
sum := sha256.Sum256([]byte(abs))
return filepath.Join(dir, "agent-"+hex.EncodeToString(sum[:8])), nil
}
// agentDial connects to the agent for keyPath and sends it op.
func agentDial(keyPath string, op byte) (net.Conn, error) {
sock, err := agentSocket(keyPath)
if err != nil {
return nil, err
}
c, err := net.DialTimeout("unix", sock, agentTimeout)
if err != nil {
return nil, err
}
c.SetDeadline(time.Now().Add(agentTimeout))
if _, err := c.Write([]byte{op}); err != nil {
c.Close()
return nil, err
}
return c, nil
}
// agentIdentity asks a running agent for the key at keyPath.
func agentIdentity(keyPath string) (*pqcrypt.PQIdentity, error) {
c, err := agentDial(keyPath, agentGet)
if err != nil {
return nil, err
}
defer c.Close()
buf := make([]byte, agentReply)
defer clear(buf)
if _, err := io.ReadFull(c, buf); err != nil {
return nil, err
}
return pqcrypt.NewPQIdentityFromSeed(binary.BigEndian.Uint16(buf), buf[2:])
}
// agentStop tells the agent for keyPath to forget its key, reporting
// whether one was running.
func agentStop(keyPath string) bool {
c, err := agentDial(keyPath, agentQuit)
if err != nil {
return false
}
defer c.Close()
io.Copy(io.Discard, c) // returns once the agent has wiped the key
return true
}
// agentStart replaces any agent for keyPath with one holding id until ttl
// has passed.
func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration) error {
sock, err := agentSocket(keyPath)
if err != nil {
return err
}
agentStop(keyPath)
self, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(self, agentCmd, sock, ttl.String())
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
secret := binary.BigEndian.AppendUint16(make([]byte, 0, agentReply), id.SuiteID())
seed := id.Seed()
secret = append(secret, seed...)
clear(seed)
_, err = stdin.Write(secret)
clear(secret)
stdin.Close()
// The agent closes its standard output once listening, after saying
// "ok" or why not.
msg, _ := io.ReadAll(stdout)
if err != nil || string(msg) != "ok" {
cmd.Process.Kill()
cmd.Wait()
if err == nil {
err = fmt.Errorf("agent did not start: %s", strings.TrimSpace(string(msg)))
}
return err
}
return cmd.Process.Release()
}
// runAgent is the agent process: it reads the key from in, says "ok" or
// why not on out and closes it, then serves the key on sock until ttl has
// passed or it is told to quit.
func runAgent(sock, ttlArg string, in io.Reader, out io.WriteCloser) {
report := func(msg string) { io.WriteString(out, msg); out.Close() }
ttl, err := time.ParseDuration(ttlArg)
if err != nil || ttl <= 0 {
report("bad duration")
return
}
secret := make([]byte, agentReply)
protectMemory(secret)
if _, err := io.ReadFull(in, secret); err != nil {
report("no key received")
return
}
// A socket left by an agent that died stops Listen; agentStart has
// already stopped any live one.
os.Remove(sock)
l, err := net.Listen("unix", sock)
if err != nil {
clear(secret)
report(err.Error())
return
}
os.Chmod(sock, 0o600)
report("ok")
done := make(chan struct{})
time.AfterFunc(ttl, func() { l.Close() })
go func() {
defer close(done)
for {
c, err := l.Accept()
if err != nil {
return
}
if !serveAgent(c, secret) {
l.Close()
c.Close()
return
}
c.Close()
}
}()
<-done
clear(secret)
os.Remove(sock)
}
// serveAgent answers one request, reporting whether to keep going.
func serveAgent(c net.Conn, secret []byte) bool {
c.SetDeadline(time.Now().Add(agentTimeout))
op := make([]byte, 1)
if _, err := io.ReadFull(c, op); err != nil {
return true
}
switch op[0] {
case agentGet:
c.Write(secret)
return true
case agentQuit:
clear(secret)
return false
}
return true
}
// keyIdentity unlocks the key at keyPath: from the agent if one is running
// and no passphrase is in the environment, else with the passphrase.
func keyIdentity(keyPath string) (*pqcrypt.PQIdentity, error) {
if _, ok := os.LookupEnv(passphraseEnv); !ok {
if id, err := agentIdentity(keyPath); err == nil {
return id, nil
}
}
pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv)
if err != nil {
return nil, err
}
return unlockKeyFile(keyPath, pass)
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import "golang.org/x/sys/unix"
// Not dumpable also means not ptrace-able by the same user.
func init() {
undumpable = func() { unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) }
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !unix
package main
// ponytail: no memory locking off unix; VirtualLock on Windows if wanted.
func protectMemory([]byte) {}
// privateDir trusts the per-user temporary directory; Windows keeps it
// private with its ACL.
func privateDir(string) error { return nil }
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build unix
package main
import (
"fmt"
"os"
"syscall"
"golang.org/x/sys/unix"
)
// undumpable stops other processes of this user reading the agent's memory,
// where the platform can.
var undumpable = func() {}
// protectMemory keeps b out of swap and the agent out of core dumps.
func protectMemory(b []byte) {
unix.Mlock(b)
unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{})
undumpable()
}
// privateDir checks dir is a real directory that only this user can use,
// so nobody else can reach, swap or plant the agent's socket.
func privateDir(dir string) error {
fi, err := os.Lstat(dir)
if err != nil {
return err
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !fi.IsDir() || !ok || int(st.Uid) != os.Getuid() || fi.Mode().Perm() != 0o700 {
return fmt.Errorf("%s is not a private directory of this user", dir)
}
return nil
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 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: 134 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 525 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: 129 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+60 -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,47 @@ 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
box.write("Projects/website/index.html", "<h1>hello</h1>\n")
box.write("Projects/website/css/site.css", "body{}\n")
box.write("Projects/website/img/logo.png", os.urandom(24_311))
t = Term(box)
t.cmd("tar -czf website.tar.gz -C Projects website")
t.cmd("vault absorb backups < website.tar.gz", P)
t.cmd("tar -c recipes | vault absorb old", P)
t.cmd("vault list backups", P)
shoot("absorb", t)
# mirror: a tiny local site, served on localhost
site = os.path.join(box.root, "site")
for rel, body in {"index.html": '<a href="/about.html">About</a> <link href="/style.css">',
"about.html": '<a href="/">Home</a> <img src="/img/me.png">',
"style.css": "body{background:url(/img/bg.png)}",
"img/me.png": "png", "img/bg.png": "png"}.items():
os.makedirs(os.path.dirname(os.path.join(site, rel)), exist_ok=True)
with open(os.path.join(site, rel), "w") as f:
f.write(body)
srv = subprocess.Popen([sys.executable, "-m", "http.server", "8000", "--bind", "127.0.0.1"],
cwd=site, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1)
t = Term(box)
t.cmd("vault mirror http://localhost:8000/ sites", P)
t.cmd("vault get sites/localhost:8000/about.html && cat about.html", P)
shoot("mirror", t)
srv.terminate()
# errors # errors
t = Term(box) t = Term(box)
t.cmd("vault list", "hunter2", highlight=("incorrect",)) t.cmd("vault list", "hunter2", highlight=("incorrect",))
@@ -345,6 +387,23 @@ def main():
t.cmd("vault get nope 2>/dev/null; echo \"exit code: $?\"", P) t.cmd("vault get nope 2>/dev/null; echo \"exit code: $?\"", P)
shoot("scripting", t) shoot("scripting", t)
# cp, folder mv, get -r
t = Term(box)
t.cmd("vault cp -r letters archive/2025", P)
t.cmd("vault mv ideas/notes.md archive/", P)
t.cmd("vault ls archive", P)
t.cmd("vault get -r archive -o restored/archive", P)
shoot("cp-mv", t)
# unlock: one passphrase, then commands stop asking until lock
t = Term(box)
t.cmd("vault unlock", P)
t.cmd("vault ls")
t.cmd("vault get archive/notes.md -o /tmp/notes.md")
t.cmd("vault lock")
t.moment("vault ls", "Enter passphrase:")
shoot("unlock", t)
# building, into a temporary OUT so an existing dist/ is left alone # building, into a temporary OUT so an existing dist/ is left alone
rel = tempfile.mkdtemp(prefix="vault-release-") rel = tempfile.mkdtemp(prefix="vault-release-")
targets = "linux/amd64 linux/arm darwin/arm64 windows/amd64" targets = "linux/amd64 linux/arm darwin/arm64 windows/amd64"
+157 -37
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,12 +511,14 @@ 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, cp, rm: the 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 them back out, \cmd{mv} and \cmd{cp} move
for your passphrase. That's the whole daily workflow. and copy, \cmd{rm} deletes (folders with \cmd{-r} throughout). Each asks for
your passphrase, unless you've run \cmd{unlock}. That's the whole daily
workflow.
\end{tldr} \end{tldr}
\section{Putting things in: \texttt{add}} \section{Putting things in: \texttt{add}}
@@ -567,6 +569,30 @@ 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 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. copy still browses once you get it out. Only the same host is followed.
\shot{mirror}{Mirroring a small site. The links in each page now point at the copy.}
\subsection{From a tar archive: \texttt{absorb}}
\cmd{absorb} reads a tar archive, gzipped or not, from standard input and
stores every plain file in it, optionally under a folder:
\begin{verbatim}
tar -cz photos | vault absorb backup
ssh server 'tar -c /etc' | vault absorb server1
vault absorb old < archive.tar.gz
\end{verbatim}
Each file is encrypted straight from the stream, so nothing is unpacked to
disk first. Directories need no entry of their own; links, devices and any
name that tries to climb out with \file{..} are skipped with a note.
vault asks for your passphrase only once the archive starts arriving, so
when it comes out of something that asks first, like
\cmd{gpg --decrypt secrets.tar.gpg | vault absorb}, gpg's prompt comes
and goes before vault's appears. If the
archive stops halfway through a file, that file isn't stored and vault
stops; everything before it is already safely in.
\shot{absorb}{A gzipped tarball and a plain tar stream, both absorbed.}
\subsection{Moving in: \texttt{migrate}} \subsection{Moving in: \texttt{migrate}}
\cmd{migrate} works exactly like \cmd{add}, but deletes each original once \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 it's safely stored, then tidies away any folders left empty. It won't take a
@@ -587,17 +613,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
@@ -610,6 +644,7 @@ than a thief can.
\begin{shell} \begin{shell}
$ vault get NAME # writes the file here, under its own name $ vault get NAME # writes the file here, under its own name
$ vault get NAME -o OUTPUT # writes it wherever you say $ vault get NAME -o OUTPUT # writes it wherever you say
$ vault get -r FOLDER # a whole folder, into ./FOLDER
\end{shell} \end{shell}
\shot{get}{Getting files back, including vault refusing to overwrite} \shot{get}{Getting files back, including vault refusing to overwrite}
@@ -627,39 +662,93 @@ Three safety habits are built in, so you don't need to think about them:
and modification time. and modification time.
\end{checks} \end{checks}
\section{Renaming and moving: \texttt{mv}} \section{Renaming, moving and copying: \texttt{mv} and \texttt{cp}}
\begin{shell} \begin{shell}
$ vault mv OLD NEW $ vault mv OLD NEW # rename a file or a whole folder
$ vault mv notes.md ideas/ # move into a folder, keeping the name
$ vault cp OLD NEW # copy a file
$ vault cp -r OLD NEW # copy a folder
\end{shell} \end{shell}
\cmd{NEW} is the \emph{full} new name, folder included. So to move They work like the everyday \cmd{mv} and \cmd{cp}: if \cmd{NEW} ends in
\file{notes.md} into \file{ideas}, it's \cmd{vault mv notes.md \cmd{/} or is a folder already, \cmd{OLD} goes inside it. Neither will
ideas/notes.md}, not \cmd{\ldots\ ideas/}. It won't overwrite an existing name, overwrite an existing name, and neither re-encrypts anything, so both are
and it doesn't re-encrypt the file, so it's instant however big the file is. instant however big the files are. A copy shares the original's encrypted
data, so it takes no extra space, and deleting one leaves the other intact.
\cmd{get -r} brings a whole folder back out, keeping its shape.
\shot{cp-mv}{A folder copied, a file moved into it, and the lot brought back out}
\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{Not typing your passphrase every time: \texttt{unlock}}
\begin{shell}
$ vault unlock # ask once, remember the key for 5 minutes
$ vault unlock --for 30m # or for as long as you say
$ vault lock # forget it right now
\end{shell}
After \cmd{unlock}, every command skips the passphrase prompt until the time
is up. It's a hard deadline, counted from when you unlocked, not from when you
last used it, so a laptop left open still locks itself. Run \cmd{unlock}
again to start a fresh countdown.
\shot{unlock}{Unlock once, work without prompts, lock when you're done}
\subsection{How it keeps the key safe}
\cmd{unlock} starts a small helper process, much like \cmd{ssh-agent}, that
holds your unlocked key in memory and nowhere else:
\begin{checks}
\item \textbf{Never on disk.} The key goes from \cmd{unlock} to the helper
through a pipe. Its memory is locked so it can't be swapped out, and
on Linux the helper can't be debugged or core-dumped, even by you.
\item \textbf{Only you can reach it.} It answers on a socket inside a folder
only your user account can open (in \file{\$XDG\_RUNTIME\_DIR}, or
your temporary folder). vault checks that folder's owner and
permissions every time.
\item \textbf{It wipes itself.} At the deadline, or on \cmd{vault lock}, the
key is overwritten and the helper exits. Rebooting ends it too.
\item \textbf{An explicit passphrase wins.} If \env{VAULT\_PASSPHRASE} is
set, vault uses that and ignores the helper, so scripts behave
exactly as before.
\end{checks}
\begin{headsup}
\textbf{While it's unlocked, anyone at your keyboard is you.} Anything
running as your user can use the key without a passphrase. Lock before you
walk away, and before handing your machine to anyone, decoy or not (see
\emph{The honest limits} in the next chapter).
\end{headsup}
\section{Flags work anywhere} \section{Flags work anywhere}
@@ -676,7 +765,9 @@ $ 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 [-r] NAME [-o OUT]}, \cmd{mv OLD NEW}, \cmd{cp [-r] OLD NEW}, \cmd{rm NAME}, \cmd{rm -r FOLDER}.
\item \cmd{unlock} remembers the key for 5 minutes; \cmd{lock} forgets it.
\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}
@@ -817,6 +908,9 @@ something was.
\begin{nopes} \begin{nopes}
\item It won't help if someone saw you type the real passphrase, or has \item It won't help if someone saw you type the real passphrase, or has
malware on your machine. malware on your machine.
\item It won't help if the real key is still unlocked. A command run
while \cmd{vault unlock} holds it uses the real key, whatever
passphrase you're asked to give. Run \cmd{vault lock} first.
\item It doesn't stop anyone deleting the raw files. It hides; it doesn't guard. \item It doesn't stop anyone deleting the raw files. It hides; it doesn't guard.
\item It isn't legal advice. Some places treat misleading an official as an \item It isn't legal advice. Some places treat misleading an official as an
offence in its own right. Know where you are. offence in its own right. Know where you are.
@@ -846,7 +940,7 @@ it worked.
\begin{vtable}{>{\leavevmode\ttfamily\small\color{vvioletdeep}}p{50mm}Y} \begin{vtable}{>{\leavevmode\ttfamily\small\color{vvioletdeep}}p{50mm}Y}
\thead{VARIABLE} & \thead{WHAT IT DOES} \\ \midrule \thead{VARIABLE} & \thead{WHAT IT DOES} \\ \midrule
VAULT\_PASSPHRASE & The passphrase. Skips the prompt. With \cmd{genkey}, it's the new key's passphrase. \\ VAULT\_PASSPHRASE & The passphrase. Skips the prompt, and any key held by \cmd{vault unlock}. With \cmd{genkey}, it's the new key's passphrase. \\
VAULT\_DECOY\_PASSPHRASE & \cmd{genkey} only: the decoy passphrase. If \env{VAULT\_PASSPHRASE} is set and this isn't, no decoy is made. \\ VAULT\_DECOY\_PASSPHRASE & \cmd{genkey} only: the decoy passphrase. If \env{VAULT\_PASSPHRASE} is set and this isn't, no decoy is made. \\
VAULT\_KEY & The key file. Default \file{\tld/.vault/key}. Same as \cmd{--key}. \\ VAULT\_KEY & The key file. Default \file{\tld/.vault/key}. Same as \cmd{--key}. \\
VAULT\_DIR & The vault folder. Default \file{\tld/.vault/store}. Same as \cmd{--vault}. \\ VAULT\_DIR & The vault folder. Default \file{\tld/.vault/store}. Same as \cmd{--vault}. \\
@@ -940,7 +1034,8 @@ covered.
\section{Locking is automatic} \section{Locking is automatic}
Commands that change the vault (\cmd{add}, \cmd{mv}, \cmd{rm}) take an Commands that change the vault (\cmd{add}, \cmd{migrate}, \cmd{mirror},
\cmd{absorb}, \cmd{mv}, \cmd{rm}) take an
\hl{exclusive} lock. Commands that only read (\cmd{list}, \cmd{get}) take a \hl{exclusive} lock. Commands that only read (\cmd{list}, \cmd{get}) take a
\hl{shared} one, so several can read at once. If a command has to wait, it \hl{shared} one, so several can read at once. If a command has to wait, it
says so, then carries on by itself: says so, then carries on by itself:
@@ -1184,16 +1279,29 @@ open \ldots/key: no such file or directory & No key there yet. Run \cmd{vault ge
\ldots\ is not a vault; run vault init & Wrong \cmd{--vault} or \env{VAULT\_DIR}, or you haven't run \cmd{init} yet. \\ \ldots\ is not a vault; run vault init & Wrong \cmd{--vault} or \env{VAULT\_DIR}, or you haven't run \cmd{init} yet. \\
\ldots\ is already a vault & You ran \cmd{init} on an existing vault. Nothing was changed. \\ \ldots\ is already a vault & You ran \cmd{init} on an existing vault. Nothing was changed. \\
NAME: not in the vault & Check the exact name with \cmd{vault list}: it's case-sensitive and includes the folder. Or you typed the other passphrase. \\ NAME: not in the vault & Check the exact name with \cmd{vault list}: it's case-sensitive and includes the folder. Or you typed the other passphrase. \\
\ldots\ already exists & \cmd{get}: the output file exists, so use \cmd{-o}. \cmd{mv}: the new name is taken. \cmd{genkey}: you already have a key. \\ \ldots\ already exists & \cmd{get}: the output file exists, so use \cmd{-o}. \cmd{mv}, \cmd{cp}: the new name is taken. \cmd{genkey}: you already have a key. \\
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. \\
\ldots\ is a folder; use -r & \cmd{cp} copies a folder only with \cmd{-r}. \\
cannot put \ldots\ inside itself & You tried to move or copy a folder into itself. \\
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. \\
\end{vtable}
\subsection{Passphrases, the unlock helper, and damage}
\begin{vtable}{>{\ttfamily\footnotesize}p{58mm}Y}
\thead{MESSAGE} & \thead{WHAT IT MEANS, AND THE FIX} \\ \midrule
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. \\
decoy passphrase must differ from the passphrase & The decoy can't be the same as the real one. \\ decoy passphrase must differ from the passphrase & The decoy can't be the same as the real one. \\
VAULT\_PASSPHRASE is set but empty & Unset it, or give it a value. \\ VAULT\_PASSPHRASE is set but empty & Unset it, or give it a value. \\
could not read passphrase (set VAULT\_PASSPHRASE\ldots) & No terminal to ask on, typically cron or CI. Set the variable. \\ could not read passphrase (set VAULT\_PASSPHRASE\ldots) & No terminal to ask on, typically cron or CI. Set the variable. \\
\ldots\ is not a private directory of this user & The folder for \cmd{unlock}'s socket belongs to someone else or is open to others. Someone may be up to something; check who made it. Commands still work, asking for the passphrase. \\
agent did not start: \ldots & \cmd{unlock} couldn't start its helper. The reason follows; commands still work, asking for the passphrase. \\
nothing was unlocked & \cmd{vault lock} found nothing to lock. Not an error. \\
waiting for another vault process to finish & Not an error. Another command is writing; this one continues when it's done. \\ waiting for another vault process to finish & Not an error. Another command is writing; this one continues when it's done. \\
vault still locked after a minute\ldots & Plan~9 and WebAssembly only. If nothing else is running, delete the file it names. \\ vault still locked after a minute\ldots & Plan~9 and WebAssembly only. If nothing else is running, delete the file it names. \\
skipping unreadable record \ldots & A catalogue entry is damaged. Everything else still works; restore \file{records/} from backup to recover that file. \\ skipping unreadable record \ldots & A catalogue entry is damaged. Everything else still works; restore \file{records/} from backup to recover that file. \\
@@ -1244,12 +1352,20 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
enhanced,colback=vmist,colframe=vline,boxrule=0.4pt,arc=4pt,left=9pt,right=9pt,top=7pt,bottom=7pt] enhanced,colback=vmist,colframe=vline,boxrule=0.4pt,arc=4pt,left=9pt,right=9pt,top=7pt,bottom=7pt]
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault genkey}\\[2pt]\small Make a key. Asks for a passphrase and an optional decoy.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault genkey}\\[2pt]\small Make a key. Asks for a passphrase and an optional decoy.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault init}\\[2pt]\small Make an empty vault, with filler. \texttt{--filler 2G} for more.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault init}\\[2pt]\small Make an empty vault, with filler. \texttt{--filler 2G} for more.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault add FILE|DIR [FOLDER]}\\[2pt]\small Encrypt in, keeping the name. Replaces an existing name.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault add FILE|DIR|URL [FOLDER]}\\[2pt]\small Encrypt in, keeping the name. Replaces an existing name.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault migrate FILE|DIR [FOLDER]}\\[2pt]\small Like add, then delete the originals. Asks before replacing.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\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 mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\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 rm NAME}\\[2pt]\small Delete one file. No undo.\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 [-r] NAME [-o OUT]}\\[2pt]\small Decrypt out, or a folder with -r. Never overwrites. Verifies first.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename or move a file or folder. Instant.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault cp [-r] OLD NEW}\\[2pt]\small Copy a file, or a folder with -r. Instant, no extra space.\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 unlock [--for 5m]}\\[2pt]\small Remember the key, so commands stop asking. Hard deadline.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault lock}\\[2pt]\small Forget the remembered key now.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox}
\end{tcbraster} \end{tcbraster}
\section*{Options and variables} \section*{Options and variables}
@@ -1261,6 +1377,10 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
\textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\ \textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\
-o OUT & \textrm{---} & the file's own name, here \\ -o OUT & \textrm{---} & the file's own name, here \\
--filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\ --filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\
--yes & \textrm{---} & ask, for \cmd{migrate} \\
-r & \textrm{---} & \cmd{rm}, \cmd{get}, \cmd{cp}: one file; \cmd{ls}: one level \\
--for DURATION & \textrm{---} & \cmd{5m}, for \cmd{unlock} \\
-f & \textrm{---} & \cmd{rm}: ask before \cmd{-r}, fail if missing \\
\end{vtable} \end{vtable}
\section*{Numbers worth knowing} \section*{Numbers worth knowing}
+84
View File
@@ -0,0 +1,84 @@
.TH VAULT-ABSORB 1 "October 2026" "vault" "User Commands"
.SH NAME
vault-absorb \- add the files in a tar archive to an encrypted vault
.SH SYNOPSIS
.B vault absorb
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ FOLDER ]
.B <
.I ARCHIVE
.SH DESCRIPTION
.B vault absorb
reads a tar archive from standard input and stores every regular file in
it, beneath
.I FOLDER
if one is given, keeping the paths, permissions and modification times
recorded in the archive. A gzip-compressed archive is detected and
unpacked automatically.
.PP
Each file is encrypted straight from the stream; nothing is unpacked to
disk. Anything already stored under the same name is replaced.
.PP
Directory entries are not stored (directories exist implicitly). Symbolic
and hard links, devices and other special entries are reported on standard
error and skipped, as is any entry whose name would escape
.I FOLDER
through
.BR .. .
A leading
.B ./
or
.B /
is dropped.
.PP
The passphrase prompt reads from the terminal, not standard input, so
it still works with an archive piped in. It waits until the archive starts
arriving, so a program feeding the pipe, such as
.BR gpg (1)
asking for its own passphrase, has finished with the terminal first.
An empty pipe fails without asking at all.
The vault is locked exclusively for the whole run.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXIT STATUS
0 if at least one file was stored and the archive was read to the end.
1 if the archive is malformed or ends early, holds no regular files, or
anything fails; files stored before the failure stay in the vault.
.SH EXAMPLES
Unpack an existing archive into the vault under
.BR backup/ :
.PP
.RS
vault absorb backup < photos.tar.gz
.RE
.PP
Store a directory under
.BR backup/photos/ :
.PP
.RS
tar \-cz photos | vault absorb backup
.RE
.PP
Pull a remote directory straight into the vault:
.PP
.RS
ssh server 'tar \-C /etc \-c .' | vault absorb server1/etc
.RE
.SH SEE ALSO
.BR vault (1),
.BR vault\-add (1),
.BR tar (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+2 -1
View File
@@ -41,6 +41,7 @@ vault add https://example.com/paper.pdf papers
.SH SEE ALSO .SH SEE ALSO
.BR vault (1), .BR vault (1),
.BR vault\-migrate (1), .BR vault\-migrate (1),
.BR vault\-mirror (1) .BR vault\-mirror (1),
.BR vault\-absorb (1)
.SH AUTHOR .SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+37
View File
@@ -0,0 +1,37 @@
.TH VAULT-CP 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-cp \- copy a stored file or folder
.SH SYNOPSIS
.B vault cp
.RB [ \-r ]
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I OLD NEW
.SH DESCRIPTION
Copies \fIOLD\fR to \fINEW\fR, following the same naming rules as
.BR vault\-mv (1).
A folder needs \fB\-r\fR. The copy shares the original's encrypted
objects rather than re-encrypting them, so it is instant and takes no
extra space; deleting either name leaves the other intact.
.SH OPTIONS
.TP
.B \-r
Copy a folder and everything under it.
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH SEE ALSO
.BR vault (1),
.BR vault\-mv (1),
.BR vault\-rm (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+11 -1
View File
@@ -3,6 +3,7 @@
vault-get \- decrypt a stored file vault-get \- decrypt a stored file
.SH SYNOPSIS .SH SYNOPSIS
.B vault get .B vault get
.RB [ \-r ]
.RB [ \-o .RB [ \-o
.IR OUTPUT ] .IR OUTPUT ]
.RB [ \-\-key .RB [ \-\-key
@@ -14,10 +15,19 @@ vault-get \- decrypt a stored file
Decrypts \fINAME\fR. Nothing appears at the output path until every object has Decrypts \fINAME\fR. Nothing appears at the output path until every object has
authenticated. The stored mode and modification time are restored. It refuses authenticated. The stored mode and modification time are restored. It refuses
to overwrite an existing file. Takes a shared lock. to overwrite an existing file. Takes a shared lock.
.PP
With \fB\-r\fR, \fINAME\fR is a folder, and every file under it is
decrypted into the output directory, keeping the folder structure. Each file
is checked as above; a clash stops the run, leaving the files already
written.
.SH OPTIONS .SH OPTIONS
.TP .TP
.BI \-o " OUTPUT" .BI \-o " OUTPUT"
Output path. Default: the base name of \fINAME\fR in the current directory. Output path, or with \fB\-r\fR output directory. Default: the base name of
\fINAME\fR in the current directory.
.TP
.B \-r
Decrypt a folder and everything under it.
.PP .PP
See also \fB\-\-key\fR and \fB\-\-vault\fR in See also \fB\-\-key\fR and \fB\-\-vault\fR in
.BR vault (1). .BR vault (1).
+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
+1
View File
@@ -0,0 +1 @@
.so man1/vault-unlock.1
+8 -4
View File
@@ -1,6 +1,6 @@
.TH VAULT-MV 1 "September 2026" "vault" "User Commands" .TH VAULT-MV 1 "September 2026" "vault" "User Commands"
.SH NAME .SH NAME
vault-mv \- rename a stored file vault-mv \- rename or move a stored file or folder
.SH SYNOPSIS .SH SYNOPSIS
.B vault mv .B vault mv
.RB [ \-\-key .RB [ \-\-key
@@ -12,9 +12,12 @@ vault-mv \- rename a stored file
.B vault rename .B vault rename
\&... \&...
.SH DESCRIPTION .SH DESCRIPTION
Renames \fIOLD\fR to \fINEW\fR, which may be in another folder. Fails if Renames \fIOLD\fR, a file or a folder and everything under it, to
\fINEW\fR exists or is not a valid vault path. File contents are not \fINEW\fR. If \fINEW\fR ends in / or names an existing folder, \fIOLD\fR
re-encrypted. moves into it, keeping its base name, as with
.BR mv (1).
Fails, changing nothing, if any new name exists or is not a valid vault
path. File contents are not re-encrypted.
.SH OPTIONS .SH OPTIONS
.TP .TP
.BI \-\-key " PATH" .BI \-\-key " PATH"
@@ -28,6 +31,7 @@ Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
Passphrase, instead of prompting on the terminal. Passphrase, instead of prompting on the terminal.
.SH SEE ALSO .SH SEE ALSO
.BR vault (1), .BR vault (1),
.BR vault\-cp (1),
.BR vault\-rm (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)
+57
View File
@@ -0,0 +1,57 @@
.TH VAULT-UNLOCK 1 "October 2026" "vault" "User Commands"
.SH NAME
vault-unlock, vault-lock \- remember the unlocked key for a while, or forget it
.SH SYNOPSIS
.B vault unlock
.RB [ \-\-for
.IR DURATION ]
.RB [ \-\-key
.IR PATH ]
.br
.B vault lock
.RB [ \-\-key
.IR PATH ]
.SH DESCRIPTION
.B unlock
asks for the passphrase, checks it, and starts a helper process, much like
.BR ssh\-agent (1),
that holds the unlocked key in memory until \fIDURATION\fR has passed
(default 5m). Until then every command using the same key file skips the
passphrase prompt. The deadline is fixed when you unlock; using the key does
not extend it. Running
.B unlock
again replaces the helper with a fresh one.
.PP
.B lock
tells the helper to wipe the key and exit at once.
.PP
The key reaches the helper over a pipe and is never written to disk. Its
memory is locked against swapping and excluded from core dumps; on Linux the
helper is also not debuggable. It answers only on a Unix socket inside
\fI$XDG_RUNTIME_DIR/vault-UID\fR (or the temporary directory), a directory
that must belong to this user with mode 0700. Rebooting ends it too.
.PP
While the key is unlocked, any program running as this user can use it, and
it is whichever key the passphrase opened, real or decoy. Lock before
handing the machine to anyone.
.SH OPTIONS
.TP
.BI \-\-for " DURATION"
How long to remember the key, such as 90s, 5m or 2h. Default 5m.
.PP
See also \fB\-\-key\fR in
.BR vault (1).
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase for
.BR unlock .
When set for any other command, it is used instead of the remembered key.
.TP
.B XDG_RUNTIME_DIR
Where the helper's socket directory goes.
.SH SEE ALSO
.BR vault (1),
.BR ssh\-agent (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+26 -6
View File
@@ -33,21 +33,40 @@ Like add, then delete each original.
.BR vault\-mirror (1) .BR vault\-mirror (1)
Crawl a website into the vault. Crawl a website into the vault.
.TP .TP
.BR vault\-absorb (1)
Add every file in a tar archive read from standard input.
.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, or a whole folder with
.BR \-r .
.TP .TP
.BR vault\-mv (1) .BR vault\-mv (1)
Rename a stored file (alias: Rename or move a stored file or folder (alias:
.BR rename ). .BR rename ).
.TP .TP
.BR vault\-cp (1)
Copy a stored file, or a whole folder with
.BR \-r .
.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
.BR vault\-unlock (1)
Remember the unlocked key for a while (default 5 minutes), so commands
skip the passphrase.
.TP
.BR vault\-lock (1)
Forget it now.
.TP
.B help .B help
Print usage. Print usage.
.SH OPTIONS .SH OPTIONS
@@ -67,7 +86,8 @@ Default key file.
Default vault directory. Default vault directory.
.TP .TP
.B VAULT_PASSPHRASE .B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal. Passphrase, instead of prompting on the terminal or using a key remembered by
.BR vault\-unlock (1).
.TP .TP
.B VAULT_DECOY_PASSPHRASE .B VAULT_DECOY_PASSPHRASE
Decoy passphrase used by Decoy passphrase used by
+271 -20
View File
@@ -5,6 +5,7 @@
package main package main
import ( import (
"bufio"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -17,6 +18,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/andydixon/vault/internal/term" "github.com/andydixon/vault/internal/term"
) )
@@ -35,16 +37,36 @@ const usage = `Usage:
vault mirror URL [FOLDER] crawl a website (same host only, one request vault mirror URL [FOLDER] crawl a website (same host only, one request
at a time) into FOLDER/HOST, rewriting its at a time) into FOLDER/HOST, rewriting its
links to relative paths links to relative paths
vault list [PREFIX] list stored files vault absorb [FOLDER] read a tar archive (optionally gzipped) from
standard input and add every file in it,
optionally under a folder inside the vault
vault ls [FOLDER] list one folder: its files, and its
sub-folders (ending in /) with their
total size; -r lists everything under it
vault list [FOLDER] list every stored file under FOLDER,
with full names
vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name) vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name)
vault mv OLD NEW rename a stored file vault get -r FOLDER [-o DIR] decrypt a folder and everything under it
into DIR (default: the folder's base name)
vault mv OLD NEW rename or move a file or folder; NEW
ending in / or naming an existing folder
moves OLD into it
vault cp [-r] OLD NEW copy a file, or with -r a folder, like mv;
copies share their encrypted data
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
vault unlock [--for DURATION] remember the passphrase for DURATION
(default 5m), so commands skip asking
vault lock forget it now
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)
--vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store) --vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store)
The passphrase is read from the terminal, or from $VAULT_PASSPHRASE. The passphrase is read from the terminal, or from $VAULT_PASSPHRASE. After
vault unlock, commands use the remembered key instead, unless
$VAULT_PASSPHRASE is set.
Decoy passphrase: genkey offers a second, optional passphrase Decoy passphrase: genkey offers a second, optional passphrase
($VAULT_DECOY_PASSPHRASE when scripted). It unlocks a separate key, so ($VAULT_DECOY_PASSPHRASE when scripted). It unlocks a separate key, so
@@ -69,6 +91,10 @@ func main() {
os.Exit(2) os.Exit(2)
} }
cmd, args := os.Args[1], os.Args[2:] cmd, args := os.Args[1], os.Args[2:]
if cmd == agentCmd && len(args) == 2 {
runAgent(args[0], args[1], os.Stdin, os.Stdout)
return
}
fset := flag.NewFlagSet("vault "+cmd, flag.ContinueOnError) fset := flag.NewFlagSet("vault "+cmd, flag.ContinueOnError)
fset.SetOutput(io.Discard) fset.SetOutput(io.Discard)
keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "") keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "")
@@ -76,6 +102,11 @@ 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, "")
ttl := fset.Duration("for", 5*time.Minute, "")
// 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)
@@ -98,6 +129,7 @@ func main() {
fail("decoy passphrase must differ from the passphrase") fail("decoy passphrase must differ from the passphrase")
} }
check(writeKeyFile(*keyPath, pass, decoy)) check(writeKeyFile(*keyPath, pass, decoy))
agentStop(*keyPath) // one left from an old key at this path would serve it
fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath) fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath)
case "init": case "init":
want(0, 0) want(0, 0)
@@ -146,6 +178,26 @@ func main() {
cat, err := s.catalogue() cat, err := s.catalogue()
check(err) check(err)
check(mirror(s, cat, pos[0], dest)) check(mirror(s, cat, pos[0], dest))
case "absorb":
want(0, 1)
if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
fail("absorb reads a tar archive from standard input; pipe one in")
}
dest := ""
if len(pos) == 1 {
dest = strings.Trim(pos[0], "/")
}
// Wait for the archive to start arriving before asking for the
// passphrase, so a program feeding the pipe (gpg, ssh) has finished
// with the terminal and cannot bury the prompt.
in := bufio.NewReader(os.Stdin)
if _, err := in.Peek(1); err != nil {
fail("nothing to absorb on standard input")
}
s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue()
check(err)
check(absorb(s, cat, in, dest))
case "list", "ls": case "list", "ls":
want(0, 1) want(0, 1)
prefix := "" prefix := ""
@@ -154,20 +206,106 @@ 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)
out := *output out := *output
if out == "" { if out == "" {
out = path.Base(pos[0]) out = path.Base(strings.TrimRight(pos[0], "/"))
} }
check(open(*keyPath, *vaultDir, false).get(pos[0], out)) s := open(*keyPath, *vaultDir, false)
case "mv", "rename": if !*recurse {
check(s.get(pos[0], out))
break
}
cat, err := s.catalogue()
check(err)
folder := strings.Trim(pos[0], "/")
names := under(cat, folder)
if folder == "" || len(names) == 0 || len(cat[folder]) > 0 {
fail("%s: not a folder in the vault", pos[0])
}
for _, n := range names {
dst := filepath.Join(out, filepath.FromSlash(n[len(folder)+1:]))
check(os.MkdirAll(filepath.Dir(dst), 0o700))
check(s.getRecord(cat[n][0], dst))
fmt.Fprintf(os.Stderr, "extracted %s\n", dst)
}
case "mv", "rename", "cp":
want(2, 2) want(2, 2)
check(open(*keyPath, *vaultDir, true).rename(pos[0], pos[1])) s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue()
check(err)
pairs, err := transfers(cat, pos[0], pos[1], cmd != "cp" || *recurse)
check(err)
verb := "copied"
if cmd != "cp" {
verb = "moved"
}
check(s.copyAll(cat, pairs, cmd != "cp", func(from, to string) {
fmt.Fprintf(os.Stderr, "%s %s -> %s\n", verb, from, to)
}))
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 "unlock":
want(0, 0)
if *ttl <= 0 {
fail("--for needs a positive duration, such as 5m or 1h")
}
pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv)
check(err)
id, err := unlockKeyFile(*keyPath, pass)
check(err)
check(agentStart(*keyPath, id, *ttl))
id.Close()
fmt.Fprintf(os.Stderr, "unlocked until %s; vault lock forgets the key sooner\n",
time.Now().Add(*ttl).Format("15:04:05"))
case "lock":
want(0, 0)
if agentStop(*keyPath) {
fmt.Fprintln(os.Stderr, "locked")
} else {
fmt.Fprintln(os.Stderr, "nothing was unlocked")
}
case "help", "-h", "--help": case "help", "-h", "--help":
fmt.Println(usage) fmt.Println(usage)
default: default:
@@ -175,6 +313,51 @@ func main() {
} }
} }
// transfers works out, like mv(1) and cp(1), which stored names src and dst
// mean: each file to copy or move, paired with its new name. src is a file,
// or with recursive a folder. dst ending in / or naming an existing folder
// means into it. No new name may already exist.
func transfers(cat map[string][]*record, src, dst string, recursive bool) ([][2]string, error) {
into := dst == "" || strings.HasSuffix(dst, "/")
src, dst = strings.Trim(src, "/"), strings.Trim(dst, "/")
if src == "" {
return nil, errors.New("name a file or folder to copy or move")
}
if dst == "" || len(cat[dst]) == 0 && len(under(cat, dst)) > 0 {
into = true
}
if into {
dst = path.Join(dst, path.Base(src))
}
var pairs [][2]string
if len(cat[src]) > 0 {
pairs = [][2]string{{src, dst}}
} else {
names := under(cat, src)
if len(names) == 0 {
return nil, fmt.Errorf("%s: not in the vault", src)
}
if !recursive {
return nil, fmt.Errorf("%s is a folder; use -r", src)
}
if dst == src || strings.HasPrefix(dst, src+"/") {
return nil, fmt.Errorf("cannot put %s inside itself", src)
}
for _, n := range names {
pairs = append(pairs, [2]string{n, dst + n[len(src):]})
}
}
for _, p := range pairs {
if !validName(p[1]) {
return nil, fmt.Errorf("invalid vault path %q", p[1])
}
if len(cat[p[1]]) > 0 {
return nil, fmt.Errorf("%s already exists", p[1])
}
}
return pairs, nil
}
// parseInterleaved lets flags appear before, between or after positional // parseInterleaved lets flags appear before, between or after positional
// arguments. // arguments.
func parseInterleaved(fset *flag.FlagSet, args []string) ([]string, error) { func parseInterleaved(fset *flag.FlagSet, args []string) ([]string, error) {
@@ -221,9 +404,7 @@ func defaultPath(env, name string) string {
// open unlocks the key and opens the vault, holding the vault lock // open unlocks the key and opens the vault, holding the vault lock
// exclusively for writers and shared for readers. // exclusively for writers and shared for readers.
func open(keyPath, vaultDir string, exclusive bool) *store { func open(keyPath, vaultDir string, exclusive bool) *store {
pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv) id, err := keyIdentity(keyPath)
check(err)
id, err := unlockKeyFile(keyPath, pass)
check(err) check(err)
s, err := openStore(vaultDir, id) s, err := openStore(vaultDir, id)
check(err) check(err)
@@ -360,11 +541,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)
} }
@@ -431,14 +623,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)
+94 -25
View File
@@ -14,6 +14,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"math" "math"
mrand "math/rand/v2" mrand "math/rand/v2"
"os" "os"
@@ -382,30 +383,43 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
if !info.Mode().IsRegular() || info.Size() > fileLimit { if !info.Mode().IsRegular() || info.Size() > fileLimit {
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src) return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
} }
replaced, err := s.addReader(cat, f, info.Size(), info.Mode().Perm(), info.ModTime(), name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return replaced, err
}
// addReader stores size bytes from r as name, like add. Reading fewer than
// size bytes stores nothing and fails with io.ErrUnexpectedEOF or io.EOF.
func (s *store) addReader(cat map[string][]*record, r io.Reader, size int64, mode fs.FileMode, mtime time.Time, name string) (bool, error) {
if !validName(name) {
return false, fmt.Errorf("invalid vault path %q", name)
}
if size < 0 || size > fileLimit {
return false, fmt.Errorf("%s: larger than 1 TiB", name)
}
seed, err := randomHex(objectSeedSize) seed, err := randomHex(objectSeedSize)
if err != nil { if err != nil {
return false, err return false, err
} }
if err := s.writeObjects(f, seed, info.Size()); err != nil { if err := s.writeObjects(r, seed, size); err != nil {
s.dropObjects(seed, info.Size()) s.dropObjects(seed, size)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return false, err return false, err
} }
r := &record{ rec := &record{
Name: name, Seed: seed, Size: info.Size(), Mode: uint32(info.Mode().Perm()), Name: name, Seed: seed, Size: size, Mode: uint32(mode.Perm()),
Modified: info.ModTime().UTC(), Added: time.Now().UTC(), Modified: mtime.UTC(), Added: time.Now().UTC(),
} }
if err := s.writeRecord(r); err != nil { if err := s.writeRecord(rec); err != nil {
s.dropObjects(seed, info.Size()) s.dropObjects(seed, size)
return false, err return false, err
} }
replaced := len(cat[name]) > 0 replaced := len(cat[name]) > 0
if err := s.remove(cat, name, true); err != nil { if err := s.remove(cat, name, true); err != nil {
return replaced, err return replaced, err
} }
cat[name] = []*record{r} cat[name] = []*record{rec}
return replaced, nil return replaced, nil
} }
@@ -523,26 +537,77 @@ 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) {})
} }
func (s *store) rename(oldName, newName string) error { // under returns the names in cat at or beneath the folder prefix, sorted.
if !validName(newName) { func under(cat map[string][]*record, prefix string) []string {
return fmt.Errorf("invalid vault path %q", newName) var names []string
for name := range cat {
if name == prefix || strings.HasPrefix(name, prefix+"/") {
names = append(names, name)
}
} }
cat, r, err := s.lookup(oldName) sort.Strings(names)
if err != nil { return names
return err }
// 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
} }
if len(cat[newName]) > 0 { inUse := map[string]bool{}
return fmt.Errorf("%s already exists", newName) for name, rs := range cat {
if !gone[name] {
for _, r := range rs {
inUse[r.Seed] = true
}
}
} }
moved := *r for _, name := range names {
moved.Name = newName for _, r := range cat[name] {
if err := s.writeRecord(&moved); err != nil { if err := s.removeFile(recordsDir, r.id); err != nil {
return err return err
}
if !inUse[r.Seed] {
if err := s.dropObjects(r.Seed, r.Size); err != nil {
return err
}
}
}
delete(cat, name)
done(name)
} }
return s.remove(cat, oldName, false) return nil
}
// copyAll stores each pair's first name again under its second, sharing its
// objects, then with move deletes the first names. New records go first, so
// an interruption leaves a file under both names, never neither.
func (s *store) copyAll(cat map[string][]*record, pairs [][2]string, move bool, done func(from, to string)) error {
var old []string
for _, p := range pairs {
r := *cat[p[0]][0]
r.Name = p[1]
if !move {
r.Added = time.Now().UTC()
}
if err := s.writeRecord(&r); err != nil {
return err
}
cat[p[1]] = []*record{&r}
old = append(old, p[0])
done(p[0], p[1])
}
if !move {
return nil
}
return s.removeAll(cat, old, func(string) {})
} }
// get decrypts name to out. Nothing appears at out until every object, // get decrypts name to out. Nothing appears at out until every object,
@@ -552,6 +617,10 @@ func (s *store) get(name, out string) error {
if err != nil { if err != nil {
return err return err
} }
return s.getRecord(r, out)
}
func (s *store) getRecord(r *record, out string) error {
if _, err := os.Lstat(out); err == nil { if _, err := os.Lstat(out); err == nil {
return fmt.Errorf("%s already exists", out) return fmt.Errorf("%s already exists", out)
} }
+261 -1
View File
@@ -4,9 +4,12 @@
package main package main
import ( import (
"archive/tar"
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"crypto/rand" "crypto/rand"
"encoding/binary"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
@@ -15,6 +18,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -72,7 +76,11 @@ func TestStoreLifecycle(t *testing.T) {
if cat, _ = s.catalogue(); len(cat) != 2 || len(cat["a/b"]) != 1 { if cat, _ = s.catalogue(); len(cat) != 2 || len(cat["a/b"]) != 1 {
t.Fatalf("catalogue = %v", cat) t.Fatalf("catalogue = %v", cat)
} }
if err := s.rename("a/b", "d"); err != nil { pairs, err := transfers(cat, "a/b", "d", false)
if err != nil {
t.Fatal(err)
}
if err := s.copyAll(cat, pairs, true, func(string, string) {}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
out := filepath.Join(dir, "out") out := filepath.Join(dir, "out")
@@ -430,3 +438,255 @@ func TestMirror(t *testing.T) {
os.Remove(out) os.Remove(out)
} }
} }
func TestAbsorb(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
t.Fatal(err)
}
s := newTestStore(t, keyPath, "pass")
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
mtime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
for _, h := range []*tar.Header{
{Name: "./d/", Typeflag: tar.TypeDir, Mode: 0o755},
{Name: "./d/a.txt", Size: 5, Mode: 0o600, ModTime: mtime},
{Name: "../escape", Size: 5, Mode: 0o600},
{Name: "./d/link", Typeflag: tar.TypeSymlink, Linkname: "a.txt"},
} {
tw.WriteHeader(h)
if h.Size > 0 {
io.WriteString(tw, "hello")
}
}
tw.Close()
zw.Close()
cat, _ := s.catalogue()
if err := absorb(s, cat, &buf, "in"); err != nil {
t.Fatal(err)
}
if len(cat) != 1 || len(cat["in/d/a.txt"]) != 1 {
t.Fatalf("catalogue = %v, want only in/d/a.txt", cat)
}
if r := cat["in/d/a.txt"][0]; !r.Modified.Equal(mtime) || r.Mode != 0o600 {
t.Errorf("record = %v, %o", r.Modified, r.Mode)
}
out := filepath.Join(dir, "out")
if err := s.get("in/d/a.txt", out); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(out); string(b) != "hello" {
t.Errorf("got %q", b)
}
}
func TestRemoveTree(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
t.Fatal(err)
}
s := newTestStore(t, keyPath, "pass")
objects := filepath.Join(dir, "store", "objects")
before := len(ciphertexts(t, objects))
cat, _ := s.catalogue()
src := filepath.Join(dir, "in")
big := make([]byte, chunkData+1) // two objects
rand.Read(big)
for name, body := range map[string][]byte{"a/x": big, "a/b/y": []byte("y"), "ab/z": []byte("z"), "b/w": []byte("w")} {
os.WriteFile(src, body, 0o600)
if _, err := s.add(cat, src, name); err != nil {
t.Fatal(err)
}
}
if n := len(ciphertexts(t, objects)) - before; n != 5 {
t.Fatalf("%d objects added, want 5", n)
}
names := under(cat, "a")
if !slices.Equal(names, []string{"a/b/y", "a/x"}) {
t.Fatalf("under a: %v", names)
}
var done []string
if err := s.removeAll(cat, names, func(n string) { done = append(done, n) }); err != nil {
t.Fatal(err)
}
if !slices.Equal(done, names) || len(cat) != 2 || len(cat["ab/z"]) != 1 || len(cat["b/w"]) != 1 {
t.Fatalf("done %v, left %v", done, cat)
}
if n := len(ciphertexts(t, objects)) - before; n != 2 {
t.Fatalf("%d objects left, want 2: the space was not freed", n)
}
fresh, _ := s.catalogue()
if len(fresh) != 2 {
t.Fatalf("on disk: %v", fresh)
}
out := filepath.Join(dir, "out")
if err := s.get("ab/z", out); err != nil {
t.Fatal(err)
}
}
func TestLs(t *testing.T) {
at := func(m int) time.Time { return time.Date(2026, 1, 1, 12, m, 0, 0, time.UTC) }
cat := map[string][]*record{
"notes.md": {{Size: 10, Modified: at(1)}},
"a/x": {{Size: 100, Modified: at(2)}},
"a/b/y": {{Size: 1000, Modified: at(5)}},
"a/b/c/z": {{Size: 1, Modified: at(3)}},
"ab/z": {{Size: 7, Modified: at(4)}},
"photos/2025/img1": {{Size: 2048, Modified: at(6)}},
}
names := func(folder string) []string {
t.Helper()
var b bytes.Buffer
if err := ls(&b, cat, folder); err != nil {
t.Fatal(err)
}
var got []string
for _, line := range strings.Split(strings.TrimRight(b.String(), "\n"), "\n") {
got = append(got, strings.TrimSpace(line[10:]))
}
return got
}
day := func(m int) string { return at(m).Local().Format("2006-01-02 15:04") + " " }
if got, want := names(""), []string{day(5) + "a/", day(4) + "ab/", day(6) + "photos/", day(1) + "notes.md"}; !slices.Equal(got, want) {
t.Fatalf("ls: %q, want %q", got, want)
}
if got, want := names("a"), []string{day(5) + "b/", day(2) + "x"}; !slices.Equal(got, want) {
t.Fatalf("ls a: %q, want %q", got, want)
}
if got, want := names("a/b"), []string{day(3) + "c/", day(5) + "y"}; !slices.Equal(got, want) {
t.Fatalf("ls a/b: %q, want %q", got, want)
}
if got, want := names("a/x"), []string{day(2) + "x"}; !slices.Equal(got, want) {
t.Fatalf("ls a/x: %q, want %q", got, want)
}
var b bytes.Buffer
ls(&b, cat, "")
if !strings.HasPrefix(b.String(), " 1.1 KiB ") {
t.Fatalf("folder a/ should total 1101 bytes: %q", b.String())
}
if err := ls(&b, cat, "nope"); err == nil {
t.Fatal("ls of a missing folder succeeded")
}
}
func TestCopyMove(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")
cat, _ := s.catalogue()
src := filepath.Join(dir, "in")
for _, name := range []string{"a/x", "a/b/y", "ab/z", "c/w"} {
os.WriteFile(src, []byte(name), 0o600)
if _, err := s.add(cat, src, name); err != nil {
t.Fatal(err)
}
}
run := func(src, dst string, recursive, move bool) error {
pairs, err := transfers(cat, src, dst, recursive)
if err != nil {
return err
}
return s.copyAll(cat, pairs, move, func(string, string) {})
}
for _, bad := range [][2]string{{"a", "d"}, {"a/x", "ab/z"}, {"nope", "d"}, {"", "d"}} {
if run(bad[0], bad[1], false, false) == nil {
t.Errorf("cp %s %s succeeded", bad[0], bad[1])
}
}
if run("a", "a/b", true, true) == nil {
t.Error("moved a folder into itself")
}
if err := run("a", "d", true, false); err != nil { // cp -r a d
t.Fatal(err)
}
if err := run("d", "c/", true, true); err != nil { // mv d c/
t.Fatal(err)
}
if err := run("ab/z", "c", false, true); err != nil { // mv ab/z into existing folder c
t.Fatal(err)
}
if err := s.removeAll(cat, under(cat, "a"), func(string) {}); err != nil {
t.Fatal(err)
}
fresh, _ := s.catalogue()
var names []string
for n := range fresh {
names = append(names, n)
}
slices.Sort(names)
if want := []string{"c/d/b/y", "c/d/x", "c/w", "c/z"}; !slices.Equal(names, want) {
t.Fatalf("names %v, want %v", names, want)
}
out := filepath.Join(dir, "out")
if err := s.get("c/d/x", out); err != nil {
t.Fatal("copy lost its data when the original went: ", err)
}
if b, _ := os.ReadFile(out); string(b) != "a/x" {
t.Errorf("got %q", b)
}
}
func TestAgent(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", dir)
t.Setenv(passphraseEnv, "") // restored after the test
os.Unsetenv(passphraseEnv) // so keyIdentity asks the agent
keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err)
}
id, err := unlockKeyFile(keyPath, "pass")
if err != nil {
t.Fatal(err)
}
if _, err := agentIdentity(keyPath); err == nil {
t.Fatal("identity from an agent that is not running")
}
sock, err := agentSocket(keyPath)
if err != nil {
t.Fatal(err)
}
secret := binary.BigEndian.AppendUint16(nil, id.SuiteID())
secret = append(secret, id.Seed()...)
pr, pw := io.Pipe()
stopped := make(chan struct{})
go func() { runAgent(sock, "1m", bytes.NewReader(secret), pw); close(stopped) }()
if msg, _ := io.ReadAll(pr); string(msg) != "ok" {
t.Fatalf("agent said %q", msg)
}
got, err := keyIdentity(keyPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.Seed(), id.Seed()) || got.SuiteID() != id.SuiteID() {
t.Fatal("agent returned a different key")
}
if !agentStop(keyPath) {
t.Fatal("agent was not running")
}
<-stopped
if _, err := os.Stat(sock); !os.IsNotExist(err) {
t.Fatal("socket left behind")
}
if agentStop(keyPath) {
t.Fatal("agent still running")
}
}