Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec204f0ea2 | ||
|
|
0d45ecea59 | ||
|
|
730877823e | ||
|
|
1403be8b1c | ||
|
|
fc5a856487 | ||
|
|
55af82f95f | ||
|
|
92ff8546fd | ||
|
|
0b3dcf08f2 | ||
|
|
10f2a88493 | ||
|
|
e40a803367 | ||
|
|
eb138b5524 |
@@ -7,7 +7,16 @@ This file provides guidance to Claude Code when working with this repository.
|
|||||||
vault is a post-quantum encrypted file vault: one `package main` at the repo
|
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`, `list`, `get`, `mv`, `rm`.
|
- `main.go` - CLI: `genkey`, `init`, `add` (file, directory
|
||||||
|
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.
|
||||||
@@ -46,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.
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ A full illustrated guide lives in `docs/guide`. Build the PDF with
|
|||||||
`docs/guide/build-pdf.sh`, which fetches Tectonic if it isn't installed.
|
`docs/guide/build-pdf.sh`, which fetches Tectonic if it isn't installed.
|
||||||
`docs/guide/shots.py` regenerates its screenshots from the real binary.
|
`docs/guide/shots.py` regenerates its screenshots from the real binary.
|
||||||
|
|
||||||
|
## Man pages
|
||||||
|
|
||||||
|
`docs/*.1` holds `vault(1)` and one page per command (`vault-add(1)`,
|
||||||
|
`vault-mirror(1)` and so on). Install them with
|
||||||
|
`cp docs/*.1 ~/.local/share/man/man1/`.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -39,12 +45,27 @@ vault init --filler 2G # the same, with at least 2 GiB of filler
|
|||||||
vault add report.pdf # stored as report.pdf
|
vault add report.pdf # stored as report.pdf
|
||||||
vault add report.pdf work/2026 # stored as work/2026/report.pdf
|
vault add report.pdf work/2026 # stored as work/2026/report.pdf
|
||||||
vault add ./photos # every regular file under photos/
|
vault add ./photos # every regular file under photos/
|
||||||
vault list # everything
|
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 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
|
||||||
@@ -62,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -16,6 +16,7 @@ import glob
|
|||||||
import grp
|
import grp
|
||||||
import html
|
import html
|
||||||
import os
|
import os
|
||||||
|
import pty
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -94,6 +95,28 @@ class Sandbox:
|
|||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||||
return self.tidy(p.stdout)
|
return self.tidy(p.stdout)
|
||||||
|
|
||||||
|
def run_tty(self, command, passphrase, answer):
|
||||||
|
"""Run on a pseudo-terminal, typing answer at vault's [y/N] question."""
|
||||||
|
env = self.env({"VAULT_PASSPHRASE": passphrase})
|
||||||
|
pid, fd = pty.fork()
|
||||||
|
if pid == 0:
|
||||||
|
os.chdir(self.home)
|
||||||
|
os.execve("/bin/bash", ["bash", "-c", command], env)
|
||||||
|
out = b""
|
||||||
|
while b"[y/N]" not in out:
|
||||||
|
out += os.read(fd, 1024)
|
||||||
|
os.write(fd, answer.encode() + b"\n")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = os.read(fd, 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out += chunk
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.waitpid(pid, 0)
|
||||||
|
return self.tidy(out.decode().replace("\r\n", "\n"))
|
||||||
|
|
||||||
def tidy(self, text):
|
def tidy(self, text):
|
||||||
owner = " %s %s " % (getpass.getuser(), grp.getgrgid(os.getgid()).gr_name)
|
owner = " %s %s " % (getpass.getuser(), grp.getgrgid(os.getgid()).gr_name)
|
||||||
return text.replace(self.home, SHOWN_HOME).replace(owner, " sam sam ").rstrip("\n")
|
return text.replace(self.home, SHOWN_HOME).replace(owner, " sam sam ").rstrip("\n")
|
||||||
@@ -243,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)
|
||||||
@@ -278,6 +302,61 @@ def main():
|
|||||||
t.cmd("find ~/.vault/store/records -type f | wc -l")
|
t.cmd("find ~/.vault/store/records -type f | wc -l")
|
||||||
shoot("decoy-hidden", t)
|
shoot("decoy-hidden", t)
|
||||||
|
|
||||||
|
# migrate: one file is already in the vault, so it asks first
|
||||||
|
box.write("Scans/receipts/march.pdf", os.urandom(48_113))
|
||||||
|
box.write("Scans/receipts/april.pdf", os.urandom(51_870))
|
||||||
|
box.run("vault add Scans/receipts/march.pdf receipts", P)
|
||||||
|
t = Term(box)
|
||||||
|
t.lines.append(prompt() + '<span class="c">vault migrate Scans/receipts</span>')
|
||||||
|
for line in box.run_tty("vault migrate Scans/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("ls Scans")
|
||||||
|
shoot("migrate", t)
|
||||||
|
|
||||||
|
# rm -r: lists the folder, asks, then frees the space
|
||||||
|
t = Term(box)
|
||||||
|
t.cmd("vault ls", P)
|
||||||
|
t.lines.append(prompt() + '<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",))
|
||||||
@@ -308,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"
|
||||||
|
|||||||
@@ -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,18 +511,20 @@ 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}}
|
||||||
|
|
||||||
\begin{shell}
|
\begin{shell}
|
||||||
$ vault add FILE-OR-FOLDER [FOLDER-IN-VAULT]
|
$ vault add FILE-OR-FOLDER-OR-URL [FOLDER-IN-VAULT]
|
||||||
\end{shell}
|
\end{shell}
|
||||||
|
|
||||||
Give it a file, and it goes in under its own name. Give it a second argument,
|
Give it a file, and it goes in under its own name. Give it a second argument,
|
||||||
@@ -540,6 +542,8 @@ Notice what happened there:
|
|||||||
\file{id/passport-scan.jpg}.
|
\file{id/passport-scan.jpg}.
|
||||||
\item Adding the folder \file{Documents/letters} kept its own name and
|
\item Adding the folder \file{Documents/letters} kept its own name and
|
||||||
structure: \file{letters/bank.txt}, \file{letters/landlord.txt}.
|
structure: \file{letters/bank.txt}, \file{letters/landlord.txt}.
|
||||||
|
\item Adding \file{.} (or \file{..}) brings in that folder's
|
||||||
|
\emph{contents}, subfolders and all, without a folder name of its own.
|
||||||
\end{itemize}
|
\end{itemize}
|
||||||
|
|
||||||
\begin{headsup}
|
\begin{headsup}
|
||||||
@@ -553,22 +557,81 @@ Adding a name that already exists \hl{replaces} it, and vault tells you so.
|
|||||||
The old version is deleted \emph{after} the new one is safely stored, so a
|
The old version is deleted \emph{after} the new one is safely stored, so a
|
||||||
crash halfway through never leaves you with neither.
|
crash halfway through never leaves you with neither.
|
||||||
|
|
||||||
|
\subsection{Straight from the web}
|
||||||
|
Give \cmd{add} an \file{http://} or \file{https://} address and vault
|
||||||
|
downloads it (as Chrome, so fussy sites play along) and stores it under the
|
||||||
|
last part of the address, such as \file{report.pdf}. The download sits in a
|
||||||
|
private temporary file only until it's encrypted.
|
||||||
|
|
||||||
|
\subsection{Copying a website: \texttt{mirror}}
|
||||||
|
\cmd{mirror} takes an \file{http://} or \file{https://} address and copies
|
||||||
|
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.
|
||||||
|
|
||||||
|
\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}}
|
||||||
|
\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
|
||||||
|
URL; there's no original to delete.
|
||||||
|
|
||||||
|
Because the originals go, \cmd{migrate} checks first: if anything would
|
||||||
|
\hl{replace} a file already in the vault, it lists those names and asks
|
||||||
|
before touching anything. Answer anything but \texttt{y} and nothing changes.
|
||||||
|
With no terminal to ask on, the answer is no. For scripts, \texttt{--yes}
|
||||||
|
answers yes up front.
|
||||||
|
|
||||||
|
\shot{migrate}{Moving a folder in. One file was already there, so vault asked first.}
|
||||||
|
|
||||||
\subsection{What gets skipped}
|
\subsection{What gets skipped}
|
||||||
When adding a folder, anything that isn't a plain file, such as symbolic
|
When adding a folder, anything that isn't a plain file, such as symbolic
|
||||||
links, sockets or devices, is skipped with a note. Files larger than 1\,TiB are
|
links, sockets or devices, is skipped with a note. So are the vault and key
|
||||||
|
themselves, should they live inside the folder, so \cmd{migrate} can never
|
||||||
|
eat them. Files larger than 1\,TiB are
|
||||||
refused. (If you have a single 1\,TiB file, we have questions.)
|
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
|
||||||
@@ -581,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}
|
||||||
@@ -598,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}
|
||||||
|
|
||||||
@@ -647,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}
|
||||||
@@ -788,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.
|
||||||
@@ -817,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}. \\
|
||||||
@@ -911,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:
|
||||||
@@ -1155,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. \\
|
||||||
@@ -1215,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}
|
||||||
@@ -1232,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}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.TH VAULT-ADD 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-add \- encrypt files, directories or URLs into the vault
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault add
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.IR FILE | DIR | URL
|
||||||
|
.RI [ FOLDER ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Encrypts a file into the vault, named after its base name. A directory is
|
||||||
|
added recursively, keeping its structure beneath its own name; a directory
|
||||||
|
named "." or ".." adds its contents directly. Non-regular files, the vault
|
||||||
|
and the key are skipped. A stored name that already exists is replaced.
|
||||||
|
.PP
|
||||||
|
An \fBhttp://\fR or \fBhttps://\fR \fIURL\fR is downloaded with a Chrome user
|
||||||
|
agent and stored under the last element of its final (post-redirect) path, or
|
||||||
|
the host name if there is none. Only a 200 response is accepted.
|
||||||
|
.PP
|
||||||
|
\fIFOLDER\fR places the result inside a folder in the vault. File contents
|
||||||
|
are cut into 4 MiB chunks, the last bucket-padded.
|
||||||
|
.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 EXAMPLES
|
||||||
|
.nf
|
||||||
|
vault add report.pdf work/2026
|
||||||
|
vault add ./photos
|
||||||
|
vault add https://example.com/paper.pdf papers
|
||||||
|
.fi
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-migrate (1),
|
||||||
|
.BR vault\-mirror (1),
|
||||||
|
.BR vault\-absorb (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.TH VAULT-GENKEY 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-genkey \- create a new vault key
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault genkey
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Creates the key file, prompting twice for a passphrase, then for an optional
|
||||||
|
decoy passphrase (empty for none). Both passphrase slots are sealed with
|
||||||
|
Argon2id and XChaCha20-Poly1305, are the same size, and are stored in random
|
||||||
|
order; the second holds either a decoy identity or random bytes. It refuses to
|
||||||
|
overwrite an existing file. No seed or private key is ever written unencrypted.
|
||||||
|
.PP
|
||||||
|
When \fBVAULT_PASSPHRASE\fR is set, no prompts are made and the decoy comes
|
||||||
|
from \fBVAULT_DECOY_PASSPHRASE\fR, or is omitted.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.BI \-\-key " PATH"
|
||||||
|
Where to write the key. Default: $VAULT_KEY, else ~/.vault/key.
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.B VAULT_PASSPHRASE
|
||||||
|
Passphrase, instead of prompting on the terminal.
|
||||||
|
.TP
|
||||||
|
.B VAULT_DECOY_PASSPHRASE
|
||||||
|
Decoy passphrase; must differ from the passphrase.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-init (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.TH VAULT-GET 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-get \- decrypt a stored file
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault get
|
||||||
|
.RB [ \-r ]
|
||||||
|
.RB [ \-o
|
||||||
|
.IR OUTPUT ]
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.I NAME
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Decrypts \fINAME\fR. Nothing appears at the output path until every object has
|
||||||
|
authenticated. The stored mode and modification time are restored. It refuses
|
||||||
|
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
|
||||||
|
.TP
|
||||||
|
.BI \-o " OUTPUT"
|
||||||
|
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
|
||||||
|
See also \fB\-\-key\fR and \fB\-\-vault\fR in
|
||||||
|
.BR vault (1).
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.B VAULT_PASSPHRASE
|
||||||
|
Passphrase, instead of prompting on the terminal.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-list (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.TH VAULT-INIT 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-init \- create an empty vault
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault init
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.RB [ \-\-filler
|
||||||
|
.IR SIZE ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Creates the vault directory, with \fBrecords/\fR and \fBobjects/\fR each
|
||||||
|
sharded into 256 subdirectories. It writes at least \fISIZE\fR bytes of
|
||||||
|
unreadable filler so that real files cannot be told apart from it by anyone
|
||||||
|
lacking the right passphrase. Filler is always written, even when \fISIZE\fR is 0.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.BI \-\-vault " DIR"
|
||||||
|
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
|
||||||
|
.TP
|
||||||
|
.BI \-\-filler " SIZE"
|
||||||
|
Minimum filler, in bytes, with an optional K, M, G or T suffix (powers of
|
||||||
|
1024), e.g. 500M or 2G. Default: 0.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-genkey (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.TH VAULT-LIST 1 "October 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-list, vault-ls \- list files and folders in the vault
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault ls
|
||||||
|
.RB [ \-r ]
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.RI [ FOLDER ]
|
||||||
|
.br
|
||||||
|
.B vault list
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.RI [ FOLDER ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Both print one line per entry: human-readable size, modification time
|
||||||
|
(local, minute resolution) and name, in fixed-width columns so the name
|
||||||
|
always starts at character 31.
|
||||||
|
.PP
|
||||||
|
.B vault ls
|
||||||
|
shows one level, like
|
||||||
|
.BR ls (1):
|
||||||
|
each sub-folder of
|
||||||
|
.I FOLDER
|
||||||
|
(the top of the vault if none is given) once, with a trailing
|
||||||
|
.BR / ,
|
||||||
|
and the files directly in it.
|
||||||
|
A folder's size is the total of everything under it and its time is the
|
||||||
|
newest among them. Names are relative to
|
||||||
|
.IR FOLDER ,
|
||||||
|
so
|
||||||
|
.B vault ls photos
|
||||||
|
then
|
||||||
|
.B vault ls photos/2025
|
||||||
|
walks down the tree. Folders are listed first, then files, each sorted by
|
||||||
|
name. If
|
||||||
|
.I FOLDER
|
||||||
|
is a file's name, that file alone is shown.
|
||||||
|
.PP
|
||||||
|
.B vault list
|
||||||
|
shows every file at or under
|
||||||
|
.IR FOLDER ,
|
||||||
|
recursively, with full names, sorted. It is the form to pipe into other
|
||||||
|
commands, and
|
||||||
|
.B vault ls \-r
|
||||||
|
is the same.
|
||||||
|
.PP
|
||||||
|
Records the passphrase cannot open are skipped silently. Takes a shared lock.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.B \-r
|
||||||
|
.BR ls :
|
||||||
|
list everything under
|
||||||
|
.I FOLDER
|
||||||
|
with full names, as
|
||||||
|
.B vault list
|
||||||
|
does.
|
||||||
|
.TP
|
||||||
|
.BI \-\-key " PATH"
|
||||||
|
Key file. Default: $VAULT_KEY, else ~/.vault/key.
|
||||||
|
.TP
|
||||||
|
.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 on success. 1 if
|
||||||
|
.B vault ls
|
||||||
|
is given a
|
||||||
|
.I FOLDER
|
||||||
|
with nothing in or under it, or on any other error.
|
||||||
|
.SH EXAMPLES
|
||||||
|
.RS
|
||||||
|
vault ls
|
||||||
|
.br
|
||||||
|
vault ls photos/2025
|
||||||
|
.br
|
||||||
|
vault list | cut \-c31\- # every name, one per line
|
||||||
|
.RE
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-get (1),
|
||||||
|
.BR vault\-rm (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.so man1/vault-unlock.1
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.TH VAULT-MIGRATE 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-migrate \- move files into the vault, deleting the originals
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault migrate
|
||||||
|
.RB [ \-\-yes ]
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.IR FILE | DIR
|
||||||
|
.RI [ FOLDER ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Works like
|
||||||
|
.BR vault\-add (1)
|
||||||
|
but deletes each original once it is stored, then any directories left empty.
|
||||||
|
Directories still holding skipped files stay. A URL cannot be migrated.
|
||||||
|
.PP
|
||||||
|
Before storing anything it lists the vault names that would be overwritten and
|
||||||
|
asks for confirmation on the terminal. With no terminal the answer is no, and
|
||||||
|
nothing changes.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.B \-\-yes
|
||||||
|
Overwrite without asking.
|
||||||
|
.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 SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-add (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
.TH VAULT-MIRROR 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-mirror \- mirror a website into an encrypted vault
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault mirror
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.I URL
|
||||||
|
.RI [ FOLDER ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B vault mirror
|
||||||
|
crawls the website at
|
||||||
|
.I URL
|
||||||
|
and stores every page and asset it finds in the vault, beneath
|
||||||
|
.IR FOLDER / HOST
|
||||||
|
(or just
|
||||||
|
.I HOST
|
||||||
|
if no folder is given).
|
||||||
|
.PP
|
||||||
|
The crawl is single threaded: one request at a time, in breadth-first order.
|
||||||
|
It follows only links on the same host as
|
||||||
|
.IR URL ,
|
||||||
|
and requests are sent with a Chrome user agent string.
|
||||||
|
.PP
|
||||||
|
Links in HTML
|
||||||
|
.RB ( href ,
|
||||||
|
.BR src ,
|
||||||
|
.BR action
|
||||||
|
and
|
||||||
|
.B poster
|
||||||
|
attributes) and in CSS
|
||||||
|
.RB ( url(...) )
|
||||||
|
that point into the mirror are rewritten to relative paths, so the copy
|
||||||
|
browses correctly once fetched out of the vault. Links to other hosts, and
|
||||||
|
fragments, are left as they were.
|
||||||
|
.PP
|
||||||
|
Pages are named after their URL path. A path that is empty or ends in
|
||||||
|
.B /
|
||||||
|
becomes
|
||||||
|
.BR index.html ;
|
||||||
|
a query string is appended as
|
||||||
|
.BI _ query
|
||||||
|
(URL-escaped). Anything already stored under the same name is replaced.
|
||||||
|
.PP
|
||||||
|
A page that fails to download, or answers with anything but
|
||||||
|
.BR "200 OK" ,
|
||||||
|
is reported on standard error and skipped; the crawl carries on.
|
||||||
|
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 EXAMPLES
|
||||||
|
Mirror a site into the vault under
|
||||||
|
.BR example.com/ :
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
vault mirror https://example.com/
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
Mirror it under a folder, then fetch the front page:
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
vault mirror https://example.com/ archive/2026
|
||||||
|
.br
|
||||||
|
vault get archive/2026/example.com/index.html
|
||||||
|
.RE
|
||||||
|
.SH LIMITATIONS
|
||||||
|
Links are found by pattern matching, not by parsing HTML: URLs assembled by
|
||||||
|
scripts, and
|
||||||
|
.B srcset
|
||||||
|
lists, are neither followed nor rewritten. There is no depth limit, rate
|
||||||
|
limit or robots.txt handling; only mirror sites you may copy.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.TH VAULT-MV 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-mv \- rename or move a stored file or folder
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault mv
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.I OLD NEW
|
||||||
|
.br
|
||||||
|
.B vault rename
|
||||||
|
\&...
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Renames \fIOLD\fR, a file or a folder and everything under it, to
|
||||||
|
\fINEW\fR. If \fINEW\fR ends in / or names an existing folder, \fIOLD\fR
|
||||||
|
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
|
||||||
|
.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\-cp (1),
|
||||||
|
.BR vault\-rm (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
.TH VAULT-RM 1 "October 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault-rm \- delete stored files or folders
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault rm
|
||||||
|
.RB [ \-f ]
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.I NAME
|
||||||
|
.br
|
||||||
|
.B vault rm \-r
|
||||||
|
.RB [ \-f ]
|
||||||
|
.RB [ \-\-key
|
||||||
|
.IR PATH ]
|
||||||
|
.RB [ \-\-vault
|
||||||
|
.IR DIR ]
|
||||||
|
.I FOLDER
|
||||||
|
.br
|
||||||
|
.B vault delete
|
||||||
|
\&...
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Deletes the stored file
|
||||||
|
.I NAME
|
||||||
|
from the vault: its catalogue record and every encrypted object holding
|
||||||
|
its contents, so the space is freed on disk straight away.
|
||||||
|
.PP
|
||||||
|
With
|
||||||
|
.BR \-r ,
|
||||||
|
deletes
|
||||||
|
.I FOLDER
|
||||||
|
and everything at or under it. Because there is no undo, it first lists
|
||||||
|
what it will delete (the first 20 names, then a count) with the total size,
|
||||||
|
and asks on the terminal. Anything but
|
||||||
|
.B y
|
||||||
|
or
|
||||||
|
.B yes
|
||||||
|
deletes nothing, and so does having no terminal to ask on. A trailing
|
||||||
|
.B /
|
||||||
|
on
|
||||||
|
.I FOLDER
|
||||||
|
is ignored, and
|
||||||
|
.B vault rm \-r /
|
||||||
|
is refused rather than emptying the vault.
|
||||||
|
.PP
|
||||||
|
Each deleted file is reported on standard error, and
|
||||||
|
.B \-r
|
||||||
|
ends with the number of files and their total size. The vault is locked
|
||||||
|
exclusively for the whole run. A key sees only its own files, so the
|
||||||
|
decoy passphrase never deletes real files, or the other way round.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.B \-r
|
||||||
|
Delete a folder and everything under it.
|
||||||
|
.TP
|
||||||
|
.B \-f
|
||||||
|
Do not ask before a recursive delete, and do not fail when there is
|
||||||
|
nothing to delete.
|
||||||
|
.TP
|
||||||
|
.BR \-rf ", " \-fr
|
||||||
|
Both of the above.
|
||||||
|
.TP
|
||||||
|
.BI \-\-key " PATH"
|
||||||
|
Key file. Default: $VAULT_KEY, else ~/.vault/key.
|
||||||
|
.TP
|
||||||
|
.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 everything asked for was deleted, or there was nothing to delete with
|
||||||
|
.BR \-f .
|
||||||
|
1 if the name is not in the vault, the delete was declined, or anything
|
||||||
|
fails; files already deleted stay deleted.
|
||||||
|
.SH EXAMPLES
|
||||||
|
.RS
|
||||||
|
vault rm notes.md
|
||||||
|
.br
|
||||||
|
vault rm \-r old/2024 # asks first
|
||||||
|
.br
|
||||||
|
vault rm \-rf tmp # no questions
|
||||||
|
.RE
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR vault (1),
|
||||||
|
.BR vault\-list (1)
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
.TH VAULT 1 "September 2026" "vault" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
vault \- post-quantum encrypted file vault
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B vault
|
||||||
|
.I command
|
||||||
|
.RI [ options ]
|
||||||
|
.RI [ arguments ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B vault
|
||||||
|
stores files in a directory of encrypted, fixed-size records and chunks.
|
||||||
|
Encryption is a hybrid of FrodoKEM-640 and ML-KEM-768 with ChaCha20-Poly1305.
|
||||||
|
The key file is protected by an Argon2id-derived passphrase key.
|
||||||
|
File names, sizes and contents are only visible to a holder of the key and
|
||||||
|
passphrase. Ciphertext sizes and timestamps are normalised to hide
|
||||||
|
metadata from the storage host.
|
||||||
|
.PP
|
||||||
|
Options may appear before, between or after the arguments.
|
||||||
|
.SH COMMANDS
|
||||||
|
.TP
|
||||||
|
.BR vault\-genkey (1)
|
||||||
|
Create a new passphrase-protected key file.
|
||||||
|
.TP
|
||||||
|
.BR vault\-init (1)
|
||||||
|
Create a new, empty vault.
|
||||||
|
.TP
|
||||||
|
.BR vault\-add (1)
|
||||||
|
Encrypt a file, directory or downloaded URL into the vault.
|
||||||
|
.TP
|
||||||
|
.BR vault\-migrate (1)
|
||||||
|
Like add, then delete each original.
|
||||||
|
.TP
|
||||||
|
.BR vault\-mirror (1)
|
||||||
|
Crawl a website into the vault.
|
||||||
|
.TP
|
||||||
|
.BR vault\-absorb (1)
|
||||||
|
Add every file in a tar archive read from standard input.
|
||||||
|
.TP
|
||||||
|
.BR vault\-list (1)
|
||||||
|
.B ls
|
||||||
|
lists one folder, with sub-folders marked;
|
||||||
|
.B list
|
||||||
|
lists every file under a folder.
|
||||||
|
.TP
|
||||||
|
.BR vault\-get (1)
|
||||||
|
Decrypt a stored file, or a whole folder with
|
||||||
|
.BR \-r .
|
||||||
|
.TP
|
||||||
|
.BR vault\-mv (1)
|
||||||
|
Rename or move a stored file or folder (alias:
|
||||||
|
.BR rename ).
|
||||||
|
.TP
|
||||||
|
.BR vault\-cp (1)
|
||||||
|
Copy a stored file, or a whole folder with
|
||||||
|
.BR \-r .
|
||||||
|
.TP
|
||||||
|
.BR vault\-rm (1)
|
||||||
|
Delete a stored file, or a whole folder with
|
||||||
|
.B \-r
|
||||||
|
(alias:
|
||||||
|
.BR delete ).
|
||||||
|
.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
|
||||||
|
Print usage.
|
||||||
|
.SH OPTIONS
|
||||||
|
These apply to every command.
|
||||||
|
.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_KEY
|
||||||
|
Default key file.
|
||||||
|
.TP
|
||||||
|
.B VAULT_DIR
|
||||||
|
Default vault directory.
|
||||||
|
.TP
|
||||||
|
.B VAULT_PASSPHRASE
|
||||||
|
Passphrase, instead of prompting on the terminal or using a key remembered by
|
||||||
|
.BR vault\-unlock (1).
|
||||||
|
.TP
|
||||||
|
.B VAULT_DECOY_PASSPHRASE
|
||||||
|
Decoy passphrase used by
|
||||||
|
.BR "vault genkey" .
|
||||||
|
.SH LOCKING
|
||||||
|
Commands that change the vault hold an exclusive lock on
|
||||||
|
.IR DIR/.lock ;
|
||||||
|
readers hold a shared lock. The lock is taken after the passphrase prompt.
|
||||||
|
.SH DECOY PASSPHRASE
|
||||||
|
.B genkey
|
||||||
|
can add a second passphrase. It unlocks a separate identity, so every
|
||||||
|
command run with it sees and changes only the decoy files, and the real
|
||||||
|
files stay invisible. The key file does not reveal whether a decoy exists,
|
||||||
|
and
|
||||||
|
.B init
|
||||||
|
fills each vault with unreadable filler, so hidden files look like filler to
|
||||||
|
anyone holding only the decoy passphrase.
|
||||||
|
.SH EXIT STATUS
|
||||||
|
0 on success; 1 on error (diagnostics on standard error); 2 when run with no command.
|
||||||
|
.SH EXAMPLES
|
||||||
|
.nf
|
||||||
|
vault genkey
|
||||||
|
vault init \-\-filler 500M
|
||||||
|
vault add report.pdf work/2026
|
||||||
|
vault list work
|
||||||
|
vault get work/2026/report.pdf \-o /tmp/report.pdf
|
||||||
|
.fi
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.I ~/.vault/key
|
||||||
|
Default key file.
|
||||||
|
.TP
|
||||||
|
.I ~/.vault/store
|
||||||
|
Default vault directory.
|
||||||
|
.SH AUTHOR
|
||||||
|
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package term
|
package term
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
@@ -72,6 +74,23 @@ func ReadSecret(prompt string) (s []byte, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirm asks a yes/no question on the terminal. Only "y" or "yes" is yes.
|
||||||
|
func Confirm(prompt string) (yes bool, err error) {
|
||||||
|
err = WithTerminal(func(in, out *os.File) error {
|
||||||
|
fmt.Fprintf(out, "%s ", prompt)
|
||||||
|
line, err := bufio.NewReader(in).ReadString('\n')
|
||||||
|
if err != nil && line == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||||
|
case "y", "yes":
|
||||||
|
yes = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ReadPassphrase returns the passphrase from the environment variable env if
|
// ReadPassphrase returns the passphrase from the environment variable env if
|
||||||
// it is set, otherwise prompts on the terminal.
|
// it is set, otherwise prompts on the terminal.
|
||||||
func ReadPassphrase(prompt, env string) (string, error) {
|
func ReadPassphrase(prompt, env string) (string, error) {
|
||||||
|
|||||||
@@ -5,16 +5,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/andydixon/vault/internal/term"
|
"github.com/andydixon/vault/internal/term"
|
||||||
)
|
)
|
||||||
@@ -23,18 +27,46 @@ const usage = `Usage:
|
|||||||
vault genkey create a new passphrase-protected key
|
vault genkey create a new passphrase-protected key
|
||||||
vault init [--filler SIZE] create a new, empty vault, with at least
|
vault init [--filler SIZE] create a new, empty vault, with at least
|
||||||
SIZE of filler (e.g. 500M, 2G)
|
SIZE of filler (e.g. 500M, 2G)
|
||||||
vault add FILE|DIR [FOLDER] encrypt a file or directory into the vault,
|
vault add FILE|DIR|URL [FOLDER] encrypt a file, a directory (recursively)
|
||||||
|
or a downloaded URL into the vault,
|
||||||
optionally under a folder inside the vault
|
optionally under a folder inside the vault
|
||||||
vault list [PREFIX] list stored files
|
vault migrate FILE|DIR [FOLDER] like add, then delete each original once
|
||||||
|
[--yes] it is stored; asks before overwriting
|
||||||
|
anything already in the vault, unless
|
||||||
|
--yes is given
|
||||||
|
vault mirror URL [FOLDER] crawl a website (same host only, one request
|
||||||
|
at a time) into FOLDER/HOST, rewriting its
|
||||||
|
links to relative paths
|
||||||
|
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
|
||||||
@@ -59,12 +91,22 @@ 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"), "")
|
||||||
vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "")
|
vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "")
|
||||||
output := fset.String("o", "", "")
|
output := fset.String("o", "", "")
|
||||||
filler := fset.String("filler", "0", "")
|
filler := fset.String("filler", "0", "")
|
||||||
|
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)
|
||||||
@@ -87,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)
|
||||||
@@ -94,7 +137,7 @@ func main() {
|
|||||||
check(err)
|
check(err)
|
||||||
check(initStore(*vaultDir, size))
|
check(initStore(*vaultDir, size))
|
||||||
fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir)
|
fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir)
|
||||||
case "add":
|
case "add", "migrate":
|
||||||
want(1, 2)
|
want(1, 2)
|
||||||
dest := ""
|
dest := ""
|
||||||
if len(pos) == 2 {
|
if len(pos) == 2 {
|
||||||
@@ -103,7 +146,58 @@ func main() {
|
|||||||
s := open(*keyPath, *vaultDir, true)
|
s := open(*keyPath, *vaultDir, true)
|
||||||
cat, err := s.catalogue()
|
cat, err := s.catalogue()
|
||||||
check(err)
|
check(err)
|
||||||
check(addPath(s, cat, pos[0], dest))
|
if isURL(pos[0]) {
|
||||||
|
if cmd == "migrate" {
|
||||||
|
fail("cannot migrate a URL; use add")
|
||||||
|
}
|
||||||
|
check(addURL(s, cat, pos[0], dest))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Never ingest, or delete, the vault or its key.
|
||||||
|
var skip []os.FileInfo
|
||||||
|
for _, p := range []string{*vaultDir, *keyPath} {
|
||||||
|
if fi, err := os.Stat(p); err == nil {
|
||||||
|
skip = append(skip, fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overwrite := confirmOverwrite
|
||||||
|
if *yes {
|
||||||
|
overwrite = func([]string) bool { return true }
|
||||||
|
}
|
||||||
|
check(addPath(s, cat, pos[0], dest, cmd == "migrate", skip, overwrite))
|
||||||
|
case "mirror":
|
||||||
|
want(1, 2)
|
||||||
|
if !isURL(pos[0]) {
|
||||||
|
fail("mirror needs an http:// or https:// URL")
|
||||||
|
}
|
||||||
|
dest := ""
|
||||||
|
if len(pos) == 2 {
|
||||||
|
dest = strings.Trim(pos[1], "/")
|
||||||
|
}
|
||||||
|
s := open(*keyPath, *vaultDir, true)
|
||||||
|
cat, err := s.catalogue()
|
||||||
|
check(err)
|
||||||
|
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 := ""
|
||||||
@@ -112,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:
|
||||||
@@ -133,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) {
|
||||||
@@ -179,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)
|
||||||
@@ -228,20 +451,52 @@ func decoyPassphrase() string {
|
|||||||
return string(p)
|
return string(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addPath adds a file, or every regular file under a directory, beneath dest.
|
// addPath adds a file, or every regular file under a directory, beneath dest,
|
||||||
func addPath(s *store, cat map[string][]*record, src, dest string) error {
|
// keeping the directory structure. A directory named "." or ".." adds its
|
||||||
|
// contents directly beneath dest. With migrate, each original is deleted
|
||||||
|
// once stored, then any directories left empty, and nothing is stored
|
||||||
|
// unless overwrite approves the vault names it would replace, if any.
|
||||||
|
func addPath(s *store, cat map[string][]*record, src, dest string, migrate bool, skip []os.FileInfo, overwrite func([]string) bool) error {
|
||||||
info, err := os.Stat(src)
|
info, err := os.Stat(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
base := filepath.Base(filepath.Clean(src))
|
base := filepath.Base(filepath.Clean(src))
|
||||||
if !info.IsDir() {
|
if base == "." || base == ".." || base == string(filepath.Separator) {
|
||||||
return addOne(s, cat, src, path.Join(dest, base))
|
base = ""
|
||||||
}
|
}
|
||||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
skipped := func(fi os.FileInfo) bool {
|
||||||
if err != nil || d.IsDir() {
|
for _, s := range skip {
|
||||||
|
if os.SameFile(fi, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if skipped(info) {
|
||||||
|
return fmt.Errorf("%s is the vault or its key", src)
|
||||||
|
}
|
||||||
|
type item struct{ src, name string }
|
||||||
|
var items []item
|
||||||
|
var dirs []string
|
||||||
|
if !info.IsDir() {
|
||||||
|
items = append(items, item{src, path.Join(dest, filepath.Base(src))})
|
||||||
|
} else {
|
||||||
|
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if fi, err := d.Info(); err == nil && skipped(fi) {
|
||||||
|
fmt.Fprintf(os.Stderr, "skipping %s: the vault or its key\n", p)
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
dirs = append(dirs, p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !d.Type().IsRegular() {
|
if !d.Type().IsRegular() {
|
||||||
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", p)
|
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", p)
|
||||||
return nil
|
return nil
|
||||||
@@ -250,11 +505,106 @@ func addPath(s *store, cat map[string][]*record, src, dest string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return addOne(s, cat, p, path.Join(dest, base, filepath.ToSlash(rel)))
|
items = append(items, item{p, path.Join(dest, base, filepath.ToSlash(rel))})
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if migrate {
|
||||||
|
var existing []string
|
||||||
|
for _, it := range items {
|
||||||
|
if len(cat[it.name]) > 0 {
|
||||||
|
existing = append(existing, it.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(existing) > 0 && !overwrite(existing) {
|
||||||
|
return errors.New("migrate cancelled; nothing changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if err := addOne(s, cat, it.src, it.name, migrate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if migrate {
|
||||||
|
// Children come after their parents in walk order, so go backwards.
|
||||||
|
// Directories still holding skipped files stay.
|
||||||
|
for i := len(dirs) - 1; i >= 0; i-- {
|
||||||
|
os.Remove(dirs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func addOne(s *store, cat map[string][]*record, src, name string) error {
|
// confirmOverwrite lists the vault names a migrate would replace and asks
|
||||||
|
// on the terminal whether to go ahead. No terminal means no.
|
||||||
|
func confirmOverwrite(names []string) bool {
|
||||||
|
return confirm("these files are already in the vault and would be overwritten:", names,
|
||||||
|
fmt.Sprintf("overwrite %d file(s)? [y/N]", len(names)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm prints intro and names, the first 20 of them, then asks question
|
||||||
|
// on the terminal. No terminal means no.
|
||||||
|
func confirm(intro string, names []string, question string) bool {
|
||||||
|
fmt.Fprintln(os.Stderr, intro)
|
||||||
|
for i, name := range names {
|
||||||
|
if i == 20 {
|
||||||
|
fmt.Fprintf(os.Stderr, " … and %d more\n", len(names)-i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\n", name)
|
||||||
|
}
|
||||||
|
ok, err := term.Confirm(question)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot ask for confirmation: %v\n", err)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isURL(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
const chromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
// addURL downloads rawURL to a private temporary file and adds it beneath
|
||||||
|
// dest, named after the last element of the final (post-redirect) URL path.
|
||||||
|
func addURL(s *store, cat map[string][]*record, rawURL, dest string) error {
|
||||||
|
req, err := http.NewRequest("GET", rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", chromeUA)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s: %s", rawURL, resp.Status)
|
||||||
|
}
|
||||||
|
name := path.Base(resp.Request.URL.Path)
|
||||||
|
if name == "/" || name == "." {
|
||||||
|
name = resp.Request.URL.Hostname()
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp("", "vault-download-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
defer tmp.Close()
|
||||||
|
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", rawURL, err)
|
||||||
|
}
|
||||||
|
if t, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil {
|
||||||
|
os.Chtimes(tmp.Name(), t, t)
|
||||||
|
}
|
||||||
|
return addOne(s, cat, tmp.Name(), path.Join(dest, name), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOne(s *store, cat map[string][]*record, src, name string, migrate bool) error {
|
||||||
replaced, err := s.add(cat, src, name)
|
replaced, err := s.add(cat, src, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -263,18 +613,83 @@ func addOne(s *store, cat map[string][]*record, src, name string) error {
|
|||||||
if replaced {
|
if replaced {
|
||||||
verb = "replaced"
|
verb = "replaced"
|
||||||
}
|
}
|
||||||
|
if migrate {
|
||||||
|
if err := os.Remove(src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
verb = "migrated"
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, "%s %s\n", verb, name)
|
fmt.Fprintf(os.Stderr, "%s %s\n", verb, name)
|
||||||
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
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)
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Copyright 2026 The vault Authors.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Attribute values in HTML, and url(...) in CSS: group 1 is everything
|
||||||
|
// up to the URL, group 2 is the URL.
|
||||||
|
htmlURLs = regexp.MustCompile(`(?i)(\b(?:href|src|action|poster)\s*=\s*["'])([^"']*)`)
|
||||||
|
cssURLs = regexp.MustCompile(`(?i)(url\(\s*["']?)([^"')\s]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// mirror crawls the site at rawURL, one request at a time, staying on its
|
||||||
|
// host, and adds every page and asset beneath dest/host. Links in HTML and
|
||||||
|
// CSS that point into the mirror are rewritten to relative paths, so the
|
||||||
|
// copy browses correctly once fetched out of the vault. A page that fails
|
||||||
|
// is reported and skipped.
|
||||||
|
// ponytail: regex rewriting, so links built by scripts or in srcset are left
|
||||||
|
// as they were; swap in golang.org/x/net/html if that matters.
|
||||||
|
func mirror(s *store, cat map[string][]*record, rawURL, dest string) error {
|
||||||
|
start, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host := strings.ToLower(start.Host)
|
||||||
|
root := path.Join(dest, host)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
queue := []*url.URL{start}
|
||||||
|
seen[key(start)] = true
|
||||||
|
|
||||||
|
for len(queue) > 0 {
|
||||||
|
u := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
err := mirrorOne(s, cat, u, host, root, func(l *url.URL) {
|
||||||
|
if k := key(l); !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
queue = append(queue, l)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "skipping %s: %v\n", u, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// key identifies a page independent of its fragment.
|
||||||
|
func key(u *url.URL) string {
|
||||||
|
c := *u
|
||||||
|
c.Fragment = ""
|
||||||
|
return c.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPath is where u lives inside the mirror, relative to its root.
|
||||||
|
func localPath(u *url.URL) string {
|
||||||
|
p := strings.TrimPrefix(u.Path, "/")
|
||||||
|
if p == "" || strings.HasSuffix(p, "/") {
|
||||||
|
p += "index.html"
|
||||||
|
}
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
p += "_" + url.QueryEscape(u.RawQuery)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// relative gives the link from the file "from" to the file "to", both
|
||||||
|
// slash-separated paths relative to the same root.
|
||||||
|
func relative(from, to string) string {
|
||||||
|
f := strings.Split(path.Dir(from), "/")
|
||||||
|
t := strings.Split(to, "/")
|
||||||
|
if f[0] == "." {
|
||||||
|
f = nil
|
||||||
|
}
|
||||||
|
for len(f) > 0 && len(t) > 1 && f[0] == t[0] {
|
||||||
|
f, t = f[1:], t[1:]
|
||||||
|
}
|
||||||
|
return strings.Repeat("../", len(f)) + strings.Join(t, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mirrorOne(s *store, cat map[string][]*record, u *url.URL, host, root string, enqueue func(*url.URL)) error {
|
||||||
|
req, err := http.NewRequest("GET", u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", chromeUA)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s", resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
self := localPath(u)
|
||||||
|
// rewrite maps a link found in the page to a relative one, queueing it.
|
||||||
|
rewrite := func(re *regexp.Regexp, text string) string {
|
||||||
|
return re.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
sub := re.FindStringSubmatch(m)
|
||||||
|
l, err := u.Parse(html.UnescapeString(sub[2]))
|
||||||
|
if err != nil || (l.Scheme != "http" && l.Scheme != "https") || strings.ToLower(l.Host) != host {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
frag := l.Fragment
|
||||||
|
l.Fragment = ""
|
||||||
|
if l.Path == "" && l.RawQuery == "" {
|
||||||
|
l.Path = "/"
|
||||||
|
}
|
||||||
|
enqueue(l)
|
||||||
|
r := relative(self, localPath(l))
|
||||||
|
if frag != "" {
|
||||||
|
r += "#" + frag
|
||||||
|
}
|
||||||
|
return sub[1] + r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
switch ct := strings.ToLower(resp.Header.Get("Content-Type")); {
|
||||||
|
case strings.HasPrefix(ct, "text/html"):
|
||||||
|
body = []byte(rewrite(cssURLs, rewrite(htmlURLs, string(body))))
|
||||||
|
case strings.HasPrefix(ct, "text/css"):
|
||||||
|
body = []byte(rewrite(cssURLs, string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "vault-mirror-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
defer tmp.Close()
|
||||||
|
if _, err := tmp.Write(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return addOne(s, cat, tmp.Name(), path.Join(root, self), false)
|
||||||
|
}
|
||||||
@@ -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)
|
}
|
||||||
if err != nil {
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAll deletes every record for names and the objects they hold, then
|
||||||
|
// drops them from cat, calling done after each name. Like remove, but it
|
||||||
|
// works out once which objects other records still use, so deleting a large
|
||||||
|
// folder stays linear.
|
||||||
|
func (s *store) removeAll(cat map[string][]*record, names []string, done func(name string)) error {
|
||||||
|
gone := map[string]bool{}
|
||||||
|
for _, name := range names {
|
||||||
|
gone[name] = true
|
||||||
|
}
|
||||||
|
inUse := map[string]bool{}
|
||||||
|
for name, rs := range cat {
|
||||||
|
if !gone[name] {
|
||||||
|
for _, r := range rs {
|
||||||
|
inUse[r.Seed] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
for _, r := range cat[name] {
|
||||||
|
if err := s.removeFile(recordsDir, r.id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cat[newName]) > 0 {
|
if !inUse[r.Seed] {
|
||||||
return fmt.Errorf("%s already exists", newName)
|
if err := s.dropObjects(r.Seed, r.Size); err != nil {
|
||||||
}
|
|
||||||
moved := *r
|
|
||||||
moved.Name = newName
|
|
||||||
if err := s.writeRecord(&moved); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.remove(cat, oldName, false)
|
}
|
||||||
|
}
|
||||||
|
delete(cat, name)
|
||||||
|
done(name)
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,21 @@
|
|||||||
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/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -69,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")
|
||||||
@@ -295,3 +306,387 @@ func TestFiller(t *testing.T) {
|
|||||||
t.Fatalf("filler objects total %d, largest %d", total, largest)
|
t.Fatalf("filler objects total %d, largest %d", total, largest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAddTree migrates "." holding the vault and key: structure is kept,
|
||||||
|
// originals and emptied directories go, the vault and key are left alone.
|
||||||
|
// A URL download is then added under a folder.
|
||||||
|
func TestAddTree(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")
|
||||||
|
os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0o600)
|
||||||
|
os.WriteFile(filepath.Join(dir, "a", "b", "y"), []byte("y"), 0o600)
|
||||||
|
t.Chdir(dir)
|
||||||
|
|
||||||
|
var skip []os.FileInfo
|
||||||
|
for _, p := range []string{"store", "key"} {
|
||||||
|
fi, _ := os.Stat(p)
|
||||||
|
skip = append(skip, fi)
|
||||||
|
}
|
||||||
|
cat, _ := s.catalogue()
|
||||||
|
if err := addPath(s, cat, "x", "d", false, skip, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Migrating over d/x must ask first, and a no must leave everything.
|
||||||
|
var asked []string
|
||||||
|
no := func(names []string) bool { asked = names; return false }
|
||||||
|
if err := addPath(s, cat, ".", "d", true, skip, no); err == nil || !slices.Equal(asked, []string{"d/x"}) {
|
||||||
|
t.Fatalf("migrate over d/x: err %v, asked %v", err, asked)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("x"); err != nil || len(cat) != 1 {
|
||||||
|
t.Fatalf("declined migrate changed things: %v, %d stored", err, len(cat))
|
||||||
|
}
|
||||||
|
if err := addPath(s, cat, ".", "d", true, skip, func([]string) bool { return true }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for name := range cat {
|
||||||
|
got = append(got, name)
|
||||||
|
}
|
||||||
|
slices.Sort(got)
|
||||||
|
if !slices.Equal(got, []string{"d/a/b/y", "d/x"}) {
|
||||||
|
t.Fatalf("stored %v", got)
|
||||||
|
}
|
||||||
|
var left []string
|
||||||
|
entries, _ := os.ReadDir(".")
|
||||||
|
for _, e := range entries {
|
||||||
|
left = append(left, e.Name())
|
||||||
|
}
|
||||||
|
if !slices.Equal(left, []string{"key", "store"}) {
|
||||||
|
t.Fatalf("left behind %v", left)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.UserAgent() != chromeUA {
|
||||||
|
http.Error(w, "bad agent", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(w, "body")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if err := addURL(s, cat, srv.URL+"/files/page.html", "web"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.get("web/page.html", "out"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile("out"); string(b) != "body" {
|
||||||
|
t.Fatalf("downloaded %q", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMirror crawls a two-page site: links in HTML and CSS become relative,
|
||||||
|
// off-host links stay, and everything is stored under host/.
|
||||||
|
func TestMirror(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 srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.UserAgent() != chromeUA {
|
||||||
|
http.Error(w, "bad agent", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/":
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
io.WriteString(w, `<a href="`+srv.URL+`/sub/p.html#x">p</a> <a href="https://elsewhere.example/">e</a> <link href='/s.css'>`)
|
||||||
|
case "/sub/p.html":
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
io.WriteString(w, `<a href="/">home</a> <img src="../img/a.png">`)
|
||||||
|
case "/s.css":
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
io.WriteString(w, `body{background:url(/img/a.png)}`)
|
||||||
|
case "/img/a.png":
|
||||||
|
io.WriteString(w, "png")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
cat, _ := s.catalogue()
|
||||||
|
if err := mirror(s, cat, srv.URL, "m"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := srv.Listener.Addr().String()
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"m/" + host + "/index.html": `<a href="sub/p.html#x">p</a> <a href="https://elsewhere.example/">e</a> <link href='s.css'>`,
|
||||||
|
"m/" + host + "/sub/p.html": `<a href="../index.html">home</a> <img src="../img/a.png">`,
|
||||||
|
"m/" + host + "/s.css": `body{background:url(img/a.png)}`,
|
||||||
|
"m/" + host + "/img/a.png": "png",
|
||||||
|
} {
|
||||||
|
out := filepath.Join(dir, "out")
|
||||||
|
if err := s.get(name, out); err != nil {
|
||||||
|
t.Fatal(name, err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(out); string(b) != want {
|
||||||
|
t.Errorf("%s = %q, want %q", name, b, want)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||