Both packages had zero tests before this change. The helpers in them
are pure (imagemgr) or scripted-runner-friendly (dmsnap), so they're
cheap to pin and worth catching regressions on.
imagemgr/paths_test.go:
* DebianBasePackages returns a defensive copy (mutating the result
can't poison subsequent calls — important because hashPackages
digests this list).
* BuildMetadataPackages stays in lockstep with DebianBasePackages.
* hashPackages is order-sensitive and includes a trailing newline
in its canonical join (regression guard for any future "sort the
list before hashing" temptation that would invalidate every
on-disk hash).
* StageOptionalArtifactPath returns "" for empty/whitespace input
and joins by name otherwise.
* WritePackagesMetadata writes <rootfs>.packages.sha256 with the
expected hash, no-ops on empty rootfs path or empty package list.
* DebianBasePackages contains the small critical-package floor
(ca-certificates, curl, git) so a future apt-list trim can't
silently drop them.
dmsnap/dmsnap_test.go:
* Create runs losetup base, losetup cow, blockdev getsz, dmsetup
create in that order, with a snapshot table referencing the loops
in (base, cow) order — a swap would corrupt every VM.
* Create's failure path unwinds with losetup -d on cow then base.
* Cleanup tears down dmsetup before losetup (otherwise dmsetup sees
EBUSY against vanished backing devices).
* Cleanup falls back to DMDev when DMName is empty.
* Cleanup tolerates "No such device" on losetup -d (idempotent
re-run after a partial cleanup).
* Cleanup surfaces non-missing losetup errors (the tolerance is
narrow on purpose).
* Remove returns nil on a missing target and surfaces non-retryable
errors immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
6 KiB
Go
169 lines
6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|