Skip to content

Commit 06c64b7

Browse files
authored
Merge pull request #8450 from sylvestre/fix-chmod-symlink-handling
chmod: fix recursive symlink handling for -H/-L/-P flags (Closes: #8422)
2 parents f7ad1ee + 21c8e42 commit 06c64b7

File tree

2 files changed

+228
-22
lines changed

2 files changed

+228
-22
lines changed

src/uu/chmod/src/chmod.rs

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -311,57 +311,94 @@ impl Chmoder {
311311
return Err(ChmodError::PreserveRoot(filename.to_string()).into());
312312
}
313313
if self.recursive {
314-
r = self.walk_dir(file);
314+
r = self.walk_dir_with_context(file, true);
315315
} else {
316316
r = self.chmod_file(file).and(r);
317317
}
318318
}
319319
r
320320
}
321321

322-
fn walk_dir(&self, file_path: &Path) -> UResult<()> {
322+
fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> {
323323
let mut r = self.chmod_file(file_path);
324-
// Determine whether to traverse symlinks based on `self.traverse_symlinks`
324+
325+
// Determine whether to traverse symlinks based on context and traversal mode
325326
let should_follow_symlink = match self.traverse_symlinks {
326327
TraverseSymlinks::All => true,
327-
TraverseSymlinks::First => {
328-
file_path == file_path.canonicalize().unwrap_or(file_path.to_path_buf())
329-
}
328+
TraverseSymlinks::First => is_command_line_arg, // Only follow symlinks that are command line args
330329
TraverseSymlinks::None => false,
331330
};
332331

333332
// If the path is a directory (or we should follow symlinks), recurse into it
334333
if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() {
335334
for dir_entry in file_path.read_dir()? {
336-
let path = dir_entry?.path();
337-
if !path.is_symlink() {
338-
r = self.walk_dir(path.as_path());
339-
} else if should_follow_symlink {
340-
r = self.chmod_file(path.as_path()).and(r);
335+
let path = match dir_entry {
336+
Ok(entry) => entry.path(),
337+
Err(err) => {
338+
r = r.and(Err(err.into()));
339+
continue;
340+
}
341+
};
342+
if path.is_symlink() {
343+
r = self.handle_symlink_during_recursion(&path).and(r);
344+
} else {
345+
r = self.walk_dir_with_context(path.as_path(), false).and(r);
341346
}
342347
}
343348
}
344349
r
345350
}
346351

352+
fn handle_symlink_during_recursion(&self, path: &Path) -> UResult<()> {
353+
// During recursion, determine behavior based on traversal mode
354+
match self.traverse_symlinks {
355+
TraverseSymlinks::All => {
356+
// Follow all symlinks during recursion
357+
// Check if the symlink target is a directory, but handle dangling symlinks gracefully
358+
match fs::metadata(path) {
359+
Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false),
360+
Ok(_) => {
361+
// It's a file symlink, chmod it
362+
self.chmod_file(path)
363+
}
364+
Err(_) => {
365+
// Dangling symlink, chmod it without dereferencing
366+
self.chmod_file_internal(path, false)
367+
}
368+
}
369+
}
370+
TraverseSymlinks::First | TraverseSymlinks::None => {
371+
// Don't follow symlinks encountered during recursion
372+
// For these symlinks, don't dereference them even if dereference is normally true
373+
self.chmod_file_internal(path, false)
374+
}
375+
}
376+
}
377+
347378
#[cfg(windows)]
348379
fn chmod_file(&self, file: &Path) -> UResult<()> {
349380
// chmod is useless on Windows
350381
// it doesn't set any permissions at all
351382
// instead it just sets the readonly attribute on the file
352383
Ok(())
353384
}
385+
354386
#[cfg(unix)]
355387
fn chmod_file(&self, file: &Path) -> UResult<()> {
388+
self.chmod_file_internal(file, self.dereference)
389+
}
390+
391+
#[cfg(unix)]
392+
fn chmod_file_internal(&self, file: &Path, dereference: bool) -> UResult<()> {
356393
use uucore::{mode::get_umask, perms::get_metadata};
357394

358-
let metadata = get_metadata(file, self.dereference);
395+
let metadata = get_metadata(file, dereference);
359396

360397
let fperm = match metadata {
361398
Ok(meta) => meta.mode() & 0o7777,
362399
Err(err) => {
363400
// Handle dangling symlinks or other errors
364-
return if file.is_symlink() && !self.dereference {
401+
return if file.is_symlink() && !dereference {
365402
if self.verbose {
366403
println!(
367404
"neither symbolic link {} nor referent has been changed",
@@ -418,7 +455,20 @@ impl Chmoder {
418455
}
419456
}
420457

421-
self.change_file(fperm, new_mode, file)?;
458+
// Special handling for symlinks when not dereferencing
459+
if file.is_symlink() && !dereference {
460+
// TODO: On most Unix systems, symlink permissions are ignored by the kernel,
461+
// so changing them has no effect. We skip this operation for compatibility.
462+
// Note that "chmod without dereferencing" effectively does nothing on symlinks.
463+
if self.verbose {
464+
println!(
465+
"neither symbolic link {} nor referent has been changed",
466+
file.quote()
467+
);
468+
}
469+
} else {
470+
self.change_file(fperm, new_mode, file)?;
471+
}
422472
// if a permission would have been removed if umask was 0, but it wasn't because umask was not 0, print an error and fail
423473
if (new_mode & !naively_expected_new_mode) != 0 {
424474
return Err(ChmodError::NewPermissions(

tests/by-util/test_chmod.rs

Lines changed: 164 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -944,10 +944,8 @@ fn test_chmod_dangling_symlink_recursive_combos() {
944944
at.symlink_file(dangling_target, symlink);
945945

946946
let mut ucmd = scene.ucmd();
947-
for f in &flags {
948-
ucmd.arg(f);
949-
}
950-
ucmd.arg("u+x")
947+
ucmd.args(&flags)
948+
.arg("u+x")
951949
.umask(0o022)
952950
.arg(symlink)
953951
.fails()
@@ -1002,10 +1000,8 @@ fn test_chmod_traverse_symlink_combo() {
10021000
set_permissions(at.plus(target), Permissions::from_mode(0o664)).unwrap();
10031001

10041002
let mut ucmd = scene.ucmd();
1005-
for f in &flags {
1006-
ucmd.arg(f);
1007-
}
1008-
ucmd.arg("u+x")
1003+
ucmd.args(&flags)
1004+
.arg("u+x")
10091005
.umask(0o022)
10101006
.arg(directory)
10111007
.succeeds()
@@ -1027,3 +1023,163 @@ fn test_chmod_traverse_symlink_combo() {
10271023
);
10281024
}
10291025
}
1026+
1027+
#[test]
1028+
fn test_chmod_recursive_symlink_to_directory_command_line() {
1029+
// Test behavior when the symlink itself is a command-line argument
1030+
let scenarios = [
1031+
(vec!["-R"], true), // Default behavior (-H): follow symlinks that are command line args
1032+
(vec!["-R", "-H"], true), // Explicit -H: follow symlinks that are command line args
1033+
(vec!["-R", "-L"], true), // -L: follow all symlinks
1034+
(vec!["-R", "-P"], false), // -P: never follow symlinks
1035+
];
1036+
1037+
for (flags, should_follow_symlink_dir) in scenarios {
1038+
let scene = TestScenario::new(util_name!());
1039+
let at = &scene.fixtures;
1040+
1041+
let target_dir = "target_dir";
1042+
let symlink_to_dir = "link_dir";
1043+
let file_in_target = "file_in_target";
1044+
1045+
at.mkdir(target_dir);
1046+
at.touch(format!("{target_dir}/{file_in_target}"));
1047+
at.symlink_dir(target_dir, symlink_to_dir);
1048+
1049+
set_permissions(
1050+
at.plus(format!("{target_dir}/{file_in_target}")),
1051+
Permissions::from_mode(0o644),
1052+
)
1053+
.unwrap();
1054+
1055+
let mut ucmd = scene.ucmd();
1056+
ucmd.args(&flags)
1057+
.arg("go-rwx")
1058+
.arg(symlink_to_dir) // The symlink itself is the command-line argument
1059+
.succeeds()
1060+
.no_stderr();
1061+
1062+
let actual_file_perms = at
1063+
.metadata(&format!("{target_dir}/{file_in_target}"))
1064+
.permissions()
1065+
.mode();
1066+
1067+
if should_follow_symlink_dir {
1068+
// When following symlinks, the file inside the target directory should have its permissions changed
1069+
assert_eq!(
1070+
actual_file_perms, 0o100_600,
1071+
"For flags {flags:?}, expected file perms when following symlinks = 600, got = {actual_file_perms:o}",
1072+
);
1073+
} else {
1074+
// When not following symlinks, the file inside the target directory should be unchanged
1075+
assert_eq!(
1076+
actual_file_perms, 0o100_644,
1077+
"For flags {flags:?}, expected file perms when not following symlinks = 644, got = {actual_file_perms:o}",
1078+
);
1079+
}
1080+
}
1081+
}
1082+
1083+
#[test]
1084+
fn test_chmod_recursive_symlink_during_traversal() {
1085+
// Test behavior when symlinks are encountered during directory traversal
1086+
let scenarios = [
1087+
(vec!["-R"], false), // Default behavior (-H): don't follow symlinks encountered during traversal
1088+
(vec!["-R", "-H"], false), // Explicit -H: don't follow symlinks encountered during traversal
1089+
(vec!["-R", "-L"], true), // -L: follow all symlinks including those found during traversal
1090+
(vec!["-R", "-P"], false), // -P: never follow symlinks
1091+
];
1092+
1093+
for (flags, should_follow_symlink_dir) in scenarios {
1094+
let scene = TestScenario::new(util_name!());
1095+
let at = &scene.fixtures;
1096+
1097+
let directory = "dir";
1098+
let target_dir = "target_dir";
1099+
let symlink_to_dir = "link_dir";
1100+
let file_in_target = "file_in_target";
1101+
1102+
at.mkdir(directory);
1103+
at.mkdir(target_dir);
1104+
at.touch(format!("{target_dir}/{file_in_target}"));
1105+
at.symlink_dir(target_dir, &format!("{directory}/{symlink_to_dir}"));
1106+
1107+
set_permissions(
1108+
at.plus(format!("{target_dir}/{file_in_target}")),
1109+
Permissions::from_mode(0o644),
1110+
)
1111+
.unwrap();
1112+
1113+
let mut ucmd = scene.ucmd();
1114+
ucmd.args(&flags)
1115+
.arg("go-rwx")
1116+
.arg(directory) // The directory is the command-line argument
1117+
.succeeds()
1118+
.no_stderr();
1119+
1120+
let actual_file_perms = at
1121+
.metadata(&format!("{target_dir}/{file_in_target}"))
1122+
.permissions()
1123+
.mode();
1124+
1125+
if should_follow_symlink_dir {
1126+
// When following symlinks, the file inside the target directory should have its permissions changed
1127+
assert_eq!(
1128+
actual_file_perms, 0o100_600,
1129+
"For flags {flags:?}, expected file perms when following symlinks = 600, got = {actual_file_perms:o}",
1130+
);
1131+
} else {
1132+
// When not following symlinks, the file inside the target directory should be unchanged
1133+
assert_eq!(
1134+
actual_file_perms, 0o100_644,
1135+
"For flags {flags:?}, expected file perms when not following symlinks = 644, got = {actual_file_perms:o}",
1136+
);
1137+
}
1138+
}
1139+
}
1140+
1141+
#[test]
1142+
fn test_chmod_recursive_symlink_combinations() {
1143+
let scene = TestScenario::new(util_name!());
1144+
let at = &scene.fixtures;
1145+
1146+
let directory = "dir";
1147+
let target_dir = "target_dir";
1148+
let target_file = "target_file";
1149+
let symlink_to_dir = "link_dir";
1150+
let symlink_to_file = "link_file";
1151+
let file_in_target = "file";
1152+
1153+
at.mkdir(directory);
1154+
at.mkdir(target_dir);
1155+
at.touch(target_file);
1156+
at.touch(format!("{target_dir}/{file_in_target}"));
1157+
at.symlink_dir(target_dir, &format!("{directory}/{symlink_to_dir}"));
1158+
at.symlink_file(target_file, &format!("{directory}/{symlink_to_file}"));
1159+
1160+
set_permissions(at.plus(target_file), Permissions::from_mode(0o644)).unwrap();
1161+
set_permissions(
1162+
at.plus(format!("{target_dir}/{file_in_target}")),
1163+
Permissions::from_mode(0o644),
1164+
)
1165+
.unwrap();
1166+
1167+
// Test with -R -L (follow all symlinks)
1168+
scene
1169+
.ucmd()
1170+
.arg("-R")
1171+
.arg("-L")
1172+
.arg("go-rwx")
1173+
.arg(directory)
1174+
.succeeds()
1175+
.no_stderr();
1176+
1177+
// Both target file and file in target directory should have permissions changed
1178+
assert_eq!(at.metadata(target_file).permissions().mode(), 0o100_600);
1179+
assert_eq!(
1180+
at.metadata(&format!("{target_dir}/{file_in_target}"))
1181+
.permissions()
1182+
.mode(),
1183+
0o100_600
1184+
);
1185+
}

0 commit comments

Comments
 (0)