Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730877823e | ||
|
|
1403be8b1c | ||
|
|
fc5a856487 | ||
|
|
55af82f95f |
@@ -8,7 +8,8 @@ vault is a post-quantum encrypted file vault: one `package main` at the repo
|
||||
root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
|
||||
|
||||
- `main.go` - CLI: `genkey`, `init`, `add` (file, directory
|
||||
or URL), `migrate` (add, then delete originals), `list`, `get`, `mv`, `rm`.
|
||||
or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar
|
||||
on stdin, `absorb.go`), `list`, `get`, `mv`, `rm`.
|
||||
- `keyfile.go` - key file with two passphrase slots (Argon2id +
|
||||
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second
|
||||
slot is either a decoy identity or random bytes; slot order is random.
|
||||
|
||||
@@ -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 migrate ./photos # like add, then delete the originals
|
||||
vault mirror https://example.com/ # crawl a site into example.com/, links rewritten
|
||||
tar -cz photos | vault absorb backup # every file in a tar (or .tar.gz) under backup/
|
||||
vault absorb backup < photos.tar.gz # same, from an existing archive
|
||||
vault list # everything
|
||||
vault list work # everything under work/
|
||||
vault get work/2026/report.pdf # writes ./report.pdf
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
@@ -315,6 +315,35 @@ def main():
|
||||
t.cmd("ls Scans")
|
||||
shoot("migrate", t)
|
||||
|
||||
# absorb: a tar archive piped in, here pulled from a gzipped tarball
|
||||
box.write("Projects/website/index.html", "<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
|
||||
t = Term(box)
|
||||
t.cmd("vault list", "hunter2", highlight=("incorrect",))
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -940,7 +964,8 @@ covered.
|
||||
|
||||
\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{shared} one, so several can read at once. If a command has to wait, it
|
||||
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]
|
||||
\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 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 get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox}
|
||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\end{tcolorbox}
|
||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm NAME}\\[2pt]\small Delete one file. No undo.\end{tcolorbox}
|
||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault 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}
|
||||
|
||||
\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 \\
|
||||
-o OUT & \textrm{---} & the file's own name, here \\
|
||||
--filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\
|
||||
--yes & \textrm{---} & ask, for \cmd{migrate} \\
|
||||
\end{vtable}
|
||||
|
||||
\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
|
||||
@@ -41,6 +41,7 @@ vault add https://example.com/paper.pdf papers
|
||||
.SH SEE ALSO
|
||||
.BR vault (1),
|
||||
.BR vault\-migrate (1),
|
||||
.BR vault\-mirror (1)
|
||||
.BR vault\-mirror (1),
|
||||
.BR vault\-absorb (1)
|
||||
.SH AUTHOR
|
||||
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
|
||||
|
||||
@@ -33,6 +33,9 @@ Like add, then delete each original.
|
||||
.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)
|
||||
List stored files (alias:
|
||||
.BR ls ).
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -35,6 +36,9 @@ const usage = `Usage:
|
||||
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 list [PREFIX] list stored files
|
||||
vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name)
|
||||
vault mv OLD NEW rename a stored file
|
||||
@@ -146,6 +150,26 @@ func main() {
|
||||
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":
|
||||
want(0, 1)
|
||||
prefix := ""
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
mrand "math/rand/v2"
|
||||
"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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.writeObjects(f, seed, info.Size()); err != nil {
|
||||
s.dropObjects(seed, info.Size())
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||
err = fmt.Errorf("%s changed while being added", src)
|
||||
}
|
||||
if err := s.writeObjects(r, seed, size); err != nil {
|
||||
s.dropObjects(seed, size)
|
||||
return false, err
|
||||
}
|
||||
r := &record{
|
||||
Name: name, Seed: seed, Size: info.Size(), Mode: uint32(info.Mode().Perm()),
|
||||
Modified: info.ModTime().UTC(), Added: time.Now().UTC(),
|
||||
rec := &record{
|
||||
Name: name, Seed: seed, Size: size, Mode: uint32(mode.Perm()),
|
||||
Modified: mtime.UTC(), Added: time.Now().UTC(),
|
||||
}
|
||||
if err := s.writeRecord(r); err != nil {
|
||||
s.dropObjects(seed, info.Size())
|
||||
if err := s.writeRecord(rec); err != nil {
|
||||
s.dropObjects(seed, size)
|
||||
return false, err
|
||||
}
|
||||
replaced := len(cat[name]) > 0
|
||||
if err := s.remove(cat, name, true); err != nil {
|
||||
return replaced, err
|
||||
}
|
||||
cat[name] = []*record{r}
|
||||
cat[name] = []*record{rec}
|
||||
return replaced, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -430,3 +432,50 @@ func TestMirror(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||