package store import ( "archive/tar" "bytes" "compress/gzip" "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" ) // tarEntry is one member of a test layer. type tarEntry struct { name, content, link string typ byte mode int64 } func tarBytes(t *testing.T, entries []tarEntry) []byte { t.Helper() var buf bytes.Buffer tw := tar.NewWriter(&buf) for _, e := range entries { hdr := &tar.Header{Name: e.name, Mode: e.mode, Typeflag: e.typ, Linkname: e.link} if hdr.Mode == 0 { hdr.Mode = 0o644 if e.typ == tar.TypeDir { hdr.Mode = 0o755 } } if e.typ == 0 { hdr.Typeflag = tar.TypeReg } hdr.Size = int64(len(e.content)) if err := tw.WriteHeader(hdr); err != nil { t.Fatal(err) } if _, err := tw.Write([]byte(e.content)); err != nil { t.Fatal(err) } } if err := tw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func gzipBytes(t *testing.T, raw []byte) []byte { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) if _, err := gz.Write(raw); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } // writeDockerSave builds a legacy-layout `docker save` archive at path: // manifest.json, .json config, /layer.tar per layer. func writeDockerSave(t *testing.T, path string, repoTags []string, config string, layers [][]byte) { t.Helper() var files []tarEntry var layerPaths []string for i, l := range layers { name := strings.Repeat("a", 3) + string(rune('0'+i)) + "/layer.tar" files = append(files, tarEntry{name: name, content: string(l)}) layerPaths = append(layerPaths, name) } files = append(files, tarEntry{name: "cfg.json", content: config}) manifest, _ := json.Marshal([]map[string]interface{}{{"Config": "cfg.json", "RepoTags": repoTags, "Layers": layerPaths}}) files = append(files, tarEntry{name: "manifest.json", content: string(manifest)}) if err := os.WriteFile(path, tarBytes(t, files), 0o644); err != nil { t.Fatal(err) } } func skipWithoutTar(t *testing.T) { t.Helper() if _, err := exec.LookPath("tar"); err != nil { t.Skip("tar not installed") } } const testConfig = `{"architecture":"amd64","os":"linux","created":"2026-09-05T12:00:00Z", "config":{"Env":["PATH=/usr/local/bin:/bin","JAVA_HOME=/opt/jdk"],"Entrypoint":["/bin/sh","-c"],"Cmd":["java -version"],"WorkingDir":"/work","Labels":{"k":"v"}}}` func TestLoadFlattensLayersWithWhiteoutsAndKeepsTheConfig(t *testing.T) { skipWithoutTar(t) lower := tarBytes(t, []tarEntry{ {name: "bin/", typ: tar.TypeDir}, {name: "bin/sh", content: "#!lower sh", mode: 0o755}, {name: "etc/", typ: tar.TypeDir}, {name: "etc/old.conf", content: "gone in upper"}, {name: "etc/keep.conf", content: "kept"}, {name: "opt/", typ: tar.TypeDir}, {name: "opt/cache/", typ: tar.TypeDir}, {name: "opt/cache/a", content: "lower a"}, {name: "opt/cache/b", content: "lower b"}, {name: "usr/", typ: tar.TypeDir}, {name: "usr/bin/", typ: tar.TypeDir}, {name: "usr/bin/real", content: "real"}, {name: "sbin", typ: tar.TypeSymlink, link: "usr/bin"}, {name: "var/", typ: tar.TypeDir}, {name: "var/wasdir/", typ: tar.TypeDir}, {name: "var/wasdir/child", content: "child"}, }) upper := gzipBytes(t, tarBytes(t, []tarEntry{ {name: "bin/sh", content: "#!upper sh", mode: 0o755}, {name: "etc/.wh.old.conf"}, {name: "opt/cache/.wh..wh..opq"}, {name: "opt/cache/c", content: "upper c"}, {name: "sbin/viasymlink", content: "through the lower layer's symlink"}, {name: "var/wasdir", content: "now a file"}, {name: "usr/bin/hard", typ: tar.TypeLink, link: "usr/bin/real"}, })) archive := filepath.Join(t.TempDir(), "saved.tar") writeDockerSave(t, archive, []string{"example/app:1.0"}, testConfig, [][]byte{lower, upper}) st := Store{Root: t.TempDir()} loaded, err := st.Load(archive, "") if err != nil { t.Fatal(err) } if strings.Join(loaded, ",") != "example/app:1.0" { t.Errorf("loaded %v", loaded) } rootfs, err := st.RootFS("example/app:1.0") if err != nil { t.Fatal(err) } read := func(rel string) string { raw, err := os.ReadFile(filepath.Join(rootfs, rel)) if err != nil { return "ERR:" + err.Error() } return string(raw) } checks := map[string]string{ "bin/sh": "#!upper sh", "etc/keep.conf": "kept", "opt/cache/c": "upper c", "usr/bin/viasymlink": "through the lower layer's symlink", "var/wasdir": "now a file", "usr/bin/hard": "real", } for rel, want := range checks { if got := read(rel); got != want { t.Errorf("%s: got %q, want %q", rel, got, want) } } for _, gone := range []string{"etc/old.conf", "opt/cache/a", "opt/cache/b", "etc/.wh.old.conf", "opt/cache/.wh..wh..opq"} { if _, err := os.Lstat(filepath.Join(rootfs, gone)); !os.IsNotExist(err) { t.Errorf("%s should not exist: %v", gone, err) } } if fi, err := os.Stat(filepath.Join(rootfs, "bin/sh")); err != nil || fi.Mode().Perm() != 0o755 { t.Errorf("bin/sh mode: %v %v", fi, err) } if link, err := os.Readlink(filepath.Join(rootfs, "sbin")); err != nil || link != "usr/bin" { t.Errorf("sbin symlink: %q %v", link, err) } meta, err := st.Meta("example/app:1.0") if err != nil { t.Fatal(err) } if meta.Architecture != "amd64" || meta.OS != "linux" || meta.CreatedAt.Year() != 2026 || strings.Join(meta.Config.Entrypoint, " ") != "/bin/sh -c" || strings.Join(meta.Config.Cmd, " ") != "java -version" || meta.Config.WorkingDir != "/work" || len(meta.Config.Env) != 2 || meta.Config.Labels["k"] != "v" { t.Errorf("metadata: %+v", meta) } if !strings.HasSuffix(meta.Source, "saved.tar") { t.Errorf("source: %q", meta.Source) } if _, err := os.Stat(filepath.Join(st.Root, "images", "example", "app", "1.0", "image.json")); err != nil { t.Errorf("image.json missing: %v", err) } leftovers, _ := filepath.Glob(filepath.Join(st.Root, "images", ".load-*")) if len(leftovers) != 0 { t.Errorf("staging left behind: %v", leftovers) } } func TestLoadContainsEscapingPathsInsideTheRootfs(t *testing.T) { skipWithoutTar(t) // Every escape attempt resolves the way it would inside the sandbox: // ".." above the root is the root, so the files land in the rootfs // and nothing is written elsewhere in the store. layer := tarBytes(t, []tarEntry{ {name: "../../dotdot", content: "x"}, {name: "escape", typ: tar.TypeSymlink, link: "../../.."}, {name: "escape/etc/passwd", content: "via relative symlink"}, {name: "abs", typ: tar.TypeSymlink, link: "/../.."}, {name: "abs/via-abs", content: "via absolute symlink"}, {name: "real", content: "hardlink target"}, {name: "h", typ: tar.TypeLink, link: "escape/real"}, }) st := Store{Root: t.TempDir()} archive := filepath.Join(t.TempDir(), "evil.tar") writeDockerSave(t, archive, []string{"evil"}, testConfig, [][]byte{layer}) if _, err := st.Load(archive, ""); err != nil { t.Fatal(err) } rootfs, _ := st.RootFS("evil") for rel, want := range map[string]string{"dotdot": "x", "etc/passwd": "via relative symlink", "via-abs": "via absolute symlink", "h": "hardlink target"} { if raw, err := os.ReadFile(filepath.Join(rootfs, rel)); err != nil || string(raw) != want { t.Errorf("%s: %q %v", rel, raw, err) } } entries, _ := os.ReadDir(filepath.Join(st.Root, "images")) for _, e := range entries { if e.Name() != "evil" { t.Errorf("unexpected entry beside the image: %s", e.Name()) } } if entries, _ := os.ReadDir(st.Root); len(entries) != 1 { t.Errorf("store root polluted: %v", entries) } } func TestLoadNamesFromTheArchiveOrTheCaller(t *testing.T) { skipWithoutTar(t) layer := tarBytes(t, []tarEntry{{name: "hello", content: "hi"}}) st := Store{Root: t.TempDir()} // Two RepoTags: the second is a hardlinked copy of the first. tagged := filepath.Join(t.TempDir(), "tagged.tar") writeDockerSave(t, tagged, []string{"app:1.0", "app:latest"}, testConfig, [][]byte{layer}) loaded, err := st.Load(tagged, "") if err != nil { t.Fatal(err) } if strings.Join(loaded, ",") != "app:1.0,app" { t.Errorf("loaded %v", loaded) } for _, ref := range []string{"app:1.0", "app"} { if _, err := st.RootFS(ref); err != nil { t.Errorf("%s: %v", ref, err) } } // No RepoTags: the caller must name it. unnamed := filepath.Join(t.TempDir(), "unnamed.tar") writeDockerSave(t, unnamed, nil, testConfig, [][]byte{layer}) if _, err := st.Load(unnamed, ""); err == nil || !strings.Contains(err.Error(), "no name") { t.Errorf("got %v, want a no-name refusal", err) } loaded, err = st.Load(unnamed, "named:v2") if err != nil || strings.Join(loaded, ",") != "named:v2" { t.Errorf("loaded %v, %v", loaded, err) } // An existing image is not replaced. if _, err := st.Load(tagged, ""); err == nil || !strings.Contains(err.Error(), "already exists") { t.Errorf("got %v, want an already-exists refusal", err) } } func TestLoadReadsAPlainOCILayoutAndPicksTheHostPlatform(t *testing.T) { skipWithoutTar(t) layer := gzipBytes(t, tarBytes(t, []tarEntry{{name: "hello", content: "from oci"}})) config := testConfig otherConfig := strings.Replace(testConfig, "amd64", "s390x", 1) manifestFor := func(cfg string) string { return `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json", "config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:` + cfg + `"}, "layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:layer1"}]}` } index := `{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:nested", "annotations":{"org.opencontainers.image.ref.name":"oci/app:2"}}]}` nested := `{"schemaVersion":2,"manifests":[ {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:m-other","platform":{"os":"linux","architecture":"s390x"}}, {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:m-host","platform":{"os":"linux","architecture":"amd64"}}]}` files := []tarEntry{ {name: "oci-layout", content: `{"imageLayoutVersion":"1.0.0"}`}, {name: "index.json", content: index}, {name: "blobs/sha256/nested", content: nested}, {name: "blobs/sha256/m-host", content: manifestFor("cfg-host")}, {name: "blobs/sha256/m-other", content: manifestFor("cfg-other")}, {name: "blobs/sha256/cfg-host", content: config}, {name: "blobs/sha256/cfg-other", content: otherConfig}, {name: "blobs/sha256/layer1", content: string(layer)}, } archive := filepath.Join(t.TempDir(), "oci.tar") if err := os.WriteFile(archive, tarBytes(t, files), 0o644); err != nil { t.Fatal(err) } st := Store{Root: t.TempDir()} loaded, err := st.Load(archive, "") if err != nil { t.Fatal(err) } if strings.Join(loaded, ",") != "oci/app:2" { t.Errorf("loaded %v", loaded) } rootfs, _ := st.RootFS("oci/app:2") if raw, err := os.ReadFile(filepath.Join(rootfs, "hello")); err != nil || string(raw) != "from oci" { t.Errorf("hello: %q %v", raw, err) } meta, _ := st.Meta("oci/app:2") if meta.Architecture != "amd64" { t.Errorf("picked the wrong platform: %+v", meta) } // An index without the host platform names what it has. noHost := strings.Replace(nested, `"amd64"`, `"arm64"`, 1) files[2].content = noHost if err := os.WriteFile(archive, tarBytes(t, files), 0o644); err != nil { t.Fatal(err) } _, err = Store{Root: t.TempDir()}.Load(archive, "") if err == nil || !strings.Contains(err.Error(), "available: linux/arm64, linux/s390x") { t.Errorf("got %v, want the available platforms", err) } } func TestLoadRefusesAPlainRootfsArchive(t *testing.T) { skipWithoutTar(t) archive := filepath.Join(t.TempDir(), "rootfs.tar.gz") writeTestArchive(t, archive) _, err := Store{Root: t.TempDir()}.Load(archive, "") if err == nil || !strings.Contains(err.Error(), "werkdock import") { t.Errorf("got %v, want a pointer to import", err) } } func TestResolveInRootfs(t *testing.T) { rootfs := t.TempDir() if err := os.MkdirAll(filepath.Join(rootfs, "usr", "bin"), 0o755); err != nil { t.Fatal(err) } for link, target := range map[string]string{"bin": "usr/bin", "abs": "/usr", "loop": "loop", "out": "../.."} { if err := os.Symlink(target, filepath.Join(rootfs, link)); err != nil { t.Fatal(err) } } tests := []struct{ in, want, wantErr string }{ {"/bin/sh", "usr/bin/sh", ""}, {"/abs/bin/x", "usr/bin/x", ""}, {"/new/dir/file", "new/dir/file", ""}, {"/../x", "x", ""}, {"/usr/../etc", "etc", ""}, {"/", "", ""}, {"/loop/x", "", "too many levels"}, {"/out/x", "x", ""}, // .. above the root stays at the root } for _, tt := range tests { got, err := resolveInRootfs(rootfs, tt.in) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("%s: got %q, %v; want error %q", tt.in, got, err, tt.wantErr) } continue } if err != nil || got != filepath.Join(rootfs, tt.want) { t.Errorf("%s: got %q, %v; want %q", tt.in, got, err, filepath.Join(rootfs, tt.want)) } } }