diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 17ccaa2b9ed..362063530fd 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -505,41 +505,47 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { } for path in paths.iter().map(Path::new) { + // Special case to match GNU's behavior: + // install -d foo/. should work and just create foo/ + // std::fs::create_dir("foo/."); fails in pure Rust + // See also mkdir.rs for another occurrence of this + let path_to_create = dir_strip_dot_for_creation(path); + // Set up first: like GNU, an unusable context fails even when nothing is created. + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] + let _selinux_guard = match directory_creation_context(&path_to_create, b) { + Ok(guard) => guard, + Err(e) => { + show!(e); + continue; + } + }; // if the path already exist, check if it's a file if path.exists() { if !path.is_dir() { show!(InstallError::ExistingFileNotADirectory(path.to_path_buf())); continue; } + // Like GNU, -Z resets an existing target to its default context; --context doesn't. + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] + if b.privileged && b.default_context && is_selinux_enabled() { + show_if_err!(set_selinux_security_context(path, None)); + } } else { - // Special case to match GNU's behavior: - // install -d foo/. should work and just create foo/ - // std::fs::create_dir("foo/."); fails in pure Rust - // See also mkdir.rs for another occurrence of this - let path_to_create = dir_strip_dot_for_creation(path); // Differently than the primary functionality // (MainFunction::Standard), the directory functionality should // create all ancestors (or components) of a directory // regardless of the presence of the "-D" flag. // // Only the target gets the requested mode; newly created ancestors - // use DEFAULT_MODE, which create_dir_all would request as 0777. - let mut builder = fs::DirBuilder::new(); - builder.recursive(true).mode(DEFAULT_MODE); - if let Err(e) = builder.create(&path_to_create).map_err_context( + // use DEFAULT_MODE. + let created = create_dir_all_with(&path_to_create, label_new_directories(b)); + if let Err(e) = created.map_err_context( || translate!("install-error-create-dir-failed", "path" => path_to_create.quote()), ) { show!(e); continue; } - // Set SELinux context for all created directories if needed - #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] - 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); - } - if b.verbose { writeln!( stdout(), @@ -559,17 +565,6 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { continue; } - // Set SELinux context for directory if needed - #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] - if b.privileged { - 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 mode::chmod(path, b.mode()).is_err() { // Error messages are printed by the mode::chmod function! uucore::error::set_exit_code(1); @@ -711,6 +706,9 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { target_filename = Some(filename.to_os_string()); } } else { + // Before the -v lines, so a context error reports no directories. + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] + let selinux_guard = directory_creation_context(to_create, b)?; if b.verbose { let mut result = PathBuf::new(); // When creating directories with -Dv, show directory creations step by step @@ -729,7 +727,15 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { #[cfg(not(target_os = "redox"))] { - match create_dir_all_safe(to_create, DEFAULT_MODE) { + let created = + create_dir_all_safe(to_create, DEFAULT_MODE, label_new_directories(b)); + // End the creation context before any file is created. + #[cfg(all( + feature = "selinux", + any(target_os = "linux", target_os = "android") + ))] + drop(selinux_guard); + match created { Ok(dir_fd) => { if b.target_dir.is_none() && sources.len() == 1 @@ -739,16 +745,6 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { target_parent_fd = Some(dir_fd); target_filename = Some(filename.to_os_string()); } - - // Set SELinux context for all created directories if needed - #[cfg(all( - feature = "selinux", - any(target_os = "linux", target_os = "android") - ))] - if should_set_selinux_context(b) { - let context = get_context_for_selinux(b); - set_selinux_context_for_directories_install(to_create, context); - } } Err(e) => { if e.kind() == std::io::ErrorKind::AlreadyExists @@ -1520,80 +1516,63 @@ fn derive_context_from_parent(parent_context: &str) -> String { } } -/// Helper function to collect paths that need `SELinux` context setting. -/// -/// Traverses from the given starting path up to existing parent directories. -/// Returns a vector of paths in reverse order (from parent to child). +/// With --context, label the directories created while the returned guard lives. +/// -Z looks up each one instead, in `label_new_directories`. #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] -fn collect_paths_for_context_setting(starting_path: &Path) -> Vec<&Path> { - let mut paths: Vec<&Path> = starting_path - .ancestors() - .take_while(|p| p.exists()) - .collect(); - paths.reverse(); - paths +fn directory_creation_context( + path: &Path, + b: &Behavior, +) -> Result, InstallError> { + if b.default_context || !should_set_selinux_context(b) || !is_selinux_enabled() { + return Ok(None); + } + uucore::selinux::FsCreateContext::new(path, None, b.context.as_ref()) + .map(Some) + .map_err(|e| InstallError::SelinuxContextFailed(e.to_string())) } -/// Sets the `SELinux` security context for a directory hierarchy. -/// -/// This function traverses from the given starting path up to existing parent directories -/// and sets the `SELinux` context on each directory in the hierarchy (from parent to child). -/// This is useful when creating directory structures and needing to set contexts on all -/// created directories. -/// -/// # Arguments -/// -/// * `target_path` - The target path (typically the deepest directory in a hierarchy) -/// * `context` - Optional `SELinux` context string to set. If None, sets default context. -/// -/// # Behavior -/// -/// - Traverses from `target_path` upward to find existing parent directories -/// - Sets the context on each directory in reverse order (parent to child) -/// - Uses `show_if_err!` to handle errors gracefully without panicking -/// - Stops at filesystem root ("/") or empty path to prevent infinite loops -/// - Only processes paths that exist on the filesystem -/// - Silently handles `SELinux` context setting failures -/// -/// # Examples -/// -/// ```no_run -/// use std::path::Path; -/// -/// // Set default context on directory hierarchy -/// // set_selinux_context_for_directories(Path::new("/tmp/new/deep/dir"), None); -/// -/// // Set specific context on directory hierarchy -/// // let context = String::from("user_u:object_r:tmp_t:s0"); -/// // set_selinux_context_for_directories(Path::new("/tmp/new/deep/dir"), Some(&context)); -/// ``` +/// The `before_create` hook for directory creation. With -Z, like GNU, each new +/// directory is labelled with the policy's default for its own name. #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] -fn set_selinux_context_for_directories(target_path: &Path, context: Option<&String>) { - for path in collect_paths_for_context_setting(target_path) { - show_if_err!(set_selinux_security_context(path, context)); +fn label_new_directories( + b: &Behavior, +) -> impl FnMut(&Path) -> std::io::Result> + '_ { + move |path| { + if !(b.privileged && b.default_context && is_selinux_enabled()) { + return Ok(None); + } + let mode = uucore::libc::S_IFDIR | DEFAULT_MODE as uucore::libc::mode_t; + uucore::selinux::FsCreateContext::new(path, Some(mode), None) + .map(Some) + .map_err(std::io::Error::other) } } -/// Sets `SELinux` context for created directories using install's -Z default behavior. -/// -/// Similar to `set_selinux_context_for_directories` but uses install's -/// specific default context derivation when no context is provided. -/// -/// # Arguments -/// -/// * `target_path` - The target path (typically the deepest directory in a hierarchy) -/// * `context` - Optional `SELinux` context string to set. If None, uses install's default derivation. -#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] -pub fn set_selinux_context_for_directories_install(target_path: &Path, context: Option<&String>) { - if context.is_some() { - // Use the standard function for explicit contexts - set_selinux_context_for_directories(target_path, context); - } else { - // For default context, we need our custom install behavior - for path in collect_paths_for_context_setting(target_path) { - show_if_err!(set_selinux_default_context(path)); +#[cfg(not(all(feature = "selinux", any(target_os = "linux", target_os = "android"))))] +fn label_new_directories(_b: &Behavior) -> impl FnMut(&Path) -> std::io::Result<()> { + |_| Ok(()) +} + +/// Like [`fs::create_dir_all`], but calls `before_create` with each missing directory +/// first and keeps what it returns until that directory is created. +fn create_dir_all_with( + path: &Path, + mut before_create: impl FnMut(&Path) -> std::io::Result, +) -> std::io::Result<()> { + let missing: Vec<&Path> = path + .ancestors() + .take_while(|dir| !dir.as_os_str().is_empty() && !dir.exists()) + .collect(); + for dir in missing.into_iter().rev() { + let _created_with = before_create(dir)?; + if let Err(e) = fs::DirBuilder::new().mode(DEFAULT_MODE).create(dir) { + // Like fs::create_dir_all, accept a directory that appeared meanwhile. + if !(e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir()) { + return Err(e); + } } } + Ok(()) } #[cfg(test)] diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index 6b61d1cf35b..620255d4199 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -778,16 +778,24 @@ fn open_or_create_subdir(parent_fd: &DirFd, name: &OsStr, mode: u32) -> io::Resu /// * `path` - The path to create directories for /// * `mode` - The mode to use when creating new directories (e.g., 0o755). The actual /// mode will be modified by the process umask. +/// * `before_create` - Called with the path of each missing directory before it is +/// created; what it returns is dropped once that directory is open. /// /// # Returns /// A [`DirFd`] for the final created directory, or the first existing parent if /// all directories already exist. #[cfg(unix)] -pub fn create_dir_all_safe(path: &Path, mode: u32) -> io::Result { - let (existing_ancestor, components_to_create) = find_existing_ancestor(path)?; - let mut dir_fd = DirFd::open_anchor(&existing_ancestor)?; +pub fn create_dir_all_safe( + path: &Path, + mode: u32, + mut before_create: impl FnMut(&Path) -> io::Result, +) -> io::Result { + let (mut current, components_to_create) = find_existing_ancestor(path)?; + let mut dir_fd = DirFd::open_anchor(¤t)?; for component in &components_to_create { + current.push(component); + let _created_with = before_create(¤t)?; dir_fd = open_or_create_subdir(&dir_fd, component.as_os_str(), mode)?; } @@ -1565,11 +1573,18 @@ mod tests { #[test] fn test_create_dir_all_safe_creates_nested_dirs() { let temp_dir = TempDir::new().unwrap(); + fs::create_dir(temp_dir.path().join("a")).unwrap(); let nested_path = temp_dir.path().join("a/b/c"); - let dir_fd = create_dir_all_safe(&nested_path, 0o755).unwrap(); + let mut seen = Vec::new(); + let dir_fd = create_dir_all_safe(&nested_path, 0o755, |p| { + seen.push(p.to_path_buf()); + Ok(()) + }) + .unwrap(); assert!(dir_fd.as_raw_fd() >= 0); assert!(nested_path.is_dir()); + assert_eq!(seen, [temp_dir.path().join("a/b"), nested_path]); } #[test] @@ -1625,7 +1640,7 @@ mod tests { let existing_path = temp_dir.path().join("existing"); fs::create_dir(&existing_path).unwrap(); - let dir_fd = create_dir_all_safe(&existing_path, 0o755).unwrap(); + let dir_fd = create_dir_all_safe(&existing_path, 0o755, |_| Ok(())).unwrap(); assert!(dir_fd.as_raw_fd() >= 0); } @@ -1641,7 +1656,7 @@ mod tests { assert!(symlink_path.is_symlink()); // create_dir_all_safe should follow the symlink (GNU coreutils behavior) - let dir_fd = create_dir_all_safe(&symlink_path, 0o755).unwrap(); + let dir_fd = create_dir_all_safe(&symlink_path, 0o755, |_| Ok(())).unwrap(); assert!(dir_fd.as_raw_fd() >= 0); // Verify the symlink is preserved (not replaced with a real directory) @@ -1655,7 +1670,7 @@ mod tests { let file_path = temp_dir.path().join("file"); fs::write(&file_path, "content").unwrap(); - let result = create_dir_all_safe(&file_path, 0o755); + let result = create_dir_all_safe(&file_path, 0o755, |_| Ok(())); assert!(result.is_err()); } @@ -1675,7 +1690,7 @@ mod tests { // Try to create parent/link/subdir - the symlink should be followed (GNU behavior) let nested_path = symlink_in_path.join("subdir"); - let dir_fd = create_dir_all_safe(&nested_path, 0o755).unwrap(); + let dir_fd = create_dir_all_safe(&nested_path, 0o755, |_| Ok(())).unwrap(); assert!(dir_fd.as_raw_fd() >= 0); // The symlink should be preserved, not replaced diff --git a/src/uucore/src/lib/features/selinux.rs b/src/uucore/src/lib/features/selinux.rs index 21b65258673..1bf742558fa 100644 --- a/src/uucore/src/lib/features/selinux.rs +++ b/src/uucore/src/lib/features/selinux.rs @@ -10,7 +10,7 @@ use std::borrow::Cow; use std::error::Error; use std::marker::PhantomData; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::translate; use selinux::SecurityContext; @@ -323,7 +323,7 @@ fn absolute_for_policy_lookup(path: &Path) -> Cow<'_, Path> { } else { directory }; - std::fs::canonicalize(directory).map(|directory| directory.join(name)) + canonicalize_allow_missing(directory).map(|directory| directory.join(name)) } // No last component to keep: the name ends in `.` or `..`, which names // a directory and never a symbolic link, so resolve the whole of it. @@ -334,6 +334,25 @@ fn absolute_for_policy_lookup(path: &Path) -> Cow<'_, Path> { resolved.map_or(Cow::Borrowed(path), Cow::Owned) } +/// Like `canonicalize`, but a directory that does not exist yet, as for one about to be +/// created, is resolved through its parents with its own name kept. +fn canonicalize_allow_missing(directory: &Path) -> std::io::Result { + match std::fs::canonicalize(directory) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let (Some(parent), Some(name)) = (directory.parent(), directory.file_name()) else { + return Err(e); + }; + let parent = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + Ok(canonicalize_allow_missing(parent)?.join(name)) + } + resolved => resolved, + } +} + /// Gets the SELinux security context for the given filesystem path. /// /// Retrieves the security context of the specified filesystem path if SELinux is enabled @@ -584,6 +603,15 @@ mod tests { ("branch/../quokka", directory.join("quokka")), ("branch/..", directory.clone()), (".", directory.clone()), + // Parents that are about to be created, as for `install -d -Z a/b/c`. + ( + "missing/deeper/quokka", + directory.join("missing/deeper/quokka"), + ), + ( + "branch/missing/quokka", + directory.join("branch/missing/quokka"), + ), ] { assert_eq!(absolute_for_policy_lookup(Path::new(given)), expected); } diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 5a29f47fb41..305f12fa826 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2467,6 +2467,236 @@ fn test_selinux_default_context() { assert!(at.file_exists(dest)); } +// Like GNU, an unusable --context fails even when the target directory exists. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_invalid_context_existing_directory() { + if !uucore::selinux::is_selinux_enabled() { + println!("Skipping SELinux test: SELinux is not enabled"); + return; + } + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("existing"); + ucmd.args(&["-d", "--context=a", "existing"]) + .fails_with_code(1) + .stderr_contains("failed to set default file creation context"); +} + +/// A scene whose `existing` directory has a context of its own, or `None` where +/// SELinux can't be used. +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn selinux_scene_with_existing_dir() -> Option { + use uucore::selinux::{is_selinux_enabled, set_selinux_security_context}; + + if !is_selinux_enabled() { + println!("Skipping SELinux test: SELinux is not enabled"); + return None; + } + let scene = TestScenario::new(util_name!()); + scene.fixtures.mkdir("existing"); + let home_ctx = String::from("unconfined_u:object_r:user_home_t:s0"); + if set_selinux_security_context(&scene.fixtures.plus("existing"), Some(&home_ctx)).is_err() { + println!("Skipping SELinux test: cannot set a context on the fixture"); + return None; + } + Some(scene) +} + +// -d --context labels only the directories it creates; an existing target or +// parent keeps its context. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_context_only_on_created_directories() { + let Some(scene) = selinux_scene_with_existing_dir() else { + return; + }; + let at = &scene.fixtures; + let before = selinux_type(&at.plus("existing")); + + scene + .ucmd() + .args(&[ + "-d", + "--context=unconfined_u:object_r:user_tmp_t:s0", + "existing/new/deeper", + "existing", + ]) + .succeeds(); + + assert_eq!(selinux_type(&at.plus("existing")), before); + for dir in ["existing/new", "existing/new/deeper"] { + assert_eq!(selinux_type(&at.plus(dir)), "user_tmp_t", "{dir}"); + } +} + +// -D --context labels only the leading directories it creates; an existing +// parent keeps its context. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_context_only_on_created_leading_directories() { + let Some(scene) = selinux_scene_with_existing_dir() else { + return; + }; + let at = &scene.fixtures; + let before = selinux_type(&at.plus("existing")); + at.write("source", "installed content\n"); + + scene + .ucmd() + .args(&[ + "-D", + "--context=unconfined_u:object_r:user_tmp_t:s0", + "source", + "existing/leading/deeper/file", + ]) + .succeeds(); + + assert_eq!(selinux_type(&at.plus("existing")), before); + for path in [ + "existing/leading", + "existing/leading/deeper", + "existing/leading/deeper/file", + ] { + assert_eq!(selinux_type(&at.plus(path)), "user_tmp_t", "{path}"); + } + assert_eq!( + at.read("existing/leading/deeper/file"), + "installed content\n" + ); +} + +/// A dir in target/tmp (with a policy default, unlike /tmp) holding an `etc_t` `parent`, +/// and the type -Z gives a new dir there; `None` where this can't be set up. +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn selinux_scene_in_policy_default_dir() -> Option<(TestScenario, tempfile::TempDir, String)> { + use uucore::selinux::{is_selinux_enabled, set_selinux_security_context}; + + if !is_selinux_enabled() { + println!("Skipping SELinux test: SELinux is not enabled"); + return None; + } + let dir = tempfile::tempdir_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); + fs::create_dir(dir.path().join("parent")).unwrap(); + let ctx = String::from("root:object_r:etc_t:s0"); + if set_selinux_security_context(&dir.path().join("parent"), Some(&ctx)).is_err() { + println!("Skipping SELinux test: cannot set a context on the fixture"); + return None; + } + let scene = TestScenario::new(util_name!()); + let absolute = dir.path().join("parent/absolute"); + scene.ucmd().arg("-d").arg("-Z").arg(&absolute).succeeds(); + let expected = selinux_type(&absolute); + if expected == "etc_t" { + println!("Skipping SELinux test: the policy has no default for {dir:?}"); + return None; + } + Some((scene, dir, expected)) +} + +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn selinux_type(path: &std::path::Path) -> String { + let ctx = uucore::selinux::get_selinux_security_context(path, false).unwrap(); + ctx.split(':').nth(2).unwrap().to_owned() +} + +// Like GNU, -d -Z resets an existing target to the policy default. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_default_context_resets_existing_directory() { + let Some((scene, dir, expected)) = selinux_scene_in_policy_default_dir() else { + return; + }; + fs::create_dir(dir.path().join("parent/existing")).unwrap(); + assert_eq!(selinux_type(&dir.path().join("parent/existing")), "etc_t"); + + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-d", "-Z", "parent/existing"]) + .succeeds(); + + assert_eq!(selinux_type(&dir.path().join("parent/existing")), expected); +} + +// -Z looks a relative name up by its absolute name, also when its parents are +// created by the same command. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_default_context_relative_name_missing_parents() { + let Some((scene, dir, expected)) = selinux_scene_in_policy_default_dir() else { + return; + }; + fs::write(dir.path().join("source"), "content").unwrap(); + + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-d", "-Z", "parent/new/deeper"]) + .succeeds(); + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-D", "-Z", "source", "parent/leading/deeper/file"]) + .succeeds(); + + for name in [ + "parent/new", + "parent/new/deeper", + "parent/leading", + "parent/leading/deeper", + ] { + assert_eq!(selinux_type(&dir.path().join(name)), expected, "{name}"); + } +} + +// Like GNU, -Z looks up each created directory, not just the last one. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_default_context_per_created_directory() { + let Some((scene, dir, expected)) = selinux_scene_in_policy_default_dir() else { + return; + }; + // Fedora's policy gives `.snapshots` under /home its own type at any depth. + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-d", "-Z", "parent/.snapshots"]) + .succeeds(); + let snapshots = selinux_type(&dir.path().join("parent/.snapshots")); + if snapshots == expected { + println!("Skipping SELinux test: the policy has no type of its own for .snapshots"); + return; + } + fs::write(dir.path().join("source"), "content").unwrap(); + + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-d", "-Z", "parent/new/.snapshots/deeper"]) + .succeeds(); + scene + .ucmd() + .current_dir(dir.path()) + .args(&[ + "-D", + "-Z", + "source", + "parent/leading/.snapshots/deeper/file", + ]) + .succeeds(); + + for (name, ty) in [ + ("parent/new", &expected), + ("parent/new/.snapshots", &snapshots), + ("parent/new/.snapshots/deeper", &snapshots), + ("parent/leading", &expected), + ("parent/leading/.snapshots", &snapshots), + ("parent/leading/.snapshots/deeper", &snapshots), + ] { + assert_eq!(&selinux_type(&dir.path().join(name)), ty, "{name}"); + } +} + #[test] #[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))] fn test_install_compare_with_mode_bits() {