diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 9f82833cf..ca4a67edb 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -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. diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 344301666..0b16dbb56 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -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. diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 0a28d9a6f..3a6e0a0c6 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -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. diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 582eb91ac..94fdeb891 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -62,6 +62,7 @@ pub struct Behavior { preserve_context: bool, context: Option, 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 { let context = matches.get_one::(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 { 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, 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; } diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 2a2e7d670..cad4e425f 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -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()); +}