From 351544b2c0bc0d888c480cff98db3ad4f1c18b35 Mon Sep 17 00:00:00 2001 From: Zack Brady Date: Mon, 10 Aug 2026 12:30:42 -0400 Subject: [PATCH] fixed audit logging with saves and loads (#727) --- cmd/hauler/cli/store.go | 6 +-- cmd/hauler/cli/store/lifecycle_test.go | 40 +++++++++++---- cmd/hauler/cli/store/load.go | 35 +++++++++++-- cmd/hauler/cli/store/load_test.go | 71 +++++++++++++++++++++++--- cmd/hauler/cli/store/save.go | 27 +++++++++- cmd/hauler/cli/store/save_test.go | 10 ++-- pkg/audit/audit.go | 40 +++++++++++++-- pkg/audit/audit_test.go | 56 ++++++++++++++++++++ 8 files changed, 252 insertions(+), 33 deletions(-) diff --git a/cmd/hauler/cli/store.go b/cmd/hauler/cli/store.go index 6630661..4fd07e5 100644 --- a/cmd/hauler/cli/store.go +++ b/cmd/hauler/cli/store.go @@ -166,9 +166,8 @@ func addStoreLoad(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman if err != nil { return err } - _ = s - return store.LoadCmd(ctx, o, rso, ro) + return store.LoadCmd(ctx, o, s, rso, ro) }, } o.AddFlags(cmd) @@ -252,9 +251,8 @@ func addStoreSave(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman if err != nil { return err } - _ = s - return store.SaveCmd(ctx, o, rso, ro) + return store.SaveCmd(ctx, o, s, rso, ro) }, } o.AddFlags(cmd) diff --git a/cmd/hauler/cli/store/lifecycle_test.go b/cmd/hauler/cli/store/lifecycle_test.go index a8ef96e..5be43a9 100644 --- a/cmd/hauler/cli/store/lifecycle_test.go +++ b/cmd/hauler/cli/store/lifecycle_test.go @@ -44,7 +44,7 @@ func TestLifecycle_FileArtifact_AddSaveLoadCopy(t *testing.T) { // Step 3: SaveCmd -> archive (absolute paths required). archivePath := filepath.Join(t.TempDir(), "lifecycle-file.tar.zst") saveOpts := newSaveOpts(storeA.Root, archivePath) - if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd: %v", err) } @@ -58,11 +58,15 @@ func TestLifecycle_FileArtifact_AddSaveLoadCopy(t *testing.T) { // Step 4: LoadCmd -> store B. storeBDir := t.TempDir() + storeBPreLoad, err := store.NewLayout(storeBDir) + if err != nil { + t.Fatalf("store.NewLayout(storeB pre-load): %v", err) + } loadOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(storeBDir), FileName: []string{archivePath}, } - if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd: %v", err) } @@ -122,17 +126,21 @@ func TestLifecycle_Image_AddSaveLoadCopyRegistry(t *testing.T) { // Step 3: SaveCmd -> archive. archivePath := filepath.Join(t.TempDir(), "lifecycle-image.tar.zst") saveOpts := newSaveOpts(storeA.Root, archivePath) - if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd: %v", err) } // Step 4: LoadCmd -> store B. storeBDir := t.TempDir() + storeBPreLoad, err := store.NewLayout(storeBDir) + if err != nil { + t.Fatalf("store.NewLayout(storeB pre-load): %v", err) + } loadOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(storeBDir), FileName: []string{archivePath}, } - if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd: %v", err) } @@ -190,17 +198,21 @@ func TestLifecycle_Chart_AddSaveLoadExtract(t *testing.T) { // Step 2: SaveCmd -> archive. archivePath := filepath.Join(t.TempDir(), "lifecycle-chart.tar.zst") saveOpts := newSaveOpts(storeA.Root, archivePath) - if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd: %v", err) } // Step 3: LoadCmd -> new store. storeBDir := t.TempDir() + storeBPreLoad, err := store.NewLayout(storeBDir) + if err != nil { + t.Fatalf("store.NewLayout(storeB pre-load): %v", err) + } loadOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(storeBDir), FileName: []string{archivePath}, } - if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd: %v", err) } @@ -274,17 +286,21 @@ func TestLifecycle_DigestOnlyImage_AddSaveLoad(t *testing.T) { // Step 3: SaveCmd -> archive archivePath := filepath.Join(t.TempDir(), "lifecycle-digestonly.tar.zst") saveOpts := newSaveOpts(storeA.Root, archivePath) - if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd: %v", err) } // Step 4: LoadCmd -> fresh store B storeBDir := t.TempDir() + storeBPreLoad, err := store.NewLayout(storeBDir) + if err != nil { + t.Fatalf("store.NewLayout(storeB pre-load): %v", err) + } loadOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(storeBDir), FileName: []string{archivePath}, } - if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd: %v", err) } @@ -339,17 +355,21 @@ func TestLifecycle_Remove_ThenSave(t *testing.T) { // Step 3: SaveCmd -> archive. archivePath := filepath.Join(t.TempDir(), "lifecycle-remove.tar.zst") saveOpts := newSaveOpts(storeA.Root, archivePath) - if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, saveOpts, storeA, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd: %v", err) } // Step 4: LoadCmd -> new store. storeBDir := t.TempDir() + storeBPreLoad, err := store.NewLayout(storeBDir) + if err != nil { + t.Fatalf("store.NewLayout(storeB pre-load): %v", err) + } loadOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(storeBDir), FileName: []string{archivePath}, } - if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, loadOpts, storeBPreLoad, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd: %v", err) } diff --git a/cmd/hauler/cli/store/load.go b/cmd/hauler/cli/store/load.go index 490b1fd..f651f36 100644 --- a/cmd/hauler/cli/store/load.go +++ b/cmd/hauler/cli/store/load.go @@ -14,6 +14,7 @@ import ( "hauler.dev/go/hauler/v2/internal/flags" "hauler.dev/go/hauler/v2/pkg/archives" + "hauler.dev/go/hauler/v2/pkg/audit" "hauler.dev/go/hauler/v2/pkg/consts" "hauler.dev/go/hauler/v2/pkg/content" "hauler.dev/go/hauler/v2/pkg/getter" @@ -28,7 +29,7 @@ import ( var legacyChunkRe = regexp.MustCompile(`_\d+\.`) // extracts the contents of an archived oci layout to an existing oci layout -func LoadCmd(ctx context.Context, o *flags.LoadOpts, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error { +func LoadCmd(ctx context.Context, o *flags.LoadOpts, s *store.Layout, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error { l := log.FromContext(ctx) tempOverride := rso.TempOverride @@ -66,6 +67,25 @@ func LoadCmd(ctx context.Context, o *flags.LoadOpts, rso *flags.StoreRootOpts, r if err != nil { return err } + + if auditLevel(ro) != "none" { + e := audit.Entry{ + StoreID: s.StoreID, + Store: s.Root, + Command: "store load", + Reference: audit.SanitizeURL(resolved), + } + if auditLevel(ro) == "verbose" { + sys := audit.BuildSystem() + g := audit.BuildGlobal(ro, rso) + e.System = &sys + e.Global = &g + } + if err := audit.Append(ro.HaulerDir, e); err != nil { + l.Warnf("failed to write audit entry: %v", err) + } + } + clearDir(tempDir) } @@ -242,8 +262,17 @@ func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDi return err } - _, err = s.CopyAll(ctx, ts, nil) - return err + if _, err := s.CopyAll(ctx, ts, nil); err != nil { + return err + } + + // CopyAll only copies OCI content; the haul's audit.log sits alongside + // it, not in it, so it has to be merged in separately. + if err := audit.MergeStoreLog(tempDir, dest); err != nil { + l.Warnf("failed to merge audit log from haul: %v", err) + } + + return nil } // matches the .NNN chunk suffix that is used to filter resolveHaulPath diff --git a/cmd/hauler/cli/store/load_test.go b/cmd/hauler/cli/store/load_test.go index 0cf4df7..e576184 100644 --- a/cmd/hauler/cli/store/load_test.go +++ b/cmd/hauler/cli/store/load_test.go @@ -103,6 +103,47 @@ func TestUnarchiveLayoutTo(t *testing.T) { } } +// TestUnarchiveLayoutTo_MergesAuditLog checks a haul's audit.log survives +// the load and merges into (not overwrites) whatever dest already has. +func TestUnarchiveLayoutTo_MergesAuditLog(t *testing.T) { + srcDir := t.TempDir() + srcLayout, err := store.NewLayout(srcDir) + if err != nil { + t.Fatalf("store.NewLayout(srcDir): %v", err) + } + // NewLayout doesn't write index.json until something forces a save -- + // unarchiveLayoutTo requires one to exist in the archive. + if err := srcLayout.OCI.SaveIndex(); err != nil { + t.Fatalf("SaveIndex(srcDir): %v", err) + } + if err := os.WriteFile(filepath.Join(srcDir, "audit.log"), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil { + t.Fatalf("seed source audit.log: %v", err) + } + + archivePath := filepath.Join(t.TempDir(), "haul.tar.zst") + if err := createRootLevelArchive(srcDir, archivePath); err != nil { + t.Fatalf("createRootLevelArchive: %v", err) + } + + destDir := t.TempDir() + if err := os.WriteFile(filepath.Join(destDir, "audit.log"), []byte(`{"command":"already-here"}`+"\n"), 0o644); err != nil { + t.Fatalf("seed dest audit.log: %v", err) + } + + ctx := newTestContext(t) + if err := unarchiveLayoutTo(ctx, archivePath, destDir, t.TempDir(), defaultCliOpts(), false); err != nil { + t.Fatalf("unarchiveLayoutTo: %v", err) + } + + data, err := os.ReadFile(filepath.Join(destDir, "audit.log")) + if err != nil { + t.Fatalf("ReadFile dest audit.log: %v", err) + } + if !strings.Contains(string(data), "already-here") || !strings.Contains(string(data), "from-haul") { + t.Fatalf("expected both entries preserved after load, got: %s", data) + } +} + // -------------------------------------------------------------------------- // TestLoadCmd_LocalFile // -------------------------------------------------------------------------- @@ -114,16 +155,22 @@ func TestLoadCmd_LocalFile(t *testing.T) { t.Run("single archive", func(t *testing.T) { destDir := t.TempDir() + s, err := store.NewLayout(destDir) + if err != nil { + t.Fatalf("store.NewLayout: %v", err) + } o := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(destDir), FileName: []string{testHaulArchive}, } - if err := LoadCmd(ctx, o, defaultRootOpts(destDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, o, s, defaultRootOpts(destDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd: %v", err) } - s, err := store.NewLayout(destDir) + // re-open: LoadCmd writes through its own content.OCI instance, so s's + // in-memory index predates the load. + s, err = store.NewLayout(destDir) if err != nil { - t.Fatalf("store.NewLayout: %v", err) + t.Fatalf("store.NewLayout (post-load): %v", err) } if countArtifactsInStore(t, s) == 0 { t.Error("expected artifacts in store after LoadCmd") @@ -135,11 +182,15 @@ func TestLoadCmd_LocalFile(t *testing.T) { // silently discarded by the OCI pusher. The descriptor count after two // loads must equal the count after a single load. singleDir := t.TempDir() + singlePreLoad, err := store.NewLayout(singleDir) + if err != nil { + t.Fatalf("store.NewLayout single (pre-load): %v", err) + } singleOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(singleDir), FileName: []string{testHaulArchive}, } - if err := LoadCmd(ctx, singleOpts, defaultRootOpts(singleDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, singleOpts, singlePreLoad, defaultRootOpts(singleDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd single: %v", err) } singleStore, err := store.NewLayout(singleDir) @@ -149,11 +200,15 @@ func TestLoadCmd_LocalFile(t *testing.T) { singleCount := countArtifactsInStore(t, singleStore) doubleDir := t.TempDir() + doublePreLoad, err := store.NewLayout(doubleDir) + if err != nil { + t.Fatalf("store.NewLayout double (pre-load): %v", err) + } doubleOpts := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(doubleDir), FileName: []string{testHaulArchive, testHaulArchive}, } - if err := LoadCmd(ctx, doubleOpts, defaultRootOpts(doubleDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, doubleOpts, doublePreLoad, defaultRootOpts(doubleDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd double: %v", err) } doubleStore, err := store.NewLayout(doubleDir) @@ -192,12 +247,16 @@ func TestLoadCmd_RemoteArchive(t *testing.T) { destDir := t.TempDir() remoteURL := srv.URL + "/haul.tar.zst" + preLoad, err := store.NewLayout(destDir) + if err != nil { + t.Fatalf("store.NewLayout (pre-load): %v", err) + } o := &flags.LoadOpts{ StoreRootOpts: defaultRootOpts(destDir), FileName: []string{remoteURL}, } - if err := LoadCmd(ctx, o, defaultRootOpts(destDir), defaultCliOpts()); err != nil { + if err := LoadCmd(ctx, o, preLoad, defaultRootOpts(destDir), defaultCliOpts()); err != nil { t.Fatalf("LoadCmd remote: %v", err) } diff --git a/cmd/hauler/cli/store/save.go b/cmd/hauler/cli/store/save.go index 57984f9..3fd4838 100644 --- a/cmd/hauler/cli/store/save.go +++ b/cmd/hauler/cli/store/save.go @@ -22,12 +22,14 @@ import ( "hauler.dev/go/hauler/v2/internal/flags" "hauler.dev/go/hauler/v2/pkg/archives" + "hauler.dev/go/hauler/v2/pkg/audit" "hauler.dev/go/hauler/v2/pkg/consts" "hauler.dev/go/hauler/v2/pkg/log" + "hauler.dev/go/hauler/v2/pkg/store" ) // saves a content store to store archives -func SaveCmd(ctx context.Context, o *flags.SaveOpts, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error { +func SaveCmd(ctx context.Context, o *flags.SaveOpts, s *store.Layout, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error { l := log.FromContext(ctx) // maps to handle compression and archival types @@ -94,6 +96,29 @@ func SaveCmd(ctx context.Context, o *flags.SaveOpts, rso *flags.StoreRootOpts, r l.Infof("saving store [%s] to archive [%s]", o.StoreDir, o.FileName) } + if auditLevel(ro) != "none" { + e := audit.Entry{ + StoreID: s.StoreID, + Store: s.Root, + Command: "store save", + Reference: o.FileName, + } + if auditLevel(ro) == "verbose" { + sys := audit.BuildSystem() + g := audit.BuildGlobal(ro, rso) + e.System = &sys + e.Global = &g + e.Flags = map[string]any{ + "platform": o.Platform, + "containerd": o.ContainerdCompatibility, + "chunk-size": o.ChunkSize, + } + } + if err := audit.Append(ro.HaulerDir, e); err != nil { + l.Warnf("failed to write audit entry: %v", err) + } + } + return nil } diff --git a/cmd/hauler/cli/store/save_test.go b/cmd/hauler/cli/store/save_test.go index 8f856bf..dc559a0 100644 --- a/cmd/hauler/cli/store/save_test.go +++ b/cmd/hauler/cli/store/save_test.go @@ -182,7 +182,7 @@ func TestSaveCmd(t *testing.T) { archivePath := filepath.Join(t.TempDir(), "haul.tar.zst") o := newSaveOpts(s.Root, archivePath) - if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd: %v", err) } @@ -215,7 +215,7 @@ func TestSaveCmd_ContainerdCompatibility(t *testing.T) { o := newSaveOpts(s.Root, archivePath) o.ContainerdCompatibility = true - if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd ContainerdCompatibility: %v", err) } @@ -244,7 +244,7 @@ func TestSaveCmd_EmptyStore(t *testing.T) { archivePath := filepath.Join(t.TempDir(), "haul-empty.tar.zst") o := newSaveOpts(s.Root, archivePath) - if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd empty store: %v", err) } @@ -315,7 +315,7 @@ func TestSaveCmd_ChunkSize(t *testing.T) { o := newSaveOpts(s.Root, archivePath) o.ChunkSize = "1K" - if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { + if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err != nil { t.Fatalf("SaveCmd with chunk-size: %v", err) } @@ -344,7 +344,7 @@ func TestSaveCmd_ChunkSize_Invalid(t *testing.T) { o := newSaveOpts(s.Root, filepath.Join(t.TempDir(), "haul.tar.zst")) o.ChunkSize = "0" - if err := SaveCmd(ctx, o, defaultRootOpts(s.Root), defaultCliOpts()); err == nil { + if err := SaveCmd(ctx, o, s, defaultRootOpts(s.Root), defaultCliOpts()); err == nil { t.Fatal("SaveCmd: expected error for chunk-size=0, got nil") } } diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 09d96ab..2271e58 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -200,9 +200,13 @@ func Append(haulerDir string, e Entry) error { return globalErr } -// appendMu serializes appendLine calls: os.OpenFile with O_APPEND is only -// atomic for a single write() syscall on POSIX, and concurrent `store sync` -// image jobs (runImageJobs) can each call this at once without it. +// LogFileName is the audit log's filename, under both haulerDir and a store's Root. +const LogFileName = "audit.log" + +// appendMu serializes appendLine/MergeStoreLog calls: os.OpenFile with +// O_APPEND is only atomic for a single write() syscall on POSIX, and +// concurrent `store sync` image jobs (runImageJobs) can each call this at +// once without it. var appendMu sync.Mutex func appendLine(dir string, v any) error { @@ -216,7 +220,7 @@ func appendLine(dir string, v any) error { if err != nil { return fmt.Errorf("audit: marshal: %w", err) } - f, err := os.OpenFile(filepath.Join(dir, "audit.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + f, err := os.OpenFile(filepath.Join(dir, LogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("audit: open log: %w", err) } @@ -225,6 +229,34 @@ func appendLine(dir string, v any) error { return err } +// MergeStoreLog appends tempDir's staged audit.log onto destDir's. No-op if tempDir has none. +func MergeStoreLog(tempDir, destDir string) error { + data, err := os.ReadFile(filepath.Join(tempDir, LogFileName)) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("audit: read staged log: %w", err) + } + if len(data) == 0 { + return nil + } + + appendMu.Lock() + defer appendMu.Unlock() + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("audit: ensure dir: %w", err) + } + f, err := os.OpenFile(filepath.Join(destDir, LogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("audit: open log: %w", err) + } + defer f.Close() + _, err = f.Write(data) + return err +} + func resolveDir(haulerDir string) string { if haulerDir != "" { return haulerDir diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go index 4f7d3ee..c3cd68f 100644 --- a/pkg/audit/audit_test.go +++ b/pkg/audit/audit_test.go @@ -186,6 +186,62 @@ func TestAppend_MultipleEntries(t *testing.T) { } } +func TestMergeStoreLog_AppendsOntoExisting(t *testing.T) { + temp, dest := t.TempDir(), t.TempDir() + + if err := os.WriteFile(filepath.Join(dest, LogFileName), []byte(`{"command":"original"}`+"\n"), 0o644); err != nil { + t.Fatalf("seed dest log: %v", err) + } + if err := os.WriteFile(filepath.Join(temp, LogFileName), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil { + t.Fatalf("seed temp log: %v", err) + } + + if err := MergeStoreLog(temp, dest); err != nil { + t.Fatalf("MergeStoreLog: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dest, LogFileName)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(data), "original") || !strings.Contains(string(data), "from-haul") { + t.Fatalf("expected both entries preserved, got: %s", data) + } +} + +func TestMergeStoreLog_CreatesDestWhenMissing(t *testing.T) { + temp := t.TempDir() + dest := filepath.Join(t.TempDir(), "not-yet-created") + + if err := os.WriteFile(filepath.Join(temp, LogFileName), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil { + t.Fatalf("seed temp log: %v", err) + } + + if err := MergeStoreLog(temp, dest); err != nil { + t.Fatalf("MergeStoreLog: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dest, LogFileName)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(data), "from-haul") { + t.Fatalf("expected dest log created with haul's entry, got: %s", data) + } +} + +func TestMergeStoreLog_NoopWhenTempHasNoLog(t *testing.T) { + temp, dest := t.TempDir(), t.TempDir() + + if err := MergeStoreLog(temp, dest); err != nil { + t.Fatalf("MergeStoreLog: %v", err) + } + + if _, err := os.Stat(filepath.Join(dest, LogFileName)); !os.IsNotExist(err) { + t.Fatalf("expected no dest log to be created, stat err: %v", err) + } +} + func TestShortFileRef(t *testing.T) { tests := []struct { name string