Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions src/uu/install/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,13 @@ uucore = { workspace = true, default-features = true, features = [

[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
rustix = { workspace = true, features = ["fs"] }
selinux = { workspace = true, optional = true }

[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["process"] }

[features]
diagnostics = ["uucore/diagnostics"]
selinux = ["dep:selinux", "uucore/selinux"]
selinux = ["uucore/selinux"]

[lints]
workspace = true
Expand Down
438 changes: 46 additions & 392 deletions src/uu/install/src/install.rs

Large diffs are not rendered by default.

35 changes: 32 additions & 3 deletions src/uucore/src/lib/features/selinux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -322,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.
Expand All @@ -333,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<PathBuf> {
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
Expand Down Expand Up @@ -583,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);
}
Expand Down
235 changes: 235 additions & 0 deletions tests/by-util/test_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2647,6 +2647,241 @@ 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<TestScenario> {
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}");
}
}

// Like GNU, -Z gives a file the policy's default context.
#[test]
#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))]
fn test_selinux_default_context_file() {
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(&["-Z", "source", "parent/file"])
.succeeds();

assert_eq!(selinux_type(&dir.path().join("parent/file")), expected);
}

// Where the policy has no default, as under /tmp, -Z leaves a file the label
// the kernel gave it, like GNU.
#[test]
#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))]
fn test_selinux_default_context_file_without_policy_default() {
if !uucore::selinux::is_selinux_enabled() {
println!("Skipping SELinux test: SELinux is not enabled");
return;
}
let (at, mut ucmd) = at_and_ucmd!();
at.touch("source");
at.touch("plain");

ucmd.args(&["-Z", "source", "dest"]).succeeds();

assert_eq!(
selinux_type(&at.plus("dest")),
selinux_type(&at.plus("plain"))
);
}

#[test]
#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))]
fn test_install_compare_with_mode_bits() {
Expand Down
56 changes: 56 additions & 0 deletions tests/by-util/test_mkdir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading