refactor(sandbox): extract Seatbelt denial collector

This commit is contained in:
Dylan Hurd
2026-06-11 19:45:10 -07:00
parent 16c7c79540
commit 484518f284
9 changed files with 444 additions and 172 deletions
-1
View File
@@ -2350,7 +2350,6 @@ dependencies = [
"owo-colors",
"predicates",
"pretty_assertions",
"regex-lite",
"serde",
"serde_json",
"sqlx",
-1
View File
@@ -64,7 +64,6 @@ http = { workspace = true }
libc = { workspace = true }
os_info = { workspace = true }
owo-colors = { workspace = true }
regex-lite = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
supports-color = { workspace = true }
+11 -11
View File
@@ -1,8 +1,3 @@
#[cfg(target_os = "macos")]
mod pid_tracker;
#[cfg(target_os = "macos")]
mod seatbelt;
use std::path::PathBuf;
use std::process::Stdio;
@@ -23,6 +18,10 @@ use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission
use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt::create_seatbelt_command_args;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt_denials::DenialLogger;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt_denials::SandboxDenial;
use codex_sandboxing::with_managed_mitm_ca_readable_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
@@ -35,9 +34,6 @@ use crate::SeatbeltCommand;
use crate::WindowsCommand;
use crate::exit_status::handle_exit_status;
#[cfg(target_os = "macos")]
use seatbelt::DenialLogger;
#[cfg(target_os = "macos")]
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
@@ -234,7 +230,11 @@ async fn run_command_under_sandbox(
}
#[cfg(target_os = "macos")]
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
let mut denial_logger = if log_denials {
DenialLogger::new().await
} else {
None
};
#[cfg(not(target_os = "macos"))]
let _ = log_denials;
@@ -338,7 +338,7 @@ async fn run_command_under_sandbox(
#[cfg(target_os = "macos")]
if let Some(denial_logger) = &mut denial_logger {
denial_logger.on_child_spawn(&child);
denial_logger.on_child_pid(child.id());
}
let status = child.wait().await?;
@@ -350,7 +350,7 @@ async fn run_command_under_sandbox(
if denials.is_empty() {
eprintln!("None found.");
} else {
for seatbelt::SandboxDenial { name, capability } in denials {
for SandboxDenial { name, capability } in denials {
eprintln!("({name}) {capability}");
}
}
-114
View File
@@ -1,114 +0,0 @@
use std::collections::HashSet;
use tokio::io::AsyncBufReadExt;
use tokio::process::Child;
use tokio::task::JoinHandle;
use super::pid_tracker::PidTracker;
pub struct SandboxDenial {
pub name: String,
pub capability: String,
}
pub struct DenialLogger {
log_stream: Child,
pid_tracker: Option<PidTracker>,
log_reader: Option<JoinHandle<Vec<u8>>>,
}
impl DenialLogger {
pub(crate) fn new() -> Option<Self> {
let mut log_stream = start_log_stream()?;
let stdout = log_stream.stdout.take()?;
let log_reader = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stdout);
let mut logs = Vec::new();
let mut chunk = Vec::new();
loop {
match reader.read_until(b'\n', &mut chunk).await {
Ok(0) | Err(_) => break,
Ok(_) => {
logs.extend_from_slice(&chunk);
chunk.clear();
}
}
}
logs
});
Some(Self {
log_stream,
pid_tracker: None,
log_reader: Some(log_reader),
})
}
pub(crate) fn on_child_spawn(&mut self, child: &Child) {
if let Some(root_pid) = child.id() {
self.pid_tracker = PidTracker::new(root_pid as i32);
}
}
pub(crate) async fn finish(mut self) -> Vec<SandboxDenial> {
let pid_set = match self.pid_tracker {
Some(tracker) => tracker.stop().await,
None => Default::default(),
};
if pid_set.is_empty() {
return Vec::new();
}
let _ = self.log_stream.kill().await;
let _ = self.log_stream.wait().await;
let logs_bytes = match self.log_reader.take() {
Some(handle) => handle.await.unwrap_or_default(),
None => Vec::new(),
};
let logs = String::from_utf8_lossy(&logs_bytes);
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut denials: Vec<SandboxDenial> = Vec::new();
for line in logs.lines() {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
&& let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str())
&& let Some((pid, name, capability)) = parse_message(msg)
&& pid_set.contains(&pid)
&& seen.insert((name.clone(), capability.clone()))
{
denials.push(SandboxDenial { name, capability });
}
}
denials
}
}
fn start_log_stream() -> Option<Child> {
use std::process::Stdio;
const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#;
tokio::process::Command::new("log")
.args(["stream", "--style", "ndjson", "--predicate", PREDICATE])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.ok()
}
fn parse_message(msg: &str) -> Option<(i32, String, String)> {
// Example message:
// Sandbox: processname(1234) deny(1) capability-name args...
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
#[expect(clippy::unwrap_used)]
regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap()
});
let (_, [name, pid_str, capability]) = re.captures(msg)?.extract();
let pid = pid_str.trim().parse::<i32>().ok()?;
Some((pid, name.to_string(), capability.to_string()))
}
+2 -1
View File
@@ -21,6 +21,7 @@ libc = { workspace = true }
serde_json = { workspace = true }
regex-lite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tokio = { workspace = true, features = ["io-util", "process", "rt", "time"] }
url = { workspace = true }
which = { workspace = true }
@@ -28,4 +29,4 @@ which = { workspace = true }
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros"] }
+1
View File
@@ -5,6 +5,7 @@ mod manager;
pub mod policy_transforms;
#[cfg(target_os = "macos")]
pub mod seatbelt;
pub mod seatbelt_denials;
#[cfg(target_os = "linux")]
pub use bwrap::find_system_bwrap_in_path;
@@ -0,0 +1,286 @@
#[cfg(target_os = "macos")]
use std::collections::HashSet;
#[cfg(target_os = "macos")]
use std::collections::VecDeque;
#[cfg(target_os = "macos")]
use tokio::io::AsyncBufReadExt;
#[cfg(target_os = "macos")]
use tokio::process::Child;
#[cfg(target_os = "macos")]
use tokio::task::JoinHandle;
#[cfg(target_os = "macos")]
use self::pid_tracker::PidTracker;
#[cfg(target_os = "macos")]
const LOG_STREAM_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
#[cfg(target_os = "macos")]
const LOG_STREAM_FLUSH_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(100);
#[cfg(target_os = "macos")]
const LOG_STREAM_READY_PREFIX: &str = "Filtering the log data using ";
#[cfg(target_os = "macos")]
const MAX_COLLECTED_LOG_CHARS: usize = 1_000;
#[cfg(target_os = "macos")]
mod pid_tracker;
#[cfg(target_os = "macos")]
/// A unique macOS Seatbelt sandbox denial emitted by a process.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxDenial {
pub name: String,
pub capability: String,
}
#[cfg(target_os = "macos")]
#[derive(Clone, Debug, PartialEq, Eq)]
struct LoggedDenial {
pid: i32,
denial: SandboxDenial,
chars: usize,
}
/// Best-effort collector for macOS Seatbelt denials emitted by a process tree.
pub struct DenialLogger {
#[cfg(target_os = "macos")]
log_stream: Child,
#[cfg(target_os = "macos")]
pid_tracker: Option<PidTracker>,
#[cfg(target_os = "macos")]
log_reader: JoinHandle<VecDeque<LoggedDenial>>,
#[cfg(target_os = "macos")]
tracked_pid_tx: std::sync::mpsc::Sender<i32>,
}
impl DenialLogger {
/// Starts collecting Seatbelt denial log messages.
#[cfg(target_os = "macos")]
pub async fn new() -> Option<Self> {
Self::new_with_limit(/*max_chars*/ None).await
}
/// Starts collecting Seatbelt denials while retaining at most 1,000 characters.
#[cfg(target_os = "macos")]
pub async fn new_bounded() -> Option<Self> {
Self::new_with_limit(Some(MAX_COLLECTED_LOG_CHARS)).await
}
#[cfg(target_os = "macos")]
async fn new_with_limit(max_chars: Option<usize>) -> Option<Self> {
let mut log_stream = start_log_stream()?;
let stdout = log_stream.stdout.take()?;
let stderr = log_stream.stderr.take()?;
let (ready_tx, mut ready_rx) = tokio::sync::mpsc::channel(1);
let (tracked_pid_tx, tracked_pid_rx) = std::sync::mpsc::channel();
let log_reader = tokio::spawn(async move {
let mut stdout = tokio::io::BufReader::new(stdout);
let mut stderr = tokio::io::BufReader::new(stderr);
let mut logged_denials = VecDeque::new();
let mut tracked_pids = HashSet::new();
let mut collected_chars = 0;
let mut stdout_line = Vec::new();
let mut stderr_line = String::new();
let mut stdout_open = true;
let mut stderr_open = true;
while stdout_open || stderr_open {
tokio::select! {
result = stdout.read_until(b'\n', &mut stdout_line), if stdout_open => {
stdout_open = result.is_ok_and(|read| read > 0);
if stdout_line.starts_with(LOG_STREAM_READY_PREFIX.as_bytes()) {
let _ = ready_tx.try_send(());
} else if let Some(denial) = parse_log_line(&stdout_line) {
tracked_pids.extend(tracked_pid_rx.try_iter());
push_logged_denial(
&mut logged_denials,
&mut collected_chars,
denial,
max_chars,
&tracked_pids,
);
}
stdout_line.clear();
}
result = stderr.read_line(&mut stderr_line), if stderr_open => {
stderr_open = result.is_ok_and(|read| read > 0);
if stderr_line.starts_with(LOG_STREAM_READY_PREFIX) {
let _ = ready_tx.try_send(());
}
stderr_line.clear();
}
}
}
logged_denials
});
let ready = tokio::time::timeout(LOG_STREAM_READY_TIMEOUT, ready_rx.recv())
.await
.is_ok_and(|result| result.is_some());
if !ready {
let _ = log_stream.kill().await;
let _ = log_stream.wait().await;
log_reader.abort();
return None;
}
Some(Self {
log_stream,
pid_tracker: None,
log_reader,
tracked_pid_tx,
})
}
/// Returns no logger on platforms without macOS Seatbelt.
#[cfg(not(target_os = "macos"))]
pub async fn new() -> Option<Self> {
None
}
/// Returns no logger on platforms without macOS Seatbelt.
#[cfg(not(target_os = "macos"))]
pub async fn new_bounded() -> Option<Self> {
None
}
/// Starts tracking the process tree rooted at `child_pid`.
#[cfg(target_os = "macos")]
pub fn on_child_pid(&mut self, child_pid: Option<u32>) {
if let Some(root_pid) = child_pid {
self.pid_tracker = PidTracker::new(root_pid as i32, self.tracked_pid_tx.clone());
}
}
/// Does nothing on platforms without macOS Seatbelt.
#[cfg(not(target_os = "macos"))]
pub fn on_child_pid(&mut self, child_pid: Option<u32>) {
let _ = child_pid;
}
/// Stops collection and returns unique denials from the tracked process tree.
#[cfg(target_os = "macos")]
pub async fn finish(mut self) -> Vec<SandboxDenial> {
let pid_set = match self.pid_tracker {
Some(tracker) => tracker.stop().await,
None => Default::default(),
};
if !pid_set.is_empty() {
tokio::time::sleep(LOG_STREAM_FLUSH_GRACE_PERIOD).await;
}
let _ = self.log_stream.kill().await;
let _ = self.log_stream.wait().await;
let logged_denials = self.log_reader.await.unwrap_or_default();
if pid_set.is_empty() {
return Vec::new();
}
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut denials: Vec<SandboxDenial> = Vec::new();
for LoggedDenial { pid, denial, .. } in logged_denials {
if pid_set.contains(&pid)
&& seen.insert((denial.name.clone(), denial.capability.clone()))
{
denials.push(denial);
}
}
denials
}
/// Returns no denials on platforms without macOS Seatbelt.
#[cfg(not(target_os = "macos"))]
pub async fn finish(self) -> Vec<SandboxDenial> {
Vec::new()
}
}
#[cfg(target_os = "macos")]
fn push_logged_denial(
logged_denials: &mut VecDeque<LoggedDenial>,
collected_chars: &mut usize,
logged_denial: LoggedDenial,
max_chars: Option<usize>,
tracked_pids: &HashSet<i32>,
) {
let denial_chars = logged_denial.chars;
if let Some(max_chars) = max_chars {
if denial_chars > max_chars {
return;
}
logged_denials.push_back(logged_denial);
*collected_chars += denial_chars;
while *collected_chars > max_chars {
let removal_index = logged_denials
.iter()
.position(|logged_denial| !tracked_pids.contains(&logged_denial.pid))
.unwrap_or(0);
let Some(removed) = logged_denials.remove(removal_index) else {
break;
};
*collected_chars -= removed.chars;
}
return;
}
logged_denials.push_back(logged_denial);
}
#[cfg(target_os = "macos")]
fn parse_log_line(line: &[u8]) -> Option<LoggedDenial> {
let json = serde_json::from_slice::<serde_json::Value>(line).ok()?;
let msg = json.get("eventMessage")?.as_str()?;
let (pid, name, capability) = parse_message(msg)?;
let chars = name.chars().count() + capability.chars().count();
Some(LoggedDenial {
pid,
denial: SandboxDenial { name, capability },
chars,
})
}
#[cfg(target_os = "macos")]
fn start_log_stream() -> Option<Child> {
use std::process::Stdio;
const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#;
tokio::process::Command::new("log")
.args(["stream", "--style", "ndjson", "--predicate", PREDICATE])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.ok()
}
#[cfg(target_os = "macos")]
fn parse_message(msg: &str) -> Option<(i32, String, String)> {
// Example message:
// Sandbox: processname(1234) deny(1) capability-name args...
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
#[expect(clippy::unwrap_used)]
regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap()
});
let (_, [name, pid_str, capability]) = re.captures(msg)?.extract();
let pid = pid_str.trim().parse::<i32>().ok()?;
Some((pid, name.to_string(), capability.to_string()))
}
/// Formats denials for appending to command output.
pub fn format_sandbox_denials(denials: &[SandboxDenial]) -> Option<Vec<u8>> {
if denials.is_empty() {
return None;
}
let mut formatted = String::from("\n=== Sandbox denials ===\n");
for SandboxDenial { name, capability } in denials {
formatted.push_str(&format!("({name}) {capability}\n"));
}
Some(formatted.into_bytes())
}
#[cfg(all(test, target_os = "macos"))]
#[path = "seatbelt_denials_tests.rs"]
mod tests;
@@ -1,4 +1,7 @@
//! Descendant process tracking for macOS Seatbelt denial collection.
use std::collections::HashSet;
use std::sync::mpsc::Sender;
use tokio::task::JoinHandle;
use tracing::warn;
@@ -10,13 +13,15 @@ pub(crate) struct PidTracker {
}
impl PidTracker {
pub(crate) fn new(root_pid: i32) -> Option<Self> {
pub(crate) fn new(root_pid: i32, tracked_pid_tx: Sender<i32>) -> Option<Self> {
if root_pid <= 0 {
return None;
}
let _ = tracked_pid_tx.send(root_pid);
let kq = unsafe { libc::kqueue() };
let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid));
let handle =
tokio::task::spawn_blocking(move || track_descendants(kq, root_pid, tracked_pid_tx));
Some(Self { kq, handle })
}
@@ -112,19 +117,29 @@ fn watch_children(
parent: i32,
seen: &mut HashSet<i32>,
active: &mut HashSet<i32>,
tracked_pid_tx: &Sender<i32>,
) {
for child_pid in list_child_pids(parent) {
add_pid_watch(kq, child_pid, seen, active);
add_pid_watch(kq, child_pid, seen, active, tracked_pid_tx);
}
}
/// Watch `pid` and its children, updating `seen` and `active` sets.
fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet<i32>, active: &mut HashSet<i32>) {
fn add_pid_watch(
kq: libc::c_int,
pid: i32,
seen: &mut HashSet<i32>,
active: &mut HashSet<i32>,
tracked_pid_tx: &Sender<i32>,
) {
if pid <= 0 {
return;
}
let newly_seen = seen.insert(pid);
if newly_seen {
let _ = tracked_pid_tx.send(pid);
}
let mut should_recurse = newly_seen;
if active.insert(pid) {
@@ -145,7 +160,7 @@ fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet<i32>, active: &mu
}
if should_recurse {
watch_children(kq, pid, seen, active);
watch_children(kq, pid, seen, active, tracked_pid_tx);
}
}
const STOP_IDENT: libc::uintptr_t = 1;
@@ -182,7 +197,7 @@ fn trigger_stop_event(kq: libc::c_int) {
}
/// Put all of the above together to track all the descendants of `root_pid`.
fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
fn track_descendants(kq: libc::c_int, root_pid: i32, tracked_pid_tx: Sender<i32>) -> HashSet<i32> {
if kq < 0 {
let mut seen = HashSet::new();
seen.insert(root_pid);
@@ -199,7 +214,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
let mut seen: HashSet<i32> = HashSet::new();
let mut active: HashSet<i32> = HashSet::new();
add_pid_watch(kq, root_pid, &mut seen, &mut active);
add_pid_watch(kq, root_pid, &mut seen, &mut active, &tracked_pid_tx);
const EVENTS_CAP: usize = 32;
let mut events: [libc::kevent; EVENTS_CAP] =
@@ -211,7 +226,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
if !pid_is_alive(root_pid) {
break;
}
add_pid_watch(kq, root_pid, &mut seen, &mut active);
add_pid_watch(kq, root_pid, &mut seen, &mut active, &tracked_pid_tx);
if active.is_empty() {
continue;
}
@@ -256,7 +271,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
}
if (ev.fflags & libc::NOTE_FORK) != 0 {
watch_children(kq, pid, &mut seen, &mut active);
watch_children(kq, pid, &mut seen, &mut active, &tracked_pid_tx);
}
if (ev.fflags & libc::NOTE_EXIT) != 0 {
@@ -281,43 +296,12 @@ mod tests {
use std::process::Stdio;
use std::time::Duration;
#[test]
fn pid_is_alive_detects_current_process() {
let pid = std::process::id() as i32;
assert!(pid_is_alive(pid));
}
#[cfg(target_os = "macos")]
#[test]
fn list_child_pids_includes_spawned_child() {
let mut child = Command::new("/bin/sleep")
.arg("5")
.stdin(Stdio::null())
.spawn()
.expect("failed to spawn child process");
let child_pid = child.id() as i32;
let parent_pid = std::process::id() as i32;
let mut found = false;
for _ in 0..100 {
if list_child_pids(parent_pid).contains(&child_pid) {
found = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let _ = child.kill();
let _ = child.wait();
assert!(found, "expected to find child pid {child_pid} in list");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn pid_tracker_collects_spawned_children() {
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
let (tracked_pid_tx, tracked_pid_rx) = std::sync::mpsc::channel();
let tracker = PidTracker::new(std::process::id() as i32, tracked_pid_tx)
.expect("failed to create tracker");
let mut child = Command::new("/bin/sleep")
.arg("0.1")
@@ -328,6 +312,15 @@ mod tests {
let child_pid = child.id() as i32;
let parent_pid = std::process::id() as i32;
let mut child_was_published = false;
for _ in 0..100 {
if tracked_pid_rx.try_iter().any(|pid| pid == child_pid) {
child_was_published = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let _ = child.wait();
let seen = tracker.stop().await;
@@ -340,12 +333,18 @@ mod tests {
seen.contains(&child_pid),
"expected tracker to include child pid {child_pid}"
);
assert!(
child_was_published,
"expected tracker to publish child pid {child_pid} before stop"
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn pid_tracker_collects_bash_subshell_descendants() {
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
let (tracked_pid_tx, _tracked_pid_rx) = std::sync::mpsc::channel();
let tracker = PidTracker::new(std::process::id() as i32, tracked_pid_tx)
.expect("failed to create tracker");
let child = Command::new("/bin/bash")
.arg("-c")
@@ -0,0 +1,101 @@
use super::*;
use pretty_assertions::assert_eq;
fn logged_denial(pid: i32, name: String) -> LoggedDenial {
let chars = name.chars().count();
LoggedDenial {
pid,
denial: SandboxDenial {
name,
capability: String::new(),
},
chars,
}
}
#[test]
fn collected_logs_are_capped_at_one_thousand_characters() {
let mut logged_denials = VecDeque::new();
let mut collected_chars = 0;
let old_denial = logged_denial(/*pid*/ 1, "a".repeat(600));
let recent_denial = logged_denial(/*pid*/ 2, "é".repeat(500));
let tracked_pids = HashSet::from([1, 2]);
push_logged_denial(
&mut logged_denials,
&mut collected_chars,
old_denial,
Some(MAX_COLLECTED_LOG_CHARS),
&tracked_pids,
);
push_logged_denial(
&mut logged_denials,
&mut collected_chars,
recent_denial.clone(),
Some(MAX_COLLECTED_LOG_CHARS),
&tracked_pids,
);
assert_eq!(logged_denials, VecDeque::from([recent_denial]));
assert_eq!(collected_chars, 500);
}
#[test]
fn tracked_denials_outlive_unrelated_noise_after_descendant_discovery() {
let mut logged_denials = VecDeque::new();
let mut collected_chars = 0;
let root_denial = logged_denial(/*pid*/ 1, "a".repeat(400));
let descendant_denial = logged_denial(/*pid*/ 2, "b".repeat(400));
let unrelated_denial = logged_denial(/*pid*/ 3, "c".repeat(300));
let mut tracked_pids = HashSet::from([1]);
push_logged_denial(
&mut logged_denials,
&mut collected_chars,
root_denial.clone(),
Some(MAX_COLLECTED_LOG_CHARS),
&tracked_pids,
);
push_logged_denial(
&mut logged_denials,
&mut collected_chars,
descendant_denial.clone(),
Some(MAX_COLLECTED_LOG_CHARS),
&tracked_pids,
);
tracked_pids.insert(2);
push_logged_denial(
&mut logged_denials,
&mut collected_chars,
unrelated_denial,
Some(MAX_COLLECTED_LOG_CHARS),
&tracked_pids,
);
assert_eq!(
logged_denials,
VecDeque::from([root_denial, descendant_denial])
);
assert_eq!(collected_chars, 800);
}
#[test]
fn parses_denial_from_large_log_record() {
let line = serde_json::to_vec(&serde_json::json!({
"eventMessage": "Sandbox: touch(1234) deny(1) file-write-create /private/tmp/nope",
"metadata": "x".repeat(2_000),
}))
.expect("valid log record");
assert_eq!(
parse_log_line(&line),
Some(LoggedDenial {
pid: 1234,
denial: SandboxDenial {
name: "touch".to_string(),
capability: "file-write-create /private/tmp/nope".to_string(),
},
chars: "touch".chars().count() + "file-write-create /private/tmp/nope".chars().count(),
})
);
}