use std::fs::{File, OpenOptions};
use std::io::Write as _;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Serialize, Serializer};
use serde_json::{json, Map, Value};
use super::Level;
pub(crate) const FILE: &str = "audit.jsonl";
pub(crate) const REDACTED: &str = "[redacted]";
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Secret(pub(crate) String);
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(REDACTED)
}
}
impl Serialize for Secret {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(REDACTED)
}
}
pub(crate) fn redact(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, value) in map.iter_mut() {
if key.to_ascii_lowercase().contains("password") {
*value = Value::String(REDACTED.into());
} else {
redact(value);
}
}
}
Value::Array(values) => values.iter_mut().for_each(redact),
_ => {}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Context {
pub(crate) profile: Option<String>,
pub(crate) transport: &'static str,
pub(crate) local: String,
}
pub(crate) struct Audit {
file: Arc<Mutex<File>>,
context: Context,
}
impl Audit {
pub(crate) fn open(workspace: &Path, context: Context) -> Result<Self, String> {
let path = workspace.join(FILE);
let mut options = OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options
.open(&path)
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
Ok(Self {
file: Arc::new(Mutex::new(file)),
context,
})
}
pub(crate) async fn record(
&self,
level: Level,
event: &str,
fields: Value,
) -> Result<(), String> {
let file = Arc::clone(&self.file);
let context = self.context.clone();
let event = event.to_string();
tokio::task::spawn_blocking(move || {
let line = line(&context, level, &event, fields);
let mut file = file
.lock()
.map_err(|_| "the audit log's lock is poisoned".to_string())?;
file.write_all(line.as_bytes())
.and_then(|()| file.flush())
.and_then(|()| file.sync_data())
.map_err(|e| format!("cannot write the audit log: {e}"))
})
.await
.map_err(|e| format!("the audit writer failed: {e}"))?
}
}
pub(crate) const MSTP_JOIN: &str = "mstp-join";
pub(crate) async fn record_join(
workspace: &Path,
profile: &str,
settings: &crate::mstp::MstpArgs,
thread: &bacnet_transport::mstp::MstpThreadConfig,
) -> Result<(), String> {
let context = Context {
profile: Some(profile.into()),
transport: "MSTP",
local: settings.port.clone(),
};
let fields = json!({
"port": settings.port,
"station": settings.mac,
"baud": settings.baud,
"max_master": settings.max_master,
"max_info_frames": settings.max_info_frames,
"realtime_priority": thread.realtime_priority,
"cpu_affinity": thread.cpu_affinity,
});
Audit::open(workspace, context)?
.record(Level::ReadOnly, MSTP_JOIN, fields)
.await
}
fn line(context: &Context, level: Level, event: &str, fields: Value) -> String {
let mut record = Map::new();
record.insert("time".into(), json!(utc_now()));
record.insert("user".into(), json!(os_user()));
record.insert("host".into(), json!(host_name()));
record.insert("profile".into(), json!(context.profile));
record.insert("transport".into(), json!(context.transport));
record.insert("local".into(), json!(context.local));
record.insert("level".into(), json!(level.audit_name()));
record.insert("event".into(), json!(event));
if let Value::Object(fields) = fields {
record.extend(fields);
}
let mut record = Value::Object(record);
redact(&mut record);
let mut line = record.to_string();
line.push('\n');
line
}
fn os_user() -> &'static str {
static USER: OnceLock<String> = OnceLock::new();
USER.get_or_init(|| {
["USER", "USERNAME", "LOGNAME"]
.iter()
.find_map(|name| std::env::var(name).ok().filter(|v| !v.is_empty()))
.unwrap_or_else(|| "unknown".into())
})
}
fn host_name() -> &'static str {
static HOST: OnceLock<String> = OnceLock::new();
HOST.get_or_init(|| {
["HOSTNAME", "COMPUTERNAME"]
.iter()
.find_map(|name| std::env::var(name).ok().filter(|v| !v.is_empty()))
.or_else(|| {
let out = std::process::Command::new("hostname").output().ok()?;
let name = String::from_utf8(out.stdout).ok()?.trim().to_string();
(out.status.success() && !name.is_empty()).then_some(name)
})
.unwrap_or_else(|| "unknown".into())
})
}
pub(crate) fn utc_now() -> String {
let since = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
utc(since.as_secs(), since.subsec_millis())
}
pub(crate) fn utc(secs: u64, millis: u32) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
rem / 3_600,
rem % 3_600 / 60,
rem % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_times_are_iso_8601() {
assert_eq!(utc(0, 0), "1970-01-01T00:00:00.000Z");
assert_eq!(utc(951_782_400, 5), "2000-02-29T00:00:00.005Z");
assert_eq!(utc(1_792_326_896, 789), "2026-10-18T12:34:56.789Z");
}
#[tokio::test]
async fn records_parse_and_never_hold_a_password() {
let dir = tempfile::tempdir().unwrap();
let audit = Audit::open(
dir.path(),
Context {
profile: Some("lab".into()),
transport: "BIP",
local: "10.0.0.1:47808".into(),
},
)
.unwrap();
let secret = Secret("hunter2-test-password".into());
let fields = json!({ "service": "DeviceCommunicationControl",
"params": { "password": "hunter2-test-password", "secret": secret } });
audit
.record(Level::ReadOnly, "write", fields)
.await
.unwrap();
audit
.record(
Level::Armed { dangerous: false },
"arm",
json!({ "reason": "ticket 7" }),
)
.await
.unwrap();
let text = std::fs::read_to_string(dir.path().join(FILE)).unwrap();
assert!(!text.contains("hunter2"), "{text}");
let records: Vec<Value> = text
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["params"]["password"], REDACTED);
assert_eq!(records[0]["params"]["secret"], REDACTED);
assert_eq!(records[1]["level"], "write-session");
assert_eq!(records[1]["profile"], "lab");
for key in ["time", "user", "host", "transport", "local", "event"] {
assert!(records[1].get(key).is_some(), "{key}");
}
}
}