use serde::Deserialize;
use sqlx::SqlitePool;
pub const MAX_AUDIT_ROWS: i64 = 1000;
pub const MAX_AUDIT_READ_LIMIT: i64 = 200;
pub const DEFAULT_AUDIT_READ_LIMIT: i64 = 50;
pub const MAX_AUDIT_DETAIL_BYTES: usize = 2048;
pub const MAX_AUDIT_TARGET_BYTES: usize = 1024;
pub const MAX_AUDIT_SCOPE_BYTES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction {
FileWrite,
FileDelete,
FileUpload,
GitPush,
AgentCreate,
AgentUpdate,
AgentDelete,
ProxyAccess,
}
impl AuditAction {
pub fn as_str(self) -> &'static str {
match self {
Self::FileWrite => "file_write",
Self::FileDelete => "file_delete",
Self::FileUpload => "file_upload",
Self::GitPush => "git_push",
Self::AgentCreate => "agent_create",
Self::AgentUpdate => "agent_update",
Self::AgentDelete => "agent_delete",
Self::ProxyAccess => "proxy_access",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AuditContext {
pub actor: String,
pub scope: Option<String>,
}
impl AuditContext {
pub fn from_ip(ip: Option<std::net::IpAddr>, scope: Option<String>) -> Self {
let actor = match ip {
Some(ip) => format!("admin@{ip}"),
None => "admin@-".to_string(),
};
Self { actor, scope }
}
}
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct AuditEntry {
pub id: i64,
pub actor: String,
pub action: String,
pub target: String,
pub scope: Option<String>,
pub detail_json: Option<String>,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct AuditLogQuery {
pub limit: Option<i64>,
}
pub fn effective_read_limit(limit: Option<i64>) -> i64 {
limit.unwrap_or(DEFAULT_AUDIT_READ_LIMIT).clamp(1, MAX_AUDIT_READ_LIMIT)
}
pub async fn record(
pool: &SqlitePool,
ctx: &AuditContext,
action: AuditAction,
target: &str,
detail: Option<&str>,
) {
let target = truncate_field(target, MAX_AUDIT_TARGET_BYTES);
let detail = detail.map(|d| truncate_detail(d, MAX_AUDIT_DETAIL_BYTES));
let scope = ctx.scope.as_deref().map(|s| truncate_field(s, MAX_AUDIT_SCOPE_BYTES));
if let Err(e) =
insert_audit(pool, &ctx.actor, action, &target, scope.as_deref(), detail.as_deref()).await
{
tracing::warn!(
action = action.as_str(),
actor = %ctx.actor,
error = %e,
"audit write failed (业务响应不受影响,但该操作未留痕)"
);
return;
}
prune_old_rows(pool).await;
}
async fn insert_audit(
pool: &SqlitePool,
actor: &str,
action: AuditAction,
target: &str,
scope: Option<&str>,
detail: Option<&str>,
) -> Result<(), sqlx::Error> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO audit_log (actor, action, target, scope, detail_json, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(actor)
.bind(action.as_str())
.bind(target)
.bind(scope)
.bind(detail)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}
async fn prune_old_rows(pool: &SqlitePool) {
if let Err(e) = sqlx::query(
"DELETE FROM audit_log WHERE id IN (\
SELECT id FROM audit_log ORDER BY id ASC \
LIMIT MAX(0, (SELECT COUNT(*) FROM audit_log) - ?)\
)",
)
.bind(MAX_AUDIT_ROWS)
.execute(pool)
.await
{
tracing::warn!("audit prune failed: {e}");
}
}
pub async fn list_recent(pool: &SqlitePool, limit: i64) -> Result<Vec<AuditEntry>, sqlx::Error> {
sqlx::query_as::<_, AuditEntry>(
"SELECT id, actor, action, target, scope, detail_json, created_at \
FROM audit_log ORDER BY id DESC LIMIT ?",
)
.bind(limit)
.fetch_all(pool)
.await
}
fn truncate_field(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let (kept, omitted) = truncate_with_suffix(s, max_bytes, |n| {
format!("{OMISSION_TAG_PREFIX}{n}{OMISSION_TAG_SUFFIX}")
});
format!("{kept}{OMISSION_TAG_PREFIX}{omitted}{OMISSION_TAG_SUFFIX}")
}
fn truncate_detail(detail: &str, max_bytes: usize) -> String {
if detail.len() <= max_bytes {
return detail.to_string();
}
let (kept, omitted) =
truncate_with_suffix(detail, max_bytes, |n| format!(",\"__omitted_chars__\":{n}}}"));
format!("{kept},\"__omitted_chars__\":{omitted}}}")
}
fn truncate_with_suffix(
s: &str,
max_bytes: usize,
suffix_of: impl Fn(usize) -> String,
) -> (String, usize) {
let (_, mut omitted) = take_chars_within(s, max_bytes);
let kept = loop {
let budget = max_bytes.saturating_sub(suffix_of(omitted).len());
let (k, o) = take_chars_within(s, budget);
if o == omitted {
break k;
}
omitted = o;
};
(kept, omitted)
}
fn take_chars_within(s: &str, budget: usize) -> (String, usize) {
let mut kept = String::with_capacity(budget.min(s.len()));
let mut omitted = 0usize;
for ch in s.chars() {
if kept.len() + ch.len_utf8() > budget {
omitted += 1;
} else {
kept.push(ch);
}
}
(kept, omitted)
}
const OMISSION_TAG_PREFIX: &str = "…(truncated ";
const OMISSION_TAG_SUFFIX: &str = " chars omitted)";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_limit_defaults_when_absent() {
assert_eq!(effective_read_limit(None), DEFAULT_AUDIT_READ_LIMIT);
}
#[test]
fn read_limit_clamps_to_hard_cap() {
assert_eq!(effective_read_limit(Some(i64::MAX)), MAX_AUDIT_READ_LIMIT);
assert_eq!(effective_read_limit(Some(1_000_000)), MAX_AUDIT_READ_LIMIT);
}
#[test]
fn read_limit_clamps_non_positive_to_one() {
assert_eq!(effective_read_limit(Some(0)), 1);
assert_eq!(effective_read_limit(Some(-5)), 1);
}
#[test]
fn read_limit_keeps_normal_value() {
assert_eq!(effective_read_limit(Some(20)), 20);
}
#[test]
fn truncate_field_keeps_short_values_verbatim() {
assert_eq!(truncate_field("abc", 1024), "abc");
assert_eq!(truncate_field(&"a".repeat(1024), 1024), "a".repeat(1024));
}
#[test]
fn truncate_field_cuts_multibyte_on_char_boundary() {
let input = "中".repeat(50);
let out = truncate_field(&input, 10);
assert!(out.ends_with(OMISSION_TAG_SUFFIX), "got: {out}");
assert!(out.contains(OMISSION_TAG_PREFIX));
assert!(out.len() < input.len());
}
#[test]
fn truncate_field_marks_omitted_amount() {
let input = "a".repeat(MAX_AUDIT_TARGET_BYTES + 100);
let out = truncate_field(&input, MAX_AUDIT_TARGET_BYTES);
let (kept_part, tag) = out.rsplit_once(OMISSION_TAG_PREFIX).expect("标注前缀必须逐字固定");
let omitted: usize = tag
.strip_suffix(OMISSION_TAG_SUFFIX)
.expect("标注后缀必须逐字固定")
.parse()
.expect("省略量应可解析");
let kept = kept_part.chars().filter(|c| *c == 'a').count();
assert_eq!(kept + omitted, input.len(), "截断必须守恒");
assert!(omitted > 0, "必须报告实际省略量");
}
#[test]
fn truncate_detail_reports_exact_omitted_chars() {
let input = "b".repeat(MAX_AUDIT_DETAIL_BYTES + 37);
let out = truncate_detail(&input, MAX_AUDIT_DETAIL_BYTES);
let kept = out.chars().filter(|c| *c == 'b').count();
let omitted: usize = out
.rsplit("\"__omitted_chars__\":")
.next()
.and_then(|tail| tail.trim_end_matches('}').parse().ok())
.expect("省略字段应可解析");
assert_eq!(kept + omitted, input.len(), "截断必须守恒");
assert!(omitted >= 37, "至少要丢掉超出上限的那 37 个,实际 {omitted}");
assert!(out.len() <= MAX_AUDIT_DETAIL_BYTES, "含标记也不得超上限");
}
#[test]
fn truncate_detail_cuts_multibyte_without_panic() {
let input = "中".repeat(MAX_AUDIT_DETAIL_BYTES / 3 + 50);
let out = truncate_detail(&input, MAX_AUDIT_DETAIL_BYTES);
assert!(out.len() < input.len(), "超限输入必须被截断");
assert!(
out.len() <= MAX_AUDIT_DETAIL_BYTES,
"截断后(含省略标记)不得超上限:实际 {} > {}",
out.len(),
MAX_AUDIT_DETAIL_BYTES
);
assert!(out.contains("\"__omitted_chars__\":"), "got: {out}");
}
#[test]
fn truncate_detail_keeps_short_values_verbatim() {
let d = r#"{"path":"x"}"#;
assert_eq!(truncate_detail(d, MAX_AUDIT_DETAIL_BYTES), d);
}
#[test]
fn truncate_detail_stays_within_limit_for_ascii_multibyte_and_huge() {
for (name, input) in [
("just over", "a".repeat(MAX_AUDIT_DETAIL_BYTES + 1)),
("way over", "a".repeat(MAX_AUDIT_DETAIL_BYTES * 50)),
("multibyte", "中".repeat(MAX_AUDIT_DETAIL_BYTES)),
("multibyte just over", "中".repeat(MAX_AUDIT_DETAIL_BYTES / 3 + 1)),
] {
let out = truncate_detail(&input, MAX_AUDIT_DETAIL_BYTES);
assert!(
out.len() <= MAX_AUDIT_DETAIL_BYTES,
"{name}: 输出 {}B 超过上限 {MAX_AUDIT_DETAIL_BYTES}B",
out.len()
);
}
}
#[test]
fn truncate_field_stays_within_limit_across_omitted_digit_counts() {
for extra in [1usize, 100, 5_000, 20_000, 100_000] {
let input = "b".repeat(MAX_AUDIT_TARGET_BYTES + extra);
let out = truncate_field(&input, MAX_AUDIT_TARGET_BYTES);
assert!(
out.len() <= MAX_AUDIT_TARGET_BYTES,
"extra={extra}: 输出 {}B 超过上限 {MAX_AUDIT_TARGET_BYTES}B",
out.len()
);
}
}
#[test]
fn action_strings_are_stable_snake_case() {
let all = [
(AuditAction::FileWrite, "file_write"),
(AuditAction::FileDelete, "file_delete"),
(AuditAction::FileUpload, "file_upload"),
(AuditAction::GitPush, "git_push"),
(AuditAction::AgentCreate, "agent_create"),
(AuditAction::AgentUpdate, "agent_update"),
(AuditAction::AgentDelete, "agent_delete"),
(AuditAction::ProxyAccess, "proxy_access"),
];
for (action, s) in all {
assert_eq!(action.as_str(), s);
}
}
#[test]
fn actor_uses_ip_for_discrimination() {
let ctx = AuditContext::from_ip(Some("192.168.1.7".parse().unwrap()), None);
assert_eq!(ctx.actor, "admin@192.168.1.7");
}
#[test]
fn actor_degrades_without_inventing_ip() {
let ctx = AuditContext::from_ip(None, None);
assert_eq!(ctx.actor, "admin@-");
}
async fn test_db() -> sqlx::SqlitePool {
let db = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite pool");
sqlx::migrate!("./migrations").run(&db).await.expect("run migrations");
db
}
async fn concurrent_test_db() -> sqlx::SqlitePool {
let db = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(4)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite pool");
sqlx::migrate!("./migrations").run(&db).await.expect("run migrations");
db
}
fn ctx() -> AuditContext {
AuditContext::from_ip(Some("10.0.0.9".parse().unwrap()), Some("session:s1".into()))
}
#[tokio::test]
async fn record_persists_actor_action_target_scope_and_detail() {
let db = test_db().await;
record(&db, &ctx(), AuditAction::FileWrite, "/tmp/x", Some(r#"{"n":1}"#)).await;
let rows = list_recent(&db, 10).await.expect("list");
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.actor, "admin@10.0.0.9");
assert_eq!(r.action, "file_write");
assert_eq!(r.target, "/tmp/x");
assert_eq!(r.scope.as_deref(), Some("session:s1"));
assert_eq!(r.detail_json.as_deref(), Some(r#"{"n":1}"#));
assert!(!r.created_at.is_empty(), "created_at 必须落库");
}
#[tokio::test]
async fn row_count_never_exceeds_cap_and_oldest_are_dropped() {
let db = test_db().await;
let total = MAX_AUDIT_ROWS + 20;
for i in 0..total {
record(&db, &ctx(), AuditAction::FileWrite, &format!("/tmp/f{i}"), None).await;
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log")
.fetch_one(&db)
.await
.expect("count");
assert_eq!(count, MAX_AUDIT_ROWS, "条目数须恰为上限,不得超出");
let rows = list_recent(&db, MAX_AUDIT_ROWS).await.expect("list");
assert_eq!(rows.len(), MAX_AUDIT_ROWS as usize);
assert_eq!(rows[0].target, format!("/tmp/f{}", total - 1), "第一条应是最新写入的");
let oldest = rows.last().expect("last").target.clone();
assert_eq!(oldest, "/tmp/f20", "最老一条应为第 21 条(前 20 条已淘汰)");
}
#[tokio::test]
async fn under_cap_nothing_is_pruned() {
let db = test_db().await;
for i in 0..(MAX_AUDIT_ROWS - 1) {
record(&db, &ctx(), AuditAction::FileWrite, &format!("/tmp/f{i}"), None).await;
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log")
.fetch_one(&db)
.await
.expect("count");
assert_eq!(count, MAX_AUDIT_ROWS - 1);
}
#[tokio::test]
async fn oversized_detail_is_truncated_before_insert() {
let db = test_db().await;
let huge = "z".repeat(MAX_AUDIT_DETAIL_BYTES * 3);
record(&db, &ctx(), AuditAction::FileUpload, "/tmp/u", Some(&huge)).await;
let rows = list_recent(&db, 1).await.expect("list");
let stored = rows[0].detail_json.as_deref().expect("detail");
assert!(stored.len() < huge.len(), "落库值必须被截断");
assert!(stored.contains("\"__omitted_chars__\":"), "截断必须显式标注省略量,got: {stored}");
}
#[tokio::test]
async fn empty_target_is_stored_as_empty_string() {
let db = test_db().await;
record(&db, &ctx(), AuditAction::FileUpload, "", None).await;
let rows = list_recent(&db, 1).await.expect("list");
assert_eq!(rows[0].target, "");
}
#[tokio::test]
async fn scope_is_null_when_unbound() {
let db = test_db().await;
let unbound = AuditContext::from_ip(Some("10.0.0.9".parse().unwrap()), None);
record(&db, &unbound, AuditAction::FileWrite, "/tmp/x", None).await;
let rows = list_recent(&db, 1).await.expect("list");
assert_eq!(rows[0].scope, None);
}
#[tokio::test]
async fn oversized_scope_is_truncated_before_insert() {
let db = test_db().await;
let long_scope = format!("repo:{}", "/very/long/path/".repeat(200));
assert!(long_scope.len() > MAX_AUDIT_SCOPE_BYTES);
let ctx =
AuditContext::from_ip(Some("10.0.0.9".parse().unwrap()), Some(long_scope.clone()));
record(&db, &ctx, AuditAction::GitPush, "/tmp/repo", None).await;
let rows = list_recent(&db, 1).await.expect("list");
let stored = rows[0].scope.as_deref().expect("scope");
assert!(
stored.len() <= MAX_AUDIT_SCOPE_BYTES,
"scope 落库值 {}B 超过上限 {MAX_AUDIT_SCOPE_BYTES}B",
stored.len()
);
}
#[tokio::test]
async fn record_reflects_one_action_not_the_caller_list() {
let db = test_db().await;
let detail = serde_json::json!({"files": ["a", "b", "c"], "count": 3}).to_string();
record(&db, &ctx(), AuditAction::FileUpload, "dest/", Some(&detail)).await;
let rows = list_recent(&db, 10).await.expect("list");
assert_eq!(rows.len(), 1, "一次上传 = 一条审计(即使内含多个文件)");
assert_eq!(rows[0].action, "file_upload");
assert_eq!(rows[0].target, "dest/");
assert!(rows[0].detail_json.as_deref().is_some_and(|d| d.contains("\"count\":3")));
}
#[tokio::test]
async fn concurrent_writers_never_breach_the_cap() {
let db = std::sync::Arc::new(concurrent_test_db().await);
let per_writer = MAX_AUDIT_ROWS / 2 + 50; let mut handles = Vec::new();
for w in 0..2u16 {
let db = db.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_writer {
let ctx = AuditContext::from_ip(
Some(format!("10.0.0.{w}").parse().unwrap()),
Some(format!("session:w{w}")),
);
record(&db, &ctx, AuditAction::FileWrite, &format!("/tmp/w{w}-{i}"), None)
.await;
}
}));
}
for h in handles {
h.await.expect("writer task");
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log")
.fetch_one(&*db)
.await
.expect("count");
assert!(count <= MAX_AUDIT_ROWS, "并发写下限不得被击穿,实际 {count}");
assert!(count > MAX_AUDIT_ROWS - 50, "修剪应发生,实际只剩 {count}");
}
#[tokio::test]
async fn list_recent_returns_newest_first() {
let db = test_db().await;
for i in 0..3 {
record(&db, &ctx(), AuditAction::FileWrite, &format!("/tmp/f{i}"), None).await;
}
let rows = list_recent(&db, 2).await.expect("list");
assert_eq!(rows.len(), 2, "limit 必须被遵守(读口有界)");
assert_eq!(rows[0].target, "/tmp/f2");
assert_eq!(rows[1].target, "/tmp/f1");
}
}