diff --git a/cmd/nerdctl/container/container_run_mount_image_linux_test.go b/cmd/nerdctl/container/container_run_mount_image_linux_test.go index 4e7435f6130..ec5eebf5108 100644 --- a/cmd/nerdctl/container/container_run_mount_image_linux_test.go +++ b/cmd/nerdctl/container/container_run_mount_image_linux_test.go @@ -95,10 +95,80 @@ func TestRunMountTypeImageReadOnly(t *testing.T) { testCase.Run(t) } +// TestRunMountTypeImageSubpath verifies that image-subpath exposes only the +// selected directory of the image rootfs at the destination: the image's +// /etc/os-release is reachable as /os-release. +func TestRunMountTypeImageSubpath(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage), + testutil.CommonImage, "cat", "/mnt/img/os-release") + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.Contains("Alpine"), + } + } + + testCase.Run(t) +} + +// TestRunMountTypeImageSubpathMultiple verifies that two image-subpath mounts of +// the same image at different destinations each expose their own subdirectory, +// exercising the multi-mount label round-trip and cleanup. +func TestRunMountTypeImageSubpathMultiple(t *testing.T) { + testCase := nerdtest.Setup() + // nerdctl-only: Docker keys an image mount by its source image and rejects + // mounting the same image twice ("mount already exists with name"). + testCase.Require = require.Not(nerdtest.Docker) + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/etc,image-subpath=etc", testutil.CommonImage), + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/bin,image-subpath=bin", testutil.CommonImage), + testutil.CommonImage, "ls", "/mnt/etc", "/mnt/bin") + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + } + } + + testCase.Run(t) +} + +// TestRunMountTypeImageSubpathReadOnly verifies an image-subpath mount is +// read-only so writing fails. nerdctl-only: Docker's image-subpath read-only +// behavior is unverified, so this is not run against Docker. +func TestRunMountTypeImageSubpathReadOnly(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.Docker) + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage), + testutil.CommonImage, "touch", "/mnt/img/should-fail") + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{fmt.Errorf("Read-only file system")}, + } + } + + testCase.Run(t) +} + // TestRunMountTypeImageErrors verifies that an image mount missing its source, -// or using the not-yet-supported image-subpath option, is rejected. Docker -// implements image-subpath, so that case diverges and the test is not run -// against Docker. +// or using the not-yet-supported subpath option, or an image-subpath that +// escapes the rootfs, is rejected. These are nerdctl-specific behaviours here, +// so the test is not run against Docker. func TestRunMountTypeImageErrors(t *testing.T) { testCase := nerdtest.Setup() testCase.Require = require.Not(nerdtest.Docker) @@ -118,16 +188,44 @@ func TestRunMountTypeImageErrors(t *testing.T) { }, }, { - Description: "image-subpath not supported", + Description: "subpath not supported", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,subpath=etc", testutil.CommonImage), + testutil.CommonImage, "true") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{fmt.Errorf("subpath")}, + } + }, + }, + { + Description: "image-subpath parent traversal rejected", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=../etc", testutil.CommonImage), + testutil.CommonImage, "true") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{fmt.Errorf("escapes")}, + } + }, + }, + { + Description: "image-subpath absolute rejected", Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "--rm", - "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage), + "--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=/etc", testutil.CommonImage), testutil.CommonImage, "true") }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: expect.ExitCodeGenericFail, - Errors: []error{fmt.Errorf("image-subpath")}, + Errors: []error{fmt.Errorf("relative")}, } }, }, diff --git a/docs/command-reference.md b/docs/command-reference.md index 9e32e1d8adf..9d997be8c80 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -328,7 +328,7 @@ Volume flags: - Options specific to `image`: - :whale: `src`, `source`: image reference (mandatory). - :whale: Currently, the image filesystem is mounted read-only. - - unimplemented options: `image-subpath` + - :whale: `image-subpath`: relative path inside the image rootfs to mount instead of the whole rootfs. Must stay within the rootfs (no absolute paths or `..` traversal). - :whale: `--volumes-from`: Mount volumes from the specified container(s), e.g. "--volumes-from my-container". Rootfs flags: diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 83abc56ad0b..a28b77e0be8 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -95,20 +95,24 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa internalLabels.platform = options.Platform internalLabels.namespace = options.GOptions.Namespace - // If creation fails after image-mount views are created, remove them so the - // snapshots do not leak (the cleanup label is only persisted on success). + // If creation fails after image-mount state is created, tear it down so the + // snapshots and host mounts do not leak (the cleanup labels are only persisted + // on success). defer func() { if retErr == nil { return } - var keys []string + var keys, hostpaths []string for _, mp := range internalLabels.mountPoints { if mp.ImageMountSnapshot != "" { keys = append(keys, mp.ImageMountSnapshot) } + if mp.ImageMountHostpath != "" { + hostpaths = append(hostpaths, mp.ImageMountHostpath) + } } - if len(keys) > 0 { - removeImageMountViews(ctx, client.SnapshotService(options.GOptions.Snapshotter), keys) + if len(keys) > 0 || len(hostpaths) > 0 { + removeImageMounts(ctx, client.SnapshotService(options.GOptions.Snapshotter), hostpaths, keys) } }() @@ -886,13 +890,16 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO m[labels.AnonymousVolumes] = string(anonVolumeJSON) } - // Record the snapshot keys of any type=image mount views so they can be - // removed when the container is deleted. - var imageMountSnapshots []string + // Record the snapshot keys and host materialization paths of any type=image + // mounts so they can be removed when the container is deleted. + var imageMountSnapshots, imageMountHostpaths []string for _, mp := range internalLabels.mountPoints { if mp.ImageMountSnapshot != "" { imageMountSnapshots = append(imageMountSnapshots, mp.ImageMountSnapshot) } + if mp.ImageMountHostpath != "" { + imageMountHostpaths = append(imageMountHostpaths, mp.ImageMountHostpath) + } } if len(imageMountSnapshots) > 0 { b, err := json.Marshal(imageMountSnapshots) @@ -901,6 +908,13 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO } m[labels.ImageMountSnapshots] = string(b) } + if len(imageMountHostpaths) > 0 { + b, err := json.Marshal(imageMountHostpaths) + if err != nil { + return nil, err + } + m[labels.ImageMountHostpaths] = string(b) + } if internalLabels.pidFile != "" { m[labels.PIDFile] = internalLabels.pidFile diff --git a/pkg/cmd/container/remove.go b/pkg/cmd/container/remove.go index 744d20aabb3..df55b173add 100644 --- a/pkg/cmd/container/remove.go +++ b/pkg/cmd/container/remove.go @@ -283,15 +283,22 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions } } - // Remove the read-only views backing type=image mounts - soft failure. + // Tear down type=image mount state (host materializations and read-only + // views) backing this container - soft failure. + var imageMountKeys, imageMountHostpaths []string if snapshotsJSON, ok := containerLabels[labels.ImageMountSnapshots]; ok { - var keys []string - if err = json.Unmarshal([]byte(snapshotsJSON), &keys); err != nil { + if err = json.Unmarshal([]byte(snapshotsJSON), &imageMountKeys); err != nil { log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount snapshots for container %q", id) - } else { - removeImageMountViews(ctx, client.SnapshotService(imageMountSnapshotter), keys) } } + if hostpathsJSON, ok := containerLabels[labels.ImageMountHostpaths]; ok { + if err = json.Unmarshal([]byte(hostpathsJSON), &imageMountHostpaths); err != nil { + log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount host paths for container %q", id) + } + } + if len(imageMountKeys) > 0 || len(imageMountHostpaths) > 0 { + removeImageMounts(ctx, client.SnapshotService(imageMountSnapshotter), imageMountHostpaths, imageMountKeys) + } }() // Get the task. diff --git a/pkg/cmd/container/run_mount.go b/pkg/cmd/container/run_mount.go index 34ab8fa1230..d97a51c1092 100644 --- a/pkg/cmd/container/run_mount.go +++ b/pkg/cmd/container/run_mount.go @@ -127,19 +127,24 @@ func parseMountFlags(volStore volumestore.VolumeStore, options types.ContainerCr const gcRootLabel = "containerd.io/gc.root" // setupImageMount ensures and unpacks ref, then creates a read-only GC-rooted -// snapshot view of its rootfs. Image mounts are always read-only, matching -// Docker. It returns the OCI mount for destination and the view's snapshot key. -func setupImageMount(ctx context.Context, client *containerd.Client, options types.ContainerCreateOptions, ref, destination string) (specs.Mount, string, error) { +// snapshot view of its rootfs. Without a subpath it returns the snapshotter's +// own mount for destination (runc owns its lifecycle). With a subpath it +// materializes the view on a host directory and returns a read-only bind mount +// of the resolved subdirectory, because an OCI overlay mount cannot select a +// subdir; that host directory is returned so it can be unmounted on removal. +// It returns the OCI mount, the view's snapshot key, and the host materialization +// path (empty when no subpath is used). +func setupImageMount(ctx context.Context, client *containerd.Client, options types.ContainerCreateOptions, ref, destination, subpath string) (specs.Mount, string, string, error) { ensured, err := imgutil.EnsureImage(ctx, client, ref, options.ImagePullOpt) if err != nil { - return specs.Mount{}, "", fmt.Errorf("failed to ensure image %q for image mount: %w", ref, err) + return specs.Mount{}, "", "", fmt.Errorf("failed to ensure image %q for image mount: %w", ref, err) } if err := ensured.Image.Unpack(ctx, options.GOptions.Snapshotter); err != nil { - return specs.Mount{}, "", fmt.Errorf("failed to unpack image %q for image mount: %w", ref, err) + return specs.Mount{}, "", "", fmt.Errorf("failed to unpack image %q for image mount: %w", ref, err) } diffIDs, err := ensured.Image.RootFS(ctx) if err != nil { - return specs.Mount{}, "", fmt.Errorf("failed to get rootfs of image %q for image mount: %w", ref, err) + return specs.Mount{}, "", "", fmt.Errorf("failed to get rootfs of image %q for image mount: %w", ref, err) } chainID := identity.ChainID(diffIDs).String() @@ -149,16 +154,26 @@ func setupImageMount(ctx context.Context, client *containerd.Client, options typ gcRootLabel: time.Now().UTC().Format(time.RFC3339), })) if err != nil { - return specs.Mount{}, "", fmt.Errorf("failed to create read-only view of image %q: %w", ref, err) + return specs.Mount{}, "", "", fmt.Errorf("failed to create read-only view of image %q: %w", ref, err) } - // overlayfs and native snapshotters each yield a single mount for a view. - if len(mounts) != 1 { + // removeView drops the snapshot view on any failure after it was created. + removeView := func() { if rmErr := s.Remove(ctx, snapshotKey); rmErr != nil && !errdefs.IsNotFound(rmErr) { log.G(ctx).WithError(rmErr).Warnf("failed to remove image-mount snapshot %q", snapshotKey) } - return specs.Mount{}, "", fmt.Errorf("image mount expects exactly one mount from the snapshotter, got %d", len(mounts)) } + if subpath != "" { + return setupImageSubpathMount(ctx, options, ref, destination, subpath, snapshotKey, mounts, removeView) + } + + // Whole-rootfs case: hand the snapshotter's mount straight to the OCI runtime, + // which mounts and unmounts it with the container. overlayfs and native + // snapshotters each yield exactly one mount for a view. + if len(mounts) != 1 { + removeView() + return specs.Mount{}, "", "", fmt.Errorf("image mount expects exactly one mount from the snapshotter, got %d", len(mounts)) + } m := mounts[0] opts := m.Options // A view without an upper dir is already read-only; make it explicit for @@ -171,13 +186,77 @@ func setupImageMount(ctx context.Context, client *containerd.Client, options typ Source: m.Source, Destination: destination, Options: opts, - }, snapshotKey, nil + }, snapshotKey, "", nil } -// removeImageMountViews removes the snapshotter views created for type=image -// mounts. NotFound is ignored; other failures are logged but not fatal. -func removeImageMountViews(ctx context.Context, s snapshots.Snapshotter, keys []string) { - for _, k := range keys { +// setupImageSubpathMount materializes the view on a host dir and returns a +// read-only bind mount of the subpath. securejoin keeps the resolved path inside +// the rootfs; being path-based it does not guard a post-resolution symlink swap, +// which the read-only view under a root-owned 0700 dir does not expose. On failure +// it unwinds the mount, dir, and view; the host path is removed on container delete. +func setupImageSubpathMount(ctx context.Context, options types.ContainerCreateOptions, ref, destination, subpath, snapshotKey string, mounts []mount.Mount, removeView func()) (specs.Mount, string, string, error) { + // Materialize under the data root keyed by snapshot key so it is unique per + // view and outlives container restarts (only removed on container deletion). + hostMountpoint := filepath.Join(options.GOptions.DataRoot, "image-mounts", snapshotKey) + if err := os.MkdirAll(hostMountpoint, 0o700); err != nil { + removeView() + return specs.Mount{}, "", "", fmt.Errorf("failed to create image-mount host dir: %w", err) + } + if err := mount.All(mounts, hostMountpoint); err != nil { + // mount.All may have applied some mounts before failing; unmount before + // removing the dir so RemoveAll never recurses into a live mount. + if uErr := mount.UnmountAll(hostMountpoint, 0); uErr != nil { + log.G(ctx).WithError(uErr).Warnf("failed to unmount image-mount host path %q after failed setup", hostMountpoint) + } + os.RemoveAll(hostMountpoint) + removeView() + return specs.Mount{}, "", "", fmt.Errorf("failed to materialize image %q for subpath mount: %w", ref, err) + } + // cleanup unwinds the host mount, its directory, and the view together. + cleanup := func() { + if uErr := mount.UnmountAll(hostMountpoint, 0); uErr != nil { + log.G(ctx).WithError(uErr).Warnf("failed to unmount image-mount host path %q", hostMountpoint) + } + os.RemoveAll(hostMountpoint) + removeView() + } + + // securejoin resolves the subpath within the rootfs, keeping the result inside it. + resolved, err := securejoin.SecureJoin(hostMountpoint, subpath) + if err != nil { + cleanup() + return specs.Mount{}, "", "", fmt.Errorf("failed to resolve image-subpath %q: %w", subpath, err) + } + if _, err := os.Stat(resolved); err != nil { + cleanup() + if os.IsNotExist(err) { + return specs.Mount{}, "", "", fmt.Errorf("image-subpath %q does not exist in image %q", subpath, ref) + } + return specs.Mount{}, "", "", fmt.Errorf("failed to stat image-subpath %q: %w", subpath, err) + } + return specs.Mount{ + Type: "bind", + Source: resolved, + Destination: destination, + Options: []string{"rbind", "ro"}, + }, snapshotKey, hostMountpoint, nil +} + +// removeImageMounts tears down type=image mount state for a container: it +// unmounts and removes any host materialization directories (image-subpath), +// then removes the read-only snapshot views. NotFound is ignored; other +// failures are logged but not fatal. +func removeImageMounts(ctx context.Context, s snapshots.Snapshotter, hostpaths, snapshotKeys []string) { + // Unmount host materializations before removing the views they hold open. + for _, p := range hostpaths { + if err := mount.UnmountAll(p, 0); err != nil { + log.G(ctx).WithError(err).Warnf("failed to unmount image-mount host path %q", p) + } + if err := os.RemoveAll(p); err != nil { + log.G(ctx).WithError(err).Warnf("failed to remove image-mount host path %q", p) + } + } + for _, k := range snapshotKeys { if err := s.Remove(ctx, k); err != nil && !errdefs.IsNotFound(err) { log.G(ctx).WithError(err).Warnf("failed to remove image-mount snapshot %q", k) } @@ -190,14 +269,15 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm volStore volumestore.VolumeStore, options types.ContainerCreateOptions) (opts []oci.SpecOpts, anonVolumes []string, mountPoints []*mountutil.Processed, retErr error) { //nolint:prealloc var ( - userMounts []specs.Mount - imageMountViews []string + userMounts []specs.Mount + imageMountViews []string + imageMountHostpaths []string ) - // Remove any image-mount views created here if this function fails, so a - // partial setup does not leak snapshots. + // Tear down any image-mount state created here if this function fails, so a + // partial setup does not leak snapshots or host mounts. defer func() { - if retErr != nil && len(imageMountViews) > 0 { - removeImageMountViews(ctx, client.SnapshotService(options.GOptions.Snapshotter), imageMountViews) + if retErr != nil && (len(imageMountViews) > 0 || len(imageMountHostpaths) > 0) { + removeImageMounts(ctx, client.SnapshotService(options.GOptions.Snapshotter), imageMountHostpaths, imageMountViews) } }() mounted := make(map[string]struct{}) @@ -299,13 +379,17 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm // type=image: build the read-only view now and record its snapshot // key for cleanup on container removal. if x.Type == mountutil.Image { - m, snapshotKey, err := setupImageMount(ctx, client, options, x.Mount.Source, x.Mount.Destination) + m, snapshotKey, hostMountpoint, err := setupImageMount(ctx, client, options, x.Mount.Source, x.Mount.Destination, x.ImageSubpath) if err != nil { return nil, nil, nil, err } imageMountViews = append(imageMountViews, snapshotKey) + if hostMountpoint != "" { + imageMountHostpaths = append(imageMountHostpaths, hostMountpoint) + } ociMounts[i] = m x.ImageMountSnapshot = snapshotKey + x.ImageMountHostpath = hostMountpoint mounted[filepath.Clean(x.Mount.Destination)] = struct{}{} continue } diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 7b47ce885e5..88bec9139e8 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -84,6 +84,11 @@ const ( // the read-only views backing `--mount type=image`, removed on container deletion. ImageMountSnapshots = Prefix + "image-mount-snapshots" + // ImageMountHostpaths is a JSON-marshalled []string of host directories where + // `--mount type=image,image-subpath=...` rootfs views are materialized; each + // must be unmounted and removed on container deletion. + ImageMountHostpaths = Prefix + "image-mount-hostpaths" + // Platform is the normalized platform string like "linux/ppc64le". Platform = Prefix + "platform" diff --git a/pkg/mountutil/mountutil.go b/pkg/mountutil/mountutil.go index f4ce3bd8f1d..78b54feab90 100644 --- a/pkg/mountutil/mountutil.go +++ b/pkg/mountutil/mountutil.go @@ -54,6 +54,13 @@ type Processed struct { // ImageMountSnapshot is the snapshotter key of the read-only view for a // type=image mount; empty for other mount types. ImageMountSnapshot string + // ImageSubpath is the relative path inside a type=image rootfs to expose at + // the destination, instead of the whole rootfs. Empty means the whole rootfs. + ImageSubpath string + // ImageMountHostpath is the host directory where a type=image rootfs is + // materialized so an image-subpath can be bind-mounted from it. It must be + // unmounted and removed on container deletion. Empty when no subpath is used. + ImageMountHostpath string } type volumeSpec struct { diff --git a/pkg/mountutil/mountutil_linux.go b/pkg/mountutil/mountutil_linux.go index 02da626213b..9caa1f339c0 100644 --- a/pkg/mountutil/mountutil_linux.go +++ b/pkg/mountutil/mountutil_linux.go @@ -21,6 +21,7 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" "strconv" "strings" @@ -360,6 +361,30 @@ func ProcessFlagTmpfs(s string) (*Processed, error) { return res, nil } +// validateImageSubpath normalizes an image-subpath value and rejects paths that +// are absolute, escape the image rootfs, or resolve to the rootfs itself. An +// empty input returns empty (no subpath). Image paths are always forward-slash, +// so it uses path, not filepath. +func validateImageSubpath(p string) (string, error) { + if p == "" { + return "", nil + } + if path.IsAbs(p) { + return "", fmt.Errorf("image-subpath must be relative to the image rootfs, got %q", p) + } + clean := path.Clean(p) + // Clean collapses ".." segments; anything still leading with ".." escapes root. + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("image-subpath %q escapes the image rootfs", p) + } + // "." means the whole rootfs (e.g. from "a/.."); that is the no-subpath case, + // not a subdirectory selection, so reject it as a misuse of image-subpath. + if clean == "." { + return "", fmt.Errorf("image-subpath %q must select a subdirectory, not the image rootfs", p) + } + return clean, nil +} + func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime string) (*Processed, error) { fields := strings.Split(s, ",") var ( @@ -370,6 +395,7 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime str bindNonRecursive bool bindRecursive string // "enabled", "disabled", "writable", or "readonly" rwOption string + imageSubpath string tmpfsSize int64 tmpfsMode os.FileMode err error @@ -441,9 +467,9 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime str rwOption = key } case "image-subpath": - // image-subpath is Docker's option to mount a subdirectory of the - // image; it is not implemented yet. - return nil, fmt.Errorf("mount option %q is not yet supported", key) + // Selects a directory inside a type=image rootfs; validated below once + // the mount type is known. + imageSubpath = value case "bind-propagation": // here don't validate the propagation value // parseVolumeOptions will do that. @@ -480,6 +506,12 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime str } } + // image-subpath only makes sense for type=image; reject it elsewhere before + // falling through to the legacy bind/volume/tmpfs handlers. + if imageSubpath != "" && mountType != Image { + return nil, fmt.Errorf("image-subpath is only supported for type=image") + } + // type=image's source is an image reference resolved later with a containerd // client; validate the intent here. Like Docker, an image mount is always // read-only: a readonly/ro option is accepted for compatibility but the @@ -491,6 +523,12 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime str if dst == "" { return nil, fmt.Errorf("type=image requires a destination") } + // Normalize and bound the subpath to the image rootfs at parse time; + // securejoin re-checks against symlinks once the rootfs is materialized. + cleanSubpath, err := validateImageSubpath(imageSubpath) + if err != nil { + return nil, err + } return &Processed{ Type: Image, // Mode "ro" so inspect/label metadata reports the mount read-only. @@ -500,6 +538,7 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime str Source: src, Destination: cleanMount(dst), }, + ImageSubpath: cleanSubpath, }, nil } diff --git a/pkg/mountutil/mountutil_linux_test.go b/pkg/mountutil/mountutil_linux_test.go index 5ee383d7065..72c37f9ed71 100644 --- a/pkg/mountutil/mountutil_linux_test.go +++ b/pkg/mountutil/mountutil_linux_test.go @@ -645,8 +645,69 @@ func TestProcessFlagMountImage(t *testing.T) { }, }, { + // bare subpath is not a type=image option; image-subpath is. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,subpath=etc", + err: "subpath", + }, + { + // image-subpath selects a directory inside the image rootfs. rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=etc", - err: "image-subpath", + wants: &Processed{ + Type: Image, + Mode: "ro", + Mount: specs.Mount{Type: Image, Source: "alpine:latest", Destination: "/mnt/img"}, + ImageSubpath: "etc", + }, + }, + { + // image-subpath is normalized: leading ./ and trailing / are stripped. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=./etc/", + wants: &Processed{ + Type: Image, + Mode: "ro", + Mount: specs.Mount{Type: Image, Source: "alpine:latest", Destination: "/mnt/img"}, + ImageSubpath: "etc", + }, + }, + { + // parent traversal must be rejected before the mount is built. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=../etc", + err: "escapes", + }, + { + // traversal that normalizes back above root must be rejected. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=a/b/../../../etc", + err: "escapes", + }, + { + // a path normalizing to "." is the whole rootfs, not a subdirectory. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=.", + err: "must select a subdirectory", + }, + { + // "a/.." also normalizes to the rootfs and must be rejected. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=a/..", + err: "must select a subdirectory", + }, + { + // nested subpath is normalized and preserved. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=usr/lib", + wants: &Processed{ + Type: Image, + Mode: "ro", + Mount: specs.Mount{Type: Image, Source: "alpine:latest", Destination: "/mnt/img"}, + ImageSubpath: "usr/lib", + }, + }, + { + // absolute image-subpath is rejected; it must be relative to the rootfs. + rawSpec: "type=image,source=alpine:latest,destination=/mnt/img,image-subpath=/etc", + err: "relative", + }, + { + // image-subpath only applies to type=image. + rawSpec: "type=bind,source=/tmp,destination=/mnt,image-subpath=etc", + err: "only supported for type=image", }, } for _, tt := range tests { @@ -662,6 +723,7 @@ func TestProcessFlagMountImage(t *testing.T) { assert.Equal(t, got.Mount.Type, tt.wants.Mount.Type) assert.Equal(t, got.Mount.Source, tt.wants.Mount.Source) assert.Equal(t, got.Mount.Destination, tt.wants.Mount.Destination) + assert.Equal(t, got.ImageSubpath, tt.wants.ImageSubpath) }) } }