diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3daa9a466..5d5a3e18fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1.10.1 - name: Tests - run: cargo test --workspace --profile ci --exclude nu_plugin_* + run: cargo test --workspace --profile ci --features selinux --exclude nu_plugin_* - name: Check for clean repo shell: bash run: | diff --git a/Cargo.lock b/Cargo.lock index fac1ad9ff1..e4e89939a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -3416,6 +3418,7 @@ dependencies = [ "rstest", "rusqlite", "scopeguard", + "selinux", "serde", "serde_json", "serde_urlencoded", @@ -5138,6 +5141,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.90", +] + [[package]] name = "print-positions" version = "0.6.1" @@ -5643,6 +5656,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "reference-counted-singleton" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5daffa8f5ca827e146485577fa9dba9bd9c6921e06e954ab8f6408c10f753086" + [[package]] name = "regex" version = "1.11.1" @@ -6184,6 +6203,32 @@ dependencies = [ "smallvec", ] +[[package]] +name = "selinux" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0139b2436c81305eb6bda33af151851f75bd62783817b25f44daa371119c30b5" +dependencies = [ + "bitflags 2.6.0", + "libc", + "once_cell", + "reference-counted-singleton", + "selinux-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "selinux-sys" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e6e2b8e07a8ff45c90f8e3611bf10c4da7a28d73a26f9ede04f927da234f52" +dependencies = [ + "bindgen", + "cc", + "dunce", + "walkdir", +] + [[package]] name = "semver" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 97bc58c0bd..e3ecadc4eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,7 @@ rstest = { version = "0.23", default-features = false } rusqlite = "0.31" rust-embed = "8.5.0" scopeguard = { version = "1.2.0" } +selinux = "0.4.6" serde = { version = "1.0" } serde_json = "1.0" serde_urlencoded = "0.7.1" @@ -293,6 +294,8 @@ trash-support = ["nu-command/trash-support", "nu-cmd-lang/trash-support"] # SQLite commands for nushell sqlite = ["nu-command/sqlite", "nu-cmd-lang/sqlite"] +selinux = ["nu-command/selinux"] + [profile.release] opt-level = "s" # Optimize for size strip = "debuginfo" diff --git a/crates/nu-cli/tests/completions/mod.rs b/crates/nu-cli/tests/completions/mod.rs index e32ac8c4f2..335269c4bb 100644 --- a/crates/nu-cli/tests/completions/mod.rs +++ b/crates/nu-cli/tests/completions/mod.rs @@ -1073,10 +1073,11 @@ fn flag_completions() { // Test completions for the 'ls' flags let suggestions = completer.complete("ls -", 4); - assert_eq!(18, suggestions.len()); + assert_eq!(20, suggestions.len()); let expected: Vec = vec![ "--all".into(), + "--context".into(), "--directory".into(), "--du".into(), "--full-paths".into(), @@ -1094,6 +1095,7 @@ fn flag_completions() { "-m".into(), "-s".into(), "-t".into(), + "-Z".into(), ]; // Match results diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index ef88aa92e4..11b0da348b 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -81,6 +81,7 @@ roxmltree = { workspace = true } rusqlite = { workspace = true, features = ["bundled", "backup", "chrono"], optional = true } rmp = { workspace = true } scopeguard = { workspace = true } +selinux = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } serde_urlencoded = { workspace = true } diff --git a/crates/nu-command/src/filesystem/ls.rs b/crates/nu-command/src/filesystem/ls.rs index 1e9e66c96b..6abb2210a5 100644 --- a/crates/nu-command/src/filesystem/ls.rs +++ b/crates/nu-command/src/filesystem/ls.rs @@ -31,6 +31,7 @@ struct Args { directory: bool, use_mime_type: bool, use_threads: bool, + security_context: bool, call_span: Span, } @@ -58,7 +59,7 @@ impl Command for Ls { .switch("all", "Show hidden files", Some('a')) .switch( "long", - "Get all available columns for each entry (slower; columns are platform-dependent)", + "Get almost all available columns for each entry (slower; columns are platform-dependent)", Some('l'), ) .switch( @@ -79,6 +80,14 @@ impl Command for Ls { ) .switch("mime-type", "Show mime-type in type column instead of 'file' (based on filenames only; files' contents are not examined)", Some('m')) .switch("threads", "Use multiple threads to list contents. Output will be non-deterministic.", Some('t')) + .switch( + "context", + match cfg!(feature = "selinux") { + true => "Get the SELinux security context for each entry, if available", + false => "Get the SELinux security context for each entry (disabled)" + }, + Some('Z'), + ) .category(Category::FileSystem) } @@ -97,6 +106,7 @@ impl Command for Ls { let directory = call.has_flag(engine_state, stack, "directory")?; let use_mime_type = call.has_flag(engine_state, stack, "mime-type")?; let use_threads = call.has_flag(engine_state, stack, "threads")?; + let security_context = call.has_flag(engine_state, stack, "context")?; let call_span = call.head; #[allow(deprecated)] let cwd = current_dir(engine_state, stack)?; @@ -110,6 +120,7 @@ impl Command for Ls { directory, use_mime_type, use_threads, + security_context, call_span, }; @@ -252,6 +263,7 @@ fn ls_for_one_pattern( use_mime_type, use_threads, call_span, + security_context, } = args; let pattern_arg = { if let Some(path) = pattern_arg { @@ -439,6 +451,7 @@ fn ls_for_one_pattern( &signals_clone, use_mime_type, args.full_paths, + security_context, ); match entry { Ok(value) => Some(value), @@ -557,6 +570,7 @@ pub(crate) fn dir_entry_dict( signals: &Signals, use_mime_type: bool, full_symlink_target: bool, + security_context: bool, ) -> Result { #[cfg(windows)] if metadata.is_none() { @@ -613,6 +627,13 @@ pub(crate) fn dir_entry_dict( } } + if security_context { + record.push( + "security_context", + security_context_value(filename, span).unwrap_or(Value::nothing(span)), // TODO: consider report_shell_warning + ) + } + if long { if let Some(md) = metadata { record.push("readonly", Value::bool(md.permissions().readonly(), span)); @@ -765,6 +786,28 @@ fn try_convert_to_local_date_time(t: SystemTime) -> Option> { } } +fn security_context_value(_path: &Path, span: Span) -> Result { + #[cfg(not(all(feature = "selinux", target_os = "linux")))] + return Ok(Value::nothing(span)); + + #[cfg(all(feature = "selinux", target_os = "linux"))] + { + use selinux; + match selinux::SecurityContext::of_path(_path, false, false) + .map_err(|e| ShellError::IOError { msg: e.to_string() })? + { + Some(con) => { + let bytes = con.as_bytes(); + Ok(Value::string( + String::from_utf8_lossy(&bytes[0..bytes.len().saturating_sub(1)]), + span, + )) + } + None => Ok(Value::nothing(span)), + } + } +} + // #[cfg(windows)] is just to make Clippy happy, remove if you ever want to use this on other platforms #[cfg(windows)] fn unix_time_to_local_date_time(secs: i64) -> Option> { diff --git a/crates/nu-command/src/system/ps.rs b/crates/nu-command/src/system/ps.rs index cece41e56f..d60228f229 100644 --- a/crates/nu-command/src/system/ps.rs +++ b/crates/nu-command/src/system/ps.rs @@ -24,6 +24,14 @@ impl Command for Ps { "list all available columns for each entry", Some('l'), ) + .switch( + "context", + match cfg!(feature = "selinux") { + true => "get the security context for each entry, if available", + false => "get the security context for each entry (disabled)", + }, + Some('Z'), + ) .filter() .category(Category::System) } @@ -85,6 +93,7 @@ fn run_ps( let mut output = vec![]; let span = call.head; let long = call.has_flag(engine_state, stack, "long")?; + let security_context = call.has_flag(engine_state, stack, "context")?; for proc in nu_system::collect_proc(Duration::from_millis(100), false) { let mut record = Record::new(); @@ -103,6 +112,13 @@ fn run_ps( record.push("mem", Value::filesize(proc.mem_size() as i64, span)); record.push("virtual", Value::filesize(proc.virtual_size() as i64, span)); + if security_context { + record.push( + "security_context", + security_context_value(proc.pid(), span).unwrap_or(Value::nothing(span)), // TODO: consider report_shell_warning + ); + } + if long { record.push("command", Value::string(proc.command(), span)); #[cfg(target_os = "linux")] @@ -189,3 +205,19 @@ fn run_ps( Ok(output.into_pipeline_data(span, engine_state.signals().clone())) } + +fn security_context_value(_pid: i32, span: Span) -> Result { + #[cfg(not(all(feature = "selinux", target_os = "linux")))] + return Ok(Value::nothing(span)); + + #[cfg(all(feature = "selinux", target_os = "linux"))] + { + use selinux; + let con = selinux::SecurityContext::of_process(_pid, false) + .map_err(|e| ShellError::IOError { msg: e.to_string() })?; + Ok(Value::string( + String::from_utf8_lossy(&con.as_bytes()).trim_ascii_end(), + span, + )) + } +} diff --git a/crates/nu-command/tests/commands/ls.rs b/crates/nu-command/tests/commands/ls.rs index 416a6aaf7f..85af92e452 100644 --- a/crates/nu-command/tests/commands/ls.rs +++ b/crates/nu-command/tests/commands/ls.rs @@ -862,3 +862,17 @@ fn consistent_list_order() { assert_eq!(no_arg.out, with_arg.out); }) } + +#[cfg(all(feature = "selinux", target_os = "linux"))] +#[test] +fn returns_correct_security_context() { + use nu_test_support::nu_with_std; + + let input = " + use std assert + ^ls -Z / | lines | each { |e| $e | str trim | split column ' ' 'coreutils_scontext' 'name' | first } \ + | join (ls -Z / | each { default '?' security_context }) name \ + | each { |e| assert equal $e.security_context $e.coreutils_scontext $'For entry ($e.name) expected ($e.coreutils_scontext), got ($e.security_context)' } + "; + assert_eq!(nu_with_std!(input).err, ""); +} diff --git a/crates/nu-command/tests/commands/mod.rs b/crates/nu-command/tests/commands/mod.rs index a8ffdeb917..b66bc08439 100644 --- a/crates/nu-command/tests/commands/mod.rs +++ b/crates/nu-command/tests/commands/mod.rs @@ -78,6 +78,7 @@ mod path; mod platform; mod prepend; mod print; +mod ps; #[cfg(feature = "sqlite")] mod query; mod random; diff --git a/crates/nu-command/tests/commands/ps.rs b/crates/nu-command/tests/commands/ps.rs new file mode 100644 index 0000000000..47a8d054ba --- /dev/null +++ b/crates/nu-command/tests/commands/ps.rs @@ -0,0 +1,13 @@ +#[cfg(all(feature = "selinux", target_os = "linux"))] +#[test] +fn returns_correct_security_context() { + use nu_test_support::nu_with_std; + + let input = " + use std assert + ^ps -o pid=,label= | lines | each { str trim | split column ' ' 'pid' 'procps_scontext' } | flatten \ + | join (ps -Z | each { default '-' security_context }) pid \ + | each { |e| assert equal $e.security_context $e.procps_scontext $'For process ($e.pid) expected ($e.procps_scontext), got ($e.security_context)' } + "; + assert_eq!(nu_with_std!(input).err, ""); +}