package store import ( "archive/tar" "compress/gzip" "encoding/json" "os" "os/exec" "path/filepath" "reflect" "strings" "testing" ) // writeTestArchive builds a minimal rootfs .tar.gz with the stdlib, so // the tests need no zstd; Load unpacks it with the system tar. func writeTestArchive(t *testing.T, path string) { writeTestArchiveWith(t, path, "etc/hello", "hello from the rootfs\n") } // writeTestArchiveWith builds a .tar.gz holding one file. func writeTestArchiveWith(t *testing.T, path, name, content string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatal(err) } gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) if err := tw.WriteHeader(&tar.Header{Name: "etc/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil { t.Fatal(err) } if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}); err != nil { t.Fatal(err) } if _, err := tw.Write([]byte(content)); err != nil { t.Fatal(err) } for _, c := range []interface{ Close() error }{tw, gz, f} { if err := c.Close(); err != nil { t.Fatal(err) } } } func TestImportUnpacksArchiveIntoTheStore(t *testing.T) { if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } st := Store{Root: t.TempDir()} archive := filepath.Join(t.TempDir(), "mini-rootfs.tar.gz") writeTestArchive(t, archive) if err := st.Import(archive, "mini", ImageConfig{}); err != nil { t.Fatal(err) } rootfs, err := st.RootFS("mini") if err != nil { t.Fatal(err) } content, err := os.ReadFile(filepath.Join(rootfs, "etc", "hello")) if err != nil || string(content) != "hello from the rootfs\n" { t.Errorf("unpacked file: %q, %v", content, err) } metaRaw, err := os.ReadFile(filepath.Join(st.Root, "images", "mini", "latest", "image.json")) if err != nil { t.Fatal(err) } var meta ImageMeta if err := json.Unmarshal(metaRaw, &meta); err != nil { t.Fatal(err) } if meta.Name != "mini" || meta.Source == "" || meta.CreatedAt.IsZero() || meta.Architecture == "" || meta.OS != "linux" { t.Errorf("image.json incomplete: %+v", meta) } } func TestImportStoresTheConfigAndMetaReadsIt(t *testing.T) { if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } st := Store{Root: t.TempDir()} archive := filepath.Join(t.TempDir(), "mini.tar.gz") writeTestArchive(t, archive) config := ImageConfig{ Env: []string{"JAVA_HOME=/opt/jdk"}, Entrypoint: []string{"/bin/sh", "-c"}, Cmd: []string{"java -version"}, WorkingDir: "/work", Labels: map[string]string{"org.opencontainers.image.title": "mini"}, } if err := st.Import(archive, "mini:1", config); err != nil { t.Fatal(err) } meta, err := st.Meta("mini:1") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(meta.Config, config) { t.Errorf("config round trip: got %+v, want %+v", meta.Config, config) } // A legacy image.json (no config) reads back with an empty config. fakeImage(t, st, "legacy") meta, err = st.Meta("legacy") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(meta.Config, ImageConfig{}) { t.Errorf("legacy config: got %+v", meta.Config) } } func TestImportRefusesAnExistingImageName(t *testing.T) { if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } st := Store{Root: t.TempDir()} archive := filepath.Join(t.TempDir(), "mini.tar.gz") writeTestArchive(t, archive) if err := st.Import(archive, "mini", ImageConfig{}); err != nil { t.Fatal(err) } err := st.Import(archive, "mini", ImageConfig{}) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Errorf("got %v, want an already-exists refusal", err) } } func TestImportLeavesNoHalfImageOnFailure(t *testing.T) { if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } st := Store{Root: t.TempDir()} broken := filepath.Join(t.TempDir(), "broken.tar.gz") if err := os.WriteFile(broken, []byte("this is not a tar archive"), 0o644); err != nil { t.Fatal(err) } if err := st.Import(broken, "example.org/broken:v1", ImageConfig{}); err == nil { t.Fatal("expected the import to fail") } if _, err := os.Stat(filepath.Join(st.Root, "images", "example.org")); !os.IsNotExist(err) { t.Errorf("expected no leftover repository directory, got %v", err) } if _, err := os.Stat(filepath.Join(st.Root, "images")); err != nil { t.Errorf("images/ itself must survive: %v", err) } } func TestImportRefusesDockerAndOCIImageArchives(t *testing.T) { if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } st := Store{Root: t.TempDir()} saved := filepath.Join(t.TempDir(), "saved.tar.gz") writeTestArchiveWith(t, saved, "manifest.json", "[]") err := st.Import(saved, "saved", ImageConfig{}) if err == nil || !strings.Contains(err.Error(), "docker image archive") { t.Errorf("got %v, want a docker-archive refusal", err) } if _, err := os.Stat(filepath.Join(st.Root, "images", "saved")); !os.IsNotExist(err) { t.Errorf("expected no image directory after the refusal, got %v", err) } // A rootfs that merely ships a manifest.json somewhere below the top // level is still a rootfs. nested := filepath.Join(t.TempDir(), "nested.tar.gz") writeTestArchiveWith(t, nested, "opt/app/manifest.json", "{}") if err := st.Import(nested, "nested", ImageConfig{}); err != nil { t.Errorf("nested manifest.json must not be mistaken for an image archive: %v", err) } } func TestRootFSValidation(t *testing.T) { st := Store{Root: t.TempDir()} if _, err := st.RootFS("no-such-image"); err == nil || !strings.Contains(err.Error(), "no such image") { t.Errorf("got %v, want a no-such-image error", err) } if _, err := st.RootFS("../escape"); err == nil || !strings.Contains(err.Error(), "invalid image reference") { t.Errorf("got %v, want an invalid-name error", err) } } // fakeImage lays out an image directory (rootfs + image.json) under // images/ without unpacking anything. func fakeImage(t *testing.T, st Store, rel string) { t.Helper() dir := filepath.Join(st.Root, "images", filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Join(dir, "rootfs"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "image.json"), []byte("{}\n"), 0o644); err != nil { t.Fatal(err) } } func TestListNamesLoadedImagesAndIgnoresTmpLeftovers(t *testing.T) { st := Store{Root: t.TempDir()} if names, err := st.List(); err != nil || names != nil { t.Fatalf("empty store: got %v, %v", names, err) } for _, rel := range []string{ "beta/latest", // beta "alpha", // legacy flat layout: alpha "eclipse-temurin/21", // eclipse-temurin:21 "ghcr.io/example/build/2026.09", // registry/namespace/repo:tag "a/b", // a:b ... "a/b/latest", // ... and a/b, coexisting below it "alpha/rootfs/x", // a stray directory inside a rootfs is not an image "ghcr.io/example/build/2026.10.tmp", // interrupted load "ghcr.io/example/build/2026.10.tmp/latest", // ... with anything below it } { fakeImage(t, st, rel) } names, err := st.List() if err != nil { t.Fatal(err) } want := []string{"a/b", "a:b", "alpha", "beta", "eclipse-temurin:21", "ghcr.io/example/build:2026.09"} if strings.Join(names, " ") != strings.Join(want, " ") { t.Errorf("got %v, want %v", names, want) } } func TestRootFSResolvesTagsAndTheLegacyLayout(t *testing.T) { st := Store{Root: t.TempDir()} fakeImage(t, st, "legacy") fakeImage(t, st, "a/b") fakeImage(t, st, "a/b/latest") fakeImage(t, st, "eclipse-temurin/21") tests := []struct{ ref, want string }{ {"legacy", "legacy/rootfs"}, {"legacy:latest", "legacy/rootfs"}, {"a:b", "a/b/rootfs"}, {"a/b", "a/b/latest/rootfs"}, {"a/b:latest", "a/b/latest/rootfs"}, {"eclipse-temurin:21", "eclipse-temurin/21/rootfs"}, } for _, tt := range tests { got, err := st.RootFS(tt.ref) if err != nil { t.Errorf("RootFS(%q): %v", tt.ref, err) continue } if want := filepath.Join(st.Root, "images", filepath.FromSlash(tt.want)); got != want { t.Errorf("RootFS(%q) = %s, want %s", tt.ref, got, want) } } // The legacy fallback must not resolve a namespaced repository to a // tag directory of the same path. if _, err := st.RootFS("eclipse-temurin"); err == nil || !strings.Contains(err.Error(), "no such image") { t.Errorf("RootFS(eclipse-temurin) = %v, want no such image", err) } if _, err := st.RootFS("a:latest"); err == nil { t.Error("RootFS(a:latest) resolved, but only a:b and a/b exist") } } func TestImportPlacesTaggedImagesInTheNestedLayout(t *testing.T) { if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } st := Store{Root: t.TempDir()} archive := filepath.Join(t.TempDir(), "mini.tar.gz") writeTestArchive(t, archive) if err := st.Import(archive, "ghcr.io/example/build:2026.09", ImageConfig{}); err != nil { t.Fatal(err) } if !isImageDir(filepath.Join(st.Root, "images", "ghcr.io", "example", "build", "2026.09")) { t.Error("expected the image under images/ghcr.io/example/build/2026.09") } if err := st.Import(archive, "ghcr.io/example/build:2026.09", ImageConfig{}); err == nil || !strings.Contains(err.Error(), "already exists") { t.Errorf("got %v, want an already-exists refusal", err) } if err := st.Import(archive, "Bad:Name", ImageConfig{}); err == nil || !strings.Contains(err.Error(), "invalid image reference") { t.Errorf("got %v, want an invalid-reference refusal", err) } } func TestParseReference(t *testing.T) { tests := []struct { in string want Reference wantErr string }{ {"java21", Reference{"java21", "latest"}, ""}, {"eclipse-temurin:21", Reference{"eclipse-temurin", "21"}, ""}, {"postgres:17", Reference{"postgres", "17"}, ""}, {"werkator-build:latest", Reference{"werkator-build", "latest"}, ""}, {"ghcr.io/example/build-environment:2026.09", Reference{"ghcr.io/example/build-environment", "2026.09"}, ""}, {"localhost:5000/team/img", Reference{"localhost:5000/team/img", "latest"}, ""}, {"localhost:5000/team/img:v1", Reference{"localhost:5000/team/img", "v1"}, ""}, {"a_b.c-d:V1.2_x-y", Reference{"a_b.c-d", "V1.2_x-y"}, ""}, {"", Reference{}, "empty repository"}, {":tag", Reference{}, "empty repository"}, {"Upper", Reference{}, "repository component"}, {"../escape", Reference{}, "repository component"}, {"a//b", Reference{}, "repository component"}, {"img:", Reference{}, "tag"}, {"img:bad tag", Reference{}, "tag"}, {"img:-leading", Reference{}, "tag"}, {"img:v1.tmp", Reference{}, "reserved"}, {"img@sha256:abc", Reference{}, "digests"}, } for _, tt := range tests { got, err := ParseReference(tt.in) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("ParseReference(%q) = %v, %v; want error containing %q", tt.in, got, err, tt.wantErr) } continue } if err != nil || got != tt.want { t.Errorf("ParseReference(%q) = %v, %v; want %v", tt.in, got, err, tt.want) } } if s := (Reference{"x", "latest"}).Short(); s != "x" { t.Errorf("Short() = %q", s) } if s := (Reference{"x", "latest"}).String(); s != "x:latest" { t.Errorf("String() = %q", s) } } func TestImageNameFromArchive(t *testing.T) { tests := []struct{ in, want string }{ {"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"}, {"/path/to/Base.TAR.GZ", "base"}, {"rootfs.tgz", "rootfs"}, {"plain", "plain"}, } for _, tt := range tests { if got := ImageNameFromArchive(tt.in); got != tt.want { t.Errorf("ImageNameFromArchive(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestRemoveDeletesImageAndEmptyRepositoryDirs(t *testing.T) { st := Store{Root: t.TempDir()} fakeImage(t, st, "ghcr.io/team/tool/1.0") fakeImage(t, st, "ghcr.io/team/other/latest") fakeImage(t, st, "oldstyle") removed, err := st.Remove("ghcr.io/team/tool:1.0") if err != nil || removed != "ghcr.io/team/tool:1.0" { t.Fatalf("got %q, %v", removed, err) } if _, err := os.Stat(filepath.Join(st.Root, "images", "ghcr.io", "team", "tool")); !os.IsNotExist(err) { t.Errorf("repository dir not pruned: %v", err) } if _, err := os.Stat(filepath.Join(st.Root, "images", "ghcr.io", "team", "other", "latest", "rootfs")); err != nil { t.Errorf("sibling image touched: %v", err) } if removed, err := st.Remove("oldstyle"); err != nil || removed != "oldstyle" { t.Fatalf("legacy layout: got %q, %v", removed, err) } if _, err := st.Remove("oldstyle"); err == nil || !strings.Contains(err.Error(), "no such image") { t.Errorf("removing twice: got %v", err) } names, _ := st.List() if !reflect.DeepEqual(names, []string{"ghcr.io/team/other"}) { t.Errorf("remaining: %v", names) } if leftovers, _ := filepath.Glob(filepath.Join(st.Root, "images", "*.tmp")); len(leftovers) != 0 { t.Errorf("tmp leftovers: %v", leftovers) } }