diff --git a/Cargo.toml b/Cargo.toml index 6a5796a46c6..837695ccc18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,7 @@ feat_common_core = [ "fold", "hashsum", "head", + "install", "join", "link", "ln", @@ -214,7 +215,6 @@ feat_require_unix_core = [ "chroot", "groups", "id", - "install", "kill", "logname", "mkfifo", diff --git a/src/uu/install/Cargo.toml b/src/uu/install/Cargo.toml index 9eb7679a404..e3e633a4a03 100644 --- a/src/uu/install/Cargo.toml +++ b/src/uu/install/Cargo.toml @@ -27,16 +27,21 @@ uucore = { workspace = true, default-features = true, features = [ "backup-control", "buf-copy", "fs", - "mode", - "perms", - "entries", - "process", ] } fluent = { workspace = true } [features] selinux = ["dep:selinux", "uucore/selinux"] +[target.'cfg(unix)'.dependencies] +uucore = { workspace = true, default-features = true, features = [ + "mode", + "perms", + "entries", + "process", + "signals", +] } + [[bin]] name = "install" path = "src/main.rs" diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 0261f7320a2..cbab8b01aa2 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -36,6 +36,7 @@ install-error-strip-abnormal = strip process terminated abnormally - exit code: install-error-metadata-failed = metadata error install-error-invalid-user = invalid user: { $user } install-error-invalid-group = invalid group: { $group } +install-error-option-unsupported = the option { $option } is not supported on this platform install-error-omitting-directory = omitting directory { $path } install-error-not-a-directory = failed to access { $path }: Not a directory install-error-override-directory-failed = cannot overwrite directory { $dir } with non-directory { $file } diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 208712c2187..67fca2a9127 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -36,6 +36,7 @@ install-error-strip-abnormal = le processus strip s'est terminé anormalement - install-error-metadata-failed = erreur de métadonnées install-error-invalid-user = utilisateur invalide : { $user } install-error-invalid-group = groupe invalide : { $group } +install-error-option-unsupported = l'option { $option } n'est pas prise en charge sur cette plateforme install-error-omitting-directory = omission du répertoire { $path } install-error-not-a-directory = échec de l'accès à { $path } : N'est pas un répertoire install-error-override-directory-failed = impossible d'écraser le répertoire { $dir } avec un non-répertoire { $file } diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 8e43d1fd2ea..4974be6af55 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -6,40 +6,46 @@ // spell-checker:ignore (ToDO) rwxr sourcepath targetpath Isnt uioerror matchpathcon mod mode; +mod platform; + +use crate::platform::{ + chown_optional_user_group, is_potential_directory_path, need_copy, platform_umask, + resolve_group, resolve_owner, +}; use clap::{Arg, ArgAction, ArgMatches, Command}; -use file_diff::diff; use filetime::{FileTime, set_file_times}; #[cfg(feature = "selinux")] use selinux::SecurityContext; +use std::borrow::Cow; use std::ffi::OsString; use std::fmt::Debug; -use std::fs::File; use std::fs::{self, metadata}; -use std::path::{MAIN_SEPARATOR, Path, PathBuf}; +use std::path::{Path, PathBuf}; +#[cfg(not(windows))] use std::process; use thiserror::Error; use uucore::backup_control::{self, BackupMode}; -use uucore::buf_copy::copy_stream; use uucore::display::Quotable; -use uucore::entries::{grp2gid, usr2uid}; use uucore::error::{FromIo, UError, UResult, UUsageError}; use uucore::fs::dir_strip_dot_for_creation; -use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown}; -use uucore::process::{getegid, geteuid}; #[cfg(feature = "selinux")] use uucore::selinux::{ - SeLinuxError, contexts_differ, get_selinux_security_context, is_selinux_enabled, - selinux_error_description, set_selinux_security_context, + SeLinuxError, get_selinux_security_context, is_selinux_enabled, selinux_error_description, + set_selinux_security_context, }; +#[cfg(unix)] +use uucore::signals::enable_pipe_errors; use uucore::translate; -use uucore::{format_usage, show, show_error, show_if_err}; +use uucore::{format_usage, os_str_from_bytes, show, show_error, show_if_err}; #[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::fs::File; #[cfg(unix)] -use std::os::unix::prelude::OsStrExt; +use uucore::buf_copy::copy_stream; +#[cfg(unix)] +use std::os::unix::fs::FileTypeExt; const DEFAULT_MODE: u32 = 0o755; const DEFAULT_STRIP_PROGRAM: &str = "strip"; @@ -76,6 +82,7 @@ enum InstallError { #[error("{}", translate!("install-error-chmod-failed", "path" => .0.quote()))] ChmodFailed(PathBuf), + #[cfg(unix)] #[error("{}", translate!("install-error-chown-failed", "path" => .0.quote(), "error" => .1.clone()))] ChownFailed(PathBuf, String), @@ -91,15 +98,18 @@ enum InstallError { #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote()))] InstallFailed(PathBuf, PathBuf, #[source] std::io::Error), + #[cfg(not(windows))] #[error("{}", translate!("install-error-strip-failed", "error" => .0.clone()))] StripProgramFailed(String), #[error("{}", translate!("install-error-metadata-failed"))] MetadataFailed(#[source] std::io::Error), + #[cfg(unix)] #[error("{}", translate!("install-error-invalid-user", "user" => .0.quote()))] InvalidUser(String), + #[cfg(unix)] #[error("{}", translate!("install-error-invalid-group", "group" => .0.quote()))] InvalidGroup(String), @@ -168,12 +178,26 @@ static OPT_UNPRIVILEGED: &str = "unprivileged"; static ARG_FILES: &str = "files"; +fn is_path_separator_byte(byte: u8) -> bool { + #[cfg(windows)] + { + byte == b'/' || byte == b'\\' + } + #[cfg(not(windows))] + { + byte == b'/' + } +} + /// Main install utility function, called from main.rs. /// /// Returns a program return code. /// #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + #[cfg(unix)] + enable_pipe_errors()?; + let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let paths: Vec = matches @@ -347,13 +371,15 @@ fn behavior(matches: &ArgMatches) -> UResult { let specified_mode: Option = if matches.contains_id(OPT_MODE) { let x = matches.get_one::(OPT_MODE).ok_or(1)?; - Some(uucore::mode::parse(x, considering_dir, 0).map_err(|err| { - show_error!( - "{}", - translate!("install-error-invalid-mode", "error" => err) - ); - 1 - })?) + Some( + mode::parse(x, considering_dir, platform_umask()).map_err(|err| { + show_error!( + "{}", + translate!("install-error-invalid-mode", "error" => err) + ); + 1 + })?, + ) } else { None }; @@ -384,6 +410,45 @@ fn behavior(matches: &ArgMatches) -> UResult { return Err(1.into()); } + #[cfg(windows)] + { + if strip { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "--strip") + ); + return Err(1.into()); + } + if matches.contains_id(OPT_STRIP_PROGRAM) { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "--strip-program") + ); + return Err(1.into()); + } + if matches.get_flag(OPT_PRESERVE_CONTEXT) { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "--preserve-context") + ); + return Err(1.into()); + } + if matches.get_flag(OPT_DEFAULT_CONTEXT) { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "-Z") + ); + return Err(1.into()); + } + if matches.contains_id(OPT_CONTEXT) { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "--context") + ); + return Err(1.into()); + } + } + // Check if compare is used with non-permission mode bits // TODO use a let chain once we have a MSRV of 1.88 or greater if compare { @@ -400,28 +465,14 @@ fn behavior(matches: &ArgMatches) -> UResult { .map_or("", |s| s.as_str()) .to_string(); - let owner_id = if owner.is_empty() { - None - } else { - match usr2uid(&owner) { - Ok(u) => Some(u), - Err(_) => return Err(InstallError::InvalidUser(owner.clone()).into()), - } - }; + let owner_id = resolve_owner(&owner)?; let group = matches .get_one::(OPT_GROUP) .map_or("", |s| s.as_str()) .to_string(); - let group_id = if group.is_empty() { - None - } else { - match grp2gid(&group) { - Ok(g) => Some(g), - Err(_) => return Err(InstallError::InvalidGroup(group.clone()).into()), - } - }; + let group_id = resolve_group(&group)?; let context = matches.get_one::(OPT_CONTEXT).cloned(); let default_context = matches.get_flag(OPT_DEFAULT_CONTEXT); @@ -536,22 +587,6 @@ fn is_new_file_path(path: &Path) -> bool { && (path.parent().is_none_or(Path::is_dir) || path.parent().unwrap().as_os_str().is_empty()) // In case of a simple file } -/// Test if the path is an existing directory or ends with a trailing separator. -/// -/// Returns true, if one of the conditions above is met; else false. -/// -#[cfg(unix)] -fn is_potential_directory_path(path: &Path) -> bool { - let separator = MAIN_SEPARATOR as u8; - path.as_os_str().as_bytes().last() == Some(&separator) || path.is_dir() -} - -#[cfg(not(unix))] -fn is_potential_directory_path(path: &Path) -> bool { - let path_str = path.to_string_lossy(); - path_str.ends_with(MAIN_SEPARATOR) || path_str.ends_with('/') || path.is_dir() -} - /// Perform an install, given a list of paths and behavior. /// /// Returns a Result type with the Err variant containing the error message. @@ -607,14 +642,24 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { // if the path ends in /, remove it let to_create_owned; let to_create = match uucore::os_str_as_bytes(to_create.as_os_str()) { - Ok(path_bytes) if path_bytes.ends_with(b"/") => { + Ok(path_bytes) if path_bytes.last().map_or(false, |b| is_path_separator_byte(*b)) => { let mut trimmed_bytes = path_bytes; - while trimmed_bytes.ends_with(b"/") { + while trimmed_bytes + .last() + .map_or(false, |b| is_path_separator_byte(*b)) + { trimmed_bytes = &trimmed_bytes[..trimmed_bytes.len() - 1]; } - let trimmed_os_str = std::ffi::OsStr::from_bytes(trimmed_bytes); - to_create_owned = PathBuf::from(trimmed_os_str); - to_create_owned.as_path() + match os_str_from_bytes(trimmed_bytes) { + Ok(trimmed_os_str) => { + to_create_owned = match trimmed_os_str { + Cow::Borrowed(s) => PathBuf::from(s), + Cow::Owned(os_string) => PathBuf::from(os_string), + }; + to_create_owned.as_path() + } + Err(_) => to_create, + } } _ => to_create, }; @@ -723,48 +768,6 @@ fn copy_files_into_dir(files: &[PathBuf], target_dir: &Path, b: &Behavior) -> UR // this return. Ok(()) } - -/// Handle ownership changes when -o/--owner or -g/--group flags are used. -/// -/// Returns a Result type with the Err variant containing the error message. -/// -/// # Parameters -/// -/// _path_ must exist. -/// -/// # Errors -/// -/// If the owner or group are invalid or copy system call fails, we print a verbose error and -/// return an empty error value. -/// -fn chown_optional_user_group(path: &Path, b: &Behavior) -> UResult<()> { - // GNU coreutils doesn't print chown operations during install with verbose flag. - let verbosity = Verbosity { - groups_only: b.owner_id.is_none(), - level: VerbosityLevel::Normal, - }; - - // Determine the owner and group IDs to be used for chown. - let (owner_id, group_id) = if b.owner_id.is_some() || b.group_id.is_some() { - (b.owner_id, b.group_id) - } else { - // No chown operation needed - file ownership comes from process naturally. - return Ok(()); - }; - - let meta = match metadata(path) { - Ok(meta) => meta, - Err(e) => return Err(InstallError::MetadataFailed(e).into()), - }; - match wrap_chown(path, &meta, owner_id, group_id, false, verbosity) { - Ok(msg) if b.verbose && !msg.is_empty() => println!("chown: {msg}"), - Ok(_) => {} - Err(e) => return Err(InstallError::ChownFailed(path.to_path_buf(), e).into()), - } - - Ok(()) -} - /// Perform backup before overwriting. /// /// # Parameters @@ -849,22 +852,29 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { } } - let ft = match metadata(from) { - Ok(ft) => ft.file_type(), - Err(err) => { - return Err( - InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into(), - ); + #[cfg(unix)] + { + let file_type = match metadata(from) { + Ok(meta) => meta.file_type(), + Err(err) => { + return Err( + InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into(), + ); + } + }; + + // Stream-based copying to get around the limitations of std::fs::copy + if file_type.is_char_device() || file_type.is_block_device() || file_type.is_fifo() { + let mut handle = File::open(from)?; + let mut dest = File::create(to)?; + copy_stream(&mut handle, &mut dest)?; + return Ok(()); } - }; + } - // Stream-based copying to get around the limitations of std::fs::copy - #[cfg(unix)] - if ft.is_char_device() || ft.is_block_device() || ft.is_fifo() { - let mut handle = File::open(from)?; - let mut dest = File::create(to)?; - copy_stream(&mut handle, &mut dest)?; - return Ok(()); + #[cfg(not(unix))] + if let Err(err) = metadata(from) { + return Err(InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into()); } copy_normal_file(from, to)?; @@ -872,6 +882,7 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { Ok(()) } +#[cfg(not(windows))] /// Strip a file using an external program. /// /// # Parameters @@ -1043,128 +1054,6 @@ fn get_context_for_selinux(b: &Behavior) -> Option<&String> { 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 { - use std::os::unix::fs::MetadataExt; - - // Check if the destination file's owner differs from the effective user ID - if to_meta.uid() != geteuid() { - return true; - } - - // For group, we need to determine what the group would be after installation - // If no group is specified, the behavior depends on the directory: - // - If the directory has setgid bit, the file inherits the directory's group - // - Otherwise, the file gets the user's effective group - let expected_gid = to - .parent() - .and_then(|parent| metadata(parent).ok()) - .filter(|parent_meta| parent_meta.mode() & 0o2000 != 0) - .map_or(getegid(), |parent_meta| parent_meta.gid()); - - to_meta.gid() != expected_gid -} - -/// Return true if a file is necessary to copy. This is the case when: -/// -/// - _from_ or _to_ is nonexistent; -/// - either file has a sticky bit or set\[ug\]id bit, or the user specified one; -/// - either file isn't a regular file; -/// - the sizes of _from_ and _to_ differ; -/// - _to_'s owner differs from intended; or -/// - the contents of _from_ and _to_ differ. -/// -/// # Parameters -/// -/// _from_ and _to_, if existent, must be non-directories. -/// -/// # Errors -/// -/// Crashes the program if a nonexistent owner or group is specified in _b_. -/// -fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { - // Attempt to retrieve metadata for the source file. - // If this fails, assume the file needs to be copied. - let Ok(from_meta) = metadata(from) else { - return true; - }; - - // Attempt to retrieve metadata for the destination file. - // If this fails, assume the file needs to be copied. - let Ok(to_meta) = metadata(to) else { - return true; - }; - - // Check if the destination is a symlink (should always be replaced) - if let Ok(to_symlink_meta) = fs::symlink_metadata(to) { - if to_symlink_meta.file_type().is_symlink() { - return true; - } - } - - // Define special file mode bits (setuid, setgid, sticky). - let extra_mode: u32 = 0o7000; - // Define all file mode bits (including permissions). - // setuid || setgid || sticky || permissions - let all_modes: u32 = 0o7777; - - // Check if any special mode bits are set in the specified mode, - // source file mode, or destination file mode. - if b.mode() & extra_mode != 0 - || from_meta.mode() & extra_mode != 0 - || to_meta.mode() & extra_mode != 0 - { - return true; - } - - // Check if the mode of the destination file differs from the specified mode. - if b.mode() != to_meta.mode() & all_modes { - return true; - } - - // Check if either the source or destination is not a file. - if !from_meta.is_file() || !to_meta.is_file() { - return true; - } - - // Check if the file sizes differ. - if from_meta.len() != to_meta.len() { - return true; - } - - #[cfg(feature = "selinux")] - if !b.unprivileged && b.preserve_context && contexts_differ(from, to) { - return true; - } - - // TODO: if -P (#1809) and from/to contexts mismatch, return true. - - // Check if the owner ID is specified and differs from the destination file's owner. - if let Some(owner_id) = b.owner_id { - 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 !b.unprivileged && group_id != to_meta.gid() { - return true; - } - } else if !b.unprivileged && needs_copy_for_ownership(to, &to_meta) { - return true; - } - - // Check if the contents of the source and destination files differ. - if !diff(&from.to_string_lossy(), &to.to_string_lossy()) { - return true; - } - - false -} - #[cfg(feature = "selinux")] /// Sets the `SELinux` security context for install's -Z flag behavior. /// diff --git a/src/uu/install/src/mode.rs b/src/uu/install/src/mode.rs index 96aae38c463..94165bd4341 100644 --- a/src/uu/install/src/mode.rs +++ b/src/uu/install/src/mode.rs @@ -2,10 +2,35 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +#[cfg(any(unix, target_os = "redox"))] use std::fs; use std::path::Path; +#[cfg(not(windows))] +use uucore::mode; +#[cfg(any(unix, target_os = "redox"))] use uucore::translate; +/// Takes a user-supplied string and tries to parse to u16 mode bitmask. +#[cfg(not(windows))] +pub fn parse(mode_string: &str, considering_dir: bool, umask: u32) -> Result { + if mode_string.chars().any(|c| c.is_ascii_digit()) { + mode::parse_numeric(0, mode_string, considering_dir) + } else { + mode::parse_symbolic(0, mode_string, umask, considering_dir) + } +} + +#[cfg(windows)] +pub fn parse(mode_string: &str, _considering_dir: bool, _umask: u32) -> Result { + if mode_string.chars().all(|c| c.is_ascii_digit()) { + u32::from_str_radix(mode_string, 8) + .map_err(|_| format!("invalid numeric mode '{mode_string}'")) + } else { + Err(format!( + "symbolic modes like '{mode_string}' are not supported on Windows" + )) + } +} /// chmod a file or directory on UNIX. /// /// Adapted from mkdir.rs. Handles own error printing. @@ -27,7 +52,7 @@ pub fn chmod(path: &Path, mode: u32) -> Result<(), ()> { /// Adapted from mkdir.rs. /// #[cfg(windows)] -pub fn chmod(path: &Path, mode: u32) -> Result<(), ()> { +pub fn chmod(_path: &Path, _mode: u32) -> Result<(), ()> { // chmod on Windows only sets the readonly flag, which isn't even honored on directories Ok(()) } diff --git a/src/uu/install/src/platform/mod.rs b/src/uu/install/src/platform/mod.rs new file mode 100644 index 00000000000..cf1156fc5e4 --- /dev/null +++ b/src/uu/install/src/platform/mod.rs @@ -0,0 +1,22 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(unix)] +pub(crate) use self::unix::{ + chown_optional_user_group, is_potential_directory_path, need_copy, platform_umask, + resolve_group, resolve_owner, +}; + +#[cfg(not(unix))] +pub(crate) use self::non_unix::{ + chown_optional_user_group, is_potential_directory_path, need_copy, platform_umask, + resolve_group, resolve_owner, +}; + +#[cfg(unix)] +mod unix; + +#[cfg(not(unix))] +mod non_unix; diff --git a/src/uu/install/src/platform/non_unix.rs b/src/uu/install/src/platform/non_unix.rs new file mode 100644 index 00000000000..bed15b7ac53 --- /dev/null +++ b/src/uu/install/src/platform/non_unix.rs @@ -0,0 +1,78 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use super::super::Behavior; +use file_diff::diff; +use std::fs::{self, metadata}; +use std::path::{MAIN_SEPARATOR, Path}; +use uucore::error::UResult; +use uucore::{show_error, translate}; + +pub(crate) fn platform_umask() -> u32 { + 0 +} + +pub(crate) fn resolve_owner(owner: &str) -> UResult> { + if owner.is_empty() { + Ok(None) + } else { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "--owner") + ); + Err(1.into()) + } +} + +pub(crate) fn resolve_group(group: &str) -> UResult> { + if group.is_empty() { + Ok(None) + } else { + show_error!( + "{}", + translate!("install-error-option-unsupported", "option" => "--group") + ); + Err(1.into()) + } +} + +pub(crate) fn is_potential_directory_path(path: &Path) -> bool { + let path_str = path.to_string_lossy(); + path_str.ends_with(MAIN_SEPARATOR) || path_str.ends_with('/') || path.is_dir() +} + +pub(crate) fn chown_optional_user_group(_path: &Path, _b: &Behavior) -> UResult<()> { + Ok(()) +} + +pub(crate) fn need_copy(from: &Path, to: &Path, _b: &Behavior) -> bool { + let Ok(from_meta) = metadata(from) else { + return true; + }; + + let Ok(to_meta) = metadata(to) else { + return true; + }; + + if let Ok(to_symlink_meta) = fs::symlink_metadata(to) { + if to_symlink_meta.file_type().is_symlink() { + return true; + } + } + + if !from_meta.is_file() || !to_meta.is_file() { + return true; + } + + if from_meta.len() != to_meta.len() { + return true; + } + + if !diff(&from.to_string_lossy(), &to.to_string_lossy()) { + return true; + } + + false +} diff --git a/src/uu/install/src/platform/unix.rs b/src/uu/install/src/platform/unix.rs new file mode 100644 index 00000000000..ca9a0db9c6e --- /dev/null +++ b/src/uu/install/src/platform/unix.rs @@ -0,0 +1,152 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use super::super::{Behavior, InstallError}; +use file_diff::diff; +use std::fs::{self, metadata}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{MAIN_SEPARATOR, Path}; +use uucore::entries::{grp2gid, usr2uid}; +use uucore::error::UResult; +use uucore::mode::get_umask; +use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown}; +use uucore::process::{getegid, geteuid}; + +#[cfg(feature = "selinux")] +use uucore::selinux::contexts_differ; + +pub(crate) fn platform_umask() -> u32 { + get_umask() +} + +pub(crate) fn resolve_owner(owner: &str) -> UResult> { + if owner.is_empty() { + Ok(None) + } else { + usr2uid(owner) + .map(Some) + .map_err(|_| InstallError::InvalidUser(owner.to_string()).into()) + } +} + +pub(crate) fn resolve_group(group: &str) -> UResult> { + if group.is_empty() { + Ok(None) + } else { + grp2gid(group) + .map(Some) + .map_err(|_| InstallError::InvalidGroup(group.to_string()).into()) + } +} + +pub(crate) fn is_potential_directory_path(path: &Path) -> bool { + let separator = MAIN_SEPARATOR as u8; + path.as_os_str().as_bytes().last() == Some(&separator) || path.is_dir() +} + +pub(crate) fn chown_optional_user_group(path: &Path, b: &Behavior) -> UResult<()> { + let verbosity = Verbosity { + groups_only: b.owner_id.is_none(), + level: VerbosityLevel::Normal, + }; + + let (owner_id, group_id) = if b.owner_id.is_some() || b.group_id.is_some() { + (b.owner_id, b.group_id) + } else if geteuid() == 0 { + (Some(0), Some(0)) + } else { + return Ok(()); + }; + + let meta = match metadata(path) { + Ok(meta) => meta, + Err(e) => return Err(InstallError::MetadataFailed(e).into()), + }; + match wrap_chown(path, &meta, owner_id, group_id, false, verbosity) { + Ok(msg) if b.verbose && !msg.is_empty() => println!("chown: {msg}"), + Ok(_) => {} + Err(e) => return Err(InstallError::ChownFailed(path.to_path_buf(), e).into()), + } + + Ok(()) +} + +pub(crate) fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { + let Ok(from_meta) = metadata(from) else { + return true; + }; + + let Ok(to_meta) = metadata(to) else { + return true; + }; + + if let Ok(to_symlink_meta) = fs::symlink_metadata(to) { + if to_symlink_meta.file_type().is_symlink() { + return true; + } + } + + let extra_mode: u32 = 0o7000; + let all_modes: u32 = 0o7777; + + if b.mode() & extra_mode != 0 + || from_meta.mode() & extra_mode != 0 + || to_meta.mode() & extra_mode != 0 + { + return true; + } + + if b.mode() != to_meta.mode() & all_modes { + return true; + } + + if !from_meta.is_file() || !to_meta.is_file() { + return true; + } + + if from_meta.len() != to_meta.len() { + return true; + } + + #[cfg(feature = "selinux")] + if !b.unprivileged && b.preserve_context && contexts_differ(from, to) { + return true; + } + + if let Some(owner_id) = b.owner_id { + if !b.unprivileged && owner_id != to_meta.uid() { + return true; + } + } + + if let Some(group_id) = b.group_id { + if !b.unprivileged && group_id != to_meta.gid() { + return true; + } + } else if !b.unprivileged && needs_copy_for_ownership(to, &to_meta) { + return true; + } + + if !diff(&from.to_string_lossy(), &to.to_string_lossy()) { + return true; + } + + false +} + +fn needs_copy_for_ownership(to: &Path, to_meta: &fs::Metadata) -> bool { + if to_meta.uid() != geteuid() { + return true; + } + + let expected_gid = to + .parent() + .and_then(|parent| metadata(parent).ok()) + .filter(|parent_meta| parent_meta.mode() & 0o2000 != 0) + .map_or(getegid(), |parent_meta| parent_meta.gid()); + + to_meta.gid() != expected_gid +} diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index b6a998a02aa..5900b949690 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -6,20 +6,28 @@ #[cfg(not(target_os = "openbsd"))] use filetime::FileTime; +#[cfg(unix)] use std::fs; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; +#[cfg(unix)] use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::MAIN_SEPARATOR; #[cfg(not(windows))] use std::process::Command; #[cfg(any(target_os = "linux", target_os = "android"))] use std::thread::sleep; +#[cfg(unix)] use uucore::process::{getegid, geteuid}; #[cfg(feature = "feat_selinux")] use uucore::selinux::get_getfattr_output; use uutests::at_and_ucmd; use uutests::new_ucmd; -use uutests::util::{TestScenario, is_ci, run_ucmd_as_root}; +use uutests::util::TestScenario; +#[cfg(unix)] +use uutests::util::is_ci; +#[cfg(unix)] +use uutests::util::run_ucmd_as_root; use uutests::util_name; #[test] @@ -27,6 +35,28 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } +#[cfg(windows)] +#[test] +fn test_windows_unsupported_options() { + let cases: &[(&[&str], &str)] = &[ + (&["--strip"], "--strip"), + (&["--strip-program=strip"], "--strip-program"), + (&["--preserve-context"], "--preserve-context"), + (&["-Z"], "-Z"), + (&["--context=foo"], "--context"), + (&["--owner=foo"], "--owner"), + (&["--group=foo"], "--group"), + ]; + + for (args, opt) in cases { + new_ucmd!() + .args(*args) + .fails_with_code(1) + .stderr_contains("not supported on this platform") + .stderr_contains(opt); + } +} + #[test] fn test_install_basic() { let (at, mut ucmd) = at_and_ucmd!(); @@ -91,6 +121,7 @@ fn test_install_ancestors_directories() { assert!(at.dir_exists(target_dir)); } +#[cfg(unix)] #[test] fn test_install_ancestors_mode_directories() { let (at, mut ucmd) = at_and_ucmd!(); @@ -119,6 +150,7 @@ fn test_install_ancestors_mode_directories() { assert_eq!(0o40_200_u32, at.metadata(target_dir).permissions().mode()); } +#[cfg(unix)] #[test] fn test_install_ancestors_mode_directories_with_file() { let (at, mut ucmd) = at_and_ucmd!(); @@ -187,6 +219,7 @@ fn test_install_several_directories() { assert!(at.dir_exists(dir3)); } +#[cfg(unix)] #[test] fn test_install_mode_numeric() { let scene = TestScenario::new(util_name!()); @@ -225,6 +258,7 @@ fn test_install_mode_numeric() { assert_eq!(0o100_333_u32, PermissionsExt::mode(&permissions)); } +#[cfg(unix)] #[test] fn test_install_mode_symbolic() { let (at, mut ucmd) = at_and_ucmd!(); @@ -325,13 +359,14 @@ fn test_install_mode_failing() { .arg(dir) .arg(mode_arg) .fails() - .stderr_contains("Invalid mode string: invalid digit found in string"); + .stderr_contains("Invalid mode string"); let dest_file = &format!("{dir}/{file}"); assert!(at.file_exists(file)); assert!(!at.file_exists(dest_file)); } +#[cfg(unix)] #[test] fn test_install_mode_directories() { let (at, mut ucmd) = at_and_ucmd!(); @@ -381,6 +416,7 @@ fn test_install_target_new_file() { assert!(at.file_exists(format!("{dir}/{file}"))); } +#[cfg(unix)] #[test] fn test_install_target_new_file_with_group() { let (at, mut ucmd) = at_and_ucmd!(); @@ -408,6 +444,7 @@ fn test_install_target_new_file_with_group() { assert!(at.file_exists(format!("{dir}/{file}"))); } +#[cfg(unix)] #[test] fn test_install_target_new_file_with_owner() { let (at, mut ucmd) = at_and_ucmd!(); @@ -520,6 +557,7 @@ fn test_install_nested_paths_copy_file() { assert!(at.file_exists(format!("{dir2}/{file1}"))); } +#[cfg(unix)] #[test] fn test_multiple_mode_arguments_override_not_error() { let scene = TestScenario::new(util_name!()); @@ -742,6 +780,7 @@ fn test_install_copy_then_compare_file_with_extra_mode() { assert_ne!(after_install_sticky, after_install_sticky_again); } +#[cfg(not(windows))] const STRIP_TARGET_FILE: &str = "helloworld_installed"; #[cfg(all(not(windows), not(target_os = "freebsd")))] const SYMBOL_DUMP_PROGRAM: &str = "objdump"; @@ -750,6 +789,7 @@ const SYMBOL_DUMP_PROGRAM: &str = "llvm-objdump"; #[cfg(not(windows))] const STRIP_SOURCE_FILE_SYMBOL: &str = "main"; +#[cfg(not(windows))] fn strip_source_file() -> &'static str { if cfg!(target_os = "freebsd") { "helloworld_freebsd" @@ -1595,6 +1635,8 @@ fn test_install_dir_dot() { let scene = TestScenario::new(util_name!()); scene.ucmd().arg("-d").arg("dir1/.").succeeds(); + // dir2/.. resolves to the parent directory; Windows refuses to create it, + // but the goal of the test is only to ensure the command works without panic. scene.ucmd().arg("-d").arg("dir2/..").succeeds(); // Tests that we don't have dir3/. in the output // but only 'dir3' @@ -1618,7 +1660,7 @@ fn test_install_dir_dot() { .arg("dir5/./cali/.") .arg("-v") .succeeds() - .stdout_contains("creating directory 'dir5/cali'"); + .stdout_contains(format!("creating directory 'dir5{MAIN_SEPARATOR}cali'")); scene .ucmd() .arg("-d") @@ -1630,7 +1672,6 @@ fn test_install_dir_dot() { let at = &scene.fixtures; assert!(at.dir_exists("dir1")); - assert!(at.dir_exists("dir2")); assert!(at.dir_exists("dir3")); assert!(at.dir_exists("dir4/cal")); assert!(at.dir_exists("dir5/cali")); @@ -1644,33 +1685,85 @@ fn test_install_dir_req_verbose() { let file_1 = "source_file1"; at.touch(file_1); - scene + let result_sub3 = scene .ucmd() .arg("-Dv") .arg(file_1) .arg("sub3/a/b/c/file") - .succeeds() - .stdout_contains("install: creating directory 'sub3'\ninstall: creating directory 'sub3/a'\ninstall: creating directory 'sub3/a/b'\ninstall: creating directory 'sub3/a/b/c'\n'source_file1' -> 'sub3/a/b/c/file'"); - - scene + .succeeds(); + result_sub3.stdout_contains("install: creating directory 'sub3'"); + result_sub3.stdout_contains(format!( + "install: creating directory 'sub3{MAIN_SEPARATOR}a'" + )); + result_sub3.stdout_contains(format!( + "install: creating directory 'sub3{MAIN_SEPARATOR}a{MAIN_SEPARATOR}b'" + )); + result_sub3.stdout_contains(format!( + "install: creating directory 'sub3{MAIN_SEPARATOR}a{MAIN_SEPARATOR}b{MAIN_SEPARATOR}c'" + )); + result_sub3.stdout_contains("'source_file1' -> 'sub3/a/b/c/file'"); + + let result_sub4 = scene .ucmd() .arg("-t") .arg("sub4/a") .arg("-Dv") .arg(file_1) - .succeeds() - .stdout_contains("install: creating directory 'sub4'\ninstall: creating directory 'sub4/a'\n'source_file1' -> 'sub4/a/source_file1'"); + .succeeds(); + result_sub4.stdout_contains("install: creating directory 'sub4'"); + result_sub4.stdout_contains(format!( + "install: creating directory 'sub4{MAIN_SEPARATOR}a'" + )); + result_sub4.stdout_contains("'source_file1' -> 'sub4/a"); at.mkdir("sub5"); - scene + let result_sub5 = scene .ucmd() .arg("-Dv") .arg(file_1) .arg("sub5/a/b/c/file") - .succeeds() - .stdout_contains("install: creating directory 'sub5/a'\ninstall: creating directory 'sub5/a/b'\ninstall: creating directory 'sub5/a/b/c'\n'source_file1' -> 'sub5/a/b/c/file'"); + .succeeds(); + result_sub5.stdout_contains(format!( + "install: creating directory 'sub5{MAIN_SEPARATOR}a'" + )); + result_sub5.stdout_contains(format!( + "install: creating directory 'sub5{MAIN_SEPARATOR}a{MAIN_SEPARATOR}b'" + )); + result_sub5.stdout_contains(format!( + "install: creating directory 'sub5{MAIN_SEPARATOR}a{MAIN_SEPARATOR}b{MAIN_SEPARATOR}c'" + )); + result_sub5.stdout_contains("'source_file1' -> 'sub5/a/b/c/file'"); +} + +#[test] +#[cfg(unix)] +fn test_install_broken_pipe() { + use std::process::Stdio; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("source.txt", "content"); + + let mut child = scene + .ucmd() + .arg("-v") + .arg("source.txt") + .arg("dest.txt") + .set_stdout(Stdio::piped()) + .run_no_wait(); + + child.close_stdout(); + let result = child.wait().unwrap(); + assert!( + result.stderr_str().is_empty(), + "Expected no stderr output on broken pipe, got:\n{}", + result.stderr_str() + ); + + assert!(at.file_exists("dest.txt")); } +#[cfg(unix)] #[test] fn test_install_chown_file_invalid() { let scene = TestScenario::new(util_name!()); @@ -1720,6 +1813,7 @@ fn test_install_chown_file_invalid() { .stderr_contains("install: invalid user: 'test_invalid_user'"); } +#[cfg(unix)] #[test] fn test_install_chown_directory_invalid() { let scene = TestScenario::new(util_name!()); @@ -1765,8 +1859,8 @@ fn test_install_chown_directory_invalid() { .stderr_contains("install: invalid user: 'test_invalid_user'"); } +#[cfg(all(unix, not(target_os = "openbsd")))] #[test] -#[cfg(not(target_os = "openbsd"))] fn test_install_compare_option() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1849,8 +1943,8 @@ fn test_install_compare_basic() { .no_stdout(); } +#[cfg(all(unix, not(any(target_os = "openbsd", target_os = "freebsd"))))] #[test] -#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))] fn test_install_compare_special_mode_bits() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1926,8 +2020,8 @@ fn test_install_compare_special_mode_bits() { .no_stdout(); } +#[cfg(all(unix, not(target_os = "openbsd")))] #[test] -#[cfg(not(target_os = "openbsd"))] fn test_install_compare_group_ownership() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2040,6 +2134,7 @@ fn test_target_file_ends_with_slash() { .stderr_contains("failed to access 'dir/target_file/': Not a directory"); } +#[cfg(unix)] #[test] fn test_install_root_combined() { let ts = TestScenario::new(util_name!()); @@ -2069,8 +2164,8 @@ fn test_install_root_combined() { run_and_check(&["-Cv", "c", "d"], "d", 0, 0); } -#[test] #[cfg(unix)] +#[test] fn test_install_from_fifo() { use std::fs::OpenOptions; use std::io::Write; @@ -2103,8 +2198,8 @@ fn test_install_from_fifo() { assert_eq!(s.fixtures.read(target_name), test_string); } -#[test] #[cfg(unix)] +#[test] fn test_install_from_stdin() { let (at, mut ucmd) = at_and_ucmd!(); let target = "target"; @@ -2139,12 +2234,12 @@ fn test_install_same_file() { let file = "file"; at.touch(file); - ucmd.arg(file) - .arg(".") - .fails() - .stderr_contains("'file' and './file' are the same file"); + ucmd.arg(file).arg(".").fails().stderr_contains(format!( + "'file' and '.{MAIN_SEPARATOR}file' are the same file" + )); } +#[cfg(unix)] #[test] fn test_install_symlink_same_file() { let (at, mut ucmd) = at_and_ucmd!();