Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
290 lines
9.3 KiB
Go
290 lines
9.3 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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: "<script>alert(1)</script>"},
|
|
{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())
|
|
}
|
|
}
|