Skip to content

Commit bdcd2ef

Browse files
sylvestrecakebaker
andcommitted
install: implement option -C
Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>
1 parent 56ce0e2 commit bdcd2ef

2 files changed

Lines changed: 221 additions & 8 deletions

File tree

src/uu/install/src/install.rs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,30 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> {
10001000
Ok(())
10011001
}
10021002

1003+
/// Check if a file needs to be copied due to ownership differences when no explicit group is specified.
1004+
/// Returns true if the destination file's ownership would differ from what it should be after installation.
1005+
fn needs_copy_for_ownership(to: &Path, to_meta: &fs::Metadata) -> bool {
1006+
use std::os::unix::fs::MetadataExt;
1007+
1008+
// Check if the destination file's owner differs from the effective user ID
1009+
if to_meta.uid() != geteuid() {
1010+
return true;
1011+
}
1012+
1013+
// For group, we need to determine what the group would be after installation
1014+
// If no group is specified, the behavior depends on the directory:
1015+
// - If the directory has setgid bit, the file inherits the directory's group
1016+
// - Otherwise, the file gets the user's effective group
1017+
let expected_gid = to
1018+
.parent()
1019+
.and_then(|parent| metadata(parent).ok())
1020+
.filter(|parent_meta| parent_meta.mode() & 0o2000 != 0)
1021+
.map(|parent_meta| parent_meta.gid())
1022+
.unwrap_or(getegid());
1023+
1024+
to_meta.gid() != expected_gid
1025+
}
1026+
10031027
/// Return true if a file is necessary to copy. This is the case when:
10041028
///
10051029
/// - _from_ or _to_ is nonexistent;
@@ -1030,6 +1054,13 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> UResult<bool> {
10301054
return Ok(true);
10311055
};
10321056

1057+
// Check if the destination is a symlink (should always be replaced)
1058+
if let Ok(to_symlink_meta) = fs::symlink_metadata(to) {
1059+
if to_symlink_meta.file_type().is_symlink() {
1060+
return Ok(true);
1061+
}
1062+
}
1063+
10331064
// Define special file mode bits (setuid, setgid, sticky).
10341065
let extra_mode: u32 = 0o7000;
10351066
// Define all file mode bits (including permissions).
@@ -1038,7 +1069,7 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> UResult<bool> {
10381069

10391070
// Check if any special mode bits are set in the specified mode,
10401071
// source file mode, or destination file mode.
1041-
if b.specified_mode.unwrap_or(0) & extra_mode != 0
1072+
if b.mode() & extra_mode != 0
10421073
|| from_meta.mode() & extra_mode != 0
10431074
|| to_meta.mode() & extra_mode != 0
10441075
{
@@ -1079,13 +1110,8 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> UResult<bool> {
10791110
if group_id != to_meta.gid() {
10801111
return Ok(true);
10811112
}
1082-
} else {
1083-
#[cfg(not(target_os = "windows"))]
1084-
// Check if the destination file's owner or group
1085-
// differs from the effective user/group ID of the process.
1086-
if to_meta.uid() != geteuid() || to_meta.gid() != getegid() {
1087-
return Ok(true);
1088-
}
1113+
} else if needs_copy_for_ownership(to, &to_meta) {
1114+
return Ok(true);
10891115
}
10901116

10911117
// Check if the contents of the source and destination files differ.

tests/by-util/test_install.rs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,6 +1694,193 @@ fn test_install_compare_option() {
16941694
.stderr_contains("Options --compare and --strip are mutually exclusive");
16951695
}
16961696

1697+
#[test]
1698+
#[cfg(not(target_os = "openbsd"))]
1699+
fn test_install_compare_basic() {
1700+
let scene = TestScenario::new(util_name!());
1701+
let at = &scene.fixtures;
1702+
1703+
let source = "source_file";
1704+
let dest = "dest_file";
1705+
1706+
at.write(source, "test content");
1707+
1708+
// First install should copy
1709+
scene
1710+
.ucmd()
1711+
.args(&["-Cv", "-m644", source, dest])
1712+
.succeeds()
1713+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1714+
1715+
// Second install with same mode should be no-op (compare works)
1716+
scene
1717+
.ucmd()
1718+
.args(&["-Cv", "-m644", source, dest])
1719+
.succeeds()
1720+
.no_stdout();
1721+
1722+
// Test that compare works correctly when content actually differs
1723+
let source2 = "source2";
1724+
at.write(source2, "different content");
1725+
1726+
scene
1727+
.ucmd()
1728+
.args(&["-Cv", "-m644", source2, dest])
1729+
.succeeds()
1730+
.stdout_contains("removed")
1731+
.stdout_contains(format!("'{source2}' -> '{dest}'"));
1732+
1733+
// Second install should be no-op since content is now identical
1734+
scene
1735+
.ucmd()
1736+
.args(&["-Cv", "-m644", source2, dest])
1737+
.succeeds()
1738+
.no_stdout();
1739+
}
1740+
1741+
#[test]
1742+
#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))]
1743+
fn test_install_compare_special_mode_bits() {
1744+
let scene = TestScenario::new(util_name!());
1745+
let at = &scene.fixtures;
1746+
1747+
let source = "source_file";
1748+
let dest = "dest_file";
1749+
1750+
at.write(source, "test content");
1751+
1752+
// Special mode bits - setgid (tests the core bug fix)
1753+
// When setgid bit is set, -C should be ignored (always copy)
1754+
// This tests the bug where b.specified_mode.unwrap_or(0) was used instead of b.mode()
1755+
scene
1756+
.ucmd()
1757+
.args(&["-Cv", "-m2755", source, dest])
1758+
.succeeds()
1759+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1760+
1761+
// Second install with same setgid mode should ALSO copy (not skip)
1762+
// because -C option should be ignored when special mode bits are present
1763+
scene
1764+
.ucmd()
1765+
.args(&["-Cv", "-m2755", source, dest])
1766+
.succeeds()
1767+
.stdout_contains("removed")
1768+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1769+
1770+
// Special mode bits - setuid
1771+
scene
1772+
.ucmd()
1773+
.args(&["-Cv", "-m4755", source, dest])
1774+
.succeeds()
1775+
.stdout_contains("removed")
1776+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1777+
1778+
// Second install with setuid should also copy
1779+
scene
1780+
.ucmd()
1781+
.args(&["-Cv", "-m4755", source, dest])
1782+
.succeeds()
1783+
.stdout_contains("removed")
1784+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1785+
1786+
// Special mode bits - sticky bit
1787+
scene
1788+
.ucmd()
1789+
.args(&["-Cv", "-m1755", source, dest])
1790+
.succeeds()
1791+
.stdout_contains("removed")
1792+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1793+
1794+
// Second install with sticky bit should also copy
1795+
scene
1796+
.ucmd()
1797+
.args(&["-Cv", "-m1755", source, dest])
1798+
.succeeds()
1799+
.stdout_contains("removed")
1800+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1801+
1802+
// Back to normal mode - compare should work again
1803+
scene
1804+
.ucmd()
1805+
.args(&["-Cv", "-m644", source, dest])
1806+
.succeeds()
1807+
.stdout_contains("removed")
1808+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1809+
1810+
// Second install with normal mode should be no-op
1811+
scene
1812+
.ucmd()
1813+
.args(&["-Cv", "-m644", source, dest])
1814+
.succeeds()
1815+
.no_stdout();
1816+
}
1817+
1818+
#[test]
1819+
#[cfg(not(target_os = "openbsd"))]
1820+
fn test_install_compare_group_ownership() {
1821+
let scene = TestScenario::new(util_name!());
1822+
let at = &scene.fixtures;
1823+
1824+
let source = "source_file";
1825+
let dest = "dest_file";
1826+
1827+
at.write(source, "test content");
1828+
1829+
let user_group = std::process::Command::new("id")
1830+
.arg("-nrg")
1831+
.output()
1832+
.map_or_else(
1833+
|_| "users".to_string(),
1834+
|output| String::from_utf8_lossy(&output.stdout).trim().to_string(),
1835+
); // fallback group name
1836+
1837+
// Install with explicit group
1838+
scene
1839+
.ucmd()
1840+
.args(&["-Cv", "-m664", "-g", &user_group, source, dest])
1841+
.succeeds()
1842+
.stdout_contains(format!("'{source}' -> '{dest}'"));
1843+
1844+
// Install without group - this should detect that no copy is needed
1845+
// because the file already has the correct group (user's group)
1846+
scene
1847+
.ucmd()
1848+
.args(&["-Cv", "-m664", source, dest])
1849+
.succeeds()
1850+
.no_stdout(); // Should be no-op if group ownership logic is correct
1851+
}
1852+
1853+
#[test]
1854+
#[cfg(not(target_os = "openbsd"))]
1855+
fn test_install_compare_symlink_handling() {
1856+
let scene = TestScenario::new(util_name!());
1857+
let at = &scene.fixtures;
1858+
1859+
let source = "source_file";
1860+
let symlink_dest = "symlink_dest";
1861+
let target_file = "target_file";
1862+
1863+
at.write(source, "test content");
1864+
at.write(target_file, "test content"); // Same content to test that symlinks are always replaced
1865+
at.symlink_file(target_file, symlink_dest);
1866+
1867+
// Create a symlink as destination pointing to a different file - should always be replaced
1868+
scene
1869+
.ucmd()
1870+
.args(&["-Cv", "-m644", source, symlink_dest])
1871+
.succeeds()
1872+
.stdout_contains("removed")
1873+
.stdout_contains(format!("'{source}' -> '{symlink_dest}'"));
1874+
1875+
// Even if content would be the same, symlink destination should be replaced
1876+
// Now symlink_dest is a regular file, so compare should work normally
1877+
scene
1878+
.ucmd()
1879+
.args(&["-Cv", "-m644", source, symlink_dest])
1880+
.succeeds()
1881+
.no_stdout(); // Now it's a regular file, so compare should work
1882+
}
1883+
16971884
#[test]
16981885
// Matches part of tests/install/basic-1
16991886
fn test_t_exist_dir() {

0 commit comments

Comments
 (0)