diff --git a/internal/transfer/containerfs.go b/internal/transfer/containerfs.go index 61baa4f0..e78ce592 100644 --- a/internal/transfer/containerfs.go +++ b/internal/transfer/containerfs.go @@ -19,6 +19,8 @@ package transfer import ( "archive/tar" "context" + "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -51,10 +53,16 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt if !ok { return errdefs.ErrNotImplemented } - rootfs := filepath.Join(t.bundleDir, s.ContainerID, "rootfs") + bundle := filepath.Join(t.bundleDir, s.ContainerID) + root, src, err := resolveMountRoot(bundle, s.Path) + if err != nil { + return err + } w := d.Writer(ctx) defer w.Close() - return writePath(rootfs, s.Path, w, d.MediaType, s.NoWalk) + // The archive's top-level name reflects the container's view of + // the path: a mount source's basename need not match it. + return writePath(root, src, path.Base(rootRel(s.Path)), w, d.MediaType, s.NoWalk) case *ReadStream: // Copy-to: ReadStream -> ContainerPath @@ -62,14 +70,110 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt if !ok { return errdefs.ErrNotImplemented } - rootfs := filepath.Join(t.bundleDir, d.ContainerID, "rootfs") + bundle := filepath.Join(t.bundleDir, d.ContainerID) + root, dst, err := resolveMountRoot(bundle, d.Path) + if err != nil { + return err + } r := s.Reader(ctx) - return readPath(r, rootfs, d.Path, s.MediaType, d.PreserveOwnership) + return readPath(r, root, dst, s.MediaType, d.PreserveOwnership) } return errdefs.ErrNotImplemented } +// resolveMountRoot maps a path expressed in the container's view onto the +// directory that backs it, returning that directory and the path relative to +// it. +// +// The bundle's rootfs backs only the paths no mount covers. Where the runtime +// spec declares a bind mount, the container's mount namespace has the source +// mounted over the destination, so the rootfs entry underneath is shadowed: +// extracting there produces a file the container never sees, and archiving +// from there reads whatever the rootfs happens to hold rather than the mounted +// content. Resolving against the mount's source keeps both directions +// consistent with the container's own view of its filesystem. +// +// The longest matching destination wins, so a mount nested inside another +// resolves against the innermost one. A bundle with no readable or parseable +// config.json resolves to the rootfs: absent mount information there is +// nothing to redirect, and the caller reports any genuine failure. +// +// A relative source is interpreted against the bundle directory, as the +// runtime does (nerdbox itself declares such mounts for bundle extra files +// like resolv.conf). A source that is not a directory — a single-file bind +// mount — cannot anchor an *os.Root, so it resolves to the file's parent +// directory with the file's name as the relative path. +// +// Known limitations, tracked by issue #164: a path whose subtree contains a +// mount deeper inside (e.g. archiving /etc when /etc/resolv.conf is a mount) +// resolves to the outer directory only, and non-bind mounts (tmpfs, ...) +// exist only in the container's mount namespace and cannot be resolved from +// the bundle at all. +func resolveMountRoot(bundleContainerDir, containerPath string) (root, rel string, err error) { + rootfs := filepath.Join(bundleContainerDir, "rootfs") + + data, err := os.ReadFile(filepath.Join(bundleContainerDir, "config.json")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return rootfs, containerPath, nil + } + return "", "", fmt.Errorf("failed to read bundle config: %w", err) + } + + var spec struct { + Mounts []struct { + Destination string `json:"destination"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"mounts"` + } + if err := json.Unmarshal(data, &spec); err != nil { + return rootfs, containerPath, nil + } + + target := path.Clean("/" + containerPath) + + var bestDest, bestSrc string + for _, m := range spec.Mounts { + if m.Type != "bind" || m.Source == "" { + continue + } + dest := path.Clean("/" + m.Destination) + if target != dest && !strings.HasPrefix(target, strings.TrimSuffix(dest, "/")+"/") { + continue + } + if len(dest) > len(bestDest) { + bestDest, bestSrc = dest, m.Source + } + } + if bestDest == "" { + return rootfs, containerPath, nil + } + + // This code runs in the Linux VM, where the two predicates agree; + // accepting either form of absolute path keeps the unit tests, which + // mix spec-style Linux sources with host temp directories, portable + // to Windows hosts. + if !filepath.IsAbs(bestSrc) && !path.IsAbs(bestSrc) { + bestSrc = filepath.Join(bundleContainerDir, bestSrc) + } + + rel = strings.TrimPrefix(target, bestDest) + + if fi, err := os.Stat(bestSrc); err == nil && !fi.IsDir() { + // Single-file mount: anchor at the parent directory. A residual + // rel below the file yields a path that fails with ENOTDIR when + // the caller stats it, which is the honest answer. + return filepath.Dir(bestSrc), filepath.Base(bestSrc) + rel, nil + } + + if rel == "" { + rel = "." + } + return bestSrc, rel, nil +} + // rootRel converts a path expressed in the container's view (which // may be absolute or contain parent-directory components) into a path // usable with *os.Root operations. Leading "/" is stripped after @@ -85,14 +189,17 @@ func rootRel(p string) string { } // writePath creates a tar archive from the given path within rootfs -// and writes it to w. When noWalk is true and path is a directory, -// only the directory entry itself is included without walking into -// it. +// and writes it to w. name is the archive's top-level entry name, +// taken from the container's view of the path: when src resolved +// through a mount, the backing file or directory's own basename may +// differ from the name the container sees. When noWalk is true and +// path is a directory, only the directory entry itself is included +// without walking into it. // // All filesystem accesses are anchored to rootfs through *os.Root, // so symlink resolution cannot escape the rootfs even if the // container concurrently mutates its own filesystem. -func writePath(rootfs, src string, w io.Writer, mediaType string, noWalk bool) error { +func writePath(rootfs, src, name string, w io.Writer, mediaType string, noWalk bool) error { if mediaType != mediaTypeTar { return fmt.Errorf("unsupported media type %q: %w", mediaType, errdefs.ErrNotImplemented) } @@ -110,12 +217,11 @@ func writePath(rootfs, src string, w io.Writer, mediaType string, noWalk bool) e return fmt.Errorf("failed to stat %s: %w", src, err) } - // The top-level entry name is the basename of the requested - // path. When the caller asks for the whole filesystem (path "/"), - // relPath is "." and baseName is "."; child entries then drop - // the leading "./" via path.Join, so the tar contains - // "bin/sh" rather than leaking the host bundle's directory name. - baseName := path.Base(relPath) + // When the caller asks for the whole filesystem (path "/"), name + // is "."; child entries then drop the leading "./" via path.Join, + // so the tar contains "bin/sh" rather than leaking the host + // bundle's directory name. + baseName := name tw := tar.NewWriter(w) @@ -229,6 +335,13 @@ func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership dst := root if relDst != "." { + // A destination naming an existing non-directory — a plain + // file in the rootfs, or the source of a single-file bind + // mount after resolution — receives the archived file's bytes + // rather than a tree extraction. + if fi, err := root.Stat(relDst); err == nil && !fi.IsDir() { + return extractOverFile(root, relDst, r, preserveOwnership) + } if err := root.MkdirAll(relDst, 0755); err != nil { return fmt.Errorf("failed to create destination: %w", err) } @@ -265,6 +378,55 @@ func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership } } +// extractOverFile extracts an archive onto a destination that is an +// existing file rather than a directory, replacing its contents with +// the archived bytes. Only an archive carrying a single regular file +// makes sense here; a directory or any other entry type cannot be +// extracted over a file and is rejected. The file is truncated in +// place rather than recreated, so it keeps its mode and, when it is a +// bind-mount source, its inode — a mounted file replaced by a new +// inode would leave the container reading the stale one. +func extractOverFile(dst *os.Root, target string, r io.Reader, preserveOwnership bool) error { + tr := tar.NewReader(r) + written := false + for { + header, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + if header.Typeflag != tar.TypeReg { + return fmt.Errorf("cannot extract %q over file %s: not a regular file", header.Name, target) + } + if written { + return fmt.Errorf("cannot extract multiple entries over file %s", target) + } + + f, err := dst.OpenFile(target, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return err + } + // Copy exactly the size the header declares; the tar reader + // bounds the entry anyway, and the explicit limit satisfies + // gosec's decompression-bomb rule (G110). + if _, err := io.CopyN(f, tr, header.Size); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if preserveOwnership { + if err := dst.Lchown(target, header.Uid, header.Gid); err != nil { + return fmt.Errorf("failed to chown %s: %w", target, err) + } + } + written = true + } +} + func extractTarEntry(dst *os.Root, target string, header *tar.Header, r io.Reader, preserveOwnership bool) error { switch header.Typeflag { case tar.TypeDir: diff --git a/internal/transfer/containerfs_test.go b/internal/transfer/containerfs_test.go index 2f57491e..cf264ae7 100644 --- a/internal/transfer/containerfs_test.go +++ b/internal/transfer/containerfs_test.go @@ -19,6 +19,7 @@ package transfer import ( "archive/tar" "bytes" + "encoding/json" "errors" "io" "io/fs" @@ -109,7 +110,7 @@ func TestWritePathExportSymlinkEscapeBlocked(t *testing.T) { buf := &bytes.Buffer{} // Asking to copy /escape/secret. Lstat would have to traverse // the symlink "/escape" out of the rootfs to reach "secret". - err := writePath(rootfs, "/escape/secret", buf, mediaTypeTar, false) + err := writePath(rootfs, "/escape/secret", "secret", buf, mediaTypeTar, false) if err == nil { t.Fatal("expected error when traversing symlink out of rootfs, got nil") } @@ -129,7 +130,7 @@ func TestWritePathExportPreservesSymlinks(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/alias", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/alias", "alias", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -167,7 +168,7 @@ func TestWritePathExportWalkContainsSymlinkToOutside(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/dir", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/dir", "dir", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -397,7 +398,7 @@ func TestRoundTripExportImport(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(src, "/a", buf, mediaTypeTar, false); err != nil { + if err := writePath(src, "/a", "a", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -617,7 +618,7 @@ func TestWritePathExportRelativeDotDotPath(t *testing.T) { // "../outside/secret" cleans to "outside/secret" (relative), // which doesn't exist inside the rootfs. buf := &bytes.Buffer{} - err := writePath(rootfs, "../outside/secret", buf, mediaTypeTar, false) + err := writePath(rootfs, "../outside/secret", "secret", buf, mediaTypeTar, false) if err == nil { t.Fatal("expected error for path escaping rootfs, got nil") } @@ -643,7 +644,7 @@ func TestWritePathExportNoWalk(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/d", buf, mediaTypeTar, true); err != nil { + if err := writePath(rootfs, "/d", "d", buf, mediaTypeTar, true); err != nil { t.Fatalf("writePath: %v", err) } @@ -675,7 +676,7 @@ func TestWritePathExportRootDoesNotLeakBundleName(t *testing.T) { leaked := filepath.Base(rootfs) // e.g. "rootfs" buf := &bytes.Buffer{} - if err := writePath(rootfs, "/", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/", ".", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -716,7 +717,7 @@ func TestRoundTripExportRootImport(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(src, "/", buf, mediaTypeTar, false); err != nil { + if err := writePath(src, "/", ".", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -786,7 +787,7 @@ func TestWritePathExportRootDotfilesPreserved(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/", ".", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -798,3 +799,393 @@ func TestWritePathExportRootDotfilesPreserved(t *testing.T) { t.Errorf("dotfile was renamed to 'bashrc' (leading dot stripped by TrimPrefix bug)") } } + +// writeBundleSpec writes a config.json declaring the given bind mounts, as +// destination -> source pairs. +func writeBundleSpec(t *testing.T, bundle string, binds map[string]string) { + t.Helper() + type mount struct { + Destination string `json:"destination"` + Type string `json:"type"` + Source string `json:"source"` + } + spec := struct { + Mounts []mount `json:"mounts"` + }{} + for dest, src := range binds { + spec.Mounts = append(spec.Mounts, mount{Destination: dest, Type: "bind", Source: src}) + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bundle, "config.json"), data, 0644); err != nil { + t.Fatal(err) + } +} + +// TestResolveMountRootNoSpec resolves to the rootfs when the bundle carries no +// config.json, so a bundle without mount information behaves as before. +func TestResolveMountRootNoSpec(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + + root, rel, err := resolveMountRoot(bundle, "/etc/hosts") + if err != nil { + t.Fatal(err) + } + if root != rootfs { + t.Fatalf("root = %q, want %q", root, rootfs) + } + if rel != "/etc/hosts" { + t.Fatalf("rel = %q, want %q", rel, "/etc/hosts") + } +} + +// TestResolveMountRootSelectsLongestDestination pins the nesting rule: a path +// covered by two mounts resolves against the innermost one. +func TestResolveMountRootSelectsLongestDestination(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + writeBundleSpec(t, bundle, map[string]string{ + "/data": "/mnt/outer", + "/data/inner": "/mnt/inner", + }) + + for _, tc := range []struct { + path string + wantRoot string + wantRel string + }{ + {"/data/file", "/mnt/outer", "/file"}, + {"/data/inner/file", "/mnt/inner", "/file"}, + {"/data", "/mnt/outer", "."}, + {"/elsewhere/file", rootfs, "/elsewhere/file"}, + // A sibling whose name merely shares the prefix is not inside the mount. + {"/database", rootfs, "/database"}, + } { + root, rel, err := resolveMountRoot(bundle, tc.path) + if err != nil { + t.Fatal(err) + } + if root != tc.wantRoot || rel != tc.wantRel { + t.Errorf("%s -> (%q, %q), want (%q, %q)", tc.path, root, rel, tc.wantRoot, tc.wantRel) + } + } +} + +// TestReadPathImportLandsInBindSource is the observable effect on the import +// side: extracting to a bind-mounted destination must produce the file in the +// mount's source directory, where the container reads it through the mount — +// not in the shadowed rootfs entry underneath. +func TestReadPathImportLandsInBindSource(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "bind-source") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + // The shadowed directory exists in the rootfs, as it does for a real + // container: the runtime creates the mount point before mounting over it. + if err := os.MkdirAll(filepath.Join(rootfs, "data"), 0755); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/data": source}) + + root, rel, err := resolveMountRoot(bundle, "/data") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + body := []byte("through the mount\n") + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "payload.txt", + Mode: 0644, + Size: int64(len(body)), + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + if err := readPath(&buf, root, rel, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(source, "payload.txt")) + if err != nil { + t.Fatalf("file missing from the bind source: %v", err) + } + if string(got) != string(body) { + t.Fatalf("content = %q, want %q", got, body) + } + if _, err := os.Stat(filepath.Join(rootfs, "data", "payload.txt")); !errors.Is(err, fs.ErrNotExist) { + t.Fatal("file was written to the shadowed rootfs entry, where the container cannot see it") + } +} + +// TestWritePathExportReadsBindSource is the same effect on the export side: an +// archive of a bind-mounted path must carry the mounted content, not whatever +// the shadowed rootfs entry holds. +func TestWritePathExportReadsBindSource(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "bind-source") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "payload.txt"), []byte("mounted\n"), 0644); err != nil { + t.Fatal(err) + } + // Same name under the shadowed rootfs entry, with different content: if + // resolution is wrong the export silently returns this instead. + if err := os.MkdirAll(filepath.Join(rootfs, "data"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "data", "payload.txt"), []byte("shadowed\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/data": source}) + + root, rel, err := resolveMountRoot(bundle, "/data/payload.txt") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, "payload.txt", &buf, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + entries := readTar(t, &buf) + e, ok := entries["payload.txt"] + if !ok { + t.Fatalf("payload.txt missing from archive, got %v", entries) + } + if string(e.body) != "mounted\n" { + t.Fatalf("archived %q, want the mounted content", e.body) + } +} + +// TestResolveMountRootSingleFileMount pins resolution for bind mounts whose +// source is a file: the root is the file's parent directory (an *os.Root +// cannot anchor at a file), and a relative source is interpreted against the +// bundle directory, as the runtime does for bundle extra files. +func TestResolveMountRootSingleFileMount(t *testing.T) { + bundle, _, _ := makeRootfs(t) + if err := os.WriteFile(filepath.Join(bundle, "resolv.conf"), []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + extra := filepath.Join(bundle, "extra") + if err := os.MkdirAll(extra, 0755); err != nil { + t.Fatal(err) + } + hosts := filepath.Join(extra, "hosts") + if err := os.WriteFile(hosts, []byte("127.0.0.1 localhost\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{ + "/etc/resolv.conf": "resolv.conf", // relative to the bundle + "/etc/hosts": hosts, // absolute + }) + + for _, tc := range []struct { + path string + wantRoot string + wantRel string + }{ + {"/etc/resolv.conf", bundle, "resolv.conf"}, + {"/etc/hosts", extra, "hosts"}, + // A path below a file mount cannot exist; the residual rel makes + // the caller's stat fail with ENOTDIR rather than silently + // resolving elsewhere. + {"/etc/hosts/sub", extra, "hosts/sub"}, + } { + root, rel, err := resolveMountRoot(bundle, tc.path) + if err != nil { + t.Fatal(err) + } + if root != tc.wantRoot || rel != tc.wantRel { + t.Errorf("%s -> (%q, %q), want (%q, %q)", tc.path, root, rel, tc.wantRoot, tc.wantRel) + } + } +} + +// TestWritePathExportSingleFileBindMount exports a file-mount destination: +// the archive must carry the mounted bytes under the container-view name, +// even though the source file's own basename differs. +func TestWritePathExportSingleFileBindMount(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "resolv-generated.conf") + if err := os.WriteFile(source, []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + // Shadowed rootfs entry with different content: the image may ship its + // own resolv.conf under the mount point. + if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "etc", "resolv.conf"), []byte("shadowed\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/etc/resolv.conf": source}) + + root, rel, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, "resolv.conf", &buf, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + entries := readTar(t, &buf) + e, ok := entries["resolv.conf"] + if !ok { + t.Fatalf("resolv.conf missing from archive, got %v", keys(entries)) + } + if string(e.body) != "nameserver 10.0.0.1\n" { + t.Fatalf("archived %q, want the mounted content", e.body) + } +} + +// TestReadPathImportOverSingleFileBindMount imports onto a file-mount +// destination: the mount source's bytes are replaced in place — same inode, +// so the container's mount keeps seeing the file — and the shadowed rootfs +// entry stays untouched. +func TestReadPathImportOverSingleFileBindMount(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "resolv.conf") + if err := os.WriteFile(source, []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "etc", "resolv.conf"), []byte("shadowed\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/etc/resolv.conf": "resolv.conf"}) + + before, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + + root, rel, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) { + body := []byte("nameserver 10.0.0.2\n") + _ = tw.WriteHeader(&tar.Header{ + Name: "resolv.conf", + Typeflag: tar.TypeReg, + Mode: 0644, + Size: int64(len(body)), + }) + _, _ = tw.Write(body) + }) + + if err := readPath(buf, root, rel, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if string(got) != "nameserver 10.0.0.2\n" { + t.Fatalf("source content = %q, want the imported bytes", got) + } + after, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("source was replaced by a new inode; the container's mount would keep the stale file") + } + shadow, err := os.ReadFile(filepath.Join(rootfs, "etc", "resolv.conf")) + if err != nil { + t.Fatal(err) + } + if string(shadow) != "shadowed\n" { + t.Fatalf("shadowed rootfs entry was modified: %q", shadow) + } +} + +// TestReadPathImportDirectoryOverFileFails rejects extracting a directory +// archive over a file-mount destination, mirroring "cannot copy a directory +// to a file" semantics. +func TestReadPathImportDirectoryOverFileFails(t *testing.T) { + bundle, _, _ := makeRootfs(t) + source := filepath.Join(bundle, "resolv.conf") + if err := os.WriteFile(source, []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/etc/resolv.conf": source}) + + root, rel, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) { + _ = tw.WriteHeader(&tar.Header{ + Name: "d", + Typeflag: tar.TypeDir, + Mode: 0755, + }) + }) + + if err := readPath(buf, root, rel, mediaTypeTar, false); err == nil { + t.Fatal("expected error extracting a directory over a file destination") + } + got, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if string(got) != "nameserver 10.0.0.1\n" { + t.Fatalf("source was modified by a failed import: %q", got) + } +} + +// TestWritePathExportDirMountExactKeepsName pins the naming contract when the +// requested path is exactly a directory mount's destination: the walk anchors +// at the mount source, but the archive's top-level name is the destination's +// basename as the container sees it — Transfer derives it from the container +// path, not from the resolved source. +func TestWritePathExportDirMountExactKeepsName(t *testing.T) { + bundle, _, _ := makeRootfs(t) + source := filepath.Join(bundle, "bind-source") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "file"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/data": source}) + + root, rel, err := resolveMountRoot(bundle, "/data") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, "data", &buf, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + entries := readTar(t, &buf) + if _, ok := entries["data/file"]; !ok { + t.Fatalf("expected 'data/file' entry, got %v", keys(entries)) + } + if _, ok := entries[filepath.Base(source)+"/file"]; ok { + t.Fatal("archive leaked the mount source's basename instead of the container-view name") + } +}