fix(backup): Extra entry failures warn and continue (cms parity) — never abort the archive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-05 09:26:43 +08:00
parent e12160f05c
commit 7089964f6b
2 changed files with 80 additions and 2 deletions

View File

@ -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))
}
}
}

View File

@ -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)
}
}