use crate::cli::LearnArgs;
use nono::{AccessMode, NonoError, Result, try_canonicalize};
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::PathBuf;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use crate::profile::{self, Profile};
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::collections::BTreeMap;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::collections::HashMap;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::collections::HashSet;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::io::{BufRead, BufReader};
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::path::Path;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::process::{Command, Stdio};
#[cfg(target_os = "linux")]
use tracing::{debug, info, warn};
#[cfg(target_os = "macos")]
use tracing::{debug, info, warn};
#[derive(Debug)]
pub struct LearnResult {
pub read_paths: BTreeSet<PathBuf>,
pub read_files: BTreeSet<PathBuf>,
pub write_paths: BTreeSet<PathBuf>,
pub write_files: BTreeSet<PathBuf>,
pub readwrite_paths: BTreeSet<PathBuf>,
pub readwrite_files: BTreeSet<PathBuf>,
pub system_covered: BTreeSet<PathBuf>,
pub profile_covered: BTreeSet<PathBuf>,
pub outbound_connections: Vec<NetworkConnectionSummary>,
pub listening_ports: Vec<NetworkConnectionSummary>,
}
impl LearnResult {
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn new() -> Self {
Self {
read_paths: BTreeSet::new(),
read_files: BTreeSet::new(),
write_paths: BTreeSet::new(),
write_files: BTreeSet::new(),
readwrite_paths: BTreeSet::new(),
readwrite_files: BTreeSet::new(),
system_covered: BTreeSet::new(),
profile_covered: BTreeSet::new(),
outbound_connections: Vec::new(),
listening_ports: Vec::new(),
}
}
pub fn has_paths(&self) -> bool {
!self.read_paths.is_empty()
|| !self.read_files.is_empty()
|| !self.write_paths.is_empty()
|| !self.write_files.is_empty()
|| !self.readwrite_paths.is_empty()
|| !self.readwrite_files.is_empty()
}
pub fn has_network_activity(&self) -> bool {
!self.outbound_connections.is_empty() || !self.listening_ports.is_empty()
}
pub fn to_json(&self) -> Result<String> {
let allow: Vec<String> = self
.readwrite_paths
.iter()
.map(|p| p.display().to_string())
.collect();
let allow_file: Vec<String> = self
.readwrite_files
.iter()
.map(|p| p.display().to_string())
.collect();
let read: Vec<String> = self
.read_paths
.iter()
.map(|p| p.display().to_string())
.collect();
let read_file: Vec<String> = self
.read_files
.iter()
.map(|p| p.display().to_string())
.collect();
let write: Vec<String> = self
.write_paths
.iter()
.map(|p| p.display().to_string())
.collect();
let write_file: Vec<String> = self
.write_files
.iter()
.map(|p| p.display().to_string())
.collect();
let outbound: Vec<serde_json::Value> = self
.outbound_connections
.iter()
.map(|c| {
let mut obj = serde_json::json!({
"addr": c.endpoint.addr.to_string(),
"port": c.endpoint.port,
"count": c.count,
});
if let Some(ref hostname) = c.endpoint.hostname {
obj["hostname"] = serde_json::Value::String(hostname.clone());
}
obj
})
.collect();
let listening: Vec<serde_json::Value> = self
.listening_ports
.iter()
.map(|c| {
let mut obj = serde_json::json!({
"addr": c.endpoint.addr.to_string(),
"port": c.endpoint.port,
"count": c.count,
});
if let Some(ref hostname) = c.endpoint.hostname {
obj["hostname"] = serde_json::Value::String(hostname.clone());
}
obj
})
.collect();
let fragment = serde_json::json!({
"filesystem": {
"allow": allow,
"read": read,
"write": write,
"allow_file": allow_file,
"read_file": read_file,
"write_file": write_file
},
"network": {
"outbound": outbound,
"listening": listening
}
});
serde_json::to_string_pretty(&fragment)
.map_err(|e| nono::NonoError::LearnError(format!("Failed to serialize JSON: {}", e)))
}
pub fn to_profile_patch(&self) -> Result<Profile> {
let home = crate::config::validated_home()?;
let home_path = std::path::Path::new(&home);
let mut profile = Profile::default();
profile.filesystem.allow = shortened_paths(&self.readwrite_paths, home_path);
profile.filesystem.allow_file = shortened_paths(&self.readwrite_files, home_path);
profile.filesystem.read = shortened_paths(&self.read_paths, home_path);
profile.filesystem.read_file = shortened_paths(&self.read_files, home_path);
profile.filesystem.write = shortened_paths(&self.write_paths, home_path);
profile.filesystem.write_file = shortened_paths(&self.write_files, home_path);
profile.filesystem.bypass_protection = learned_bypass_protection_paths(self, home_path)?;
Ok(profile)
}
pub fn to_named_profile(
&self,
name: &str,
command: &str,
extends: Option<Vec<String>>,
) -> Result<Profile> {
let mut profile = self.to_profile_patch()?;
let has_base = extends.is_some();
profile.extends = extends;
profile.meta = profile::ProfileMeta {
name: name.to_string(),
version: "1.0.0".to_string(),
description: Some(if has_base {
format!("Learned path additions for {}", command)
} else {
format!("Auto-generated profile for {}", command)
}),
author: None,
};
if !has_base {
profile.network.block = !self.has_network_activity();
profile.workdir.access = profile::WorkdirAccess::ReadWrite;
}
Ok(profile)
}
pub fn to_summary(&self) -> String {
use colored::Colorize;
let mut lines = Vec::new();
let separator = "=".repeat(60);
lines.push(String::new());
lines.push(format!("{}", separator.dimmed()));
lines.push(format!("{}", " nono learn - Discovered Paths".bold()));
lines.push(format!("{}", separator.dimmed()));
if !self.has_paths() && !self.has_network_activity() {
lines.push(String::new());
lines.push(" No additional paths needed.".to_string());
lines.push(String::new());
return lines.join("\n");
}
push_fs_summary_section(
&mut lines,
"READ".cyan().bold(),
"--read",
&self.read_paths,
"--read-file",
&self.read_files,
);
push_fs_summary_section(
&mut lines,
"WRITE".yellow().bold(),
"--write",
&self.write_paths,
"--write-file",
&self.write_files,
);
push_fs_summary_section(
&mut lines,
"READ+WRITE".green().bold(),
"--allow",
&self.readwrite_paths,
"--allow-file",
&self.readwrite_files,
);
if !self.system_covered.is_empty() || !self.profile_covered.is_empty() {
lines.push(String::new());
if !self.system_covered.is_empty() {
lines.push(format!(
" {} {} paths already covered by system defaults",
"i".dimmed(),
self.system_covered.len()
));
}
if !self.profile_covered.is_empty() {
lines.push(format!(
" {} {} paths already covered by profile",
"i".dimmed(),
self.profile_covered.len()
));
}
}
if !self.outbound_connections.is_empty() {
lines.push(String::new());
lines.push(format!(
" {} ({} endpoints)",
"OUTBOUND NETWORK".magenta().bold(),
self.outbound_connections.len()
));
lines.push(format!(" {}", "-".repeat(40).dimmed()));
for conn in &self.outbound_connections {
lines.push(format_network_summary(conn));
}
}
if !self.listening_ports.is_empty() {
lines.push(String::new());
lines.push(format!(
" {} ({} ports)",
"LISTENING PORTS".magenta().bold(),
self.listening_ports.len()
));
lines.push(format!(" {}", "-".repeat(40).dimmed()));
for conn in &self.listening_ports {
lines.push(format_network_summary(conn));
}
}
lines.push(String::new());
lines.push(format!("{}", separator.dimmed()));
lines.join("\n")
}
}
fn push_fs_summary_section(
lines: &mut Vec<String>,
label: colored::ColoredString,
dir_flag: &str,
dir_paths: &BTreeSet<PathBuf>,
file_flag: &str,
file_paths: &BTreeSet<PathBuf>,
) {
use colored::Colorize;
let count = dir_paths.len() + file_paths.len();
if count == 0 {
return;
}
lines.push(String::new());
lines.push(format!(" {} ({} grants)", label, count));
lines.push(format!(" {}", "-".repeat(40).dimmed()));
for path in dir_paths {
lines.push(format!(" {} {}", dir_flag, path.display()));
}
for path in file_paths {
lines.push(format!(" {} {}", file_flag, path.display()));
}
}
fn shortened_paths(paths: &BTreeSet<PathBuf>, home_path: &Path) -> Vec<String> {
paths
.iter()
.map(|path| crate::profile_save_runtime::shorten_path_for_profile(path, home_path))
.collect()
}
fn learned_bypass_protection_paths(result: &LearnResult, home_path: &Path) -> Result<Vec<String>> {
let mut bypass_protection = Vec::new();
for path in result
.readwrite_paths
.iter()
.chain(result.readwrite_files.iter())
.chain(result.read_paths.iter())
.chain(result.read_files.iter())
.chain(result.write_paths.iter())
.chain(result.write_files.iter())
{
let shortened = crate::profile_save_runtime::shorten_path_for_profile(path, home_path);
if crate::config::check_sensitive_path(&shortened)?.is_some()
&& !bypass_protection.contains(&shortened)
{
bypass_protection.push(shortened);
}
}
Ok(bypass_protection)
}
pub(crate) fn merge_learned_profile_patch(profile: &mut Profile, patch: &Profile) {
crate::profile_save_runtime::merge_profile_patch(profile, patch);
}
fn format_network_summary(conn: &NetworkConnectionSummary) -> String {
let count_str = if conn.count > 1 {
format!(" ({}x)", conn.count)
} else {
String::new()
};
if let Some(ref hostname) = conn.endpoint.hostname {
format!(
" {} ({}):{}{}",
hostname, conn.endpoint.addr, conn.endpoint.port, count_str
)
} else {
format!(
" {}:{}{}",
conn.endpoint.addr, conn.endpoint.port, count_str
)
}
}
#[cfg(target_os = "linux")]
fn check_strace() -> Result<()> {
match Command::new("strace").arg("--version").output() {
Ok(output) if output.status.success() => Ok(()),
_ => Err(NonoError::LearnError(
"strace not found. Install with: apt install strace".to_string(),
)),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn run_learn(_args: &LearnArgs) -> Result<LearnResult> {
Err(NonoError::LearnError(
"nono learn is only available on Linux (strace) and macOS (fs_usage)".to_string(),
))
}
#[cfg(target_os = "macos")]
fn check_fs_usage() -> Result<()> {
if std::path::Path::new("/usr/bin/fs_usage").exists() {
Ok(())
} else {
Err(NonoError::LearnError(
"fs_usage not found at /usr/bin/fs_usage".to_string(),
))
}
}
#[cfg(target_os = "macos")]
fn acquire_sudo() -> Result<()> {
let status = Command::new("sudo")
.arg("-v")
.status()
.map_err(|e| NonoError::LearnError(format!("Failed to run sudo: {}", e)))?;
if !status.success() {
return Err(NonoError::LearnError(
"Failed to acquire sudo credentials. fs_usage requires root access.".to_string(),
));
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn run_learn(args: &LearnArgs) -> Result<LearnResult> {
check_fs_usage()?;
acquire_sudo()?;
let profile = if let Some(ref profile_name) = args.profile {
Some(profile::load_profile(profile_name)?)
} else {
None
};
let (file_accesses, network_accesses) = run_fs_usage_and_nettop(&args.command, args.timeout)?;
let mut result = process_accesses(file_accesses, profile.as_ref(), args.all)?;
let (outbound, listening) = process_network_accesses(network_accesses, vec![], !args.no_rdns);
result.outbound_connections = outbound;
result.listening_ports = listening;
Ok(result)
}
#[cfg(target_os = "macos")]
fn run_fs_usage_and_nettop(
command: &[String],
timeout: Option<u64>,
) -> Result<(Vec<FileAccess>, Vec<NetworkAccess>)> {
use std::time::Duration;
if command.is_empty() {
return Err(NonoError::NoCommand);
}
let cmd_path = std::path::Path::new(&command[0]);
let resolved_path = std::fs::canonicalize(cmd_path).unwrap_or_else(|_| cmd_path.to_path_buf());
let cmd_name = resolved_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| NonoError::LearnError("Invalid command name".to_string()))?;
if !cmd_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(NonoError::LearnError(format!(
"Command name '{}' contains invalid characters for fs_usage filter",
cmd_name
)));
}
let fs_usage_outfile = tempfile::NamedTempFile::new().map_err(|e| {
NonoError::LearnError(format!("Failed to create temp file for fs_usage: {e}"))
})?;
let fs_usage_out_path = fs_usage_outfile.path().to_path_buf();
let fs_usage_errfile = tempfile::NamedTempFile::new().map_err(|e| {
NonoError::LearnError(format!(
"Failed to create temp file for fs_usage stderr: {e}"
))
})?;
let fs_usage_err_path = fs_usage_errfile.path().to_path_buf();
let mut fs_usage = Command::new("sudo")
.args([
"bash",
"-c",
&format!(
"exec fs_usage -w -f filesys -f pathname {} > '{}' 2> '{}'",
cmd_name,
fs_usage_out_path.display(),
fs_usage_err_path.display()
),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| {
NonoError::LearnError(format!("Failed to spawn fs_usage (sudo required): {}", e))
})?;
std::thread::sleep(Duration::from_secs(2));
let mut child = Command::new(&command[0])
.args(&command[1..])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| NonoError::LearnError(format!("Failed to spawn command: {}", e)))?;
let child_pid = child.id();
debug!("Spawned child process with PID {}", child_pid);
let nettop_result = start_nettop(child_pid);
let (nettop_process, nettop_reader_handle) = match nettop_result {
Ok((proc, handle)) => (Some(proc), Some(handle)),
Err(e) => {
info!("nettop unavailable, skipping network tracing: {}", e);
(None, None)
}
};
let timeout_duration = timeout.map(Duration::from_secs);
if let Some(timeout) = timeout_duration {
let child_id = child.id();
std::thread::spawn(move || {
std::thread::sleep(timeout);
warn!("Timeout reached, sending SIGTERM to child PID {}", child_id);
unsafe {
nix::libc::kill(child_id as i32, nix::libc::SIGTERM);
}
std::thread::sleep(Duration::from_secs(3));
unsafe {
nix::libc::kill(child_id as i32, nix::libc::SIGKILL);
}
});
}
let _ = child.wait();
debug!("Child process exited");
kill_fs_usage(&fs_usage);
let _ = fs_usage.wait();
if let Some(mut nettop) = nettop_process {
let _ = nettop.kill();
let _ = nettop.wait();
}
if let Ok(err_content) = std::fs::read_to_string(&fs_usage_err_path) {
let trimmed = err_content.trim();
if !trimmed.is_empty() {
debug!("fs_usage stderr: {}", trimmed);
}
}
let file_accesses = {
let file = std::fs::File::open(&fs_usage_out_path)
.map_err(|e| NonoError::LearnError(format!("Failed to read fs_usage output: {e}")))?;
let reader = BufReader::new(file);
let mut accesses = Vec::new();
for line in reader.lines() {
match line {
Ok(l) => {
if let Some(access) = parse_fs_usage_line(&l) {
accesses.push(access);
}
}
Err(e) => {
debug!("Error reading fs_usage line: {}", e);
}
}
}
debug!(
"Parsed {} file accesses from fs_usage output",
accesses.len()
);
accesses
};
let network_accesses = match nettop_reader_handle {
Some(handle) => match handle.join() {
Ok(accesses) => accesses,
Err(_) => {
warn!("nettop reader thread panicked, returning partial results");
Vec::new()
}
},
None => Vec::new(),
};
Ok((file_accesses, network_accesses))
}
#[cfg(target_os = "macos")]
fn start_nettop(
child_pid: u32,
) -> Result<(
std::process::Child,
std::thread::JoinHandle<Vec<NetworkAccess>>,
)> {
let pid_str = child_pid.to_string();
let mut nettop = Command::new("nettop")
.args([
"-L", "0", "-n", "-p", &pid_str, "-s", "1", ])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| NonoError::LearnError(format!("Failed to spawn nettop: {}", e)))?;
let nettop_stdout = nettop
.stdout
.take()
.ok_or_else(|| NonoError::LearnError("Failed to capture nettop stdout".to_string()))?;
let reader_handle = std::thread::spawn(move || {
let reader = BufReader::new(nettop_stdout);
let mut accesses = Vec::new();
let mut seen: HashSet<(IpAddr, u16, bool)> = HashSet::new();
let mut listening_ports: HashSet<u16> = HashSet::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
debug!("Error reading nettop line: {}", e);
continue;
}
};
if let Some(access) = parse_nettop_line(&line, &listening_ports) {
let is_bind = matches!(access.kind, NetworkAccessKind::Bind);
if is_bind {
listening_ports.insert(access.port);
}
let key = (access.addr, access.port, is_bind);
if seen.insert(key) {
accesses.push(access);
}
}
}
accesses
});
Ok((nettop, reader_handle))
}
#[cfg(target_os = "macos")]
fn parse_nettop_line(line: &str, listening_ports: &HashSet<u16>) -> Option<NetworkAccess> {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() < 4 {
return None;
}
let conn_field = fields[1].trim();
if !conn_field.starts_with("tcp4 ")
&& !conn_field.starts_with("tcp6 ")
&& !conn_field.starts_with("udp4 ")
&& !conn_field.starts_with("udp6 ")
{
return None;
}
let state = fields[3].trim();
let addr_part = &conn_field[5..];
let parts: Vec<&str> = addr_part.split("<->").collect();
if parts.len() != 2 {
return None;
}
let local_part = parts[0];
let remote_part = parts[1];
let is_ipv6 = conn_field.starts_with("tcp6 ") || conn_field.starts_with("udp6 ");
if state == "Listen" || remote_part == "*:*" || remote_part == "*.*" {
let (addr, port) = parse_nettop_endpoint(local_part, is_ipv6)?;
if port == 0 {
return None;
}
Some(NetworkAccess {
addr,
port,
kind: NetworkAccessKind::Bind,
queried_hostname: None,
})
} else if state == "Established" || !remote_part.contains('*') {
if let Some((_, local_port)) = parse_nettop_endpoint(local_part, is_ipv6)
&& listening_ports.contains(&local_port)
{
return None;
}
let (addr, port) = parse_nettop_endpoint(remote_part, is_ipv6)?;
if port == 0 {
return None;
}
if addr.is_loopback() {
return None;
}
Some(NetworkAccess {
addr,
port,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
})
} else {
None
}
}
#[cfg(target_os = "macos")]
fn parse_nettop_endpoint(endpoint: &str, is_ipv6: bool) -> Option<(IpAddr, u16)> {
if endpoint == "*:*" || endpoint == "*.*" {
return None;
}
if is_ipv6 {
if let Some(pct_pos) = endpoint.find('%') {
let after_scope = &endpoint[pct_pos..];
if let Some(dot_pos) = after_scope.rfind('.') {
let port_str = &after_scope[dot_pos + 1..];
let addr_str = &endpoint[..pct_pos];
return parse_addr_port_pair(addr_str, port_str);
}
return None;
}
let dot_pos = endpoint.rfind('.')?;
let addr_str = &endpoint[..dot_pos];
let port_str = &endpoint[dot_pos + 1..];
parse_addr_port_pair(addr_str, port_str)
} else {
let colon_pos = endpoint.rfind(':')?;
let addr_str = &endpoint[..colon_pos];
let port_str = &endpoint[colon_pos + 1..];
if addr_str == "*" {
let port: u16 = port_str.parse().ok()?;
Some(("0.0.0.0".parse().ok()?, port))
} else {
parse_addr_port_pair(addr_str, port_str)
}
}
}
#[cfg(target_os = "macos")]
fn parse_addr_port_pair(addr_str: &str, port_str: &str) -> Option<(IpAddr, u16)> {
let port: u16 = port_str.parse().ok()?;
let addr: IpAddr = addr_str.parse().ok()?;
Some((addr, port))
}
#[cfg(target_os = "macos")]
fn kill_fs_usage(fs_usage: &std::process::Child) {
let sudo_pid = fs_usage.id().to_string();
let _ = Command::new("sudo")
.args(["pkill", "-P", &sudo_pid])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let _ = Command::new("sudo")
.args(["kill", &sudo_pid])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(target_os = "macos")]
fn parse_fs_usage_line(line: &str) -> Option<FileAccess> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.len() < 4 {
return None;
}
let operation = tokens[1];
let tracked_ops = [
"open",
"open_nocancel",
"stat64",
"stat64_extended",
"lstat64",
"lstat64_extended",
"getattrlist",
"getxattr",
"listxattr",
"readlink",
"access",
"access_extended",
"execve",
"posix_spawn",
"mkdir",
"mkdir_extended",
"rename",
"unlink",
"rmdir",
"link",
"symlink",
"write",
"write_nocancel",
"pwrite",
"ftruncate",
"truncate",
];
if !tracked_ops.contains(&operation) {
return None;
}
let path = extract_fs_usage_path(trimmed)?;
if path.starts_with("/dev/") || path == "/dev" {
return None;
}
let is_write = is_fs_usage_write(operation, trimmed);
Some(FileAccess {
path: PathBuf::from(path),
is_write,
})
}
#[cfg(target_os = "macos")]
fn extract_fs_usage_path(line: &str) -> Option<String> {
let path_start = line.find(" /")?;
let path_region = &line[path_start..].trim_start();
let mut end = path_region.len();
for (i, window) in path_region.as_bytes().windows(3).enumerate().rev() {
if window[0] == b' ' && window[1].is_ascii_digit() && window[2] == b'.' {
let candidate = &path_region[i + 1..];
if candidate.split_whitespace().next().is_some_and(|s| {
s.contains('.') && s.bytes().all(|b| b.is_ascii_digit() || b == b'.')
}) {
end = i;
break;
}
}
}
let path = path_region[..end].trim_end();
if path.is_empty() || !path.starts_with('/') {
return None;
}
let path = if let Some(bracket_pos) = path.rfind(" [") {
path[..bracket_pos].trim_end()
} else {
path
};
Some(path.to_string())
}
#[cfg(target_os = "macos")]
fn is_fs_usage_write(operation: &str, line: &str) -> bool {
match operation {
"mkdir" | "mkdir_extended" | "rename" | "unlink" | "rmdir" | "link" | "symlink"
| "write" | "write_nocancel" | "pwrite" | "ftruncate" | "truncate" => true,
"open" | "open_nocancel" => {
line.contains("(W")
|| line.contains("O_WRONLY")
|| line.contains("O_RDWR")
|| line.contains("O_CREAT")
|| line.contains("O_TRUNC")
}
_ => false,
}
}
#[cfg(target_os = "linux")]
pub fn run_learn(args: &LearnArgs) -> Result<LearnResult> {
check_strace()?;
let profile = if let Some(ref profile_name) = args.profile {
Some(profile::load_profile(profile_name)?)
} else {
None
};
let (raw_file_accesses, raw_network_accesses, dns_queries) =
run_strace(&args.command, args.timeout)?;
let mut result = process_accesses(raw_file_accesses, profile.as_ref(), args.all)?;
let (outbound, listening) =
process_network_accesses(raw_network_accesses, dns_queries, !args.no_rdns);
result.outbound_connections = outbound;
result.listening_ports = listening;
Ok(result)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone)]
struct FileAccess {
path: PathBuf,
is_write: bool,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LearnedPathEntry {
access: AccessMode,
is_file: bool,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone)]
enum NetworkAccessKind {
Connect,
Bind,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone)]
struct NetworkAccess {
addr: IpAddr,
port: u16,
kind: NetworkAccessKind,
queried_hostname: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NetworkEndpoint {
pub addr: IpAddr,
pub port: u16,
pub hostname: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NetworkConnectionSummary {
pub endpoint: NetworkEndpoint,
pub count: usize,
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
enum TracedAccess {
File(FileAccess),
Network(NetworkAccess),
DnsQuery(String),
}
#[cfg(target_os = "linux")]
fn run_strace(
command: &[String],
timeout: Option<u64>,
) -> Result<(Vec<FileAccess>, Vec<NetworkAccess>, Vec<String>)> {
use std::time::{Duration, Instant};
if command.is_empty() {
return Err(NonoError::NoCommand);
}
let mut strace_args = vec![
"-f".to_string(), "-s".to_string(), "256".to_string(),
"-e".to_string(), "openat,open,access,stat,lstat,readlink,execve,creat,mkdir,rename,unlink,connect,bind,sendto,sendmsg"
.to_string(),
"-o".to_string(),
"/dev/stderr".to_string(), "--".to_string(),
];
strace_args.extend(command.iter().cloned());
info!("Running strace with args: {:?}", strace_args);
let mut child = Command::new("strace")
.args(&strace_args)
.stdout(Stdio::inherit()) .stderr(Stdio::piped()) .spawn()
.map_err(|e| NonoError::LearnError(format!("Failed to spawn strace: {}", e)))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| NonoError::LearnError("Failed to capture strace stderr".to_string()))?;
let start = Instant::now();
let timeout_duration = timeout.map(Duration::from_secs);
let mut file_accesses = Vec::new();
let mut network_accesses = Vec::new();
let mut dns_queries = Vec::new();
let mut pid_hostnames: HashMap<u32, String> = HashMap::new();
let reader = BufReader::new(stderr);
for line in reader.lines() {
if let Some(timeout) = timeout_duration
&& start.elapsed() > timeout
{
warn!("Timeout reached, killing child process");
let _ = child.kill();
break;
}
let line = match line {
Ok(l) => l,
Err(e) => {
debug!("Error reading strace line: {}", e);
continue;
}
};
let pid = extract_strace_pid(&line);
if let Some(access) = parse_strace_line(&line) {
match access {
TracedAccess::File(fa) => file_accesses.push(fa),
TracedAccess::Network(mut na) => {
na.queried_hostname =
pid.and_then(|p| pid_hostnames.get(&p).cloned())
.or_else(|| {
if pid.is_none() && pid_hostnames.len() == 1 {
pid_hostnames.values().next().cloned()
} else {
None
}
});
network_accesses.push(na);
}
TracedAccess::DnsQuery(hostname) => {
if let Some(p) = pid {
pid_hostnames.insert(p, hostname.clone());
} else if pid_hostnames.is_empty() {
pid_hostnames.insert(0, hostname.clone());
}
dns_queries.push(hostname);
}
}
}
}
let _ = child.wait();
Ok((file_accesses, network_accesses, dns_queries))
}
#[cfg(target_os = "linux")]
fn extract_strace_pid(line: &str) -> Option<u32> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("[pid ")?;
let end = rest.find(']')?;
rest[..end].trim().parse().ok()
}
#[cfg(target_os = "linux")]
fn parse_strace_line(line: &str) -> Option<TracedAccess> {
if line.contains("sendto(") || line.contains("sendmsg(") {
if let Some(hostname) = parse_dns_sendto(line) {
return Some(TracedAccess::DnsQuery(hostname));
}
if let Some(hostname) = parse_resolved_sendto(line) {
return Some(TracedAccess::DnsQuery(hostname));
}
return None;
}
let network_syscalls = ["connect", "bind"];
for &syscall in &network_syscalls {
if line.contains(&format!("{}(", syscall)) {
let kind = match syscall {
"connect" => NetworkAccessKind::Connect,
_ => NetworkAccessKind::Bind,
};
if let Some(na) = parse_network_syscall(line, kind) {
return Some(TracedAccess::Network(na));
}
return None;
}
}
let file_syscalls = [
"openat", "open", "access", "stat", "lstat", "readlink", "execve", "creat", "mkdir",
"rename", "unlink",
];
let syscall = file_syscalls
.iter()
.find(|&s| line.contains(&format!("{}(", s)))?;
let path = extract_path_from_syscall(line, syscall)?;
let is_write = is_write_access(line, syscall);
if path.is_empty() || path == "." || path == ".." {
return None;
}
Some(TracedAccess::File(FileAccess {
path: PathBuf::from(path),
is_write,
}))
}
#[cfg(target_os = "linux")]
fn extract_path_from_syscall(line: &str, syscall: &str) -> Option<String> {
let start_idx = line.find(&format!("{}(", syscall))?;
let after_paren = &line[start_idx + syscall.len() + 1..];
let path_start = if syscall == "openat" {
if let Some(comma_idx) = after_paren.find(',') {
comma_idx + 2 } else {
return None;
}
} else {
0
};
let remaining = &after_paren[path_start..];
if !remaining.starts_with('"') {
return None;
}
let end_quote = remaining[1..].find('"')?;
let path = &remaining[1..end_quote + 1];
let path = unescape_strace_string(path);
Some(path)
}
#[cfg(target_os = "linux")]
fn unescape_strace_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('n') => {
chars.next();
result.push('\n');
}
Some('t') => {
chars.next();
result.push('\t');
}
Some('r') => {
chars.next();
result.push('\r');
}
Some('\\') => {
chars.next();
result.push('\\');
}
Some('"') => {
chars.next();
result.push('"');
}
Some(c) if ('0'..='7').contains(c) => {
let mut octal = String::new();
while octal.len() < 3 && chars.peek().is_some_and(|c| ('0'..='7').contains(c)) {
if let Some(c) = chars.next() {
octal.push(c);
}
}
if let Ok(val) = u8::from_str_radix(&octal, 8) {
result.push(val as char);
} else {
result.push('\\');
result.push_str(&octal);
}
}
Some('x') => {
chars.next(); let mut hex = String::new();
for _ in 0..2 {
if chars.peek().is_some_and(|c| c.is_ascii_hexdigit()) {
if let Some(c) = chars.next() {
hex.push(c);
}
} else {
break;
}
}
if hex.len() == 2 {
if let Ok(val) = u8::from_str_radix(&hex, 16) {
result.push(val as char);
} else {
result.push('\\');
result.push('x');
result.push_str(&hex);
}
} else {
result.push('\\');
result.push('x');
result.push_str(&hex);
}
}
_ => {
result.push('\\');
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(target_os = "linux")]
fn is_write_access(line: &str, syscall: &str) -> bool {
match syscall {
"creat" | "mkdir" | "unlink" | "rename" => true,
"openat" | "open" => {
line.contains("O_WRONLY")
|| line.contains("O_RDWR")
|| line.contains("O_CREAT")
|| line.contains("O_TRUNC")
}
_ => false,
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn process_accesses(
accesses: Vec<FileAccess>,
profile: Option<&Profile>,
show_all: bool,
) -> Result<LearnResult> {
let mut result = LearnResult::new();
let loaded_policy = crate::policy::load_embedded_policy()?;
let system_read_paths = crate::policy::get_system_read_paths(&loaded_policy);
let system_read_set: HashSet<&str> = system_read_paths.iter().map(|s| s.as_str()).collect();
let profile_paths: HashSet<String> = if let Some(prof) = profile {
let mut paths = HashSet::new();
paths.extend(prof.filesystem.allow.iter().cloned());
paths.extend(prof.filesystem.read.iter().cloned());
paths.extend(prof.filesystem.write.iter().cloned());
paths.extend(prof.filesystem.allow_file.iter().cloned());
paths.extend(prof.filesystem.read_file.iter().cloned());
paths.extend(prof.filesystem.write_file.iter().cloned());
paths
} else {
HashSet::new()
};
let mut learned_entries: BTreeMap<PathBuf, LearnedPathEntry> = BTreeMap::new();
for access in accesses {
let canonical = canonicalize_existing_path(&access.path);
if is_covered_by_set(&canonical, &system_read_set)? {
if show_all {
result.system_covered.insert(canonical);
}
continue;
}
if is_covered_by_profile(&canonical, &profile_paths)? {
if show_all {
result.profile_covered.insert(canonical);
}
continue;
}
let access_mode = if access.is_write {
AccessMode::Write
} else {
AccessMode::Read
};
let (target_path, is_file) = learned_target_for_access(&access.path, access.is_write);
observe_learned_path(&mut learned_entries, target_path, is_file, access_mode);
}
minimize_learned_entries(&mut learned_entries);
for (path, entry) in learned_entries {
match (entry.access, entry.is_file) {
(AccessMode::Read, true) => {
result.read_files.insert(path);
}
(AccessMode::Read, false) => {
result.read_paths.insert(path);
}
(AccessMode::Write, true) => {
result.write_files.insert(path);
}
(AccessMode::Write, false) => {
result.write_paths.insert(path);
}
(AccessMode::ReadWrite, true) => {
result.readwrite_files.insert(path);
}
(AccessMode::ReadWrite, false) => {
result.readwrite_paths.insert(path);
}
}
}
Ok(result)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn canonicalize_existing_path(path: &Path) -> PathBuf {
try_canonicalize(path)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn learned_target_for_access(path: &Path, is_write: bool) -> (PathBuf, bool) {
match std::fs::metadata(path) {
Ok(metadata) => {
let canonical = canonicalize_existing_path(path);
(canonical, !metadata.is_dir())
}
Err(_) if is_write => match path.parent() {
Some(parent) => (canonicalize_existing_path(parent), false),
None => (path.to_path_buf(), false),
},
Err(_) => (path.to_path_buf(), true),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn merge_access_modes(existing: AccessMode, new: AccessMode) -> AccessMode {
if existing == new {
existing
} else {
AccessMode::ReadWrite
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn observe_learned_path(
learned_entries: &mut BTreeMap<PathBuf, LearnedPathEntry>,
path: PathBuf,
is_file: bool,
access: AccessMode,
) {
match learned_entries.get_mut(&path) {
Some(entry) => {
entry.access = merge_access_modes(entry.access, access);
if !is_file {
entry.is_file = false;
}
}
None => {
learned_entries.insert(path, LearnedPathEntry { access, is_file });
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn minimize_learned_entries(learned_entries: &mut BTreeMap<PathBuf, LearnedPathEntry>) {
let directory_entries: Vec<(PathBuf, AccessMode)> = learned_entries
.iter()
.filter_map(|(path, entry)| (!entry.is_file).then_some((path.clone(), entry.access)))
.collect();
let redundant_children: Vec<PathBuf> = learned_entries
.iter()
.filter_map(|(candidate_path, candidate_entry)| {
directory_entries
.iter()
.any(|(dir_path, dir_access)| {
candidate_path != dir_path
&& candidate_path.starts_with(dir_path)
&& dir_access.contains(candidate_entry.access)
})
.then_some(candidate_path.clone())
})
.collect();
for child in redundant_children {
learned_entries.remove(&child);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn is_covered_by_set(path: &Path, allowed: &HashSet<&str>) -> Result<bool> {
for allowed_path in allowed {
let allowed_expanded = expand_home(allowed_path)?;
if let Ok(allowed_canonical) = std::fs::canonicalize(&allowed_expanded)
&& path.starts_with(&allowed_canonical)
{
return Ok(true);
}
let allowed_path_buf = PathBuf::from(&allowed_expanded);
if path.starts_with(&allowed_path_buf) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn is_covered_by_profile(path: &Path, profile_paths: &HashSet<String>) -> Result<bool> {
for profile_path in profile_paths {
let expanded = expand_home(profile_path)?;
if let Ok(canonical) = std::fs::canonicalize(&expanded)
&& path.starts_with(&canonical)
{
return Ok(true);
}
let path_buf = PathBuf::from(&expanded);
if path.starts_with(&path_buf) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn expand_home(path: &str) -> Result<String> {
use crate::config;
if path.starts_with('~') {
let home = config::validated_home()?;
return Ok(path.replacen('~', &home, 1));
}
if path.starts_with("$HOME") {
let home = config::validated_home()?;
return Ok(path.replacen("$HOME", &home, 1));
}
Ok(path.to_string())
}
#[cfg(target_os = "linux")]
fn extract_between<'a>(s: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
let start = s.find(prefix)?;
let after = &s[start + prefix.len()..];
let end = after.find(suffix)?;
Some(&after[..end])
}
#[cfg(target_os = "linux")]
fn parse_network_syscall(line: &str, kind: NetworkAccessKind) -> Option<NetworkAccess> {
if line.contains("sa_family=AF_UNIX") || line.contains("sa_family=AF_LOCAL") {
return None;
}
let (addr, port) = if line.contains("sa_family=AF_INET6") {
let port_str = extract_between(line, "sin6_port=htons(", ")")?;
let addr_str = extract_between(line, "inet_pton(AF_INET6, \"", "\"")?;
let port: u16 = port_str.parse().ok()?;
let addr: IpAddr = addr_str.parse().ok()?;
(addr, port)
} else if line.contains("sa_family=AF_INET") {
let port_str = extract_between(line, "sin_port=htons(", ")")?;
let addr_str = extract_between(line, "inet_addr(\"", "\"")?;
let port: u16 = port_str.parse().ok()?;
let addr: IpAddr = addr_str.parse().ok()?;
(addr, port)
} else {
return None;
};
if port == 0 {
return None;
}
Some(NetworkAccess {
addr,
port,
kind,
queried_hostname: None,
})
}
#[cfg(target_os = "linux")]
fn parse_dns_sendto(line: &str) -> Option<String> {
if !line.contains("htons(53)") {
return None;
}
if !line.contains("AF_INET") {
return None;
}
let buf_str = extract_sendto_buffer(line)?;
let bytes = unescape_strace_bytes(&buf_str);
parse_dns_query_hostname(&bytes)
}
#[cfg(target_os = "linux")]
fn parse_resolved_sendto(line: &str) -> Option<String> {
if !line.contains("ResolveHostname") {
return None;
}
let buf_str = extract_sendto_buffer(line)?;
let unescaped = unescape_strace_string(&buf_str);
let json_str = unescaped.trim_end_matches('\0');
let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?;
let name_str = parsed.pointer("/parameters/name")?.as_str()?;
if name_str.is_empty() || !name_str.contains('.') {
return None;
}
if !name_str
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return None;
}
Some(name_str.to_string())
}
#[cfg(target_os = "linux")]
fn extract_sendto_buffer(line: &str) -> Option<String> {
let search_start = line.find("iov_base=").or_else(|| line.find("sendto("))?;
let after = &line[search_start..];
let q_start = after.find('"')? + 1;
let remaining = &after[q_start..];
let bytes = remaining.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2; } else if bytes[i] == b'"' {
return Some(remaining[..i].to_string());
} else {
i += 1;
}
}
None
}
#[cfg(target_os = "linux")]
fn unescape_strace_bytes(s: &str) -> Vec<u8> {
unescape_strace_string(s).chars().map(|c| c as u8).collect()
}
#[cfg(target_os = "linux")]
fn parse_dns_query_hostname(data: &[u8]) -> Option<String> {
if data.len() < 13 {
return None;
}
let mut pos = 12; let mut labels = Vec::new();
loop {
if pos >= data.len() {
return None; }
let len = data[pos] as usize;
pos += 1;
if len == 0 {
break; }
if len & 0xC0 != 0 {
return None;
}
if len > 63 {
return None;
}
if pos + len > data.len() {
return None; }
let label = std::str::from_utf8(&data[pos..pos + len]).ok()?;
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return None;
}
labels.push(label.to_string());
pos += len;
}
if labels.is_empty() {
return None;
}
Some(labels.join("."))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn process_network_accesses(
accesses: Vec<NetworkAccess>,
dns_queries: Vec<String>,
resolve_dns: bool,
) -> (Vec<NetworkConnectionSummary>, Vec<NetworkConnectionSummary>) {
let mut connect_counts: HashMap<(IpAddr, u16), usize> = HashMap::new();
let mut bind_counts: HashMap<(IpAddr, u16), usize> = HashMap::new();
for access in &accesses {
let key = (access.addr, access.port);
match access.kind {
NetworkAccessKind::Connect => {
*connect_counts.entry(key).or_insert(0) += 1;
}
NetworkAccessKind::Bind => {
*bind_counts.entry(key).or_insert(0) += 1;
}
}
}
let hostnames = if resolve_dns {
let mut map: HashMap<IpAddr, String> = HashMap::new();
for access in &accesses {
if let Some(ref hostname) = access.queried_hostname {
map.entry(access.addr).or_insert_with(|| hostname.clone());
}
}
let all_ips: HashSet<IpAddr> = accesses.iter().map(|a| a.addr).collect();
let unresolved_after_timing: HashSet<IpAddr> = all_ips
.iter()
.filter(|ip| !map.contains_key(ip))
.copied()
.collect();
if !unresolved_after_timing.is_empty() && !dns_queries.is_empty() {
let forward = resolve_forward_dns(&dns_queries);
for (ip, hostname) in forward {
map.entry(ip).or_insert(hostname);
}
}
let unresolved_after_forward: HashSet<IpAddr> = all_ips
.iter()
.filter(|ip| !map.contains_key(ip))
.copied()
.collect();
if !unresolved_after_forward.is_empty() {
let reverse = resolve_reverse_dns(&unresolved_after_forward);
map.extend(reverse);
}
map
} else {
HashMap::new()
};
let build_summaries =
|counts: &HashMap<(IpAddr, u16), usize>| -> Vec<NetworkConnectionSummary> {
let mut summaries: Vec<NetworkConnectionSummary> = counts
.iter()
.map(|(&(addr, port), &count)| NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr,
port,
hostname: hostnames.get(&addr).cloned(),
},
count,
})
.collect();
summaries.sort();
summaries
};
(
build_summaries(&connect_counts),
build_summaries(&bind_counts),
)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn resolve_forward_dns(hostnames: &[String]) -> HashMap<IpAddr, String> {
let mut result = HashMap::new();
let unique: HashSet<&String> = hostnames.iter().collect();
for hostname in unique {
match dns_lookup::lookup_host(hostname) {
Ok(ips) => {
for ip in ips {
result.entry(ip).or_insert_with(|| hostname.clone());
}
}
Err(e) => {
debug!("Forward DNS lookup failed for {}: {}", hostname, e);
}
}
}
result
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn resolve_reverse_dns(ips: &HashSet<IpAddr>) -> HashMap<IpAddr, String> {
let mut result = HashMap::new();
for &ip in ips {
match dns_lookup::lookup_addr(&ip) {
Ok(hostname) => {
if hostname != ip.to_string() {
result.insert(ip, hostname);
}
}
Err(e) => {
debug!("Reverse DNS lookup failed for {}: {}", ip, e);
}
}
}
result
}
#[cfg(all(test, target_os = "linux"))]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::Builder;
fn expect_file_access(traced: Option<TracedAccess>) -> FileAccess {
match traced {
Some(TracedAccess::File(fa)) => fa,
other => panic!("Expected File, got {:?}", other),
}
}
fn expect_network_access(traced: Option<TracedAccess>) -> NetworkAccess {
match traced {
Some(TracedAccess::Network(na)) => na,
other => panic!("Expected Network, got {:?}", other),
}
}
#[test]
fn test_parse_strace_openat() {
let line = r#"openat(AT_FDCWD, "/etc/passwd", O_RDONLY|O_CLOEXEC) = 3"#;
let access = expect_file_access(parse_strace_line(line));
assert_eq!(access.path, PathBuf::from("/etc/passwd"));
assert!(!access.is_write);
}
#[test]
fn test_parse_strace_openat_write() {
let line = r#"openat(AT_FDCWD, "/tmp/test", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 4"#;
let access = expect_file_access(parse_strace_line(line));
assert_eq!(access.path, PathBuf::from("/tmp/test"));
assert!(access.is_write);
}
#[test]
fn test_parse_strace_stat() {
let line = r#"stat("/usr/bin/bash", {st_mode=S_IFREG|0755, ...}) = 0"#;
let access = expect_file_access(parse_strace_line(line));
assert_eq!(access.path, PathBuf::from("/usr/bin/bash"));
assert!(!access.is_write);
}
#[test]
fn test_parse_strace_execve() {
let line = r#"execve("/usr/bin/ls", ["ls", "-la"], 0x...) = 0"#;
let access = expect_file_access(parse_strace_line(line));
assert_eq!(access.path, PathBuf::from("/usr/bin/ls"));
assert!(!access.is_write);
}
#[test]
fn test_extract_path_from_openat() {
let line = r#"openat(AT_FDCWD, "/some/path", O_RDONLY) = 3"#;
let path = extract_path_from_syscall(line, "openat").expect("should extract");
assert_eq!(path, "/some/path");
}
#[test]
fn test_is_write_access() {
assert!(is_write_access(
"openat(..., O_WRONLY|O_CREAT, ...)",
"openat"
));
assert!(is_write_access("openat(..., O_RDWR, ...)", "openat"));
assert!(!is_write_access("openat(..., O_RDONLY, ...)", "openat"));
assert!(is_write_access("creat(...)", "creat"));
assert!(is_write_access("mkdir(...)", "mkdir"));
}
#[test]
fn test_expand_home() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let _env = crate::test_env::EnvVarGuard::set_all(&[("HOME", "/home/test")]);
assert_eq!(expand_home("~/foo").expect("valid home"), "/home/test/foo");
assert_eq!(
expand_home("$HOME/bar").expect("valid home"),
"/home/test/bar"
);
assert_eq!(
expand_home("/absolute/path").expect("no expansion needed"),
"/absolute/path"
);
}
#[test]
fn test_learned_target_for_missing_write_uses_parent_directory() {
let path = PathBuf::from("/some/dir/file.txt");
let (target, is_file) = learned_target_for_access(&path, true);
assert_eq!(target, PathBuf::from("/some/dir"));
assert!(!is_file);
}
#[test]
fn test_learned_target_for_existing_file_preserves_file_path() {
let cwd = std::env::current_dir().expect("cwd should be available");
let tempdir = Builder::new()
.prefix("learn-target-")
.tempdir_in(&cwd)
.expect("tempdir should be created");
let file_path = tempdir.path().join("config.json");
std::fs::write(&file_path, "{}").expect("file should be created");
let (target, is_file) = learned_target_for_access(&file_path, false);
assert_eq!(
target,
file_path
.canonicalize()
.expect("file should canonicalize successfully")
);
assert!(is_file);
}
#[test]
fn test_learn_result_to_json() -> Result<()> {
let mut result = LearnResult::new();
result.read_paths.insert(PathBuf::from("/some/read/path"));
result.write_paths.insert(PathBuf::from("/some/write/path"));
result
.read_files
.insert(PathBuf::from("/some/read/file.txt"));
let json = result.to_json()?;
assert!(json.contains("filesystem"));
assert!(json.contains("/some/read/path"));
assert!(json.contains("/some/write/path"));
assert!(json.contains("read_file"));
assert!(json.contains("/some/read/file.txt"));
Ok(())
}
#[test]
fn test_learn_result_to_profile_includes_file_permissions() -> Result<()> {
let mut result = LearnResult::new();
result.write_files.insert(PathBuf::from("/tmp/output.txt"));
let profile = result.to_named_profile("touch", "touch", None)?;
let profile_json =
serde_json::to_string_pretty(&profile).expect("profile should serialize successfully");
assert!(profile_json.contains("\"write_file\""));
assert!(profile_json.contains("/tmp/output.txt"));
Ok(())
}
#[test]
fn test_unescape_simple() {
assert_eq!(unescape_strace_string(r#"hello"#), "hello");
assert_eq!(unescape_strace_string(r#"hello\nworld"#), "hello\nworld");
assert_eq!(unescape_strace_string(r#"hello\tworld"#), "hello\tworld");
assert_eq!(unescape_strace_string(r#"hello\\world"#), "hello\\world");
assert_eq!(unescape_strace_string(r#"hello\"world"#), "hello\"world");
}
#[test]
fn test_unescape_hex() {
assert_eq!(unescape_strace_string(r#"\x41"#), "A");
assert_eq!(
unescape_strace_string(r#"/path\x2fwith\x2fslash"#),
"/path/with/slash"
);
}
#[test]
fn test_unescape_octal() {
assert_eq!(unescape_strace_string(r#"\101"#), "A");
assert_eq!(unescape_strace_string(r#"hello\040world"#), "hello world");
}
#[test]
fn test_unescape_null() {
assert_eq!(unescape_strace_string(r#"hello\0world"#), "hello\0world");
}
#[test]
fn test_unescape_incomplete_hex() {
assert_eq!(unescape_strace_string(r#"\x1"#), r#"\x1"#);
assert_eq!(unescape_strace_string(r#"path\x1gnd"#), r#"path\x1gnd"#);
}
#[test]
fn test_unescape_invalid_hex() {
assert_eq!(unescape_strace_string(r#"\xZZ"#), r#"\xZZ"#);
assert_eq!(unescape_strace_string(r#"\xGH"#), r#"\xGH"#);
}
#[test]
fn test_unescape_invalid_octal() {
assert_eq!(unescape_strace_string(r#"\18"#), "\x018");
assert_eq!(unescape_strace_string(r#"\19"#), "\x019");
}
#[test]
fn test_unescape_trailing_backslash() {
assert_eq!(unescape_strace_string(r#"hello\"#), r#"hello\"#);
}
#[test]
fn test_parse_connect_ipv4() {
let line = r#"connect(3, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("93.184.216.34")}, 16) = 0"#;
let access = expect_network_access(parse_strace_line(line));
assert_eq!(access.addr, "93.184.216.34".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 443);
assert!(matches!(access.kind, NetworkAccessKind::Connect));
}
#[test]
fn test_parse_connect_ipv6() {
let line = r#"connect(3, {sa_family=AF_INET6, sin6_port=htons(443), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2606:2800:220:1:248:1893:25c8:1946"), sin6_scope_id=0}, 28) = 0"#;
let access = expect_network_access(parse_strace_line(line));
assert_eq!(
access.addr,
"2606:2800:220:1:248:1893:25c8:1946"
.parse::<IpAddr>()
.unwrap()
);
assert_eq!(access.port, 443);
assert!(matches!(access.kind, NetworkAccessKind::Connect));
}
#[test]
fn test_parse_connect_unix_ignored() {
let line =
r#"connect(3, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT"#;
assert!(parse_strace_line(line).is_none());
}
#[test]
fn test_parse_bind_ipv4() {
let line = r#"bind(4, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("0.0.0.0")}, 16) = 0"#;
let access = expect_network_access(parse_strace_line(line));
assert_eq!(access.addr, "0.0.0.0".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 8080);
assert!(matches!(access.kind, NetworkAccessKind::Bind));
}
#[test]
fn test_parse_bind_ipv6() {
let line = r#"bind(4, {sa_family=AF_INET6, sin6_port=htons(3000), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::"), sin6_scope_id=0}, 28) = 0"#;
let access = expect_network_access(parse_strace_line(line));
assert_eq!(access.addr, "::".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 3000);
assert!(matches!(access.kind, NetworkAccessKind::Bind));
}
#[test]
fn test_parse_connect_failed() {
let line = r#"connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("10.0.0.1")}, 16) = -1 ECONNREFUSED (Connection refused)"#;
let access = expect_network_access(parse_strace_line(line));
assert_eq!(access.addr, "10.0.0.1".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 80);
}
#[test]
fn test_parse_connect_port_zero_ignored() {
let line = r#"connect(3, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("0.0.0.0")}, 16) = 0"#;
assert!(parse_strace_line(line).is_none());
}
#[test]
fn test_existing_file_parsing_unchanged() {
let lines = [
(
r#"openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 3"#,
"/etc/hosts",
false,
),
(
r#"access("/etc/ld.so.preload", R_OK) = -1 ENOENT"#,
"/etc/ld.so.preload",
false,
),
(r#"mkdir("/tmp/newdir", 0755) = 0"#, "/tmp/newdir", true),
];
for (line, expected_path, expected_write) in &lines {
let access = expect_file_access(parse_strace_line(line));
assert_eq!(access.path, PathBuf::from(expected_path));
assert_eq!(access.is_write, *expected_write);
}
}
#[test]
fn test_network_dedup() {
let accesses = vec![
NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
},
NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
},
NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
},
];
let (outbound, listening) = process_network_accesses(accesses, vec![], false);
assert_eq!(outbound.len(), 1);
assert_eq!(outbound[0].count, 3);
assert!(listening.is_empty());
}
#[test]
fn test_learn_result_network_json() -> Result<()> {
let mut result = LearnResult::new();
result.outbound_connections.push(NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
hostname: Some("example.com".to_string()),
},
count: 5,
});
result.listening_ports.push(NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "0.0.0.0".parse().unwrap(),
port: 3000,
hostname: None,
},
count: 1,
});
let json = result.to_json()?;
assert!(json.contains("\"network\""));
assert!(json.contains("\"outbound\""));
assert!(json.contains("\"listening\""));
assert!(json.contains("93.184.216.34"));
assert!(json.contains("443"));
assert!(json.contains("example.com"));
assert!(json.contains("0.0.0.0"));
assert!(json.contains("3000"));
Ok(())
}
#[test]
fn test_learn_result_network_summary() {
let mut result = LearnResult::new();
result.read_files.insert(PathBuf::from("/etc/hostname"));
result.outbound_connections.push(NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
hostname: Some("example.com".to_string()),
},
count: 12,
});
result.listening_ports.push(NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "0.0.0.0".parse().unwrap(),
port: 3000,
hostname: None,
},
count: 1,
});
let summary = result.to_summary();
assert!(summary.contains("--read-file /etc/hostname"));
assert!(summary.contains("OUTBOUND NETWORK"));
assert!(summary.contains("example.com (93.184.216.34):443 (12x)"));
assert!(summary.contains("LISTENING PORTS"));
assert!(summary.contains("0.0.0.0:3000"));
assert!(!summary.contains("(1x)"));
}
#[test]
fn test_has_network_activity() {
let mut result = LearnResult::new();
assert!(!result.has_network_activity());
result.outbound_connections.push(NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "10.0.0.1".parse().unwrap(),
port: 80,
hostname: None,
},
count: 1,
});
assert!(result.has_network_activity());
let mut result2 = LearnResult::new();
result2.listening_ports.push(NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "0.0.0.0".parse().unwrap(),
port: 8080,
hostname: None,
},
count: 1,
});
assert!(result2.has_network_activity());
}
#[test]
fn test_process_accesses_promotes_file_to_readwrite() {
let cwd = std::env::current_dir().expect("cwd should be available");
let tempdir = Builder::new()
.prefix("learn-process-")
.tempdir_in(&cwd)
.expect("tempdir should be created");
let file_path = tempdir.path().join("state.txt");
std::fs::write(&file_path, "state").expect("file should be created");
let canonical = file_path
.canonicalize()
.expect("file should canonicalize successfully");
let accesses = vec![
FileAccess {
path: file_path.clone(),
is_write: false,
},
FileAccess {
path: file_path,
is_write: true,
},
];
let result = process_accesses(accesses, None, false).expect("accesses should process");
assert!(result.read_files.is_empty());
assert!(result.write_files.is_empty());
assert!(result.readwrite_files.contains(&canonical));
}
#[test]
fn test_process_accesses_drops_children_covered_by_directory() {
let cwd = std::env::current_dir().expect("cwd should be available");
let tempdir = Builder::new()
.prefix("learn-min-")
.tempdir_in(&cwd)
.expect("tempdir should be created");
let nested_dir = tempdir.path().join("nested");
std::fs::create_dir_all(&nested_dir).expect("nested dir should be created");
let nested_file = nested_dir.join("file.txt");
std::fs::write(&nested_file, "hello").expect("nested file should be created");
let root = tempdir
.path()
.canonicalize()
.expect("tempdir should canonicalize successfully");
let nested = nested_dir
.canonicalize()
.expect("nested dir should canonicalize successfully");
let file = nested_file
.canonicalize()
.expect("nested file should canonicalize successfully");
let accesses = vec![
FileAccess {
path: tempdir.path().to_path_buf(),
is_write: false,
},
FileAccess {
path: nested_dir,
is_write: false,
},
FileAccess {
path: nested_file,
is_write: false,
},
];
let result = process_accesses(accesses, None, false).expect("accesses should process");
assert!(result.read_paths.contains(&root));
assert!(!result.read_paths.contains(&nested));
assert!(!result.read_files.contains(&file));
}
#[test]
fn test_extract_between() {
assert_eq!(extract_between("htons(443)", "htons(", ")"), Some("443"));
assert_eq!(
extract_between(r#"inet_addr("1.2.3.4")"#, r#"inet_addr(""#, r#"""#),
Some("1.2.3.4")
);
assert_eq!(extract_between("no match here", "foo(", ")"), None);
assert_eq!(extract_between("prefix(", "prefix(", ")"), None);
}
#[test]
fn test_parse_connect_af_local_ignored() {
let line = r#"connect(3, {sa_family=AF_LOCAL, sun_path="/tmp/socket"}, 110) = 0"#;
assert!(parse_strace_line(line).is_none());
}
#[test]
fn test_format_network_summary_with_hostname() {
let conn = NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
hostname: Some("example.com".to_string()),
},
count: 5,
};
let line = format_network_summary(&conn);
assert_eq!(line, " example.com (93.184.216.34):443 (5x)");
}
#[test]
fn test_format_network_summary_without_hostname() {
let conn = NetworkConnectionSummary {
endpoint: NetworkEndpoint {
addr: "10.0.0.1".parse().unwrap(),
port: 8080,
hostname: None,
},
count: 1,
};
let line = format_network_summary(&conn);
assert_eq!(line, " 10.0.0.1:8080");
}
#[test]
fn test_parse_dns_query_hostname_simple() {
let mut data = vec![
0xab, 0x12, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
data.push(7); data.extend_from_slice(b"example");
data.push(3); data.extend_from_slice(b"com");
data.push(0); data.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]);
let hostname = parse_dns_query_hostname(&data).expect("should parse");
assert_eq!(hostname, "example.com");
}
#[test]
fn test_parse_dns_query_hostname_subdomain() {
let mut data = vec![0; 12]; data.push(3);
data.extend_from_slice(b"api");
data.push(7);
data.extend_from_slice(b"example");
data.push(3);
data.extend_from_slice(b"com");
data.push(0);
data.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]);
let hostname = parse_dns_query_hostname(&data).expect("should parse");
assert_eq!(hostname, "api.example.com");
}
#[test]
fn test_parse_dns_query_hostname_truncated() {
assert!(parse_dns_query_hostname(&[0; 10]).is_none());
assert!(parse_dns_query_hostname(&[0; 12]).is_none());
}
#[test]
fn test_unescape_strace_bytes() {
let bytes = unescape_strace_bytes(r#"\7example\3com\0"#);
assert_eq!(bytes[0], 7);
assert_eq!(&bytes[1..8], b"example");
assert_eq!(bytes[8], 3);
assert_eq!(&bytes[9..12], b"com");
assert_eq!(bytes[12], 0);
}
#[test]
fn test_extract_sendto_buffer() {
let line = r#"sendto(5, "\7example\3com\0", 13, 0, {}, 16) = 13"#;
let buf = extract_sendto_buffer(line).expect("should extract");
assert_eq!(buf, r#"\7example\3com\0"#);
}
#[test]
fn test_extract_sendto_buffer_with_escaped_backslash() {
let line = r#"sendto(5, "hello\\world", 11, 0, {}, 16) = 11"#;
let buf = extract_sendto_buffer(line).expect("should extract");
assert_eq!(buf, r#"hello\\world"#);
}
#[test]
fn test_parse_dns_sendto_ipv4() {
let line = r#"sendto(5, "\xab\x12\1\0\0\1\0\0\0\0\0\0\7example\3com\0\0\1\0\1", 29, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("8.8.8.8")}, 16) = 29"#;
let hostname = parse_dns_sendto(line).expect("should parse DNS query");
assert_eq!(hostname, "example.com");
}
#[test]
fn test_parse_dns_sendto_ipv6_dest() {
let line = r#"sendto(5, "\xab\x12\1\0\0\1\0\0\0\0\0\0\6google\3com\0\0\1\0\1", 28, 0, {sa_family=AF_INET6, sin6_port=htons(53), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:4860:4860::8888"), sin6_scope_id=0}, 28) = 28"#;
let hostname = parse_dns_sendto(line).expect("should parse DNS query via IPv6");
assert_eq!(hostname, "google.com");
}
#[test]
fn test_parse_dns_sendto_non_dns_ignored() {
let line = r#"sendto(5, "GET / HTTP/1.1\r\n", 16, 0, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = 16"#;
assert!(parse_dns_sendto(line).is_none());
}
#[test]
fn test_parse_strace_line_dns_query() {
let line = r#"sendto(5, "\xab\x12\1\0\0\1\0\0\0\0\0\0\7example\3com\0\0\1\0\1", 29, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("8.8.8.8")}, 16) = 29"#;
match parse_strace_line(line) {
Some(TracedAccess::DnsQuery(hostname)) => assert_eq!(hostname, "example.com"),
other => panic!("Expected DnsQuery, got {:?}", other),
}
}
#[test]
fn test_parse_strace_line_sendto_non_dns_returns_none() {
let line = r#"sendto(5, "data", 4, 0, {sa_family=AF_INET, sin_port=htons(1234), sin_addr=inet_addr("10.0.0.1")}, 16) = 4"#;
assert!(parse_strace_line(line).is_none());
}
#[test]
fn test_dns_timing_correlation_maps_hostname() {
let accesses = vec![NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: Some("example.com".to_string()),
}];
let dns_queries = vec!["example.com".to_string()];
let (outbound, _) = process_network_accesses(accesses, dns_queries, true);
assert_eq!(outbound.len(), 1);
assert_eq!(
outbound[0].endpoint.hostname,
Some("example.com".to_string())
);
}
#[test]
fn test_extract_strace_pid_with_prefix() {
let line = r#"[pid 12345] sendto(5, "data", 4, 0, {sa_family=AF_INET, ...}, 16) = 4"#;
assert_eq!(extract_strace_pid(line), Some(12345));
}
#[test]
fn test_extract_strace_pid_without_prefix() {
let line = r#"sendto(5, "data", 4, 0, {sa_family=AF_INET, ...}, 16) = 4"#;
assert_eq!(extract_strace_pid(line), None);
}
#[test]
fn test_extract_strace_pid_padded() {
let line = r#"[pid 1234] openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3"#;
assert_eq!(extract_strace_pid(line), Some(1234));
}
#[test]
fn test_extract_sendmsg_buffer() {
let line = r#"sendmsg(5, {msg_name={sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("8.8.8.8")}, msg_namelen=16, msg_iov=[{iov_base="\7example\3com\0", iov_len=13}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, 0) = 13"#;
let buf = extract_sendto_buffer(line).expect("should extract from sendmsg");
assert_eq!(buf, r#"\7example\3com\0"#);
}
#[test]
fn test_parse_resolved_sendto_json() {
let line = r#"sendto(5, "{\"method\":\"io.systemd.Resolve.ResolveHostname\",\"parameters\":{\"name\":\"example.com\",\"flags\":0}}\0", 94, MSG_DONTWAIT|MSG_NOSIGNAL, NULL, 0) = 94"#;
let hostname = parse_resolved_sendto(line).expect("should parse resolved JSON");
assert_eq!(hostname, "example.com");
}
#[test]
fn test_parse_sendmsg_dns_query() {
let line = r#"sendmsg(5, {msg_name={sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("8.8.8.8")}, msg_namelen=16, msg_iov=[{iov_base="\xab\x12\1\0\0\1\0\0\0\0\0\0\7example\3com\0\0\1\0\1", iov_len=29}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, 0) = 29"#;
let hostname = parse_dns_sendto(line).expect("should parse DNS query from sendmsg");
assert_eq!(hostname, "example.com");
}
}
#[cfg(all(test, target_os = "macos"))]
#[allow(clippy::unwrap_used)]
mod macos_tests {
use super::*;
#[test]
fn test_parse_fs_usage_open_read() {
let line = "14:23:45.123456 open /etc/passwd 0.000012 ls.12345";
let access = parse_fs_usage_line(line).expect("should parse open");
assert_eq!(access.path, PathBuf::from("/etc/passwd"));
assert!(!access.is_write);
}
#[test]
fn test_parse_fs_usage_stat64() {
let line =
"14:23:45.123456 stat64 /usr/lib/libSystem.B.dylib 0.000003 ls.12345";
let access = parse_fs_usage_line(line).expect("should parse stat64");
assert_eq!(access.path, PathBuf::from("/usr/lib/libSystem.B.dylib"));
assert!(!access.is_write);
}
#[test]
fn test_parse_fs_usage_mkdir_is_write() {
let line = "14:23:45.123456 mkdir /tmp/test_dir 0.000008 my_app.12345";
let access = parse_fs_usage_line(line).expect("should parse mkdir");
assert_eq!(access.path, PathBuf::from("/tmp/test_dir"));
assert!(access.is_write);
}
#[test]
fn test_parse_fs_usage_unlink_is_write() {
let line = "14:23:45.123456 unlink /tmp/test_file 0.000005 my_app.12345";
let access = parse_fs_usage_line(line).expect("should parse unlink");
assert_eq!(access.path, PathBuf::from("/tmp/test_file"));
assert!(access.is_write);
}
#[test]
fn test_parse_fs_usage_rename_is_write() {
let line = "14:23:45.123456 rename /tmp/old_name 0.000005 my_app.12345";
let access = parse_fs_usage_line(line).expect("should parse rename");
assert!(access.is_write);
}
#[test]
fn test_parse_fs_usage_skips_dev_paths() {
let line = "14:23:45.123456 open /dev/null 0.000002 my_app.12345";
assert!(parse_fs_usage_line(line).is_none());
}
#[test]
fn test_parse_fs_usage_skips_unknown_ops() {
let line = "14:23:45.123456 mmap /some/file 0.000002 my_app.12345";
assert!(parse_fs_usage_line(line).is_none());
}
#[test]
fn test_parse_fs_usage_empty_line() {
assert!(parse_fs_usage_line("").is_none());
assert!(parse_fs_usage_line(" ").is_none());
}
#[test]
fn test_parse_fs_usage_getattrlist() {
let line = "14:23:45.123456 getattrlist /Applications/Safari.app 0.000004 Finder.12345";
let access = parse_fs_usage_line(line).expect("should parse getattrlist");
assert_eq!(access.path, PathBuf::from("/Applications/Safari.app"));
assert!(!access.is_write);
}
#[test]
fn test_parse_fs_usage_readlink() {
let line = "14:23:45.123456 readlink /var 0.000002 ls.12345";
let access = parse_fs_usage_line(line).expect("should parse readlink");
assert_eq!(access.path, PathBuf::from("/var"));
assert!(!access.is_write);
}
#[test]
fn test_parse_fs_usage_write_op() {
let line = "14:23:45.123456 write /tmp/output.log 0.000010 my_app.12345";
let access = parse_fs_usage_line(line).expect("should parse write");
assert_eq!(access.path, PathBuf::from("/tmp/output.log"));
assert!(access.is_write);
}
#[test]
fn test_parse_fs_usage_execve() {
let line = "14:23:45.123456 execve /usr/bin/env 0.000015 bash.12345";
let access = parse_fs_usage_line(line).expect("should parse execve");
assert_eq!(access.path, PathBuf::from("/usr/bin/env"));
assert!(!access.is_write);
}
#[test]
fn test_parse_fs_usage_path_with_spaces() {
let line = "14:23:45.123456 stat64 /Users/test/Library/Application Support 0.000003 my_app.12345";
let access = parse_fs_usage_line(line).expect("should parse path with spaces");
assert_eq!(
access.path,
PathBuf::from("/Users/test/Library/Application Support")
);
}
#[test]
fn test_parse_fs_usage_with_errno() {
let line =
"14:23:45.123456 stat64 /nonexistent/path [2] 0.000003 my_app.12345";
let access = parse_fs_usage_line(line).expect("should parse line with errno");
assert_eq!(access.path, PathBuf::from("/nonexistent/path"));
}
#[test]
fn test_extract_fs_usage_path_basic() {
let line = "14:23:45.123456 open /etc/hosts 0.000012 ls.12345";
let path = extract_fs_usage_path(line).expect("should extract path");
assert_eq!(path, "/etc/hosts");
}
#[test]
fn test_is_fs_usage_write_open_flags() {
assert!(is_fs_usage_write(
"open",
"open (W_____) /tmp/file 0.000001 app.1"
));
assert!(is_fs_usage_write(
"open",
"open O_WRONLY /tmp/file 0.000001 app.1"
));
assert!(is_fs_usage_write(
"open",
"open O_RDWR /tmp/file 0.000001 app.1"
));
assert!(!is_fs_usage_write(
"open",
"open (R_____) /tmp/file 0.000001 app.1"
));
}
#[test]
fn test_is_fs_usage_write_operations() {
assert!(is_fs_usage_write("mkdir", ""));
assert!(is_fs_usage_write("unlink", ""));
assert!(is_fs_usage_write("rename", ""));
assert!(is_fs_usage_write("write", ""));
assert!(is_fs_usage_write("truncate", ""));
assert!(!is_fs_usage_write("stat64", ""));
assert!(!is_fs_usage_write("readlink", ""));
assert!(!is_fs_usage_write("access", ""));
}
#[test]
fn test_parse_nettop_tcp4_established() {
let no_listen = HashSet::new();
let line = "06:54:20.707620,tcp4 192.168.178.103:63660<->17.57.146.10:5223,en0,Established,179190,282920,6632,0,2362,18.41 ms,131072,164736,RD,-,cubic,-,-,-,-,so,";
let access = parse_nettop_line(line, &no_listen).expect("should parse established TCP4");
assert_eq!(access.addr, "17.57.146.10".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 5223);
assert!(matches!(access.kind, NetworkAccessKind::Connect));
}
#[test]
fn test_parse_nettop_tcp4_listen() {
let no_listen = HashSet::new();
let line =
"06:54:20.706434,tcp4 127.0.0.1:8021<->*:*,lo0,Listen,,,,,,,,,,-,cubic,-,-,-,-,so,";
let access = parse_nettop_line(line, &no_listen).expect("should parse listening TCP4");
assert_eq!(access.addr, "127.0.0.1".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 8021);
assert!(matches!(access.kind, NetworkAccessKind::Bind));
}
#[test]
fn test_parse_nettop_tcp4_listen_wildcard() {
let no_listen = HashSet::new();
let line = "06:54:20.706434,tcp4 *:3000<->*:*,en0,Listen,,,,,,,,,,,,,,,,,";
let access = parse_nettop_line(line, &no_listen).expect("should parse wildcard listen");
assert_eq!(access.addr, "0.0.0.0".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 3000);
assert!(matches!(access.kind, NetworkAccessKind::Bind));
}
#[test]
fn test_parse_nettop_udp4_bind() {
let no_listen = HashSet::new();
let line = "06:54:20.700522,udp4 *:56734<->*:*,lo0,,0,15678,,,,,786896,,BE,,,,,,,so,";
let access = parse_nettop_line(line, &no_listen).expect("should parse UDP4 bind");
assert_eq!(access.addr, "0.0.0.0".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 56734);
assert!(matches!(access.kind, NetworkAccessKind::Bind));
}
#[test]
fn test_parse_nettop_tcp6_established() {
let no_listen = HashSet::new();
let line = "06:54:20.707869,tcp6 fe80::73:3e64:c6b8:6476%en0.63975<->fe80::1402:4271:cc19:5e6f%en0.50715,en0,Established,1860,2955,0,0,0,22.31 ms,131072,130752,BE,-,cubic,-,-,-,-,so,";
let access = parse_nettop_line(line, &no_listen)
.expect("should parse established TCP6 with scope ID");
assert_eq!(
access.addr,
"fe80::1402:4271:cc19:5e6f".parse::<IpAddr>().unwrap()
);
assert_eq!(access.port, 50715);
assert!(matches!(access.kind, NetworkAccessKind::Connect));
}
#[test]
fn test_parse_nettop_tcp6_listen() {
let no_listen = HashSet::new();
let line = "06:54:20.706413,tcp6 ::1.8021<->*.*,lo0,Listen,,,,,,,,,,-,cubic,-,-,-,-,so,";
let access = parse_nettop_line(line, &no_listen).expect("should parse IPv6 listen");
assert_eq!(access.addr, "::1".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 8021);
assert!(matches!(access.kind, NetworkAccessKind::Bind));
}
#[test]
fn test_parse_nettop_skips_process_summary() {
let no_listen = HashSet::new();
let line = "06:54:20.708439,apsd.358,,,179190,282920,6632,0,2362,,,,,,,,,,,,";
assert!(parse_nettop_line(line, &no_listen).is_none());
}
#[test]
fn test_parse_nettop_skips_header() {
let no_listen = HashSet::new();
let line = "time,,interface,state,bytes_in,bytes_out,rx_dupe,rx_ooo,re-tx,rtt_avg,rcvsize,tx_win,tc_class,tc_mgt,cc_algo,P,C,R,W,arch,";
assert!(parse_nettop_line(line, &no_listen).is_none());
}
#[test]
fn test_parse_nettop_skips_loopback_connect() {
let no_listen = HashSet::new();
let line = "06:54:20.707620,tcp4 127.0.0.1:49152<->127.0.0.1:8080,lo0,Established,100,200,0,0,0,0.5 ms,,,,,,,,,,so,";
assert!(parse_nettop_line(line, &no_listen).is_none());
}
#[test]
fn test_parse_nettop_skips_port_zero() {
let no_listen = HashSet::new();
let line = "06:54:20.700522,udp4 *:0<->*:*,lo0,,0,0,,,,,,,,,,,,,,,";
assert!(parse_nettop_line(line, &no_listen).is_none());
}
#[test]
fn test_parse_nettop_skips_accepted_connection() {
let mut listening = HashSet::new();
listening.insert(3000u16);
let line = "06:54:20.707620,tcp4 192.168.1.1:3000<->10.0.0.5:52431,en0,Established,1000,2000,0,0,0,1.0 ms,,,,,,,,,,so,";
assert!(parse_nettop_line(line, &listening).is_none());
}
#[test]
fn test_parse_nettop_allows_outbound_on_non_listen_port() {
let mut listening = HashSet::new();
listening.insert(3000u16);
let line = "06:54:20.707620,tcp4 192.168.1.1:49999<->93.184.216.34:443,en0,Established,1000,2000,0,0,0,1.0 ms,,,,,,,,,,so,";
let access = parse_nettop_line(line, &listening).expect("should parse outbound");
assert_eq!(access.addr, "93.184.216.34".parse::<IpAddr>().unwrap());
assert_eq!(access.port, 443);
assert!(matches!(access.kind, NetworkAccessKind::Connect));
}
#[test]
fn test_parse_nettop_endpoint_ipv4() {
let (addr, port) = parse_nettop_endpoint("192.168.1.1:443", false).unwrap();
assert_eq!(addr, "192.168.1.1".parse::<IpAddr>().unwrap());
assert_eq!(port, 443);
}
#[test]
fn test_parse_nettop_endpoint_ipv4_wildcard() {
let (addr, port) = parse_nettop_endpoint("*:8080", false).unwrap();
assert_eq!(addr, "0.0.0.0".parse::<IpAddr>().unwrap());
assert_eq!(port, 8080);
}
#[test]
fn test_parse_nettop_endpoint_ipv6() {
let (addr, port) = parse_nettop_endpoint("::1.443", true).unwrap();
assert_eq!(addr, "::1".parse::<IpAddr>().unwrap());
assert_eq!(port, 443);
}
#[test]
fn test_parse_nettop_endpoint_wildcard_star() {
assert!(parse_nettop_endpoint("*:*", false).is_none());
assert!(parse_nettop_endpoint("*.*", true).is_none());
}
#[test]
fn test_nettop_network_dedup() {
let accesses = vec![
NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
},
NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
},
NetworkAccess {
addr: "93.184.216.34".parse().unwrap(),
port: 443,
kind: NetworkAccessKind::Connect,
queried_hostname: None,
},
];
let (outbound, listening) = process_network_accesses(accesses, vec![], false);
assert_eq!(outbound.len(), 1);
assert_eq!(outbound[0].count, 3);
assert!(listening.is_empty());
}
}