werkdock load -i ARCHIVE reads the docker save manifest (legacy and OCI layout) or a plain OCI image layout, applies the layers in order into one rootfs with whiteout handling, and keeps the OCI config as the image metadata. Layer extraction is werkdock's own: every path is resolved inside the rootfs the way the sandbox sees it, following lower layers' symlinks and clamping .. at the root as a chroot does, so a hostile layer can rewrite the image but never the host. gzip layers in-process, zstd through the zstd binary. RepoTags name the image, each further tag is a hardlinked copy; a multi-arch index picks the host platform, otherwise the available ones are listed. Verified against docker 27 save archives of busybox:1.36 and alpine:3.20, run in the sandbox afterwards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
365 lines
13 KiB
Go
365 lines
13 KiB
Go
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, <id>.json config, <n>/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))
|
|
}
|
|
}
|
|
}
|