package imagemgr import ( "crypto/sha256" "fmt" "os" "path/filepath" "strings" "testing" ) // TestDebianBasePackagesReturnsCopy pins the contract that mutating the // slice returned by DebianBasePackages() can't poison subsequent calls. // hashPackages digests this list, so a caller that sorts or appends in // place would silently change every image's package metadata. func TestDebianBasePackagesReturnsCopy(t *testing.T) { t.Parallel() first := DebianBasePackages() original := append([]string(nil), first...) if len(first) == 0 { t.Fatal("DebianBasePackages returned empty slice") } first[0] = "tampered" second := DebianBasePackages() if second[0] == "tampered" { t.Fatalf("DebianBasePackages leaks internal state; second[0] = %q after first[0] mutation", second[0]) } for i := range original { if second[i] != original[i] { t.Fatalf("DebianBasePackages drifted at %d: got %q, want %q", i, second[i], original[i]) } } } // TestBuildMetadataPackagesMatchesDebianBase confirms the metadata // packages used for image-drift detection are the same set we apply // during build. If these diverge the hash recorded next to a rootfs // stops matching the actual installed package set. func TestBuildMetadataPackagesMatchesDebianBase(t *testing.T) { t.Parallel() build := BuildMetadataPackages() debian := DebianBasePackages() if len(build) != len(debian) { t.Fatalf("BuildMetadataPackages len = %d, DebianBasePackages len = %d", len(build), len(debian)) } for i := range build { if build[i] != debian[i] { t.Fatalf("BuildMetadataPackages[%d] = %q, want %q", i, build[i], debian[i]) } } } func TestHashPackagesStableForSameInput(t *testing.T) { t.Parallel() pkgs := []string{"git", "make", "vim"} first := hashPackages(pkgs) second := hashPackages(append([]string(nil), pkgs...)) if first != second { t.Fatalf("hashPackages drifted between identical calls: %q vs %q", first, second) } // Sanity: hash differs when input differs. if first == hashPackages([]string{"git", "make"}) { t.Fatal("hashPackages collapsed two distinct inputs to the same hash") } // Verify the format is hex sha256 of "git\nmake\nvim\n" — pin the // concrete digest so a future refactor that changes joining (e.g. // drops the trailing newline) trips this test. want := fmt.Sprintf("%x", sha256.Sum256([]byte("git\nmake\nvim\n"))) if first != want { t.Fatalf("hashPackages format drifted: got %q, want %q", first, want) } } func TestStageOptionalArtifactPathEmptyStaysEmpty(t *testing.T) { t.Parallel() if got := StageOptionalArtifactPath("/tmp/artifacts", "", "initrd.img"); got != "" { t.Fatalf("StageOptionalArtifactPath(empty staged) = %q, want empty", got) } if got := StageOptionalArtifactPath("/tmp/artifacts", " ", "initrd.img"); got != "" { t.Fatalf("StageOptionalArtifactPath(whitespace staged) = %q, want empty", got) } } func TestStageOptionalArtifactPathJoinsName(t *testing.T) { t.Parallel() got := StageOptionalArtifactPath("/tmp/artifacts", "/host/path/initrd.img", "initrd.img") want := filepath.Join("/tmp/artifacts", "initrd.img") if got != want { t.Fatalf("StageOptionalArtifactPath = %q, want %q", got, want) } } func TestWritePackagesMetadataWritesHashFile(t *testing.T) { t.Parallel() dir := t.TempDir() rootfs := filepath.Join(dir, "rootfs.ext4") if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil { t.Fatalf("write rootfs: %v", err) } pkgs := []string{"git", "vim"} if err := WritePackagesMetadata(rootfs, pkgs); err != nil { t.Fatalf("WritePackagesMetadata: %v", err) } got, err := os.ReadFile(rootfs + ".packages.sha256") if err != nil { t.Fatalf("read metadata: %v", err) } want := hashPackages(pkgs) + "\n" if string(got) != want { t.Fatalf("metadata content = %q, want %q", got, want) } } func TestWritePackagesMetadataNoOpOnEmptyInputs(t *testing.T) { t.Parallel() dir := t.TempDir() rootfs := filepath.Join(dir, "rootfs.ext4") if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil { t.Fatalf("write rootfs: %v", err) } // Empty package list is the "managed-image build skipped apt" case. if err := WritePackagesMetadata(rootfs, nil); err != nil { t.Fatalf("WritePackagesMetadata(nil packages): %v", err) } if _, err := os.Stat(rootfs + ".packages.sha256"); !os.IsNotExist(err) { t.Fatalf("metadata file was created for empty packages; err = %v", err) } // Empty rootfs path is a no-op too — callers pass "" when they // haven't decided where to write yet. if err := WritePackagesMetadata("", []string{"git"}); err != nil { t.Fatalf("WritePackagesMetadata(empty rootfs): %v", err) } } // TestHashPackagesIgnoresOrder confirms the canonical join is // strict-order-sensitive: callers must keep the ordering they want the // hash to digest. Pin this so a future "convenience" sort doesn't // silently invalidate every recorded image hash on disk. func TestHashPackagesOrderSensitive(t *testing.T) { t.Parallel() a := hashPackages([]string{"git", "make"}) b := hashPackages([]string{"make", "git"}) if a == b { t.Fatal("hashPackages collapsed two orderings to the same hash; metadata-on-disk would be ambiguous") } // Trailing newlines must be normalised by the joiner, not the // caller. If callers had to remember to add their own, every // historical hash on disk would be a footgun. withTrailing := hashPackages([]string{"git", "make", ""}) if withTrailing == a { t.Fatalf("hashPackages tolerated an empty trailing element silently; got %q == %q", withTrailing, a) } } // TestDebianBasePackagesContainsCriticalEntries pins the small core of // packages every managed image must have. Stops a future refactor // from dropping (say) ca-certificates without the owner noticing — a // rebuilt image without it can't talk to TLS endpoints. func TestDebianBasePackagesContainsCriticalEntries(t *testing.T) { t.Parallel() pkgs := strings.Join(DebianBasePackages(), " ") for _, must := range []string{"ca-certificates", "curl", "git"} { if !strings.Contains(pkgs, must) { t.Errorf("DebianBasePackages missing critical entry %q; got %q", must, pkgs) } } }