Skip to content

Fix cp tar stream support for stdin and stdout - #1947

Open
yash-mandaviya wants to merge 5 commits into
apple:mainfrom
yash-mandaviya:yash/fix/1908-cp-tar-stream-support
Open

Fix cp tar stream support for stdin and stdout#1947
yash-mandaviya wants to merge 5 commits into
apple:mainfrom
yash-mandaviya:yash/fix/1908-cp-tar-stream-support

Conversation

@yash-mandaviya

@yash-mandaviya yash-mandaviya commented Jul 14, 2026

Copy link
Copy Markdown

Summary

Add docker cp - / podman cp - style tar streaming to container cp, using - as a stdin/stdout endpoint. Closes #1908.

Why

The point of tar streaming is exact control over ownership and mode: the tar headers, not host filesystem metadata, decide what lands in the container. That makes cp - usable as a dynamic image layer — injecting certs and config with the right uid/gid/mode — which is the use case in #1908.

Approach

The descriptor is passed down, not the contents. cp - sends the CLI's stdin or stdout through both XPC hops on a new archiveFd key, reusing the file-handle passing that dial and logs already rely on, into LinuxContainer's stream-based copyIn(archive:) / copyOut(to:). The bytes reach the guest over vsock and the guest extracts them as root.

Consequences:

  • Ownership, mode and symlink targets are applied verbatim from the tar headers.
  • Nothing is unpacked on the host, so host permission rules and path-length limits don't constrain the archive.
  • No dependency on tar being present locally.
  • Path traversal is rejected during extraction, where the archive is parsed, rather than by pre-scanning entry names on the host.

An earlier revision of this PR unpacked to a host temp dir and shelled out to /usr/bin/tar; that gave up all four properties and was reworked after review.

Behaviour

  • container cp CONTAINER:/path - writes an uncompressed tar to stdout, entries named relative to the source's parent so the source's basename is the top-level entry, for files as well as directories.
  • container cp - CONTAINER:/path reads a tar from stdin and extracts it into the destination. Uncompressed, gzip, bzip2 and xz inputs are all accepted.

Known deviations from docker

Measured, not inferred. All are in the guest extractor:

docker here
mtime preserved not restored
hardlinks / devices / FIFOs extracted silently skipped, copy exits 0
xattrs preserved not applied
symlink ownership lchown not set
cp - CONTAINER:/nonexistent errors creates the path

mtime, xattrs and hardlinks are outside the initial scope in #1908. Flagging rather than hiding them.

Testing

TestCLICopyCommand: 42/42, run against a locally built vminitd and initfs. Six new tests assert metadata rather than content:

  • a stdin stream carrying uid/gid and modes the host cannot reproduce unprivileged, verified with stat inside the container
  • a .. entry that must not escape the destination
  • single-file output is an uncompressed tar with the guest's real uid/gid/mode
  • directory output is parent-relative
  • a full round trip out and back in, with modes intact
  • cp - /local/path is rejected

Dependency

Requires the stream copy API from apple/containerization#812. CI will fail here until that lands and scVersion moves off 0.40.1.

@danegsta

Copy link
Copy Markdown

It looks like this implementation is acting more as a wrapper around the existing container cp functionality by unpacking a tar file to disk before copying into the container (and writing the contents of files copied from the container to disk before packing them into a tar file) and does assume tar is present locally. That could have implications on file permissions/ownership depending on how the files are unpacked, copied, etc.

One of the main reasons I'd want streaming tar support in cp is to be able to have full control over the ownership and mode settings for a given file and folder. I use it as a way to dynamically add image layers to a container before I start it. There's no real way to reconcile the need to honor the ownership and and file mode settings exactly with archiving and unarchiving the tar files on the host machine as local permission requirements would get in the way.

There are also implications on path length limitations in the tar files. Because the contents are fully serialized/deserialized on the host machine in a temp folder, contents with a valid path length in the archive would fail to copy due to exceeding the maximum path length when extracted under a temp folder. This is definitely more of an edge case, but still one that could trip up users.

Ideally the tar streaming implementation would provide as close to 1:1 parity with Docker and Podman behavior for the same feature as possible.

@yash-mandaviya

Copy link
Copy Markdown
Author

It looks like this implementation is acting more as a wrapper around the existing container cp functionality by unpacking a tar file to disk before copying into the container (and writing the contents of files copied from the container to disk before packing them into a tar file) and does assume tar is present locally. That could have implications on file permissions/ownership depending on how the files are unpacked, copied, etc.

One of the main reasons I'd want streaming tar support in cp is to be able to have full control over the ownership and mode settings for a given file and folder. I use it as a way to dynamically add image layers to a container before I start it. There's no real way to reconcile the need to honor the ownership and and file mode settings exactly with archiving and unarchiving the tar files on the host machine as local permission requirements would get in the way.

There are also implications on path length limitations in the tar files. Because the contents are fully serialized/deserialized on the host machine in a temp folder, contents with a valid path length in the archive would fail to copy due to exceeding the maximum path length when extracted under a temp folder. This is definitely more of an edge case, but still one that could trip up users.

Ideally the tar streaming implementation would provide as close to 1:1 parity with Docker and Podman behavior for the same feature as possible.

Thanks for the detailed feedback, this makes sense and I agree.
You are right that my current approach is a host-side unpack/repack wrapper and does not provide true Docker/Podman-style tar streaming semantics, especially around ownership/mode fidelity and host path constraints.
I will rework this to avoid materializing archive contents on the host and to implement a true stream-based path for stdin/stdout tar transfer. I will update this PR as draft while I rework, and I will target behavior parity as closely as possible.

Rework target checklist

  • Remove host temp extraction/repacking flow.
  • Add fd/stream-based tar transfer path end-to-end.
  • Preserve tar header metadata as much as possible (mode, uid/gid, symlink type, etc.).
  • Keep path safety checks during extraction.
  • Add parity tests that verify metadata behavior, not just content transfer.

@yash-mandaviya
yash-mandaviya marked this pull request as draft July 24, 2026 03:17
The first pass wrapped the existing path based cp: it shelled out to
/usr/bin/tar, unpacked the stdin archive into a host temp directory, then
copied the extracted files in, and did the reverse on the way out. That gave up
the two things tar streaming is for. Ownership and mode came from whatever the
host filesystem allowed rather than from the tar headers, and every entry had
to fit the host's path length limits under a temp directory even when its path
was valid inside the archive.

Hand the descriptor down instead. `cp -` now passes the CLI's stdin or stdout
through both XPC hops on a new archiveFd key, using the same file handle
passing that dial and logs already rely on, to LinuxContainer's stream based
copyIn(archive:)/copyOut(to:). The bytes go straight to the guest over vsock
and the guest extracts them as root, so the ownership, mode and symlink targets
in the headers are applied verbatim and nothing is unpacked on the host. Path
traversal is rejected during extraction, where the archive is actually read,
rather than by pre-scanning entry names on the host.

Each hop closes the descriptor it owns and duplicates for the outgoing message.
Setting a file handle on an XPC message closes the descriptor it is given, and
the container lookups on the receiving side can throw before the handle is ever
forwarded, which otherwise leaked an fd in the apiserver on every copy to a
stopped container.

Behavior now follows docker and podman: output is uncompressed tar named
relative to the source's parent so the source's basename is the top level
entry, for files as well as directories, and input may be uncompressed or
gzip, bzip2 or xz compressed.

The integration tests assert metadata rather than content: a stream carrying
uid, gid and modes the host could not reproduce unprivileged, a traversal entry
that must not escape, entry naming on the way out, and a round trip.

Requires the stream based copy API from apple/containerization#812; the
containerization pin still needs bumping once that lands.
@yash-mandaviya
yash-mandaviya force-pushed the yash/fix/1908-cp-tar-stream-support branch from 164fef8 to 75fa939 Compare August 1, 2026 23:19
Resolves the import collision in TestCLICopyCommand and adopts the warmup
image API the tests were moved to on main:

- keep both ContainerTestSupport and ContainerizationArchive imports; the
  former is where the fixture moved, the latter is used to build tar streams
  with ownership and modes the host cannot reproduce unprivileged
- WarmupImage.alpine320.rawValue replaces
  f.copyWarmupImage(ContainerFixture.warmupImages[0])

main also moved the containerization pin from 0.37.0 to 0.40.1.
@yash-mandaviya
yash-mandaviya force-pushed the yash/fix/1908-cp-tar-stream-support branch from 283a657 to 99cf494 Compare August 1, 2026 23:45
@yash-mandaviya

yash-mandaviya commented Aug 1, 2026

Copy link
Copy Markdown
Author

@danegsta You were right on all three counts, and reworking it surfaced two more parity gaps I'd otherwise have shipped.

No more host staging. The /usr/bin/tar shell-out and the temp-dir unpack/repack are gone. cp - now hands the CLI's stdin/stdout descriptor down through both XPC hops — the same fd-passing dial and logs already use — to new stream entrypoints on LinuxContainer. The bytes go straight to the guest over vsock and the guest extracts them as root, so ownership, mode and symlink targets come from the tar headers verbatim. Nothing is written to the host, so host permission rules and path-length limits no longer apply, and there's no local tar dependency.

Path-traversal rejection also moved into the guest extractor, where the archive is actually parsed, rather than pre-scanning entry names on the host.

Two further parity gaps found while doing this, both fixed in the containerization PR:

  • Directory output used contents-relative naming with a leading ./. Docker names entries relative to the source's parent, so the source's basename is the top-level entry. Without that, cp ctr:/dir - | cp - other:/dest scatters dir's children into /dest instead of producing /dest/dir.
  • Output was gzip. Docker emits uncompressed tar, and the gzip framing breaks consumers that read the stream with a plain tar reader rather than shelling out to tar.

Still not 1:1. Measured against this branch, not inferred:

docker here
mtime preserved not restored
hardlinks / devices / FIFOs extracted silently skipped, copy still exits 0
xattrs preserved not applied
symlink ownership lchown not set (target and mode are correct)
cp - CONTAINER:/nonexistent errors creates the path

These all live in the guest extractor. mtime, xattrs and hardlinks are outside the initial scope listed in #1908, so I've left them for now — happy to pick any of them up here or as follow-ups, whichever you prefer. The one I'd least want to leave is the silent skip: dropping a hardlink while exiting 0 is worse than failing, and erroring there is a small change.

Verification: 42/42 in TestCLICopyCommand. The new tests assert metadata rather than content — a stream carrying uid/gid/modes the host can't reproduce unprivileged, a .. entry that must not escape, entry naming on the way out, and a round trip.

Ordering: this depends on apple/containerization#812 for the stream API. CI here stays red until that lands and the pin moves off 0.40.1.

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.

[Request]: cp command tar stream support

2 participants