ls/ps: add switches to show SELinux security contexts

# User-Facing Changes

A new optional compile-time `selinux` feature (requires `libselinux`)
enables `ls --context` and `ps --context` to show the security contexts
of file entries and processes.
This commit is contained in:
Solomon Victorino 2024-11-25 14:16:50 -07:00
parent ebce62629e
commit ee513e4630
10 changed files with 157 additions and 3 deletions

View file

@ -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: |

45
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<String> = 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

View file

@ -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 }

View file

@ -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<Value, ShellError> {
#[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<DateTime<Local>> {
}
}
fn security_context_value(_path: &Path, span: Span) -> Result<Value, ShellError> {
#[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<DateTime<Local>> {

View file

@ -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<Value, ShellError> {
#[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,
))
}
}

View file

@ -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, "");
}

View file

@ -78,6 +78,7 @@ mod path;
mod platform;
mod prepend;
mod print;
mod ps;
#[cfg(feature = "sqlite")]
mod query;
mod random;

View file

@ -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, "");
}