install: add -U (unprivileged) option

This commit is contained in:
Alexandre Fresnais
2025-11-30 00:35:53 +01:00
parent ebaae2e2d5
commit 8c7434cf36
5 changed files with 85 additions and 25 deletions
+4
View File
@@ -204,3 +204,7 @@ With `-U`/`--no-utf8`, you can interpret input files as 8-bit ASCII rather than
## `expand`
`expand` also offers the `-U`/`--no-utf8` option to interpret input files as 8-bit ASCII instead of UTF-8.
## `install`
`install` offers FreeBSD's `-U` unprivileged option to not change the owner, the group, or the file flags of the destination.
+1
View File
@@ -19,6 +19,7 @@ install-help-verbose = explain what is being done
install-help-preserve-context = preserve security context
install-help-context = set security context of files and directories
install-help-default-context = set SELinux security context of destination file and each created directory to default type
install-help-unprivileged = do not require elevated privileges to change the owner, the group, or the file flags of the destination
# Error messages
install-error-dir-needs-arg = { $util_name } with -d requires at least one argument.
+1
View File
@@ -19,6 +19,7 @@ install-help-verbose = expliquer ce qui est fait
install-help-preserve-context = préserver le contexte de sécurité
install-help-context = définir le contexte de sécurité des fichiers et répertoires
install-help-default-context = définir le contexte de sécurité SELinux du fichier de destination et de chaque répertoire créé au type par défaut
install-help-unprivileged = ne pas nécessiter de privilèges élevés pour changer le propriétaire, le groupe ou les attributs du fichier de destination
# Messages d'erreur
install-error-dir-needs-arg = { $util_name } avec -d nécessite au moins un argument.
+47 -25
View File
@@ -62,6 +62,7 @@ pub struct Behavior {
preserve_context: bool,
context: Option<String>,
default_context: bool,
unprivileged: bool,
}
#[derive(Error, Debug)]
@@ -163,6 +164,7 @@ static OPT_VERBOSE: &str = "verbose";
static OPT_PRESERVE_CONTEXT: &str = "preserve-context";
static OPT_CONTEXT: &str = "context";
static OPT_DEFAULT_CONTEXT: &str = "default-context";
static OPT_UNPRIVILEGED: &str = "unprivileged";
static ARG_FILES: &str = "files";
@@ -317,6 +319,13 @@ pub fn uu_app() -> Command {
.value_hint(clap::ValueHint::AnyPath)
.value_parser(clap::value_parser!(OsString)),
)
.arg(
Arg::new(OPT_UNPRIVILEGED)
.short('U')
.long(OPT_UNPRIVILEGED)
.help(translate!("install-help-unprivileged"))
.action(ArgAction::SetTrue),
)
}
/// Determine behavior, given command line arguments.
@@ -416,6 +425,7 @@ fn behavior(matches: &ArgMatches) -> UResult<Behavior> {
let context = matches.get_one::<String>(OPT_CONTEXT).cloned();
let default_context = matches.get_flag(OPT_DEFAULT_CONTEXT);
let unprivileged = matches.get_flag(OPT_UNPRIVILEGED);
Ok(Behavior {
main_function,
@@ -439,6 +449,7 @@ fn behavior(matches: &ArgMatches) -> UResult<Behavior> {
preserve_context: matches.get_flag(OPT_PRESERVE_CONTEXT),
context,
default_context,
unprivileged,
})
}
@@ -479,7 +490,7 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> {
// Set SELinux context for all created directories if needed
#[cfg(feature = "selinux")]
if b.context.is_some() || b.default_context {
if should_set_selinux_context(b) {
let context = get_context_for_selinux(b);
set_selinux_context_for_directories_install(path_to_create.as_path(), context);
}
@@ -498,15 +509,17 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> {
continue;
}
show_if_err!(chown_optional_user_group(path, b));
if !b.unprivileged {
show_if_err!(chown_optional_user_group(path, b));
// Set SELinux context for directory if needed
#[cfg(feature = "selinux")]
if b.default_context {
show_if_err!(set_selinux_default_context(path));
} else if b.context.is_some() {
let context = get_context_for_selinux(b);
show_if_err!(set_selinux_security_context(path, context));
// Set SELinux context for directory if needed
#[cfg(feature = "selinux")]
if b.default_context {
show_if_err!(set_selinux_default_context(path));
} else if b.context.is_some() {
let context = get_context_for_selinux(b);
show_if_err!(set_selinux_security_context(path, context));
}
}
}
// If the exit code was set, or show! has been called at least once
@@ -628,7 +641,7 @@ fn standard(mut paths: Vec<OsString>, b: &Behavior) -> UResult<()> {
// Set SELinux context for all created directories if needed
#[cfg(feature = "selinux")]
if b.context.is_some() || b.default_context {
if should_set_selinux_context(b) {
let context = get_context_for_selinux(b);
set_selinux_context_for_directories_install(to_create, context);
}
@@ -918,7 +931,9 @@ fn set_ownership_and_permissions(to: &Path, b: &Behavior) -> UResult<()> {
return Err(InstallError::ChmodFailed(to.to_path_buf()).into());
}
chown_optional_user_group(to, b)?;
if !b.unprivileged {
chown_optional_user_group(to, b)?;
}
Ok(())
}
@@ -984,16 +999,18 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> {
}
#[cfg(feature = "selinux")]
if b.preserve_context {
uucore::selinux::preserve_security_context(from, to)
.map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?;
} else if b.default_context {
set_selinux_default_context(to)
.map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?;
} else if b.context.is_some() {
let context = get_context_for_selinux(b);
set_selinux_security_context(to, context)
.map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?;
if !b.unprivileged {
if b.preserve_context {
uucore::selinux::preserve_security_context(from, to)
.map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?;
} else if b.default_context {
set_selinux_default_context(to)
.map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?;
} else if b.context.is_some() {
let context = get_context_for_selinux(b);
set_selinux_security_context(to, context)
.map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?;
}
}
if b.verbose {
@@ -1022,6 +1039,11 @@ fn get_context_for_selinux(b: &Behavior) -> Option<&String> {
}
}
#[cfg(feature = "selinux")]
fn should_set_selinux_context(b: &Behavior) -> bool {
!b.unprivileged && (b.context.is_some() || b.default_context)
}
/// Check if a file needs to be copied due to ownership differences when no explicit group is specified.
/// Returns true if the destination file's ownership would differ from what it should be after installation.
fn needs_copy_for_ownership(to: &Path, to_meta: &fs::Metadata) -> bool {
@@ -1113,7 +1135,7 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool {
}
#[cfg(feature = "selinux")]
if b.preserve_context && contexts_differ(from, to) {
if !b.unprivileged && b.preserve_context && contexts_differ(from, to) {
return true;
}
@@ -1121,17 +1143,17 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool {
// Check if the owner ID is specified and differs from the destination file's owner.
if let Some(owner_id) = b.owner_id {
if owner_id != to_meta.uid() {
if !b.unprivileged && owner_id != to_meta.uid() {
return true;
}
}
// Check if the group ID is specified and differs from the destination file's group.
if let Some(group_id) = b.group_id {
if group_id != to_meta.gid() {
if !b.unprivileged && group_id != to_meta.gid() {
return true;
}
} else if needs_copy_for_ownership(to, &to_meta) {
} else if !b.unprivileged && needs_copy_for_ownership(to, &to_meta) {
return true;
}
+32
View File
@@ -2489,3 +2489,35 @@ fn test_install_non_utf8_paths() {
ucmd.arg("-D").arg(source_file).arg(&target_path).succeeds();
}
#[test]
fn test_install_unprivileged_option_u_skips_chown() {
// This test only makes sense when not running as root.
if geteuid() == 0 {
return;
}
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
let src = "source_file";
let dst_fail = "target_fail";
let dst_ok = "target_ok";
at.touch(src);
// Without -U, attempting to chown to root should fail for an unprivileged user.
let res = scene.ucmd().args(&["--owner=root", src, dst_fail]).run();
res.failure();
// With -U, install should not require elevated privileges for owner/group changes,
// meaning it should succeed and leave ownership as the current user.
scene
.ucmd()
.args(&["-U", "--owner=root", src, dst_ok])
.succeeds()
.no_stderr();
assert!(at.file_exists(dst_ok));
assert_eq!(at.metadata(dst_ok).uid(), geteuid());
}