use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::error::ComputerError;
static AUDIT_LOCK: Mutex<()> = Mutex::new(());
std::thread_local! {
static STATE_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
pub struct StateDirGuard {
previous: Option<PathBuf>,
}
impl Drop for StateDirGuard {
fn drop(&mut self) {
let previous = self.previous.take();
STATE_DIR.with(|slot| *slot.borrow_mut() = previous);
}
}
pub fn push_state_dir(dir: impl Into<PathBuf>) -> StateDirGuard {
let previous = STATE_DIR.with(|slot| slot.borrow_mut().replace(dir.into()));
StateDirGuard { previous }
}
pub fn state_dir() -> Option<PathBuf> {
if let Some(overridden) = STATE_DIR.with(|slot| slot.borrow().clone()) {
return Some(overridden);
}
ambient_state_dir()
}
#[cfg(test)]
fn ambient_state_dir() -> Option<PathBuf> {
None
}
#[cfg(not(test))]
fn ambient_state_dir() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("computer-use"));
}
}
production_state_dir()
}
#[cfg(not(test))]
fn production_state_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
if !local.is_empty() {
return Some(PathBuf::from(local).join("computer-use"));
}
}
}
dirs::home_dir().map(|home| home.join(".local").join("state").join("computer-use"))
}
#[derive(Debug, Clone)]
pub struct AuditRecord<'a> {
pub session: &'a str,
pub action: &'a str,
pub bundle_id: Option<&'a str>,
pub pid: Option<i32>,
pub window_id: Option<u32>,
pub element: Option<u32>,
pub code: &'a str,
pub decision: &'a str,
pub source: &'a str,
pub tier: Option<&'a str>,
pub chars: Option<u64>,
}
#[derive(Serialize)]
struct AuditLine<'a> {
ts: String,
session: &'a str,
action: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
bundle_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pid: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
window_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
element: Option<u32>,
code: &'a str,
decision: &'a str,
source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
tier: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
chars: Option<u64>,
}
pub fn record(entry: &AuditRecord<'_>) -> Result<(), ComputerError> {
let Some(dir) = state_dir() else {
return Ok(());
};
let line = AuditLine {
ts: utc_timestamp(unix_secs()),
session: entry.session,
action: entry.action,
bundle_id: entry.bundle_id,
pid: entry.pid,
window_id: entry.window_id,
element: entry.element,
code: entry.code,
decision: entry.decision,
source: entry.source,
tier: entry.tier,
chars: entry.chars,
};
let text = serde_json::to_string(&line)
.map_err(|error| ComputerError::ExecutionFailed(format!("audit encode failed: {error}")))?;
let _guard = AUDIT_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
append_line(&dir.join("audit.jsonl"), &text)
}
fn append_line(path: &Path, line: &str) -> Result<(), ComputerError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(ComputerError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
}
let mut options = OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
#[cfg(target_os = "linux")]
options.custom_flags(0x20000);
#[cfg(target_os = "macos")]
options.custom_flags(0x0100);
}
let mut file = options.open(path).map_err(ComputerError::Io)?;
file.write_all(line.as_bytes()).map_err(ComputerError::Io)?;
file.write_all(b"\n").map_err(ComputerError::Io)?;
Ok(())
}
fn unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
pub fn utc_timestamp(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400) as u32;
let (year, month, day) = civil_from_days(days);
let hour = tod / 3600;
let minute = (tod % 3600) / 60;
let second = tod % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let year = (if month <= 2 { y + 1 } else { y }) as i32;
(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unix_epoch_formats_as_1970() {
assert_eq!(utc_timestamp(0), "1970-01-01T00:00:00Z");
assert_eq!(utc_timestamp(86_400), "1970-01-02T00:00:00Z");
}
#[test]
fn writer_redacts_a_value_because_it_has_no_value_field() {
let dir = std::env::temp_dir().join(format!(
"computer-use-audit-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let _guard = push_state_dir(&dir);
record(&AuditRecord {
session: "9",
action: "set_value",
bundle_id: Some("com.apple.Notes"),
pid: Some(412),
window_id: Some(88),
element: Some(12),
code: "policy_denied",
decision: "deny",
source: "builtin",
tier: Some("ax"),
chars: None,
})
.unwrap();
let text = fs::read_to_string(dir.join("audit.jsonl")).unwrap();
assert!(text.contains("\"action\":\"set_value\""));
assert!(text.contains("\"code\":\"policy_denied\""));
assert!(text.contains("\"decision\":\"deny\""));
assert!(text.contains("\"source\":\"builtin\""));
assert!(!text.contains("\"value\""));
assert!(!text.contains("super-secret-token"));
let _ = fs::remove_dir_all(&dir);
}
}