// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package backup import ( "archive/tar" "bytes" "compress/gzip" "os" "path/filepath" "strings" "testing" "sourcedock.dev/petrbalvin/volumen/internal/store" ) // layout builds a deployment on disk and returns its options. func layout(t *testing.T) Options { t.Helper() dir := t.TempDir() content := filepath.Join(dir, "posts") media := filepath.Join(content, store.MediaDirName) revisions := filepath.Join(content, ".revisions", "hello") for _, d := range []string{content, media, revisions} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } } write := func(path, body string) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } write(filepath.Join(content, "hello.md"), "+++\nslug = \"hello\"\n+++\nbody\n") write(filepath.Join(content, "cs", "ahoj.md"), "+++\nslug = \"ahoj\"\n+++\ntelo\n") write(filepath.Join(media, "abcd1234-upload.webp"), "RIFF....WEBPVP8 ") write(filepath.Join(revisions, "20260102T030405Z.md"), "old body") return Options{ ContentDir: content, UsersFile: filepath.Join(dir, "users.toml"), TemplatesFile: filepath.Join(dir, "templates.toml"), TokensFile: filepath.Join(dir, "tokens.toml"), } } func withSecrets(t *testing.T, opts Options) Options { t.Helper() writeFixture(t, opts.UsersFile, "[[users]]\nusername = \"admin\"\nrole = \"admin\"\n") writeFixture(t, opts.TemplatesFile, "[[templates]]\nname = \"Review\"\n") writeFixture(t, opts.TokensFile, "[[tokens]]\nname = \"ci\"\ntoken_hash = \"abc\"\n") return opts } func writeFixture(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write %s: %v", path, err) } } func TestRoundTrip(t *testing.T) { opts := withSecrets(t, layout(t)) var buf bytes.Buffer if err := Write(&buf, opts); err != nil { t.Fatalf("Write: %v", err) } // A fresh deployment restores what the archive carries. fresh := layout(t) fresh.UsersFile = filepath.Join(t.TempDir(), "renamed-users.toml") fresh.TemplatesFile = filepath.Join(t.TempDir(), "renamed-templates.toml") fresh.TokensFile = filepath.Join(t.TempDir(), "renamed-tokens.toml") for _, path := range []string{ filepath.Join(fresh.ContentDir, "hello.md"), filepath.Join(fresh.ContentDir, "cs", "ahoj.md"), filepath.Join(fresh.ContentDir, store.MediaDirName, "abcd1234-upload.webp"), filepath.Join(fresh.ContentDir, ".revisions", "hello", "20260102T030405Z.md"), } { if err := os.Remove(path); err != nil { t.Fatalf("remove %s: %v", path, err) } } written, err := Restore(&buf, fresh) if err != nil { t.Fatalf("Restore: %v", err) } // Four files inside posts/ plus the three standalone files. if written != 7 { t.Fatalf("written = %d, want 7", written) } for _, path := range []string{ filepath.Join(fresh.ContentDir, "hello.md"), filepath.Join(fresh.ContentDir, "cs", "ahoj.md"), filepath.Join(fresh.ContentDir, store.MediaDirName, "abcd1234-upload.webp"), filepath.Join(fresh.ContentDir, ".revisions", "hello", "20260102T030405Z.md"), fresh.UsersFile, fresh.TemplatesFile, fresh.TokensFile, } { if _, err := os.Stat(path); err != nil { t.Errorf("missing after restore: %s (%v)", path, err) } } // The renamed files receive their contents under the configured // names, not under the archive's names. raw, err := os.ReadFile(fresh.UsersFile) if err != nil || !strings.Contains(string(raw), "admin") { t.Fatalf("users file = %q, %v", raw, err) } } func TestRestoreRefusesHostileEntries(t *testing.T) { opts := layout(t) archive := buildArchive(t, []archiveEntry{ {name: "../escape.md", body: "pwned"}, {name: "posts/../../escape.md", body: "pwned"}, {name: "/etc/passwd", body: "pwned"}, {name: "posts/media/evil.html", body: ""}, {name: "posts/media/evil.js", body: "alert(1)"}, {name: "posts/notes.txt", body: "not a post"}, {name: "posts/keep.md", body: "+++\nslug = \"keep\"\n+++\nok\n"}, {name: "posts/media/abcd1234-upload.webp", body: "RIFF....WEBPVP8 "}, {name: "unrelated.toml", body: "x = 1"}, {name: "config.toml", body: "session_key = \"secret\""}, }) written, err := Restore(bytes.NewReader(archive), opts) if err != nil { t.Fatalf("Restore: %v", err) } if written != 2 { t.Fatalf("written = %d, want 2 (keep.md and the image)", written) } if _, err := os.Stat(filepath.Join(opts.ContentDir, "keep.md")); err != nil { t.Errorf("valid post not restored: %v", err) } if _, err := os.Stat(filepath.Join(opts.ContentDir, store.MediaDirName, "abcd1234-upload.webp")); err != nil { t.Errorf("valid image not restored: %v", err) } for _, bad := range []string{ filepath.Join(filepath.Dir(opts.ContentDir), "escape.md"), filepath.Join(opts.ContentDir, "media", "evil.html"), filepath.Join(opts.ContentDir, "media", "evil.js"), filepath.Join(opts.ContentDir, "notes.txt"), } { if _, err := os.Stat(bad); err == nil { t.Errorf("hostile entry was written: %s", bad) } } } func TestRestoreRejectsNonArchives(t *testing.T) { opts := layout(t) if _, err := Restore(strings.NewReader("this is not a tar.gz"), opts); err == nil { t.Fatal("a text file was accepted as an archive") } empty := buildArchive(t, nil) if _, err := Restore(bytes.NewReader(empty), opts); err != nil { t.Fatalf("an empty archive is not an error: %v", err) } } func TestRestoreRefusesAnOversizedEntry(t *testing.T) { opts := layout(t) // The header declares more than the budget; the body is short, which // is exactly the shape a compression bomb has. archive := buildArchiveRaw(t, []archiveEntry{ {name: "posts/huge.md", body: "x", size: MaxDecompressed + 1}, }, false) if _, err := Restore(bytes.NewReader(archive), opts); err == nil { t.Fatal("an entry declaring more than the budget was accepted") } if _, err := os.Stat(filepath.Join(opts.ContentDir, "huge.md")); err == nil { t.Fatal("the oversized entry was written") } } func TestWriteRefusesAMissingDirectory(t *testing.T) { opts := layout(t) opts.ContentDir = filepath.Join(t.TempDir(), "absent", "posts") if err := Write(&bytes.Buffer{}, opts); err == nil { t.Fatal("a missing content directory was archived as if it were empty") } } type archiveEntry struct { name string body string size int64 } // buildArchive writes a tar.gz with the given entries, taking sizes from // the body unless a size is given. func buildArchive(t *testing.T, entries []archiveEntry) []byte { t.Helper() return buildArchiveRaw(t, entries, true) } // buildArchiveRaw writes the entries; complete controls whether a // declared size larger than the body is padded to match. func buildArchiveRaw(t *testing.T, entries []archiveEntry, complete bool) []byte { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) for _, entry := range entries { size := int64(len(entry.body)) if entry.size > 0 { size = entry.size } if err := tw.WriteHeader(&tar.Header{ Name: entry.name, Mode: 0o644, Size: size, Typeflag: tar.TypeReg, }); err != nil { t.Fatalf("header: %v", err) } if _, err := tw.Write([]byte(entry.body)); err != nil { t.Fatalf("body: %v", err) } if !complete { // The reader stops at the declared size, so the remaining // bytes are never needed. _ = tw.Flush() _ = gz.Close() return buf.Bytes() } } if err := tw.Close(); err != nil { t.Fatalf("close tar: %v", err) } if err := gz.Close(); err != nil { t.Fatalf("close gzip: %v", err) } return buf.Bytes() } // A restore leaves no temp files behind and writes the standalone files // owner-only, the same mode a direct write always used. func TestRestoreLeavesNoTempFiles(t *testing.T) { dir := t.TempDir() content := filepath.Join(dir, "posts") if err := os.MkdirAll(content, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } users := filepath.Join(dir, "users.toml") archive := filepath.Join(dir, "backup.tar.gz") data := buildArchive(t, []archiveEntry{ {name: "users.toml", body: "[[users]]\n"}, {name: "posts/a.md", body: "+++\nslug = \"a\"\ntitle = \"A\"\n+++\nx\n"}, }) if err := os.WriteFile(archive, data, 0o600); err != nil { t.Fatalf("write archive: %v", err) } file, err := os.Open(archive) if err != nil { t.Fatalf("open: %v", err) } defer file.Close() if _, err := Restore(file, Options{ ContentDir: content, UsersFile: users, TemplatesFile: filepath.Join(dir, "templates.toml"), TokensFile: filepath.Join(dir, "tokens.toml"), }); err != nil { t.Fatalf("restore: %v", err) } for _, list := range []string{dir, content} { entries, err := os.ReadDir(list) if err != nil { t.Fatalf("read %s: %v", list, err) } for _, e := range entries { if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".tmp") { t.Fatalf("temp file %s left behind in %s", e.Name(), list) } } } info, err := os.Stat(users) if err != nil { t.Fatalf("stat users: %v", err) } if info.Mode().Perm() != 0o600 { t.Fatalf("users.toml mode = %v, want 0600", info.Mode().Perm()) } }