4 Commits
Author SHA1 Message Date
Andy DixonandClaude Opus 5.5 730877823e Absorb: ask for the passphrase once the archive starts arriving
With gpg --decrypt x.tar.gpg | vault absorb, vault printed its prompt
while gpg was still writing to the terminal, which buried it, so the
command looked hung. Peek at standard input first: the upstream program
has finished with the terminal by the time data arrives, and an empty
pipe now fails without asking at all.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:57:38 +01:00
Andy Dixon 55af82f95f Absorb functionality 2026-10-02 16:43:19 +01:00
24 changed files with 327 additions and 15 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ vault is a post-quantum encrypted file vault: one `package main` at the repo
root that builds a single `vault` binary. Module `github.com/andydixon/vault`. root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
- `main.go` - CLI: `genkey`, `init`, `add` (file, directory - `main.go` - CLI: `genkey`, `init`, `add` (file, directory
or URL), `migrate` (add, then delete originals), `list`, `get`, `mv`, `rm`. or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar
on stdin, `absorb.go`), `list`, `get`, `mv`, `rm`.
- `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.
+2
View File
@@ -48,6 +48,8 @@ vault add ./photos # every regular file under photos/
vault add https://example.com/a.pdf # download and store a URL vault add https://example.com/a.pdf # download and store a URL
vault migrate ./photos # like add, then delete the originals vault migrate ./photos # like add, then delete the originals
vault mirror https://example.com/ # crawl a site into example.com/, links rewritten vault mirror https://example.com/ # crawl a site into example.com/, links rewritten
tar -cz photos | vault absorb backup # every file in a tar (or .tar.gz) under backup/
vault absorb backup < photos.tar.gz # same, from an existing archive
vault list # everything vault list # everything
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
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
)
// absorb reads a tar archive, gzipped or not, from r and adds every regular
// file in it beneath dest, streaming each straight into the vault so no
// plaintext touches the disk. Other entries, and names that would escape
// dest, are reported and skipped.
func absorb(s *store, cat map[string][]*record, r io.Reader, dest string) error {
br := bufio.NewReader(r)
if magic, _ := br.Peek(2); bytes.Equal(magic, []byte{0x1f, 0x8b}) {
zr, err := gzip.NewReader(br)
if err != nil {
return err
}
defer zr.Close()
r = zr
} else {
r = br
}
tr := tar.NewReader(r)
n := 0
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("reading archive: %w", err)
}
if hdr.Typeflag != tar.TypeReg {
if hdr.Typeflag != tar.TypeDir {
fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", hdr.Name)
}
continue
}
name := strings.TrimPrefix(path.Clean("/"+hdr.Name), "/")
if strings.Contains(hdr.Name, "\\") || name == "" || name != path.Clean(strings.TrimPrefix(strings.TrimPrefix(hdr.Name, "./"), "/")) {
fmt.Fprintf(os.Stderr, "skipping %s: unsafe name\n", hdr.Name)
continue
}
name = path.Join(dest, name)
replaced, err := s.addReader(cat, tr, hdr.Size, hdr.FileInfo().Mode(), hdr.ModTime, name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s: archive ends early", hdr.Name)
}
if err != nil {
return err
}
verb := "added"
if replaced {
verb = "replaced"
}
fmt.Fprintf(os.Stderr, "%s %s\n", verb, name)
n++
}
if n == 0 {
return errors.New("no files in archive")
}
return nil
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+29
View File
@@ -315,6 +315,35 @@ def main():
t.cmd("ls Scans") t.cmd("ls Scans")
shoot("migrate", t) shoot("migrate", 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",))
+32 -2
View File
@@ -567,6 +567,30 @@ the whole site, one request at a time, into a folder named after its host.
Links inside pages and stylesheets are rewritten to relative paths, so the Links inside pages and stylesheets are rewritten to relative paths, so the
copy still browses once you get it out. Only the same host is followed. copy still browses once you get it out. Only the same host is followed.
\shot{mirror}{Mirroring a small site. The links in each page now point at the copy.}
\subsection{From a tar archive: \texttt{absorb}}
\cmd{absorb} reads a tar archive, gzipped or not, from standard input and
stores every plain file in it, optionally under a folder:
\begin{verbatim}
tar -cz photos | vault absorb backup
ssh server 'tar -c /etc' | vault absorb server1
vault absorb old < archive.tar.gz
\end{verbatim}
Each file is encrypted straight from the stream, so nothing is unpacked to
disk first. Directories need no entry of their own; links, devices and any
name that tries to climb out with \file{..} are skipped with a note.
vault asks for your passphrase only once the archive starts arriving, so
when it comes out of something that asks first, like
\cmd{gpg --decrypt secrets.tar.gpg | vault absorb}, gpg's prompt comes
and goes before vault's appears. If the
archive stops halfway through a file, that file isn't stored and vault
stops; everything before it is already safely in.
\shot{absorb}{A gzipped tarball and a plain tar stream, both absorbed.}
\subsection{Moving in: \texttt{migrate}} \subsection{Moving in: \texttt{migrate}}
\cmd{migrate} works exactly like \cmd{add}, but deletes each original once \cmd{migrate} works exactly like \cmd{add}, but deletes each original once
it's safely stored, then tidies away any folders left empty. It won't take a it's safely stored, then tidies away any folders left empty. It won't take a
@@ -940,7 +964,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:
@@ -1244,12 +1269,16 @@ 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 migrate FILE|DIR [FOLDER]}\\[2pt]\small Like add, then delete the originals. Asks before replacing.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mirror URL [FOLDER]}\\[2pt]\small Crawl a website in, links rewritten to work offline.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault absorb [FOLDER] < TAR}\\[2pt]\small Every file in a tar or .tar.gz from standard input.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm NAME}\\[2pt]\small Delete one file. No undo.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm NAME}\\[2pt]\small Delete one file. No undo.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox}
\end{tcbraster} \end{tcbraster}
\section*{Options and variables} \section*{Options and variables}
@@ -1261,6 +1290,7 @@ 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} \\
\end{vtable} \end{vtable}
\section*{Numbers worth knowing} \section*{Numbers worth knowing}
+84
View File
@@ -0,0 +1,84 @@
.TH VAULT-ABSORB 1 "October 2026" "vault" "User Commands"
.SH NAME
vault-absorb \- add the files in a tar archive to an encrypted vault
.SH SYNOPSIS
.B vault absorb
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ FOLDER ]
.B <
.I ARCHIVE
.SH DESCRIPTION
.B vault absorb
reads a tar archive from standard input and stores every regular file in
it, beneath
.I FOLDER
if one is given, keeping the paths, permissions and modification times
recorded in the archive. A gzip-compressed archive is detected and
unpacked automatically.
.PP
Each file is encrypted straight from the stream; nothing is unpacked to
disk. Anything already stored under the same name is replaced.
.PP
Directory entries are not stored (directories exist implicitly). Symbolic
and hard links, devices and other special entries are reported on standard
error and skipped, as is any entry whose name would escape
.I FOLDER
through
.BR .. .
A leading
.B ./
or
.B /
is dropped.
.PP
The passphrase prompt reads from the terminal, not standard input, so
it still works with an archive piped in. It waits until the archive starts
arriving, so a program feeding the pipe, such as
.BR gpg (1)
asking for its own passphrase, has finished with the terminal first.
An empty pipe fails without asking at all.
The vault is locked exclusively for the whole run.
.SH OPTIONS
.TP
.BI \-\-key " PATH"
Key file. Default: $VAULT_KEY, else ~/.vault/key.
.TP
.BI \-\-vault " DIR"
Vault directory. Default: $VAULT_DIR, else ~/.vault/store.
.SH ENVIRONMENT
.TP
.B VAULT_PASSPHRASE
Passphrase, instead of prompting on the terminal.
.SH EXIT STATUS
0 if at least one file was stored and the archive was read to the end.
1 if the archive is malformed or ends early, holds no regular files, or
anything fails; files stored before the failure stay in the vault.
.SH EXAMPLES
Unpack an existing archive into the vault under
.BR backup/ :
.PP
.RS
vault absorb backup < photos.tar.gz
.RE
.PP
Store a directory under
.BR backup/photos/ :
.PP
.RS
tar \-cz photos | vault absorb backup
.RE
.PP
Pull a remote directory straight into the vault:
.PP
.RS
ssh server 'tar \-C /etc \-c .' | vault absorb server1/etc
.RE
.SH SEE ALSO
.BR vault (1),
.BR vault\-add (1),
.BR tar (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+2 -1
View File
@@ -41,6 +41,7 @@ vault add https://example.com/paper.pdf papers
.SH SEE ALSO .SH SEE ALSO
.BR vault (1), .BR vault (1),
.BR vault\-migrate (1), .BR vault\-migrate (1),
.BR vault\-mirror (1) .BR vault\-mirror (1),
.BR vault\-absorb (1)
.SH AUTHOR .SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+3
View File
@@ -33,6 +33,9 @@ Like add, then delete each original.
.BR vault\-mirror (1) .BR vault\-mirror (1)
Crawl a website into the vault. Crawl a website into the vault.
.TP .TP
.BR vault\-absorb (1)
Add every file in a tar archive read from standard input.
.TP
.BR vault\-list (1) .BR vault\-list (1)
List stored files (alias: List stored files (alias:
.BR ls ). .BR ls ).
+24
View File
@@ -5,6 +5,7 @@
package main package main
import ( import (
"bufio"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -35,6 +36,9 @@ const usage = `Usage:
vault mirror URL [FOLDER] crawl a website (same host only, one request vault mirror URL [FOLDER] crawl a website (same host only, one request
at a time) into FOLDER/HOST, rewriting its at a time) into FOLDER/HOST, rewriting its
links to relative paths links to relative paths
vault absorb [FOLDER] read a tar archive (optionally gzipped) from
standard input and add every file in it,
optionally under a folder inside the vault
vault list [PREFIX] list stored files vault list [PREFIX] list stored files
vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name) vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name)
vault mv OLD NEW rename a stored file vault mv OLD NEW rename a stored file
@@ -146,6 +150,26 @@ func main() {
cat, err := s.catalogue() cat, err := s.catalogue()
check(err) check(err)
check(mirror(s, cat, pos[0], dest)) check(mirror(s, cat, pos[0], dest))
case "absorb":
want(0, 1)
if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
fail("absorb reads a tar archive from standard input; pipe one in")
}
dest := ""
if len(pos) == 1 {
dest = strings.Trim(pos[0], "/")
}
// Wait for the archive to start arriving before asking for the
// passphrase, so a program feeding the pipe (gpg, ssh) has finished
// with the terminal and cannot bury the prompt.
in := bufio.NewReader(os.Stdin)
if _, err := in.Peek(1); err != nil {
fail("nothing to absorb on standard input")
}
s := open(*keyPath, *vaultDir, true)
cat, err := s.catalogue()
check(err)
check(absorb(s, cat, in, dest))
case "list", "ls": case "list", "ls":
want(0, 1) want(0, 1)
prefix := "" prefix := ""
+25 -11
View File
@@ -14,6 +14,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"math" "math"
mrand "math/rand/v2" mrand "math/rand/v2"
"os" "os"
@@ -382,30 +383,43 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
if !info.Mode().IsRegular() || info.Size() > fileLimit { if !info.Mode().IsRegular() || info.Size() > fileLimit {
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src) return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
} }
replaced, err := s.addReader(cat, f, info.Size(), info.Mode().Perm(), info.ModTime(), name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return replaced, err
}
// addReader stores size bytes from r as name, like add. Reading fewer than
// size bytes stores nothing and fails with io.ErrUnexpectedEOF or io.EOF.
func (s *store) addReader(cat map[string][]*record, r io.Reader, size int64, mode fs.FileMode, mtime time.Time, name string) (bool, error) {
if !validName(name) {
return false, fmt.Errorf("invalid vault path %q", name)
}
if size < 0 || size > fileLimit {
return false, fmt.Errorf("%s: larger than 1 TiB", name)
}
seed, err := randomHex(objectSeedSize) seed, err := randomHex(objectSeedSize)
if err != nil { if err != nil {
return false, err return false, err
} }
if err := s.writeObjects(f, seed, info.Size()); err != nil { if err := s.writeObjects(r, seed, size); err != nil {
s.dropObjects(seed, info.Size()) s.dropObjects(seed, size)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return false, err return false, err
} }
r := &record{ rec := &record{
Name: name, Seed: seed, Size: info.Size(), Mode: uint32(info.Mode().Perm()), Name: name, Seed: seed, Size: size, Mode: uint32(mode.Perm()),
Modified: info.ModTime().UTC(), Added: time.Now().UTC(), Modified: mtime.UTC(), Added: time.Now().UTC(),
} }
if err := s.writeRecord(r); err != nil { if err := s.writeRecord(rec); err != nil {
s.dropObjects(seed, info.Size()) s.dropObjects(seed, size)
return false, err return false, err
} }
replaced := len(cat[name]) > 0 replaced := len(cat[name]) > 0
if err := s.remove(cat, name, true); err != nil { if err := s.remove(cat, name, true); err != nil {
return replaced, err return replaced, err
} }
cat[name] = []*record{r} cat[name] = []*record{rec}
return replaced, nil return replaced, nil
} }
+49
View File
@@ -4,8 +4,10 @@
package main package main
import ( import (
"archive/tar"
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"crypto/rand" "crypto/rand"
"io" "io"
"io/fs" "io/fs"
@@ -430,3 +432,50 @@ func TestMirror(t *testing.T) {
os.Remove(out) os.Remove(out)
} }
} }
func TestAbsorb(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "key")
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
t.Fatal(err)
}
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
t.Fatal(err)
}
s := newTestStore(t, keyPath, "pass")
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
mtime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
for _, h := range []*tar.Header{
{Name: "./d/", Typeflag: tar.TypeDir, Mode: 0o755},
{Name: "./d/a.txt", Size: 5, Mode: 0o600, ModTime: mtime},
{Name: "../escape", Size: 5, Mode: 0o600},
{Name: "./d/link", Typeflag: tar.TypeSymlink, Linkname: "a.txt"},
} {
tw.WriteHeader(h)
if h.Size > 0 {
io.WriteString(tw, "hello")
}
}
tw.Close()
zw.Close()
cat, _ := s.catalogue()
if err := absorb(s, cat, &buf, "in"); err != nil {
t.Fatal(err)
}
if len(cat) != 1 || len(cat["in/d/a.txt"]) != 1 {
t.Fatalf("catalogue = %v, want only in/d/a.txt", cat)
}
if r := cat["in/d/a.txt"][0]; !r.Modified.Equal(mtime) || r.Mode != 0o600 {
t.Errorf("record = %v, %o", r.Modified, r.Mode)
}
out := filepath.Join(dir, "out")
if err := s.get("in/d/a.txt", out); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(out); string(b) != "hello" {
t.Errorf("got %q", b)
}
}