use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub const ROTATE_BYTES: u64 = 8 << 20;
const MAX_DETAIL: usize = 300;
const NOTIFY_TIMEOUT: Duration = Duration::from_secs(10);
pub mod kind {
pub const REFUSED_PEER: &str = "refused_peer";
pub const AUTH_FAILURE: &str = "auth_failure";
pub const OVERSIZED: &str = "oversized_request";
pub const MALFORMED: &str = "malformed_request";
pub const RATE_LIMITED: &str = "rate_limited";
pub const OUTSIDE_SANDBOX: &str = "outside_sandbox";
pub const HOOK_DENIED: &str = "hook_denied";
pub const RECONCILE_ERROR: &str = "reconcile_error";
pub const UNMEDIATED: &str = "unmediated_import";
pub const FREEZE: &str = "freeze";
pub const INTEGRITY_ALARM: &str = "integrity_alarm";
pub const TAINT: &str = "taint";
pub const SUDO_WITHOUT_PASSWORD: &str = "sudo_without_password";
pub const TOKEN_ROTATED: &str = "token_rotated";
pub const UNHOOKED: &str = "unhooked";
pub const ALL: [&str; 15] = [
REFUSED_PEER,
AUTH_FAILURE,
OVERSIZED,
MALFORMED,
RATE_LIMITED,
OUTSIDE_SANDBOX,
HOOK_DENIED,
RECONCILE_ERROR,
UNMEDIATED,
FREEZE,
INTEGRITY_ALARM,
TAINT,
SUDO_WITHOUT_PASSWORD,
TOKEN_ROTATED,
UNHOOKED,
];
pub fn alarming(k: &str) -> bool {
matches!(k, FREEZE | INTEGRITY_ALARM)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Event {
pub time_ms: i64,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub detail: String,
}
impl Event {
pub fn new(time_ms: i64, kind: &str) -> Self {
Self {
time_ms,
kind: kind.into(),
..Self::default()
}
}
pub fn repo(mut self, repo: &str) -> Self {
self.repo = Some(clean(repo, 64));
self
}
pub fn session(mut self, session: &str) -> Self {
self.session = Some(clean(session, 64));
self
}
pub fn method(mut self, method: &str) -> Self {
self.method = Some(clean(method, 64));
self
}
pub fn uid(mut self, uid: Option<u32>) -> Self {
self.uid = uid;
self
}
pub fn code(mut self, code: &str) -> Self {
self.code = Some(clean(code, 64));
self
}
pub fn detail(mut self, detail: &str) -> Self {
self.detail = clean(detail, MAX_DETAIL);
self
}
}
pub fn clean(text: &str, max: usize) -> String {
let kept: String = text
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.take(max)
.collect();
mask_tokens(&kept)
}
fn mask_tokens(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find("lonca_") {
out.push_str(&rest[..at]);
let tail = &rest[at + "lonca_".len()..];
let tail = tail.strip_prefix("h_").unwrap_or(tail);
let hex = tail.bytes().take_while(u8::is_ascii_hexdigit).count();
if hex >= 16 {
out.push_str("lonca_[token]");
rest = &tail[hex..];
} else {
out.push_str("lonca_");
rest = &rest[at + "lonca_".len()..];
}
}
out.push_str(rest);
out
}
#[derive(Clone, Debug)]
pub enum Notify {
Off,
Desktop,
Command(PathBuf),
}
pub struct SecurityLog {
path: PathBuf,
rotate_bytes: u64,
notify: Notify,
write: Mutex<()>,
}
impl SecurityLog {
pub fn new(home: &Path, notify: Notify) -> Self {
Self {
path: home.join("security.log"),
rotate_bytes: ROTATE_BYTES,
notify,
write: Mutex::new(()),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn record(&self, event: Event) {
let alarming = kind::alarming(&event.kind);
if let Err(e) = self.append(&event) {
eprintln!("lonca: security log {}: {e}", self.path.display());
}
if alarming {
self.tell(&event);
}
}
fn append(&self, event: &Event) -> std::io::Result<()> {
let _held = self.write.lock().unwrap_or_else(|e| e.into_inner());
if fs::metadata(&self.path).is_ok_and(|m| m.len() >= self.rotate_bytes) {
fs::rename(&self.path, self.path.with_extension("log.1"))?;
}
let mut opts = fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&self.path)?;
let mut line = serde_json::to_vec(event).map_err(std::io::Error::other)?;
line.push(b'\n');
f.write_all(&line)
}
pub fn read_since(&self, since_ms: i64) -> Vec<Event> {
let _held = self.write.lock().unwrap_or_else(|e| e.into_inner());
let mut out = Vec::new();
for p in [self.path.with_extension("log.1"), self.path.clone()] {
let Ok(text) = fs::read_to_string(&p) else {
continue;
};
let events = text
.lines()
.filter_map(|l| serde_json::from_str::<Event>(l).ok());
out.extend(events.filter(|e| e.time_ms >= since_ms));
}
out
}
fn tell(&self, event: &Event) {
let message = format!(
"{}{}: {}",
event.kind,
event
.repo
.as_ref()
.map(|r| format!(" in {r}"))
.unwrap_or_default(),
event.detail
);
let line = serde_json::to_string(event).unwrap_or_default();
let notify = self.notify.clone();
std::thread::spawn(move || {
let child = match notify {
Notify::Off => return,
Notify::Desktop => desktop(&message),
Notify::Command(p) => command(&p, &line),
};
if let Some(mut child) = child {
let started = std::time::Instant::now();
while started.elapsed() < NOTIFY_TIMEOUT {
if child.try_wait().ok().flatten().is_some() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
}
});
}
}
fn desktop(message: &str) -> Option<std::process::Child> {
use std::process::{Command, Stdio};
let mut cmd = if cfg!(target_os = "macos") {
let mut c = Command::new("/usr/bin/osascript");
c.args([
"-e",
"on run argv",
"-e",
"display notification (item 1 of argv) with title \"Lonca\"",
"-e",
"end run",
message,
]);
c
} else {
let mut c = Command::new("notify-send");
c.args(["--app-name=Lonca", "Lonca", message]);
c
};
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn().ok()
}
fn command(program: &Path, line: &str) -> Option<std::process::Child> {
use std::process::{Command, Stdio};
let mut child = Command::new(program)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = writeln!(stdin, "{line}");
}
Some(child)
}
pub fn summary(events: &[Event]) -> std::collections::BTreeMap<String, usize> {
let mut out = std::collections::BTreeMap::new();
for e in events {
*out.entry(e.kind.clone()).or_insert(0) += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_text_holds_no_token_and_no_control() {
let token = format!("lonca_{}", "ab".repeat(32));
let human = format!("lonca_h_{}", "cd".repeat(32));
let said = clean(&format!("a {token} b {human}\x1b[2J\nc lonca_x"), 300);
assert_eq!(said, "a lonca_[token] b lonca_[token] [2J c lonca_x");
assert_eq!(clean("abcdef", 3), "abc");
}
#[test]
fn events_go_to_a_private_file_that_rotates() {
let dir = tempfile::tempdir().unwrap();
let mut log = SecurityLog::new(dir.path(), Notify::Off);
log.rotate_bytes = 200;
for i in 0..10 {
log.record(
Event::new(i, kind::AUTH_FAILURE)
.method("status")
.code("unauthenticated"),
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(log.path()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
assert!(dir.path().join("security.log.1").exists());
let all = log.read_since(0);
assert!(!all.is_empty() && all.len() <= 10);
assert!(
all.windows(2).all(|w| w[0].time_ms < w[1].time_ms),
"oldest first"
);
assert_eq!(log.read_since(9).len(), 1);
assert_eq!(summary(&all)[kind::AUTH_FAILURE], all.len());
}
#[test]
fn an_alarm_runs_the_notify_command_with_the_event() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("told");
let script = dir.path().join("notify.sh");
fs::write(&script, format!("#!/bin/sh\ncat > {}\n", out.display())).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
}
let log = SecurityLog::new(dir.path(), Notify::Command(script));
log.record(Event::new(1, kind::AUTH_FAILURE));
log.record(
Event::new(2, kind::FREEZE)
.repo("demo")
.detail("refs/heads/main moved"),
);
let mut told = String::new();
for _ in 0..200 {
told = fs::read_to_string(&out).unwrap_or_default();
if told.ends_with('\n') {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let event: Event = serde_json::from_str(told.trim()).unwrap();
assert_eq!(event.kind, kind::FREEZE);
assert_eq!(event.repo.as_deref(), Some("demo"));
}
}