Initial commit
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
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package backup writes and restores the deployment's data as a
// gzip-compressed tar: the content directory under posts/, plus the
// users, templates and tokens files. The CLI and the admin UI both go
// through here, so an archive written by one restores in the other.
package backup
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// Archive entry names. The users, templates and tokens files are stored
// under these names rather than under their configured paths, so a
// deployment that renamed them still restores into its own layout.
const (
postsPrefix = "posts/"
usersEntry = "users.toml"
templatesEntry = "templates.toml"
tokensEntry = "tokens.toml"
)
// MaxDecompressed bounds the total size a restore will decompress, so a
// small archive cannot expand until the process runs out of memory.
const MaxDecompressed = 512 << 20
// Options names the files and directories an archive carries.
type Options struct {
ContentDir string
UsersFile string
TemplatesFile string
TokensFile string
}
// Write writes the archive. A file that is absent is skipped; a file
// that exists but cannot be read aborts the backup, because an archive
// that silently omits data looks complete and is not.
func Write(w io.Writer, opts Options) error {
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
if err := writeTree(tw, opts.ContentDir); err != nil {
return err
}
for _, file := range []struct{ entry, source string }{
{usersEntry, opts.UsersFile},
{templatesEntry, opts.TemplatesFile},
{tokensEntry, opts.TokensFile},
} {
if file.source == "" {
continue
}
if err := writeFile(tw, file.entry, file.source); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return fmt.Errorf("finish archive: %w", err)
}
if err := gz.Close(); err != nil {
return fmt.Errorf("finish compression: %w", err)
}
return nil
}
func writeTree(tw *tar.Writer, contentDir string) error {
if contentDir == "" {
return nil
}
err := filepath.WalkDir(contentDir, func(filePath string, d os.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("read %s: %w", filePath, err)
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(contentDir, filePath)
if err != nil {
return fmt.Errorf("locate %s: %w", filePath, err)
}
return writeFile(tw, postsPrefix+filepath.ToSlash(rel), filePath)
})
if err != nil {
return err
}
return nil
}
func writeFile(tw *tar.Writer, entry, source string) error {
info, err := os.Stat(source)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("read %s: %w", source, err)
}
if !info.Mode().IsRegular() {
return nil
}
file, err := os.Open(source)
if err != nil {
return fmt.Errorf("read %s: %w", source, err)
}
defer file.Close()
header := &tar.Header{
Name: entry,
Mode: int64(info.Mode().Perm()),
Size: info.Size(),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("archive %s: %w", entry, err)
}
if _, err := io.Copy(tw, file); err != nil {
return fmt.Errorf("archive %s: %w", source, err)
}
return nil
}
// Restore reads an archive and writes its entries into the deployment.
// It returns the number of files written. Entries the layout does not
// name are skipped; an entry that tries to escape its destination is
// refused outright.
func Restore(r io.Reader, opts Options) (int, error) {
gz, err := gzip.NewReader(io.LimitReader(r, MaxDecompressed))
if err != nil {
return 0, fmt.Errorf("the archive is not a gzip file: %w", err)
}
defer gz.Close()
// The limit applies to the decompressed bytes, which is what a
// compression bomb expands into.
tr := tar.NewReader(io.LimitReader(gz, MaxDecompressed))
root, err := openContentRoot(opts.ContentDir)
if err != nil {
return 0, err
}
defer root.Close()
written := 0
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return written, fmt.Errorf("read the archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
name := path.Clean(strings.TrimPrefix(header.Name, "./"))
switch {
case name == usersEntry:
if err := writeTarget(opts.UsersFile, tr, header.Size); err != nil {
return written, err
}
case name == templatesEntry:
if err := writeTarget(opts.TemplatesFile, tr, header.Size); err != nil {
return written, err
}
case name == tokensEntry:
if err := writeTarget(opts.TokensFile, tr, header.Size); err != nil {
return written, err
}
case strings.HasPrefix(name, postsPrefix):
rel := strings.TrimPrefix(name, postsPrefix)
if !restorablePath(rel) {
continue
}
if err := writeInRoot(root, rel, tr, header.Size); err != nil {
return written, err
}
default:
continue
}
written++
}
return written, nil
}
// restorablePath reports whether a path inside posts/ may be written
// back. Only posts, revision archives and media files with an allowed
// image extension are accepted: the media directory is served from a
// public route, so an archive must not be able to plant a document
// there that a browser would execute.
func restorablePath(rel string) bool {
if rel == "" || strings.HasPrefix(rel, "..") || path.IsAbs(rel) {
return false
}
switch {
case strings.HasPrefix(rel, store.MediaDirName+"/"):
return imagefile.Allowed(path.Base(rel))
case rel == store.MediaDirName:
return false
}
return strings.HasSuffix(rel, ".md")
}
func openContentRoot(contentDir string) (*os.Root, error) {
if contentDir == "" {
return nil, errors.New("no content directory is configured")
}
if err := os.MkdirAll(contentDir, 0o755); err != nil {
return nil, fmt.Errorf("create the content directory: %w", err)
}
root, err := os.OpenRoot(contentDir)
if err != nil {
return nil, fmt.Errorf("open the content directory: %w", err)
}
return root, nil
}
// writeInRoot writes rel inside the content directory. os.Root resolves
// every operation inside that directory, so a name that escapes one,
// through .. or through a symlink, is refused by construction.
func writeInRoot(root *os.Root, rel string, r io.Reader, size int64) error {
clean := path.Clean("/" + rel)
rel = strings.TrimPrefix(clean, "/")
if dir := path.Dir(rel); dir != "." {
if err := root.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
data, err := readEntry(r, size)
if err != nil {
return fmt.Errorf("read %s: %w", rel, err)
}
if err := atomicWriteInRoot(root, rel, data); err != nil {
return fmt.Errorf("write %s: %w", rel, err)
}
return nil
}
// atomicWriteInRoot replaces rel through a temp file and a rename, the
// same shape the post store writes with: a crash mid-restore leaves the
// previous file intact rather than a truncated one.
func atomicWriteInRoot(root *os.Root, rel string, data []byte) error {
dir, base := path.Split(rel)
tmp := path.Join(dir, "."+base+".tmp")
handle, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
if _, err := handle.Write(data); err != nil {
handle.Close()
root.Remove(tmp)
return fmt.Errorf("write temp file: %w", err)
}
if err := handle.Close(); err != nil {
root.Remove(tmp)
return fmt.Errorf("close temp file: %w", err)
}
if err := root.Rename(tmp, rel); err != nil {
root.Remove(tmp)
return fmt.Errorf("replace: %w", err)
}
return nil
}
// writeTarget writes one of the standalone TOML files. The destination
// is the configured path, never a path from the archive.
func writeTarget(target string, r io.Reader, size int64) error {
if target == "" {
return errors.New("no destination is configured for that file")
}
data, err := readEntry(r, size)
if err != nil {
return fmt.Errorf("read %s: %w", filepath.Base(target), err)
}
dir := filepath.Dir(target)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create the directory for %s: %w", target, err)
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("chmod temp file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpPath, target); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("write %s: %w", target, err)
}
return nil
}
// readEntry reads one entry, refusing a declared size beyond the budget.
func readEntry(r io.Reader, size int64) ([]byte, error) {
if size > MaxDecompressed {
return nil, fmt.Errorf("entry declares %d bytes, over the %d byte limit", size, int64(MaxDecompressed))
}
data, err := io.ReadAll(io.LimitReader(r, MaxDecompressed))
if err != nil {
return nil, err
}
return data, nil
}
+289
View File
@@ -0,0 +1,289 @@
// 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())
}
}