Skip to content

Commit 16ecfdd

Browse files
committed
chown: detect symlink cycles and exit early just like GNU does it
1 parent 78021b0 commit 16ecfdd

File tree

3 files changed

+97
-17
lines changed

3 files changed

+97
-17
lines changed

src/uucore/src/lib/features/perms.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ use clap::{Arg, ArgMatches, Command};
1616

1717
use libc::{gid_t, uid_t};
1818
use options::traverse;
19+
#[cfg(target_os = "linux")]
20+
use std::collections::HashSet;
1921
use std::ffi::OsString;
2022

2123
#[cfg(not(target_os = "linux"))]
2224
use walkdir::WalkDir;
2325

26+
#[cfg(target_os = "linux")]
27+
use crate::features::fs::FileInformation;
2428
#[cfg(target_os = "linux")]
2529
use crate::features::safe_traversal::{DirFd, SymlinkBehavior};
2630

@@ -449,13 +453,28 @@ impl ChownExecutor {
449453
return 1;
450454
};
451455

456+
let mut ancestors = HashSet::new();
452457
let mut ret = 0;
453-
self.safe_traverse_dir(&dir_fd, root, &mut ret);
458+
self.safe_traverse_dir(&dir_fd, root, &mut ret, &mut ancestors);
454459
ret
455460
}
456461

457462
#[cfg(target_os = "linux")]
458-
fn safe_traverse_dir(&self, dir_fd: &DirFd, dir_path: &Path, ret: &mut i32) {
463+
fn safe_traverse_dir(
464+
&self,
465+
dir_fd: &DirFd,
466+
dir_path: &Path,
467+
ret: &mut i32,
468+
ancestors: &mut HashSet<FileInformation>,
469+
) {
470+
// Cycle detection: resolve through symlinks so a symlink-to-ancestor is caught.
471+
if let Ok(info) = FileInformation::from_path(dir_path, true) {
472+
if ancestors.contains(&info) {
473+
return; // cycle detected, stop silently
474+
}
475+
ancestors.insert(info);
476+
}
477+
459478
// Read directory entries
460479
let entries = match dir_fd.read_dir() {
461480
Ok(entries) => entries,
@@ -539,7 +558,7 @@ impl ChownExecutor {
539558
if meta.is_dir() && (follow || !meta.file_type().is_symlink()) {
540559
match dir_fd.open_subdir(&entry_name, SymlinkBehavior::Follow) {
541560
Ok(subdir_fd) => {
542-
self.safe_traverse_dir(&subdir_fd, &entry_path, ret);
561+
self.safe_traverse_dir(&subdir_fd, &entry_path, ret, ancestors);
543562
}
544563
Err(e) => {
545564
*ret = 1;
@@ -554,6 +573,11 @@ impl ChownExecutor {
554573
}
555574
}
556575
}
576+
577+
// Backtrack: remove this directory so sibling subtrees are not falsely flagged.
578+
if let Ok(info) = FileInformation::from_path(dir_path, true) {
579+
ancestors.remove(&info);
580+
}
557581
}
558582

559583
#[cfg(not(target_os = "linux"))]

tests/by-util/test_chmod.rs

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
5-
// spell-checker:ignore (words) dirfd subdirs openat FDCWD
5+
// spell-checker:ignore (words) dirfd subdirs openat FDCWD rwxr
66

77
use std::fs::{OpenOptions, Permissions, metadata, set_permissions};
88
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
@@ -1448,17 +1448,29 @@ fn test_chmod_symlink_cycles() {
14481448
at.mkdir_all("a/b/c");
14491449
at.symlink_dir("a", "a/b/c/d");
14501450

1451-
scene
1452-
.ucmd()
1453-
.arg("-vRL")
1454-
.arg("+r")
1455-
.arg("a")
1456-
.succeeds()
1457-
.stdout_contains_line("mode of 'a' retained as 0755 (rwxr-xr-x)")
1458-
.stdout_contains_line("mode of 'a/b' retained as 0755 (rwxr-xr-x)")
1459-
.stdout_contains_line("mode of 'a/b/c' retained as 0755 (rwxr-xr-x)")
1460-
.stdout_contains_line("mode of 'a/b/c/d' retained as 0755 (rwxr-xr-x)")
1461-
.stdout_does_not_contain("mode of 'a/b/c/d/b' retained as 0755 (rwxr-xr-x)")
1462-
.stdout_does_not_contain("mode of 'a/b/c/d/b/c' retained as 0755 (rwxr-xr-x)")
1463-
.stdout_does_not_contain("mode of 'a/b/c/d/b/c/d' retained as 0755 (rwxr-xr-x)");
1451+
let result = scene.ucmd().arg("-vRL").arg("+r").arg("a").run();
1452+
1453+
if cfg!(target_os = "android") {
1454+
result
1455+
// cSpell:disable
1456+
.stdout_contains_line("mode of 'a' retained as 0700 (rwx------)")
1457+
.stdout_contains_line("mode of 'a/b' retained as 0700 (rwx------)")
1458+
.stdout_contains_line("mode of 'a/b/c' retained as 0700 (rwx------)")
1459+
.stdout_contains_line("mode of 'a/b/c/d' retained as 0700 (rwx------)")
1460+
.stdout_does_not_contain("mode of 'a/b/c/d/b' retained as 0700 (rwx------)")
1461+
.stdout_does_not_contain("mode of 'a/b/c/d/b/c' retained as 0700 (rwx------)")
1462+
.stdout_does_not_contain("mode of 'a/b/c/d/b/c/d' retained as 0700 (rwx------)");
1463+
// cSpell:enable
1464+
} else {
1465+
result
1466+
// cSpell:disable
1467+
.stdout_contains_line("mode of 'a' retained as 0755 (rwxr-xr-x)")
1468+
.stdout_contains_line("mode of 'a/b' retained as 0755 (rwxr-xr-x)")
1469+
.stdout_contains_line("mode of 'a/b/c' retained as 0755 (rwxr-xr-x)")
1470+
.stdout_contains_line("mode of 'a/b/c/d' retained as 0755 (rwxr-xr-x)")
1471+
.stdout_does_not_contain("mode of 'a/b/c/d/b' retained as 0755 (rwxr-xr-x)")
1472+
.stdout_does_not_contain("mode of 'a/b/c/d/b/c' retained as 0755 (rwxr-xr-x)")
1473+
.stdout_does_not_contain("mode of 'a/b/c/d/b/c/d' retained as 0755 (rwxr-xr-x)");
1474+
// cSpell:enable
1475+
}
14641476
}

tests/by-util/test_chown.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,3 +899,47 @@ fn test_chown_reference_file() {
899899
.stderr_contains("ownership of 'b' retained as")
900900
.no_stdout();
901901
}
902+
903+
#[test]
904+
fn test_chown_symlink_cycles() {
905+
let scene = TestScenario::new(util_name!());
906+
let at = &scene.fixtures;
907+
908+
let result = scene.cmd("whoami").run();
909+
if skipping_test_is_okay(&result, "whoami: cannot find name for user ID") {
910+
return;
911+
}
912+
let user_name = String::from(result.stdout_str().trim());
913+
assert!(!user_name.is_empty());
914+
915+
at.mkdir_all("a/b/c");
916+
at.symlink_dir("a", "a/b/c/d");
917+
918+
let result = scene.ucmd().arg("-vRL").arg(&user_name).arg("a").run();
919+
920+
if cfg!(target_os = "macos") || cfg!(target_os = "openbsd") || cfg!(target_os = "android") {
921+
result
922+
.stderr_contains(format!("ownership of 'a' retained as {user_name}"))
923+
.stderr_contains(format!("ownership of 'a/b' retained as {user_name}"))
924+
.stderr_contains(format!("ownership of 'a/b/c' retained as {user_name}"))
925+
.stderr_does_not_contain(format!("ownership of 'a/b/c/d' retained as {user_name}"))
926+
.stderr_does_not_contain(format!("ownership of 'a/b/c/d/b' retained as {user_name}"))
927+
.stderr_does_not_contain(format!(
928+
"ownership of 'a/b/c/d/b/c' retained as {user_name}"
929+
));
930+
} else {
931+
result
932+
.success()
933+
.stderr_contains(format!("ownership of 'a' retained as {user_name}"))
934+
.stderr_contains(format!("ownership of 'a/b' retained as {user_name}"))
935+
.stderr_contains(format!("ownership of 'a/b/c' retained as {user_name}"))
936+
.stderr_contains(format!("ownership of 'a/b/c/d' retained as {user_name}"))
937+
.stderr_does_not_contain(format!("ownership of 'a/b/c/d/b' retained as {user_name}"))
938+
.stderr_does_not_contain(format!(
939+
"ownership of 'a/b/c/d/b/c' retained as {user_name}"
940+
))
941+
.stderr_does_not_contain(format!(
942+
"ownership of 'a/b/c/d/b/c/d' retained as {user_name}"
943+
));
944+
}
945+
}

0 commit comments

Comments
 (0)