Skip to content

fix(transfer): honor bind-mount semantics in filesystem transfers - #287

Open
ilopezluna wants to merge 20 commits into
containerd:mainfrom
ilopezluna:transfer-readonly-mounts
Open

fix(transfer): honor bind-mount semantics in filesystem transfers#287
ilopezluna wants to merge 20 commits into
containerd:mainfrom
ilopezluna:transfer-readonly-mounts

Conversation

@ilopezluna

@ilopezluna ilopezluna commented Sep 1, 2026

Copy link
Copy Markdown

Supersedes #260.

This PR contains #260's two commits unchanged, preserving their authorship, plus the read-only enforcement and file-import hardening added during follow-up review. It is intended to merge directly into main as the complete mount-aware transfer change.

Problem

The container-FS transferrer anchors imports and exports at the bundle's rootfs. That directory only backs paths not covered by a mount: when the OCI spec declares a bind mount, its source shadows the corresponding rootfs path inside the container.

As a result:

  • importing to a bind-mounted path can report success after writing a file the container never sees;
  • exporting from that path can archive stale or unrelated rootfs content; and
  • resolving directly to a bind source without honoring mount options can bypass the container's read-only view and modify host-backed content exposed as ro.

Changes

Resolve paths against bind mounts

resolveMountRoot reads the bundle spec and maps a container-view path to the directory that backs it. The last matching bind mount in spec order wins, so a later parent mount can hide an earlier child mount. Both transfer directions use the resolved path.

The resolver also:

  • interprets relative mount sources against the bundle directory;
  • interprets deprecated relative mount destinations against /, as required by OCI on Linux;
  • handles single-file bind mounts by anchoring os.Root at the source's parent;
  • preserves the container-view name when exporting, even if the source basename differs; and
  • updates an existing file destination in place so a single-file bind mount keeps the same inode.

Enforce the container's read-only view

Resolution happens outside the mount namespace where MS_RDONLY is enforced, so the resolver now reports whether the destination is write-protected:

  • the matched mount's options use last-option-wins ro/rw and recursive rro/rrw semantics; or
  • when no mount covers the path, the OCI spec's root.readonly flag applies.

Copy-to rejects a read-only destination with ErrPermissionDenied before opening the input stream. A writable mount inside a read-only root remains writable, matching Docker semantics. Exports remain allowed.

Validate file-destination imports before mutation

An import over an existing file must contain exactly one regular file. The payload is staged in a temporary sibling and the complete archive is validated before the destination is truncated in place. Empty, truncated, non-regular, or multi-entry archives therefore fail without changing the original file or leaving staging debris.

Bundle-spec behavior

A missing config.json falls back to the rootfs. A config that exists but cannot be read or parsed returns an error: resolving blindly could write to a shadowed path or bypass a read-only mount.

Known limitations

Issue #164 continues to track paths whose subtree crosses into a deeper mount and non-bind mount types, such as tmpfs, whose contents only exist in the container's mount namespace.

Tests

  • Mount selection, relative sources and destinations, single-file mounts, and container-view archive names.
  • Import and export against bind sources.
  • Last-option-wins ro/rw and rro/rrw behavior and the read-only-root fallback.
  • End-to-end rejection of imports to read-only mounts and root filesystems before the stream is opened.
  • Missing-config fallback and malformed-config rejection.
  • Global PAX metadata plus empty, malformed, and multi-entry archives over file destinations, including preservation of the original bytes and inode.

ndeloof and others added 3 commits July 29, 2026 17:47
The container-FS transferrer anchored both directions at the bundle's rootfs.
That directory 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.

Importing to such a path therefore produced a file the container never sees,
and exporting from one archived whatever the rootfs happened to hold instead
of the mounted content. Neither reported an error.

resolveMountRoot reads the bundle spec and maps a container-view path onto the
directory backing it, preferring the longest matching bind destination so a
nested mount wins over its parent. Both directions go through it, keeping the
import and export views consistent with the container's own.

A bundle with no readable or parseable config.json resolves to the rootfs, so
callers that supply no mount information are unaffected.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…rces

A bind mount whose source is a file (nerdbox itself declares one for
/etc/resolv.conf on every networked container) cannot anchor an
*os.Root: resolving it to the source path made both transfer
directions fail with ENOTDIR. Resolve such mounts to the source's
parent directory with the file's name as the relative path. On
import, an existing-file destination now receives the archived
file's bytes in place — same inode, so the container's mount keeps
seeing the update — and rejects directory archives. On export, the
archive's top-level name is derived from the container-view path
rather than the resolved source, whose basename need not match.

Relative mount sources are interpreted against the bundle directory,
as the runtime does for bundle extra files. A source is treated as
absolute when either filepath.IsAbs or path.IsAbs says so: the code
runs in the Linux VM where both agree, and the unit tests mix
spec-style sources with Windows host temp directories.

Not covered here, tracked by containerd#164: paths whose subtree crosses into
a deeper mount, and non-bind mount types (tmpfs) whose content only
exists in the container's mount namespace.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
resolveMountRoot maps a container path onto its backing directory,
outside the mount namespace where MS_RDONLY is enforced, so an import
to a path the container sees as read-only would silently modify
content the container cannot write itself. Surface the protection
during resolution — the matched bind mount's options, scanned with
last-option-wins ro/rw semantics like the shim's mount transform, or
the spec's root read-only flag when no mount covers the path — and
refuse the transfer before the input stream is consumed.

Exports are unaffected: reading a read-only mount is what the
container itself may do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Copilot AI lite review requested due to automatic review settings September 1, 2026 11:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the container filesystem transferrer so imports (copy-to) do not bypass the container’s read-only view when paths are backed by bind mounts or a read-only root, by resolving container paths against bundle config.json mounts and refusing writes to destinations marked read-only.

Changes:

  • Extend resolveMountRoot to resolve container paths to their backing directory and report whether the container-view path is write-protected (mount ro/rw options + root.readonly fallback).
  • Update copy-to to reject imports to read-only destinations with ErrPermissionDenied before consuming the input stream; keep exports allowed.
  • Adjust export tar top-level naming to reflect the container-view path (even when mount source basenames differ) and add file-destination import support (extractOverFile) for single-file bind mounts.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/transfer/containerfs.go Adds mount-aware path resolution (including readonly flag), enforces read-only import refusal, and improves export/import behavior around mounts and file destinations.
internal/transfer/containerfs_test.go Adds/updates unit and end-to-end tests covering mount resolution, readonly semantics, and copy-to refusal behavior.
Suppressed comments (1)

internal/transfer/containerfs.go:438

  • extractOverFile can modify the target file and still return an error when the tar contains multiple entries: the first entry is applied, then the second header triggers "cannot extract multiple entries". This violates the expectation that a rejected archive leaves the destination untouched; consider validating single-entry-ness (e.g., stream to a temp file / buffer, ensure EOF, then truncate+write) before touching the target.
		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)
		}

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/transfer/containerfs.go Outdated
Comment thread internal/transfer/containerfs.go Outdated
ilopezluna and others added 2 commits September 1, 2026 12:27
extractOverFile's contract is exactly one regular file. An archive
with no entries left the destination's old bytes in place while the
transfer reported success, masking a truncated input stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
The doc claimed any unreadable config.json falls back to the rootfs,
but only a missing or unparseable one does; a config that exists and
cannot be read is reported, since resolving blind could land a
transfer on a shadowed path or bypass a read-only mount. Describe the
deliberate behavior instead of changing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Copilot AI review requested due to automatic review settings September 1, 2026 12:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread internal/transfer/containerfs.go Outdated
Copilot AI review requested due to automatic review settings September 1, 2026 12:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

…rget

extractOverFile truncated the destination while the archive could
still turn out malformed: a second entry or a truncated payload was
only discovered after the target's bytes were gone, so a rejected
import mutated the file it rejected. Stage the payload in a temporary
sibling, verify the archive holds exactly one regular file, and only
then truncate the destination in place — same inode, so a bind-mount
source keeps being seen by the container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
@ilopezluna
ilopezluna force-pushed the transfer-readonly-mounts branch from 8aa7217 to 08e8102 Compare September 1, 2026 12:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Copilot AI review requested due to automatic review settings September 1, 2026 12:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

internal/transfer/containerfs.go:369

  • In readPath, the parameters and error message still refer to "rootfs", but callers now pass the resolved backing directory (rootfs or a bind-mount source). The inline comment here also says "file in the rootfs", which is no longer always true and can confuse debugging when OpenRoot fails on a mount source path.
		// 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

Comment thread internal/transfer/containerfs.go Outdated
Since mount resolution, writePath and readPath receive whichever
directory backs the container path — a rootfs or a bind-mount source.
Name the parameter dir and drop rootfs from the docs and errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Copilot AI review requested due to automatic review settings September 1, 2026 13:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

internal/transfer/containerfs.go:90

  • The comment for resolveMountRoot says it returns "the path relative" to the resolved root, but the function currently returns values like "/file" (see strings.TrimPrefix at line 174). This mismatch makes the contract unclear for callers; either normalize rel to be truly relative (no leading "/") or adjust the docstring to reflect that rel may be absolute-like and is intended to be passed through rootRel().
// 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.

internal/transfer/containerfs.go:440

  • extractOverFile returns the raw error from io.CopyN / Close without context; in common failure cases this can surface as a bare EOF/UnexpectedEOF, which is hard to diagnose from a Transfer error. Wrapping these with target/path context would make failures actionable.
	// 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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
@ilopezluna
ilopezluna force-pushed the transfer-readonly-mounts branch from 03a721b to 0f3a566 Compare September 1, 2026 13:23
@ilopezluna
ilopezluna marked this pull request as ready for review September 1, 2026 13:41
Copilot AI review requested due to automatic review settings September 1, 2026 13:41
Copilot AI review requested due to automatic review settings September 3, 2026 12:34
@ilopezluna
ilopezluna force-pushed the transfer-readonly-mounts branch from 7df41dc to f2a11a9 Compare September 3, 2026 12:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new single-file overwrite path (extractOverFile) appears to enforce “exactly one tar entry” rather than “exactly one regular-file payload,” which can reject otherwise-valid single-file archives that include common tar metadata entries.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/transfer/containerfs.go:424

  • extractOverFile enforces “exactly one tar entry” (tr.Next() must hit EOF immediately after the first regular file). Many tar writers may include non-payload metadata entries (e.g., PAX xheaders / GNU longname/link records) while still carrying exactly one regular-file payload; those would be rejected here even though they satisfy the stated “exactly one regular file” contract. If compatibility with such archives matters, consider skipping known metadata entry types and enforcing “exactly one regular file payload entry” instead.
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 3, 2026 13:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes security-sensitive filesystem/path resolution and extraction behavior around mounts and read-only enforcement, so a final human review is warranted despite strong test coverage.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 3, 2026 13:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go Outdated
Comment thread internal/transfer/containerfs.go
Copilot AI review requested due to automatic review settings September 3, 2026 14:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go
Copilot AI review requested due to automatic review settings September 3, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go
Comment thread internal/transfer/containerfs.go
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
@ilopezluna
ilopezluna force-pushed the transfer-readonly-mounts branch from 5895d3e to bc879fd Compare September 3, 2026 20:41
@ilopezluna ilopezluna changed the title fix(transfer): refuse imports to read-only destinations fix(transfer): honor bind-mount semantics in filesystem transfers Sep 4, 2026

@Kern-- Kern-- left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

I'm not sure the development history is that useful, but squashing is definitely not blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants