chmod/chown: symlink cycles detection (#11805)

This commit is contained in:
Nikola Lukovic
2026-06-01 15:13:58 +02:00
committed by GitHub
parent b286558423
commit 3fbcb79a30
5 changed files with 265 additions and 19 deletions
+61 -1
View File
@@ -2,7 +2,7 @@
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore (words) dirfd subdirs openat FDCWD
// spell-checker:ignore (words) dirfd subdirs openat FDCWD rwxr
use std::fs::{OpenOptions, Permissions, metadata, set_permissions};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
@@ -1440,3 +1440,63 @@ fn test_chmod_colored_output() {
.stderr_contains("\x1b[31merreur\x1b[0m") // Red "erreur" in French
.stderr_contains("\x1b[33m--invalid-option\x1b[0m"); // Yellow invalid option
}
#[test]
fn test_chmod_symlink_cycles() {
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.mkdir_all("a/b/c");
at.symlink_dir("a", "a/b/c/d");
// Pin the directory modes so the assertions below don't depend on the ambient
// umask (a fresh mkdir under umask 077/027 would not be 0755).
at.set_mode("a", 0o755);
at.set_mode("a/b", 0o755);
at.set_mode("a/b/c", 0o755);
scene
.ucmd()
.arg("-vRL")
.arg("+r")
.arg("a")
.run()
// cSpell:disable
.stdout_contains_line("mode of 'a' retained as 0755 (rwxr-xr-x)")
.stdout_contains_line("mode of 'a/b' retained as 0755 (rwxr-xr-x)")
.stdout_contains_line("mode of 'a/b/c' retained as 0755 (rwxr-xr-x)")
.stdout_contains_line("mode of 'a/b/c/d' retained as 0755 (rwxr-xr-x)")
.stdout_does_not_contain("mode of 'a/b/c/d/b' retained as 0755 (rwxr-xr-x)")
.stdout_does_not_contain("mode of 'a/b/c/d/b/c' retained as 0755 (rwxr-xr-x)")
.stdout_does_not_contain("mode of 'a/b/c/d/b/c/d' retained as 0755 (rwxr-xr-x)");
// cSpell:enable
}
#[test]
fn test_chmod_symlink_two_links_same_dir() {
// Two symlinks pointing at the same directory is NOT a cycle: neither link is
// an ancestor of the other, so the target's contents must be visited through
// *both* links (and through the real directory). This guards the backtracking
// in the cycle-detection logic against false positives.
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
// cSpell:disable
at.mkdir_all("base/realdir");
at.touch("base/realdir/file");
at.symlink_dir("base/realdir", "base/link1");
at.symlink_dir("base/realdir", "base/link2");
// Assert on the path only (not the mode bits), so the test is robust to the
// file's umask-dependent permissions across platforms.
scene
.ucmd()
.arg("-vRL")
.arg("+r")
.arg("base")
.run()
.stdout_contains("mode of 'base/realdir/file'")
.stdout_contains("mode of 'base/link1/file'")
.stdout_contains("mode of 'base/link2/file'");
// cSpell:enable
}
+86
View File
@@ -943,3 +943,89 @@ fn test_chown_no_dereference_symlink_to_dir() {
"dir's ctime should not have changed"
);
}
#[test]
fn test_chown_symlink_cycles() {
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
let result = scene.cmd("whoami").run();
if skipping_test_is_okay(&result, "whoami: cannot find name for user ID") {
return;
}
let user_name = String::from(result.stdout_str().trim());
assert!(!user_name.is_empty());
at.mkdir_all("a/b/c");
at.symlink_dir("a", "a/b/c/d");
let result = scene.ucmd().arg("-vRL").arg(&user_name).arg("a").run();
if cfg!(target_os = "macos") || cfg!(target_os = "openbsd") || cfg!(target_os = "android") {
result
.stderr_contains(format!("ownership of 'a' retained as {user_name}"))
.stderr_contains(format!("ownership of 'a/b' retained as {user_name}"))
.stderr_contains(format!("ownership of 'a/b/c' retained as {user_name}"))
.stderr_does_not_contain(format!("ownership of 'a/b/c/d' retained as {user_name}"))
.stderr_does_not_contain(format!("ownership of 'a/b/c/d/b' retained as {user_name}"))
.stderr_does_not_contain(format!(
"ownership of 'a/b/c/d/b/c' retained as {user_name}"
));
} else {
result
.success()
.stderr_contains(format!("ownership of 'a' retained as {user_name}"))
.stderr_contains(format!("ownership of 'a/b' retained as {user_name}"))
.stderr_contains(format!("ownership of 'a/b/c' retained as {user_name}"))
.stderr_contains(format!("ownership of 'a/b/c/d' retained as {user_name}"))
.stderr_does_not_contain(format!("ownership of 'a/b/c/d/b' retained as {user_name}"))
.stderr_does_not_contain(format!(
"ownership of 'a/b/c/d/b/c' retained as {user_name}"
))
.stderr_does_not_contain(format!(
"ownership of 'a/b/c/d/b/c/d' retained as {user_name}"
));
}
}
#[test]
fn test_chown_symlink_two_links_same_dir() {
// Two symlinks pointing at the same directory is NOT a cycle: neither link is
// an ancestor of the other, so the target's contents must be visited through
// *both* links. This guards the backtracking in the linux safe-traversal
// cycle detection against false positives.
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
let result = scene.cmd("whoami").run();
if skipping_test_is_okay(&result, "whoami: cannot find name for user ID") {
return;
}
let user_name = String::from(result.stdout_str().trim());
assert!(!user_name.is_empty());
// cSpell:disable
at.mkdir_all("base/realdir");
at.touch("base/realdir/file");
at.symlink_dir("base/realdir", "base/link1");
at.symlink_dir("base/realdir", "base/link2");
let result = scene.ucmd().arg("-vRL").arg(&user_name).arg("base").run();
// Only linux uses the safe-traversal cycle detection that this test exercises;
// other platforms fall back to walkdir with its own loop handling.
if cfg!(target_os = "linux") {
result
.success()
.stderr_contains(format!(
"ownership of 'base/realdir/file' retained as {user_name}"
))
.stderr_contains(format!(
"ownership of 'base/link1/file' retained as {user_name}"
))
.stderr_contains(format!(
"ownership of 'base/link2/file' retained as {user_name}"
));
}
// cSpell:enable
}