3 Commits
Author SHA1 Message Date
Andy DixonandClaude Sonnet 5.5 92ff8546fd Document mirror, migrate and the man pages in the README
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 18:43:16 +01:00
Andy DixonandClaude Sonnet 5.5 0b3dcf08f2 Add man pages for vault and every command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:04:28 +01:00
Andy DixonandClaude Sonnet 5.5 10f2a88493 Add a mirror command that crawls a site into the vault
Single-threaded, same host only, Chrome user agent. Links in HTML and
CSS are rewritten to relative paths. Includes a man page and a guide note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:02:12 +01:00
15 changed files with 707 additions and 0 deletions
+9
View File
@@ -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,6 +45,9 @@ 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 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
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
+6
View File
@@ -561,6 +561,12 @@ 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 last part of the address, such as \file{report.pdf}. The download sits in a
private temporary file only until it's encrypted. 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.
\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
+46
View File
@@ -0,0 +1,46 @@
.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)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+32
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
.TH VAULT-GET 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-get \- decrypt a stored file
.SH SYNOPSIS
.B vault get
.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.
.SH OPTIONS
.TP
.BI \-o " OUTPUT"
Output path. Default: the base name of \fINAME\fR in the current directory.
.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
+27
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
.TH VAULT-LIST 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-list \- list files in the vault
.SH SYNOPSIS
.B vault list
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.RI [ PREFIX ]
.br
.B vault ls
\&...
.SH DESCRIPTION
Prints one line per stored file, sorted by name: human-readable size,
modification time (local, minute resolution) and name. With \fIPREFIX\fR only
that file, or files under that folder, are shown. Records the passphrase
cannot open are skipped silently. Takes a shared lock.
.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\-get (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+41
View File
@@ -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
+86
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
.TH VAULT-MV 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-mv \- rename a stored file
.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 to \fINEW\fR, which may be in another folder. Fails if
\fINEW\fR 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\-rm (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+32
View File
@@ -0,0 +1,32 @@
.TH VAULT-RM 1 "September 2026" "vault" "User Commands"
.SH NAME
vault-rm \- delete a stored file
.SH SYNOPSIS
.B vault rm
.RB [ \-\-key
.IR PATH ]
.RB [ \-\-vault
.IR DIR ]
.I NAME
.br
.B vault delete
\&...
.SH DESCRIPTION
Deletes the stored file \fINAME\fR from the vault. It does not ask for
confirmation.
.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\-list (1)
.SH AUTHOR
Andy Dixon <vault@dixon.cx>, https://www.dixon.cx
+106
View File
@@ -0,0 +1,106 @@
.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\-list (1)
List stored files (alias:
.BR ls ).
.TP
.BR vault\-get (1)
Decrypt a stored file.
.TP
.BR vault\-mv (1)
Rename a stored file (alias:
.BR rename ).
.TP
.BR vault\-rm (1)
Delete a stored file (alias:
.BR delete ).
.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.
.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
+16
View File
@@ -32,6 +32,9 @@ const usage = `Usage:
[--yes] it is stored; asks before overwriting [--yes] it is stored; asks before overwriting
anything already in the vault, unless anything already in the vault, unless
--yes is given --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 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
@@ -130,6 +133,19 @@ func main() {
overwrite = func([]string) bool { return true } overwrite = func([]string) bool { return true }
} }
check(addPath(s, cat, pos[0], dest, cmd == "migrate", skip, overwrite)) 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 "list", "ls": case "list", "ls":
want(0, 1) want(0, 1)
prefix := "" prefix := ""
+150
View File
@@ -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)
}
+57
View File
@@ -373,3 +373,60 @@ func TestAddTree(t *testing.T) {
t.Fatalf("downloaded %q", b) 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)
}
}