Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one commit: the review fixes sit in the same hunks. Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list): - backup.snapshotDB refuses a database that is not there and opens the source mode=ro (measured: mode=ro creates no file). - selfpost-backup holds an encrypting password to validate.MinSecretFilePasswordLen; -o of both directions writes a temporary file beside the name and renames it whole; -decrypt peeks the first chunk (password and type authenticated) before the output exists. - what a backup leaves out (a link under data/, a link under certs/ to a directory, a socket...) is told to OnWarn; certs/ with no file is backup.ErrNoCerts, refused by Check and Create, named by the panel. - parseTrustedProxies: a bare address takes /128 when written with a colon (::ffff:10.0.0.1/32 was ::/32). Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2; bytes after the last chunk are ErrCorrupt. Step 5 (L7): atomicfile.SyncDir after the rename, used by Write, replaceSASLDB and the CLI; atomicfile_test.go. Step 6: security.md and guide on backup and restore. Reviews (technical, security): - H1: a restore of a server with DMARC on was refused on every start - the archive records no owner, tar as root left the DMARC maps root's, and the entrypoint only chowned the maps it named. It now hands every file beside queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map before the backup and finds it panel:selfpost 640 after a root unpack. - the comparison reads regular files only (Lstat, size, LimitReader); a record unpacked again wins over the earlier check's mark; ConsumeRestore removes both; the mark is synced; a journal file beside a freshly unpacked database is refused. - AttachRefusalLine refuses an empty line as the old statement did; skipped entries named in words; -decrypt of an empty payload is no failure; guide wording on the shell's '>' and a killed CLI; the in-place restore's 'mv .' (older than this stage) fixed. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 643 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
3.2 KiB
Go
114 lines
3.2 KiB
Go
package atomicfile
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// leftovers lists what is in dir besides the names given.
|
|
func leftovers(t *testing.T, dir string, names ...string) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keep := map[string]bool{}
|
|
for _, n := range names {
|
|
keep[n] = true
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if !keep[e.Name()] {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A new file and a replaced one hold what was written, with the mode asked
|
|
// for, and nothing is left beside them.
|
|
func TestWriteCreatesAndReplaces(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "map")
|
|
for _, body := range []string{"first\n", "second, longer than the first\n", ""} {
|
|
if err := Write(path, []byte(body), 0o640); err != nil {
|
|
t.Fatalf("Write %q: %v", body, err)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil || string(got) != body {
|
|
t.Fatalf("file = %q, %v; want %q", got, err, body)
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
|
|
t.Errorf("mode = %v, want 0640", info.Mode().Perm())
|
|
}
|
|
}
|
|
if rest := leftovers(t, dir, "map"); len(rest) != 0 {
|
|
t.Errorf("Write left %v", rest)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A reader that opened the old file reads the old file to its end: the new
|
|
// one takes the name, it does not overwrite the old one's bytes.
|
|
func TestAReaderKeepsTheOldFile(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows does not rename over an open file; the container is Linux")
|
|
}
|
|
path := filepath.Join(t.TempDir(), "map")
|
|
if err := Write(path, []byte("old contents\n"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
if err := Write(path, []byte("new\n"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
buf := make([]byte, 64)
|
|
n, _ := f.Read(buf)
|
|
if string(buf[:n]) != "old contents\n" {
|
|
t.Errorf("the open reader read %q", buf[:n])
|
|
}
|
|
}
|
|
|
|
// A write that fails leaves the old file and no temporary one.
|
|
func TestAFailedWriteLeavesTheOldFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := Write(filepath.Join(dir, "missing", "map"), []byte("x"), 0o640); err == nil {
|
|
t.Error("Write into a directory that is not there succeeded")
|
|
}
|
|
|
|
// The name is a directory: the rename fails after the temporary file
|
|
// was written.
|
|
target := filepath.Join(dir, "taken")
|
|
if err := os.MkdirAll(filepath.Join(target, "inside"), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Write(target, []byte("x"), 0o640); err == nil {
|
|
t.Error("Write over a directory succeeded")
|
|
}
|
|
if rest := leftovers(t, dir, "taken"); len(rest) != 0 {
|
|
t.Errorf("the failed Write left %v", rest)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(target, "inside")); err != nil {
|
|
t.Errorf("the failed Write changed the directory in its way: %v", err)
|
|
}
|
|
}
|
|
|
|
// The directory is synced where the system allows it; on Linux, where the
|
|
// container runs, it does.
|
|
func TestSyncDir(t *testing.T) {
|
|
err := SyncDir(t.TempDir())
|
|
if runtime.GOOS == "linux" && err != nil {
|
|
t.Errorf("SyncDir on Linux = %v", err)
|
|
}
|
|
if err := SyncDir(filepath.Join(t.TempDir(), "missing")); err == nil {
|
|
t.Error("SyncDir of a directory that is not there = nil")
|
|
}
|
|
}
|