Skip to content
Merged
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: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions sentinel-capabilities/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ chrono = { workspace = true }
tokio = { workspace = true }
tempfile = { workspace = true }
mockall = { workspace = true }
uuid = { workspace = true }
114 changes: 114 additions & 0 deletions sentinel-capabilities/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ impl Capability for NetworkInterfaces {
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use sentinel_exec::{CommandExecutorTrait, CommandOutput};
use uuid::Uuid;

struct DummyExecutor;
#[async_trait::async_trait]
Expand All @@ -266,6 +268,118 @@ mod tests {
Arc::new(DummyExecutor)
}

/// Mock executor that records each invocation and returns canned stdout
/// per program (always exit 0). Lets us drive `invoke()` without the OS.
struct MockExecutor {
calls: Mutex<Vec<(String, Vec<String>)>>,
}

impl MockExecutor {
fn new() -> Arc<Self> {
Arc::new(Self { calls: Mutex::new(Vec::new()) })
}
}

#[async_trait::async_trait]
impl CommandExecutorTrait for MockExecutor {
async fn run(
&self,
program: &str,
args: &[&str],
_env: &HashMap<String, String>,
_max_output_bytes: usize,
) -> Result<CommandOutput, sentinel_exec::ExecError> {
self.calls
.lock()
.unwrap()
.push((program.to_string(), args.iter().map(|s| s.to_string()).collect()));

let stdout = match program {
// `which <tool>` succeeds → primary tool (ss / ip) is selected.
"which" => "/usr/bin/tool\n",
"ss" => {
"Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\n\
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n\
tcp ESTAB 0 0 192.168.1.1:22 10.0.0.1:54321\n"
}
"netstat" => "Proto Recv-Q Send-Q Local Address Foreign Address State\n",
"ip" => {
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN\n \
inet 127.0.0.1/8 scope host lo\n\
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP\n \
inet 192.168.1.10/24 brd 192.168.1.255 scope global eth0\n"
}
"ifconfig" => "",
_ => "",
};

Ok(CommandOutput {
exit_code: Some(0),
stdout: stdout.to_string(),
stderr: String::new(),
truncated: false,
})
}
}

fn ctx() -> ExecutionContext {
ExecutionContext::new(Uuid::new_v4(), "localhost")
}

#[tokio::test]
async fn network_connections_invoke_parses_ss_output() {
let exec = MockExecutor::new();
let cap = NetworkConnections::new(Arc::clone(&exec) as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({}), &ctx()).await;
assert!(result.is_success());
if let CapabilityResult::Success { output } = result {
assert_eq!(output["tool"], "ss");
// Two data lines (header skipped) → two connections.
assert_eq!(output["count"], 2);
assert_eq!(output["connections"][0]["proto"], "tcp");
assert_eq!(output["connections"][0]["state"], "LISTEN");
}

// Verify it actually shelled out to `ss -tuln` (no shell, explicit args).
let calls = exec.calls.lock().unwrap();
assert!(calls.iter().any(|(p, a)| p == "ss" && a == &["-tuln"]));
}

#[tokio::test]
async fn network_connections_invoke_state_filter() {
let exec = MockExecutor::new();
let cap = NetworkConnections::new(exec as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({ "state": "LISTEN" }), &ctx()).await;
assert!(result.is_success());
if let CapabilityResult::Success { output } = result {
// Only the LISTEN line survives the filter.
assert_eq!(output["count"], 1);
assert_eq!(output["connections"][0]["state"], "LISTEN");
}
}

#[tokio::test]
async fn network_interfaces_invoke_parses_ip_output() {
let exec = MockExecutor::new();
let cap = NetworkInterfaces::new(Arc::clone(&exec) as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({}), &ctx()).await;
assert!(result.is_success());
if let CapabilityResult::Success { output } = result {
assert_eq!(output["tool"], "ip");
let ifaces = output["interfaces"].as_array().unwrap();
assert_eq!(ifaces.len(), 2);
assert_eq!(ifaces[0]["name"], "lo");
assert_eq!(ifaces[1]["name"], "eth0");
assert_eq!(ifaces[1]["state"], "UP");
}

let calls = exec.calls.lock().unwrap();
assert!(calls.iter().any(|(p, a)| p == "ip" && a == &["addr"]));
}

#[test]
fn network_connections_no_args() {
let cap = NetworkConnections::new(make_executor());
Expand Down
128 changes: 128 additions & 0 deletions sentinel-capabilities/src/packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ impl Capability for PackageUpgrade {
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use sentinel_exec::{CommandExecutorTrait, CommandOutput};
use uuid::Uuid;

struct DummyExecutor;
#[async_trait::async_trait]
Expand All @@ -343,6 +345,132 @@ mod tests {
Arc::new(DummyExecutor)
}

/// Mock executor: records calls and returns canned stdout (exit 0). `which`
/// succeeds for every candidate, so `apt` (first probed) is selected.
struct MockExecutor {
calls: Mutex<Vec<(String, Vec<String>)>>,
}

impl MockExecutor {
fn new() -> Arc<Self> {
Arc::new(Self { calls: Mutex::new(Vec::new()) })
}
}

#[async_trait::async_trait]
impl CommandExecutorTrait for MockExecutor {
async fn run(
&self,
program: &str,
args: &[&str],
_env: &HashMap<String, String>,
_max_output_bytes: usize,
) -> Result<CommandOutput, sentinel_exec::ExecError> {
self.calls
.lock()
.unwrap()
.push((program.to_string(), args.iter().map(|s| s.to_string()).collect()));

let stdout = match program {
"which" => "/usr/bin/apt\n",
"apt" if args.contains(&"list") => {
"Listing... Done\n\
nginx/stable 1.18.0 amd64 [installed]\n\
openssl/stable 1.1.1 amd64 [installed]\n"
}
"apt" => "Reading package lists... Done\nUpgrading nginx (1.18.0 -> 1.18.1)\n",
_ => "",
};

Ok(CommandOutput {
exit_code: Some(0),
stdout: stdout.to_string(),
stderr: String::new(),
truncated: false,
})
}
}

fn ctx() -> ExecutionContext {
ExecutionContext::new(Uuid::new_v4(), "localhost")
}

#[tokio::test]
async fn package_list_invoke_uses_apt() {
let exec = MockExecutor::new();
let cap = PackageList::new(Arc::clone(&exec) as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({}), &ctx()).await;
assert!(result.is_success());
if let CapabilityResult::Success { output } = result {
assert_eq!(output["package_manager"], "apt");
// Three stdout lines → three "packages" (no header skipping here).
assert_eq!(output["count"], 3);
}

let calls = exec.calls.lock().unwrap();
assert!(calls.iter().any(|(p, a)| p == "apt" && a == &["list", "--installed"]));
}

#[tokio::test]
async fn package_list_invoke_filter() {
let exec = MockExecutor::new();
let cap = PackageList::new(exec as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({ "filter": "nginx" }), &ctx()).await;
assert!(result.is_success());
if let CapabilityResult::Success { output } = result {
// Only the nginx line matches the filter.
assert_eq!(output["count"], 1);
}
}

#[tokio::test]
async fn package_upgrade_all_invoke() {
let exec = MockExecutor::new();
let cap = PackageUpgrade::new(Arc::clone(&exec) as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({ "all": true }), &ctx()).await;
assert!(result.is_success());
if let CapabilityResult::Success { output } = result {
assert_eq!(output["package_manager"], "apt");
assert_eq!(output["success"], true);
}

let calls = exec.calls.lock().unwrap();
assert!(calls.iter().any(|(p, a)| p == "apt" && a == &["upgrade", "-y"]));
}

#[tokio::test]
async fn package_upgrade_specific_invoke() {
let exec = MockExecutor::new();
let cap = PackageUpgrade::new(Arc::clone(&exec) as Arc<dyn CommandExecutorTrait>);

let result = cap.invoke(json!({ "packages": ["nginx"] }), &ctx()).await;
assert!(result.is_success());

// No shell — explicit `apt install --only-upgrade -y nginx`.
let calls = exec.calls.lock().unwrap();
assert!(calls
.iter()
.any(|(p, a)| p == "apt" && a == &["install", "--only-upgrade", "-y", "nginx"]));
}

#[tokio::test]
async fn package_upgrade_inverse_returns_failure() {
let cap = PackageUpgrade::new(make_executor());
let result = cap.invoke_inverse(json!({ "all": true }), &ctx()).await;
// has_inverse is true, but auto-rollback is intentionally unsupported:
// it returns a non-recoverable failure with manual guidance.
match result {
Some(CapabilityResult::Failure { error, recoverable }) => {
assert!(!recoverable);
assert!(error.contains("rollback") || error.contains("downgrade"));
}
other => panic!("expected Some(Failure), got {other:?}"),
}
}

#[test]
fn package_list_no_filter() {
let cap = PackageList::new(make_executor());
Expand Down
Loading
Loading