diff --git a/backup/archive.go b/backup/archive.go index 6c2b054..b78f704 100644 --- a/backup/archive.go +++ b/backup/archive.go @@ -163,6 +163,10 @@ func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, [ } // Extra fixed entries, in a stable (sorted) order for reproducibility. + // Failure contract (cms parity — the cssManifestsFile block in + // buildBackupArchive): a failed Create skips the entry silently, a failed + // Write appends a warning. Extra entries are regenerable extras; a problem + // with one must never abort the archive. if len(in.Extra) > 0 { names := make([]string, 0, len(in.Extra)) for name := range in.Extra { @@ -172,10 +176,10 @@ func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, [ for _, name := range names { entry, err := zipWriter.Create(name) if err != nil { - return nil, warnings, fmt.Errorf("failed to create %s in zip: %w", name, err) + continue } if _, err := entry.Write(in.Extra[name]); err != nil { - return nil, warnings, fmt.Errorf("failed to write %s: %w", name, err) + warnings = append(warnings, fmt.Sprintf("Failed to write %s: %v", name, err)) } } } diff --git a/backup/archive_test.go b/backup/archive_test.go index 60aecda..a4e6812 100644 --- a/backup/archive_test.go +++ b/backup/archive_test.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" ) @@ -95,3 +96,76 @@ func TestBuildArchiveRoundTrip(t *testing.T) { t.Fatal("media file missing or corrupted in archive") } } + +// TestBuildArchiveExtraEntries locks the happy path of the Extra contract: extra +// entries land in the archive with their exact contents. The failure half of the +// contract (Create failure → skip silently, Write failure → warning, never a +// fatal error — cms cssManifestsFile parity) is documented at the Extra block in +// BuildArchive; zip.Writer failures cannot be injected deterministically without +// error-injection infrastructure, so the failure path is covered for section +// files below where the failure is controllable. +func TestBuildArchiveExtraEntries(t *testing.T) { + var buf bytes.Buffer + _, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{ + Source: Source{SiteTitle: "Test Site"}, + Dump: []byte("dump"), + Password: "correct horse", + Extra: map[string][]byte{ + "state/css-manifests.json": []byte(`{"plugins":{}}`), + }, + }) + if err != nil { + t.Fatalf("BuildArchive: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + if got := string(readZipEntry(t, buf.Bytes(), "state/css-manifests.json")); got != `{"plugins":{}}` { + t.Fatalf("extra entry content = %q", got) + } +} + +// TestBuildArchiveUnreadableFileWarnsAndContinues proves the warning-not-error +// contract: a file that cannot be opened inside an existing section dir appends +// a warning and the archive still completes with every readable file present. +func TestBuildArchiveUnreadableFileWarnsAndContinues(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("running as root: chmod 0 files remain readable") + } + dir := t.TempDir() + mediaDir := filepath.Join(dir, "media") + if err := os.MkdirAll(mediaDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mediaDir, "good.txt"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + badPath := filepath.Join(mediaDir, "bad.txt") + if err := os.WriteFile(badPath, []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(badPath, 0o000); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{ + Source: Source{SiteTitle: "Test Site"}, + Dump: []byte("dump"), + Password: "correct horse", + Sections: []Section{{Prefix: "media/", Dir: mediaDir}}, + }) + if err != nil { + t.Fatalf("unreadable file must not abort the archive, got error: %v", err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "bad.txt") { + t.Fatalf("want exactly one warning mentioning bad.txt, got: %v", warnings) + } + // Both files were collected (open fails later, at write time) — cms parity. + if manifest.MediaCount != 2 { + t.Fatalf("media count = %d, want 2", manifest.MediaCount) + } + if got := string(readZipEntry(t, buf.Bytes(), "media/good.txt")); got != "ok" { + t.Fatalf("good.txt content = %q", got) + } +}