From 203d45c92afa061c95ce7edb0d0c4f3465a5129e Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Tue, 13 Oct 2026 22:48:09 -0700 Subject: [PATCH 1/2] uucore: look up the -Z context of a relative name by its absolute name --- src/uucore/src/lib/features/selinux.rs | 3 +- tests/by-util/test_mkdir.rs | 56 ++++++++++++++++++++++++++ tests/by-util/test_mkfifo.rs | 48 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/uucore/src/lib/features/selinux.rs b/src/uucore/src/lib/features/selinux.rs index 4036bf1d95a..21b65258673 100644 --- a/src/uucore/src/lib/features/selinux.rs +++ b/src/uucore/src/lib/features/selinux.rs @@ -146,8 +146,9 @@ impl FsCreateContext { SeLinuxError::ContextRetrievalFailure(selinux_error_description(&e)) })?; + let path = absolute_for_policy_lookup(path); let default_context = - match labeler.look_up_by_path(path, mode.and_then(selinux::FileAccessMode::new)) { + match labeler.look_up_by_path(&path, mode.and_then(selinux::FileAccessMode::new)) { Ok(context) => context, Err(e) if is_ignorable_context_error(&e) => { return Ok(Self { diff --git a/tests/by-util/test_mkdir.rs b/tests/by-util/test_mkdir.rs index b12e90c280e..d3ffaff868a 100644 --- a/tests/by-util/test_mkdir.rs +++ b/tests/by-util/test_mkdir.rs @@ -594,6 +594,62 @@ fn test_selinux_invalid() { assert!(!at.dir_exists(dest)); } +// -Z looks the name up in the policy, which lists absolute names only: a relative +// name must get the same label as the absolute one. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_selinux_default_context_relative_name() { + use uucore::selinux::{ + get_selinux_security_context, is_selinux_enabled, set_selinux_security_context, + }; + use uutests::util::AtPath; + + if !is_selinux_enabled() { + println!("Skipping SELinux test: SELinux is not enabled"); + return; + } + // The policy has no default under /tmp, so both names would inherit there; + // target/tmp is usually under $HOME, which has one. + let dir = tempfile::tempdir_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let at = AtPath::new(dir.path()); + let scene = TestScenario::new(util_name!()); + // A type the policy won't hand out here, so a missed lookup shows as inheritance. + at.mkdir("parent"); + let ctx = String::from("root:object_r:etc_t:s0"); + if set_selinux_security_context(&at.plus("parent"), Some(&ctx)).is_err() { + println!("Skipping SELinux test: cannot set a context on the fixture"); + return; + } + let type_of = |p: &str| { + let ctx = get_selinux_security_context(&at.plus(p), false).unwrap(); + ctx.split(':').nth(2).unwrap().to_owned() + }; + + scene + .ucmd() + .args(&["-Z", &at.plus_as_string("parent/absolute")]) + .succeeds(); + let expected = type_of("parent/absolute"); + if expected == "etc_t" { + println!("Skipping SELinux test: the policy has no default for {dir:?}"); + return; + } + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-Z", "parent/relative"]) + .succeeds(); + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-p", "-Z", "parent/new/relative"]) + .succeeds(); + + for name in ["parent/relative", "parent/new", "parent/new/relative"] { + assert_eq!(type_of(name), expected, "{name}"); + } +} + #[test] fn test_mkdir_deep_nesting() { // Regression test for stack overflow with deeply nested directories. diff --git a/tests/by-util/test_mkfifo.rs b/tests/by-util/test_mkfifo.rs index d2b41f72415..58b93d358f1 100644 --- a/tests/by-util/test_mkfifo.rs +++ b/tests/by-util/test_mkfifo.rs @@ -215,6 +215,54 @@ fn test_mkfifo_selinux_invalid() { } } +// -Z looks the name up in the policy, which lists absolute names only: a relative +// name must get the same label as the absolute one. +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_mkfifo_selinux_default_context_relative_name() { + use uucore::selinux::{ + get_selinux_security_context, is_selinux_enabled, set_selinux_security_context, + }; + use uutests::util::AtPath; + + if !is_selinux_enabled() { + println!("Skipping SELinux test: SELinux is not enabled"); + return; + } + // The policy has no default under /tmp, so both names would inherit there; + // target/tmp is usually under $HOME, which has one. + let dir = tempfile::tempdir_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let at = AtPath::new(dir.path()); + let scene = TestScenario::new(util_name!()); + // A type the policy won't hand out here, so a missed lookup shows as inheritance. + at.mkdir("parent"); + let ctx = String::from("root:object_r:etc_t:s0"); + if set_selinux_security_context(&at.plus("parent"), Some(&ctx)).is_err() { + println!("Skipping SELinux test: cannot set a context on the fixture"); + return; + } + let type_of = |p: &str| { + let ctx = get_selinux_security_context(&at.plus(p), false).unwrap(); + ctx.split(':').nth(2).unwrap().to_owned() + }; + + scene + .ucmd() + .args(&["-Z", &at.plus_as_string("parent/absolute")]) + .succeeds(); + let expected = type_of("parent/absolute"); + if expected == "etc_t" { + println!("Skipping SELinux test: the policy has no default for {dir:?}"); + return; + } + scene + .ucmd() + .current_dir(dir.path()) + .args(&["-Z", "parent/relative"]) + .succeeds(); + assert_eq!(type_of("parent/relative"), expected); +} + #[test] fn test_mkfifo_permission_unchanged_when_failed() { use uucore::fs::display_permissions; From c28736c8b0fa3abbfb43d061abf71bd2d3e25aba Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Tue, 13 Oct 2026 22:13:44 -0700 Subject: [PATCH 2/2] install: label only the directories it creates --- src/uu/install/src/install.rs | 148 ++++++------------- src/uucore/src/lib/features/selinux.rs | 32 +++- tests/by-util/test_install.rs | 196 +++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 108 deletions(-) diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index cb938bf0f2b..4897ba2f290 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -489,18 +489,32 @@ 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 @@ -517,13 +531,6 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { 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(), @@ -543,17 +550,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); @@ -696,6 +692,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 @@ -716,7 +715,14 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { { // Use DEFAULT_MODE (0o755) for created directories - this matches GNU install // behavior. The actual mode will be modified by umask at the kernel level. - match create_dir_all_safe(to_create, DEFAULT_MODE) { + let created = create_dir_all_safe(to_create, DEFAULT_MODE); + // 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 @@ -726,16 +732,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 @@ -1488,80 +1484,20 @@ 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 -Z or --context, label the directories created while the returned guard lives. +/// One policy lookup for `path` serves all of them; GNU looks up each component. #[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 -} - -/// 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)); -/// ``` -#[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)); - } -} - -/// 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)); - } +fn directory_creation_context( + path: &Path, + b: &Behavior, +) -> Result, InstallError> { + if !should_set_selinux_context(b) || !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), get_context_for_selinux(b)) + .map(Some) + .map_err(|e| InstallError::SelinuxContextFailed(e.to_string())) } #[cfg(test)] 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 63fd335ae24..abbaa03f0bf 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2647,6 +2647,202 @@ 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() { + use uucore::selinux::get_selinux_security_context; + + let Some(scene) = selinux_scene_with_existing_dir() else { + return; + }; + let at = &scene.fixtures; + let ctx_of = |p: &str| get_selinux_security_context(&at.plus(p), false).unwrap(); + let before = ctx_of("existing"); + + scene + .ucmd() + .args(&[ + "-d", + "--context=unconfined_u:object_r:user_tmp_t:s0", + "existing/new/deeper", + "existing", + ]) + .succeeds(); + + assert_eq!(ctx_of("existing"), before); + for dir in ["existing/new", "existing/new/deeper"] { + assert!( + ctx_of(dir).contains(":user_tmp_t:"), + "{dir}: {}", + ctx_of(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() { + use uucore::selinux::get_selinux_security_context; + + let Some(scene) = selinux_scene_with_existing_dir() else { + return; + }; + let at = &scene.fixtures; + let ctx_of = |p: &str| get_selinux_security_context(&at.plus(p), false).unwrap(); + let before = ctx_of("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!(ctx_of("existing"), before); + for path in [ + "existing/leading", + "existing/leading/deeper", + "existing/leading/deeper/file", + ] { + assert!( + ctx_of(path).contains(":user_tmp_t:"), + "{path}: {}", + ctx_of(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}"); + } +} + #[test] #[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))] fn test_install_compare_with_mode_bits() {