Skip to content

cp: FIxed permission race when creating files#10204

Open
max-amb wants to merge 21 commits intouutils:mainfrom
max-amb:cp_perm_race
Open

cp: FIxed permission race when creating files#10204
max-amb wants to merge 21 commits intouutils:mainfrom
max-amb:cp_perm_race

Conversation

@max-amb
Copy link
Contributor

@max-amb max-amb commented Jan 12, 2026

Fixes #10011.

@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cp/thru-dangling. tests/cp/thru-dangling is passing on 'main'. Maybe you have to rebase?
Skip an intermittent issue tests/shuf/shuf-reservoir (fails in this run but passes in the 'main' branch)
Skip an intermittent issue tests/sort/sort-stale-thread-mem (fails in this run but passes in the 'main' branch)

@max-amb max-amb marked this pull request as draft January 12, 2026 16:55
@max-amb max-amb marked this pull request as ready for review January 14, 2026 18:43
@max-amb
Copy link
Contributor Author

max-amb commented Jan 18, 2026

I realise this is rather untenable right now, so I have dug up some logs to show the change in action, here is gnu cp:

openat(AT_FDCWD, "/tmp/b.txt", O_RDONLY|O_PATH|O_DIRECTORY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY) = 3
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_EXCL, 0644) = 8
+++ exited with 0 +++

and when b.txt already exists:

openat(AT_FDCWD, "/tmp/b.txt", O_RDONLY|O_PATH|O_DIRECTORY) = -1 ENOTDIR (Not a directory)
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY) = 3
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_TRUNC) = 8
+++ exited with 0 +++

Here is the implementations strace

penat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0600) = 8
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 9
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0100644) = 12
fchmod(12, 0100644)                     = 0
chmod("/tmp/b.txt", 0644)               = 0
+++ exited with 0 +++

Note that the first 3 reads of a.txt are for other checks and opening the file and haven't been changed in this PR. The only difference between the current implementation and this implementation is in the initial opening of b.txt which is now

openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0600) = 8

instead of

openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 8

on main.

Now when we already have b.txt, in main right now we have

openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 8
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 9
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0100644) = 12
fchmod(12, 0100644)                     = 0
chmod("/tmp/b.txt", 0100644)            = 0
+++ exited with 0 +++

which opens b.txt with 666 and tries to ioctl clone it over, this is insecure as it would allow other users to read the file before the data is fully copied over which may not be intended. The later open is done by fs::copy as a fallback. In contrast here is the new implementation.

openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0600) = -1 EEXIST (File exists)
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_TRUNC|O_CLOEXEC) = 8
fchmod(8, 0600)                         = 0
openat(AT_FDCWD, "/tmp/a.txt", O_RDONLY|O_CLOEXEC) = 9
openat(AT_FDCWD, "/tmp/b.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0100644) = 12
fchmod(12, 0100644)                     = 0
chmod("/tmp/b.txt", 0100644)            = 0
+++ exited with 0 +++

By changing the files permissions we ensure that the file cannot be read while data may be being copied in. This permission is later loosened to the correct permission.

@sylvestre I hope that this makes it less hand-wavy :)

@sylvestre
Copy link
Contributor

sylvestre commented Jan 18, 2026

and it needs tests :)

@max-amb
Copy link
Contributor Author

max-amb commented Jan 19, 2026

and it needs tests :)

Specific tests are at test_cp_to_existing_file_permissions and test_copy_through_dangling_symlink_posixly_correct. The only intended change of the PR is during the process of opening files, the end result is the same.

@codspeed-hq
Copy link

codspeed-hq bot commented Jan 19, 2026

Merging this PR will not alter performance

✅ 288 untouched benchmarks
⏩ 38 skipped benchmarks1


Comparing max-amb:cp_perm_race (21eaa42) with main (8eb77a0)2

Open in CodSpeed

Footnotes

  1. 38 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (7acacd0) during the generation of this report, so 8eb77a0 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cp/sparse-to-pipe. tests/cp/sparse-to-pipe is passing on 'main'. Maybe you have to rebase?

@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cp/sparse-to-pipe. tests/cp/sparse-to-pipe is passing on 'main'. Maybe you have to rebase?

@github-actions
Copy link

GNU testsuite comparison:

Skipping an intermittent issue tests/shuf/shuf-reservoir (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/sort/sort-stale-thread-mem (passes in this run but fails in the 'main' branch)

@max-amb
Copy link
Contributor Author

max-amb commented Jan 22, 2026

ok so how I was handling truncation is the issue.

@max-amb max-amb requested a review from sylvestre January 22, 2026 14:50
@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cp/sparse. tests/cp/sparse is passing on 'main'. Maybe you have to rebase?
GNU test failed: tests/cp/sparse-2. tests/cp/sparse-2 is passing on 'main'. Maybe you have to rebase?
GNU test failed: tests/cp/sparse-extents-2. tests/cp/sparse-extents-2 is passing on 'main'. Maybe you have to rebase?
GNU test failed: tests/cp/sparse-to-pipe. tests/cp/sparse-to-pipe is passing on 'main'. Maybe you have to rebase?
Skip an intermittent issue tests/shuf/shuf-reservoir (fails in this run but passes in the 'main' branch)
Skip an intermittent issue tests/sort/sort-stale-thread-mem (fails in this run but passes in the 'main' branch)

@sylvestre
Copy link
Contributor

it regressed a bunch of tests, sorry!

@max-amb
Copy link
Contributor Author

max-amb commented Jan 28, 2026

Ah yeah I think I see why now, the rewind as a replacement for truncate would mean sparse files are just layered on with any 0 spaces being filled with the old file.

@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cp/sparse-to-pipe. tests/cp/sparse-to-pipe is passing on 'main'. Maybe you have to rebase?

@github-actions
Copy link

GNU testsuite comparison:

Skipping an intermittent issue tests/shuf/shuf-reservoir (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/sort/sort-stale-thread-mem (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/inotify-dir-recreate (passes in this run but fails in the 'main' branch)

@github-actions
Copy link

GNU testsuite comparison:

Skipping an intermittent issue tests/shuf/shuf-reservoir (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/sort/sort-stale-thread-mem (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/inotify-dir-recreate (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/basenc/bounded-memory is now passing!

@github-actions
Copy link

GNU testsuite comparison:

Skipping an intermittent issue tests/shuf/shuf-reservoir (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/sort/sort-stale-thread-mem (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/inotify-dir-recreate (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/basenc/bounded-memory is now passing!

let mut desired_permissions = dest_metadata.permissions();
// This will be reset to the correct permissions later, this is defensive as it is
// the most restrictive
let mut dst = OpenOptions::new().write(true).open(&dest)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

The file is opened with existing permissions before they're tightened to 0o600. The permissions should be set on the file path using std::fs::set_permissions() BEFORE opening the file handle. no ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For this, std::fs::set_permissions pre open sets the files permissions to writeable when the file is readonly and then it opens it without issue (because now it is writeable). This isn't the desired behaviour

@max-amb max-amb marked this pull request as draft February 5, 2026 09:04
@max-amb max-amb marked this pull request as ready for review February 5, 2026 11:06
@max-amb max-amb requested a review from sylvestre February 5, 2026 15:05
@github-actions
Copy link

GNU testsuite comparison:

Congrats! The gnu test tests/pr/bounded-memory is no longer failing!

@github-actions
Copy link

GNU testsuite comparison:

GNU test failed: tests/cut/bounded-memory. tests/cut/bounded-memory is passing on 'main'. Maybe you have to rebase?
GNU test failed: tests/seq/seq-io-errors. tests/seq/seq-io-errors is passing on 'main'. Maybe you have to rebase?
Congrats! The gnu test tests/tail/tail-n0f is no longer failing!

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 permission race

2 participants

Comments