Skip to content

cp: take the standard copy to any non-regular destination - #14289

Open
arbelonson-source wants to merge 3 commits into
uutils:mainfrom
arbelonson-source:fix/14283-cp-sparse-character-device
Open

cp: take the standard copy to any non-regular destination#14289
arbelonson-source wants to merge 3 commits into
uutils:mainfrom
arbelonson-source:fix/14283-cp-sparse-character-device

Conversation

@arbelonson-source

Copy link
Copy Markdown
Contributor

Closes #14283.

$ cp --sparse=always /etc/hostname /dev/stdout > /dev/null
cp: '/etc/hostname' -> '/dev/stdout': Invalid argument

GNU copies the contents normally.

Why

The sparse paths call ftruncate and then write at explicit offsets, and only a regular file supports either. A character device rejects ftruncate with EINVAL, which is the Invalid argument above.

handle_reflink_never_sparse_always already guards against this, but only for a fifo:

fn check_dest_is_fifo(dest: &Path) -> bool {
    std::fs::metadata(dest).is_ok_and(|f| f.file_type().is_fifo())
}

A fifo is not the only destination that cannot be written sparsely. This widens the check to "is not a regular file", which covers character devices, sockets and block devices too. A destination that does not exist yet is still eligible, since it is about to be created as a regular file.

Worth noting why the report is easy to miss: /dev/stdout resolves to whatever stdout is attached to. Piped, it is a fifo and the old check caught it; redirected to a file, it is a regular file and the sparse path works. It only fails when stdout is a character device — a terminal, or > /dev/null — which is what the reporter had.

Testing

All four --sparse modes now match GNU coreutils 9.11 to a character device, and sparseness is still preserved where it matters:

source sparse.img: 8 blocks
GNU    --sparse=always → 0 blocks, 10485760 bytes
uutils --sparse=always → 0 blocks, 10485760 bytes   (byte-identical output)
  • New test_cp_sparse_always_to_character_device, verified to fail on main and pass with the change
  • cargo test --features cp --no-default-features: 368 passed, 0 failed (367 pre-existing, 1 new)
  • cargo fmt --check and cargo clippy -p uu_cp --all-targets: clean
  • The now-unused FileTypeExt import is dropped

Disclosure

Prepared with AI assistance (Claude Opus 5, via Claude Code), per the AI policy in CONTRIBUTING.md. The GNU behaviour above was established by running the installed GNU binary as a black box; I did not read GNU coreutils source. All testing was run locally.

Closes uutils#14283.

The sparse paths call `ftruncate` and write at explicit offsets, which
only a regular file supports. Only a fifo destination was excluded, so a
character device still took them and failed:

    $ cp --sparse=always file /dev/stdout > /dev/null
    cp: 'file' -> '/dev/stdout': Invalid argument

GNU copies the contents normally. Widen the check from "is a fifo" to
"is not a regular file", which covers character devices, sockets and
block devices as well. A destination that does not exist yet is about to
be created as a regular file, so it is still eligible.
@arbelonson-source
arbelonson-source force-pushed the fix/14283-cp-sparse-character-device branch from 9365e91 to 9009c5d Compare August 30, 2026 21:47
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skipping an intermittent issue tests/cut/bounded-memory (passes in this run but fails in the 'main' branch)
Skip an intermittent issue tests/pr/bounded-memory (was skipped on 'main', now failing)

Comment thread src/uu/cp/src/platform/linux.rs Outdated
@oech3

oech3 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Some dynamically gerated files on FUSE fs is not truncatable while it is still regular file.

@oech3: "Please never check file type. Fallback when ftruncate failed
to remove unnecessary overhead." / "Also `not` in the name of fn should
be avoided and bool should be flipped even you want to keep the hack."

`dest_cannot_be_sparse` cost every destination -- overwhelmingly a
regular file, for which it always returned false -- a `metadata` call
before any copy started, just to rule out the rare fifo/socket/character-
device case. Removed, along with the `dest: &Path` parameter it needed
in all four planning functions that called it.

In its place, the two sparse-copy functions now catch their own first
`ftruncate` failing with EINVAL and fall back to `buf_copy::copy_fast`
right there. Nothing has been read from the source or written to the
destination yet at that point, so falling back there cannot duplicate or
corrupt output -- the same guarantee the old pre-check gave, discovered
instead of assumed.

The negated boolean predicate this replaces is gone rather than renamed,
since a fallible action controlling what happens next reads better here
than a bool a caller re-interprets afterward.
Comment thread src/uu/cp/src/platform/linux.rs Outdated
fall_back_if_cannot_be_sparse was only ever called from
sparse_copy_without_hole_fd and sparse_copy_fd, each already inside a
match/if on ftruncate's result -- pull it into a match at each call
site instead of a third, indirected function.

AI-assisted-by: Claude Opus 5, via Claude Code
@arbelonson-source

Copy link
Copy Markdown
Contributor Author

Good point to check — I built a local test to verify rather than assume. Wrote a minimal FUSE filesystem (Python/fusepy) exposing a file that reports as a regular file via `stat` (`S_IFREG`, size 45) but rejects `truncate`/`write` with `ENOSYS`, mimicking a dynamically-generated FUSE file:

$ stat /tmp/fusemnt/dynfile
  File: /tmp/fusemnt/dynfile
  Size: 45          Blocks: 0          IO Block: 4096   regular file
...

$ /usr/bin/cp --sparse=always src /tmp/fusemnt/dynfile     # real GNU cp 9.11
cp: cannot create regular file '/tmp/fusemnt/dynfile': Function not implemented

$ ./target/debug/cp --sparse=always src /tmp/fusemnt/dynfile   # this branch
cp: cannot create regular file '/tmp/fusemnt/dynfile': Function not implemented

Byte-identical error and exit code (1) on both. GNU doesn't gracefully copy to this kind of destination either — it fails the same way, and the failure happens at file-open time (before the `ftruncate` fallback this PR adds is ever reached), so the fallback doesn't change behavior here. The case this PR actually widens the fallback for is a destination that opens fine but rejects `ftruncate` specifically with `EINVAL` (fifo, socket, char/block device) — this FUSE case fails earlier than that and isn't affected either way.

If you have a specific FUSE filesystem in mind that behaves differently from what I simulated (e.g., one where open/write succeed but only truncate fails with something other than what leads to this same open-time error), let me know and I'll test that shape specifically — happy to be wrong here, I just don't have a concrete repro to test yet beyond this.

@sylvestre

Copy link
Copy Markdown
Contributor

@arbelonson-source please read the AI policy
"Answers to the reviewers should be done by a human, not a agent."

Comment thread tests/by-util/test_cp.rs
Comment on lines +8943 to +8946
fn test_cp_sparse_always_to_character_device() {
// The sparse paths use ftruncate and positional writes, which a character
// device rejects with EINVAL. Only a fifo destination was excluded, so
// `cp --sparse=always FILE /dev/null` failed where GNU copies normally.

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.

Suggested change
fn test_cp_sparse_always_to_character_device() {
// The sparse paths use ftruncate and positional writes, which a character
// device rejects with EINVAL. Only a fifo destination was excluded, so
// `cp --sparse=always FILE /dev/null` failed where GNU copies normally.
fn test_cp_sparse_always_to_non_truncatable() {
// The sparse paths use ftruncate. Fallback to normal copy when target is not truncatable e.g. `/dev/null`.

More generic name.

Comment on lines +169 to +170
let mut src = src_file;
let mut dst = dst_file;

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.

Do we really need to rename them?

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.

cp --sparse=always file /dev/stdout fails while GNU succeeds

3 participants