use std::fmt;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
const ROTATE_AT_BYTES: u64 = 16 * 1024 * 1024;
const GENERATIONS: usize = 5;
const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Off,
NonApprovals,
Everything,
}
impl Mode {
pub fn from_flags(log: bool, log_everything: bool) -> Self {
match (log, log_everything) {
(_, true) => Mode::Everything,
(true, false) => Mode::NonApprovals,
(false, false) => Mode::Off,
}
}
pub fn keeps(self, outcome: Outcome) -> bool {
match self {
Mode::Off => false,
Mode::Everything => true,
Mode::NonApprovals => outcome != Outcome::Allowed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Allowed,
Denied,
Abstained,
Unparseable,
}
impl Outcome {
fn as_str(self) -> &'static str {
match self {
Outcome::Allowed => "allowed",
Outcome::Denied => "denied",
Outcome::Abstained => "abstained",
Outcome::Unparseable => "unparseable",
}
}
}
impl fmt::Display for Outcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub struct Context<'a> {
pub command: &'a str,
pub cwd: Option<&'a str>,
pub root: Option<&'a str>,
pub session_id: Option<&'a str>,
pub harness: &'a str,
pub level: &'a str,
}
pub fn record(
mode: Mode,
outcome: Outcome,
ctx: &Context<'_>,
explanation: Option<&crate::cst::Explanation>,
) {
if !mode.keeps(outcome) {
return;
}
let Some(path) = log_path() else { return };
let entry = build_entry(outcome, ctx, explanation);
let Ok(line) = serde_json::to_string(&entry) else { return };
append_line(&path, &line);
}
fn log_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
if home.is_empty() {
return None;
}
Some(PathBuf::from(home).join(".local/state/safe-chains/log.jsonl"))
}
fn build_entry(
outcome: Outcome,
ctx: &Context<'_>,
explanation: Option<&crate::cst::Explanation>,
) -> Value {
let now_ms = unix_millis();
let mut digest = Sha256::new();
digest.update(ctx.command.as_bytes());
let hash: String = digest.finalize().iter().take(4).map(|b| format!("{b:02x}")).collect();
let segments: Vec<Value> = explanation
.map(|e| {
e.segments
.iter()
.map(|s| {
let allowed = s.verdict.is_allowed();
json!({
"text": s.text,
"verdict": if allowed { "allowed" } else { "denied" },
"culprit": s.culprit,
"facets": if allowed { Value::Null } else { facets_of(&s.text) },
})
})
.collect()
})
.unwrap_or_default();
let (triage, unknown) = if outcome == Outcome::Allowed {
("allowed", Vec::new())
} else {
triage_of(ctx.command)
};
json!({
"schema": SCHEMA,
"id": format!("{now_ms}-{hash}"),
"at": rfc3339_utc(now_ms),
"version": env!("CARGO_PKG_VERSION"),
"harness": ctx.harness,
"outcome": outcome.as_str(),
"level": ctx.level,
"command": ctx.command,
"cwd": ctx.cwd,
"root": ctx.root,
"session_id": ctx.session_id,
"triage": triage,
"unknown_commands": unknown,
"segments": segments,
"stateful": explanation.is_some_and(|e| e.stateful),
})
}
fn triage_of(command: &str) -> (&'static str, Vec<String>) {
use crate::suggest::Outcome as S;
match crate::suggest::analyze(command) {
S::AlreadyAllowed => ("recognized-but-denied", Vec::new()),
S::Unparseable => ("unparseable", Vec::new()),
S::RecognizedButDenied { .. } => ("recognized-but-denied", Vec::new()),
S::Generated { entries, .. } => {
("unknown-command", entries.iter().map(|e| e.name.clone()).collect())
}
}
}
fn facets_of(command: &str) -> Value {
if crate::cst::explain(command).segments.len() != 1 {
return Value::Null;
}
let Ok(words) = shell_words::split(command) else { return Value::Null };
if words.is_empty() {
return Value::Null;
}
let tokens: Vec<crate::parse::Token> =
words.into_iter().map(crate::parse::Token::from_raw).collect();
let Some(ex) = crate::engine::bridge::explain_profile(&tokens) else {
return Value::Null;
};
let capabilities: Vec<Value> = ex
.capabilities
.iter()
.map(|(because, facets)| {
let profile: serde_json::Map<String, Value> = facets
.iter()
.map(|(name, term)| ((*name).to_string(), Value::String((*term).to_string())))
.collect();
json!({ "because": because, "profile": profile })
})
.collect();
json!({
"capabilities": capabilities,
"refused_by": ex.blocked_by.as_ref().map(|(level, mismatch)| json!({
"level": level,
"clause": mismatch.to_string(),
})),
})
}
fn append_line(path: &std::path::Path, line: &str) {
let Some(dir) = path.parent() else { return };
if fs::create_dir_all(dir).is_err() {
return;
}
rotate_if_large(path);
let mut opts = fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let Ok(mut file) = opts.open(path) else { return };
let mut buf = String::with_capacity(line.len() + 1);
buf.push_str(line);
buf.push('\n');
let _ = file.write_all(buf.as_bytes());
}
fn rotate_if_large(path: &std::path::Path) {
rotate_at(path, ROTATE_AT_BYTES, GENERATIONS);
}
fn rotate_at(path: &std::path::Path, cap: u64, generations: usize) {
let Ok(meta) = fs::metadata(path) else { return };
if meta.len() < cap {
return;
}
let nth = |n: usize| path.with_extension(format!("jsonl.{n}"));
let _ = fs::remove_file(nth(generations));
for n in (1..generations).rev() {
let _ = fs::rename(nth(n), nth(n + 1));
}
let _ = fs::rename(path, nth(1));
}
fn unix_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn rfc3339_utc(ms: u64) -> String {
let secs = (ms / 1000) as i64;
let millis = ms % 1000;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 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 / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests;