use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result, anyhow, bail}; use chrono::{SecondsFormat, Utc}; use clap::Parser; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use urlencoding::decode; const APP_NAME: &str = "aw-dlp-policy-engine"; const APP_VERSION: &str = "0.1.0"; #[derive(Debug, Parser)] #[command(about = "AW DLP Policy Engine")] struct Cli { #[arg(long, default_value = "127.0.0.1")] bind_host: String, #[arg(long, default_value_t = 5601)] port: u16, #[arg( long, default_value = "/var/lib/activitywatch/dlp-policy-engine.sqlite" )] db_path: PathBuf, } #[derive(Clone)] struct AppState { db_path: PathBuf, agents: Arc>>, } fn main() { if let Err(err) = run() { eprintln!("{err:#}"); std::process::exit(1); } } fn run() -> Result<()> { let cli = apply_env(Cli::parse()); let storage = PolicyStorage::new(cli.db_path.clone())?; storage.init_schema()?; let state = AppState { db_path: cli.db_path, agents: Arc::new(Mutex::new(HashMap::new())), }; let address = format!("{}:{}", cli.bind_host, cli.port); let server = Server::http(&address).map_err(|err| anyhow!("bind {address}: {err}"))?; eprintln!("{APP_NAME} {APP_VERSION} listening on {address}"); for request in server.incoming_requests() { if let Err(err) = handle_request(&state, request) { eprintln!("request error: {err:#}"); } } Ok(()) } fn apply_env(mut cli: Cli) -> Cli { if !cli_arg_present("--bind-host") { if let Ok(value) = std::env::var("AW_DLP_POLICY_ENGINE_BIND_HOST") { if !value.is_empty() { cli.bind_host = value; } } } if !cli_arg_present("--port") { if let Ok(value) = std::env::var("AW_DLP_POLICY_ENGINE_PORT") { if let Ok(port) = value.parse() { cli.port = port; } } } if !cli_arg_present("--db-path") { if let Ok(value) = std::env::var("AW_DLP_POLICY_ENGINE_DB_PATH") { if !value.is_empty() { cli.db_path = PathBuf::from(value); } } } cli } fn cli_arg_present(flag: &str) -> bool { std::env::args().any(|arg| arg == flag || arg.starts_with(&format!("{flag}="))) } fn handle_request(state: &AppState, mut request: Request) -> Result<()> { let method = request.method().clone(); let url = request.url().to_string(); let (path, query) = split_url(&url); let segments = path_segments(&path); let storage = PolicyStorage::new(state.db_path.clone())?; let response = match (method, segments.as_slice()) { (Method::Get, ["healthz"]) => json_response( StatusCode(200), json!({ "status": "ok", "service": APP_NAME, "db_path": state.db_path.to_string_lossy(), "db_exists": state.db_path.exists().to_string(), }), ), (Method::Get, ["api", "0", "dlp", "policies"]) => { json_response(StatusCode(200), json!({"items": storage.list_policies()?})) } (Method::Post, ["api", "0", "dlp", "policies"]) => { let payload = read_json_body(&mut request)?; match storage.create_policy(&payload) { Ok(item) => json_response(StatusCode(201), json!({"item": item})), Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), } } (Method::Get, ["api", "0", "dlp", "policies", "active"]) => { match storage.get_active_policy()? { Some(item) => json_response(StatusCode(200), build_policy_bundle(&item)), None => json_response( StatusCode(404), json!({"detail": "no active policy configured"}), ), } } (Method::Get, ["api", "0", "dlp", "policies", "active", "version"]) => { match storage.get_active_policy()? { Some(item) => json_response( StatusCode(200), json!({ "active": true, "policyId": item.get("id").cloned().unwrap_or(Value::Null), "version": item.get("current_version").cloned().unwrap_or(Value::Null), "checksum": item.get("checksum").cloned().unwrap_or(Value::Null), "updatedAtUtc": item.get("updated_at").cloned().unwrap_or(Value::Null), }), ), None => json_response( StatusCode(404), json!({"detail": "no active policy configured"}), ), } } ( Method::Post, [ "api", "0", "dlp", "policies", "agents", agent_id, "heartbeat", ], ) => { let payload = read_json_body(&mut request)?; let agent = agent_heartbeat(agent_id, &payload); state .agents .lock() .map_err(|_| anyhow!("agent state lock poisoned"))? .insert(agent_id.to_string(), agent.clone()); json_response(StatusCode(200), json!({"ok": true, "agent": agent})) } (Method::Get, ["api", "0", "dlp", "policies", "agents", agent_id, "desired"]) => { match storage.get_active_policy()? { Some(item) => { let current = state .agents .lock() .map_err(|_| anyhow!("agent state lock poisoned"))? .get(*agent_id) .cloned() .unwrap_or_else(|| json!({})); json_response(StatusCode(200), desired_policy(agent_id, ¤t, &item)) } None => json_response( StatusCode(404), json!({"detail": "no active policy configured"}), ), } } (Method::Post, ["api", "0", "dlp", "policies", "rollback"]) => { let payload = read_json_body(&mut request)?; match storage.rollback_active_policy(actor_from(&payload))? { Some(item) => json_response(StatusCode(200), json!({"item": item})), None => json_response( StatusCode(404), json!({"detail": "no active policy configured"}), ), } } (Method::Get, ["api", "0", "dlp", "policies", "audit"]) => { let limit = query_limit(&query); json_response( StatusCode(200), json!({"items": storage.list_audit(None, limit)?}), ) } (Method::Get, ["api", "0", "dlp", "policies", policy_id]) => { match parse_id(policy_id).and_then(|id| storage.get_policy(id))? { Some(item) => json_response(StatusCode(200), json!({"item": item})), None => json_response(StatusCode(404), json!({"detail": "policy not found"})), } } (Method::Put, ["api", "0", "dlp", "policies", policy_id]) => { let payload = read_json_body(&mut request)?; let policy_id = parse_id(policy_id)?; match storage.update_policy(policy_id, &payload) { Ok(Some(item)) => json_response(StatusCode(200), json!({"item": item})), Ok(None) => json_response(StatusCode(404), json!({"detail": "policy not found"})), Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), } } (Method::Post, ["api", "0", "dlp", "policies", policy_id, "activate"]) => { let payload = read_json_body(&mut request)?; let policy_id = parse_id(policy_id)?; match storage.activate_policy(policy_id, actor_from(&payload)) { Ok(Some(item)) => json_response(StatusCode(200), json!({"item": item})), Ok(None) => json_response(StatusCode(404), json!({"detail": "policy not found"})), Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), } } (Method::Post, ["api", "0", "dlp", "policies", policy_id, action]) if matches!(*action, "submit" | "approve" | "draft") => { let payload = read_json_body(&mut request)?; let policy_id = parse_id(policy_id)?; let status = match *action { "submit" => "pending_approval", "approve" => "approved", "draft" => "draft", _ => unreachable!(), }; match storage.set_policy_status( policy_id, status, actor_from(&payload), optional_string(payload.get("comment")), ) { Ok(Some(item)) => json_response(StatusCode(200), json!({"item": item})), Ok(None) => json_response(StatusCode(404), json!({"detail": "policy not found"})), Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), } } (Method::Delete, ["api", "0", "dlp", "policies", policy_id]) => { let policy_id = parse_id(policy_id)?; match storage.delete_policy(policy_id) { Ok(true) => json_response(StatusCode(200), json!({"deleted": true})), Ok(false) => json_response(StatusCode(404), json!({"detail": "policy not found"})), Err(err) => json_response(StatusCode(409), json!({"detail": err.to_string()})), } } (Method::Get, ["api", "0", "dlp", "policies", policy_id, "audit"]) => { let policy_id = parse_id(policy_id)?; if storage.get_policy(policy_id)?.is_none() { json_response(StatusCode(404), json!({"detail": "policy not found"})) } else { let limit = query_limit(&query); json_response( StatusCode(200), json!({"items": storage.list_audit(Some(policy_id), limit)?}), ) } } _ => json_response(StatusCode(404), json!({"detail": "not found"})), }; request.respond(response).context("send HTTP response") } struct PolicyStorage { db_path: PathBuf, } impl PolicyStorage { fn new(db_path: PathBuf) -> Result { if let Some(parent) = db_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("create db parent {}", parent.display()))?; } Ok(Self { db_path }) } fn connect(&self) -> Result { let conn = Connection::open(&self.db_path) .with_context(|| format!("open sqlite {}", self.db_path.display()))?; conn.pragma_update(None, "journal_mode", "WAL")?; Ok(conn) } fn init_schema(&self) -> Result<()> { let conn = self.connect()?; conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS policies ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT, status TEXT NOT NULL DEFAULT 'draft', is_active INTEGER NOT NULL DEFAULT 0, current_version INTEGER NOT NULL DEFAULT 1, checksum TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS policy_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, policy_id INTEGER NOT NULL, version INTEGER NOT NULL, policy_json TEXT NOT NULL, checksum TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT, rollback_of_version INTEGER, FOREIGN KEY(policy_id) REFERENCES policies(id), UNIQUE(policy_id, version) ); CREATE TABLE IF NOT EXISTS policy_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, policy_id INTEGER, action TEXT NOT NULL, actor TEXT, comment TEXT, details_json TEXT, created_at TEXT NOT NULL, FOREIGN KEY(policy_id) REFERENCES policies(id) ); CREATE INDEX IF NOT EXISTS idx_policies_active ON policies(is_active); CREATE INDEX IF NOT EXISTS idx_policy_versions_policy ON policy_versions(policy_id, version DESC); CREATE INDEX IF NOT EXISTS idx_policy_audit_policy ON policy_audit(policy_id, id DESC); "#, )?; let has_status = { let mut stmt = conn.prepare("PRAGMA table_info(policies)")?; let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; let mut found = false; for row in rows { if row? == "status" { found = true; break; } } found }; if !has_status { conn.execute( "ALTER TABLE policies ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'", [], )?; } Ok(()) } fn list_policies(&self) -> Result> { let conn = self.connect()?; let mut stmt = conn.prepare( r#" SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at FROM policies ORDER BY is_active DESC, updated_at DESC, id DESC "#, )?; let rows = stmt.query_map([], policy_summary_from_row)?; rows.collect::, _>>() .map_err(Into::into) } fn get_policy(&self, policy_id: i64) -> Result> { let conn = self.connect()?; let policy = conn .query_row( r#" SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at FROM policies WHERE id = ? "#, [policy_id], policy_summary_from_row, ) .optional()?; let Some(mut policy) = policy else { return Ok(None); }; let current_version = policy .get("current_version") .and_then(Value::as_i64) .ok_or_else(|| anyhow!("policy current_version missing"))?; let version = conn .query_row( r#" SELECT version, policy_json, checksum, created_at, created_by FROM policy_versions WHERE policy_id = ? AND version = ? "#, params![policy_id, current_version], |row| { Ok(( row.get::<_, String>(1)?, row.get::<_, String>(3)?, row.get::<_, Option>(4)?, )) }, ) .optional()?; let Some((policy_json, version_created_at, version_created_by)) = version else { return Ok(None); }; let policy_doc: Value = serde_json::from_str(&policy_json).context("decode policy_json")?; let obj = policy .as_object_mut() .ok_or_else(|| anyhow!("policy row is not object"))?; obj.insert("policy".to_string(), policy_doc); obj.insert( "version_created_at".to_string(), Value::String(version_created_at), ); obj.insert( "version_created_by".to_string(), version_created_by.map(Value::String).unwrap_or(Value::Null), ); Ok(Some(policy)) } fn get_active_policy(&self) -> Result> { let conn = self.connect()?; let id = conn .query_row( "SELECT id FROM policies WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1", [], |row| row.get::<_, i64>(0), ) .optional()?; match id { Some(id) => self.get_policy(id), None => Ok(None), } } fn create_policy(&self, payload: &Value) -> Result { let name = required_string(payload.get("name"), "name")?; validate_name(&name)?; let description = optional_string(payload.get("description")); validate_description(description.as_deref())?; let policy = payload .get("policy") .cloned() .ok_or_else(|| anyhow!("policy is required"))?; let activate = payload .get("activate") .and_then(Value::as_bool) .unwrap_or(false); let actor = actor_from(payload); let checksum = checksum_policy(&policy); let policy_json = canonical_json(&policy)?; let now = utc_now(); let status = if activate { "deployed" } else { "draft" }; let conn = self.connect()?; let tx = conn.unchecked_transaction()?; if activate { tx.execute("UPDATE policies SET is_active = 0", [])?; } tx.execute( r#" INSERT INTO policies(name, description, status, is_active, current_version, checksum, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?, ?) "#, params![ name, description, status, if activate { 1 } else { 0 }, checksum, now, now ], )?; let policy_id = tx.last_insert_rowid(); tx.execute( r#" INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) VALUES(?, 1, ?, ?, ?, ?, NULL) "#, params![policy_id, policy_json, checksum, now, actor], )?; audit( &tx, Some(policy_id), "create", actor.as_deref(), None, Some(json!({"activate": activate, "status": status})), )?; tx.commit()?; self.get_policy(policy_id)? .ok_or_else(|| anyhow!("created policy not found")) } fn update_policy(&self, policy_id: i64, payload: &Value) -> Result> { let current = match self.get_policy(policy_id)? { Some(item) => item, None => return Ok(None), }; let name = match payload.get("name") { Some(Value::Null) | None => current .get("name") .and_then(Value::as_str) .unwrap_or_default() .to_string(), Some(value) => { let name = required_string(Some(value), "name")?; validate_name(&name)?; name } }; let description = match payload.get("description") { Some(value) => optional_string(Some(value)), None => optional_string(current.get("description")), }; validate_description(description.as_deref())?; let activate = payload .get("activate") .and_then(Value::as_bool) .unwrap_or(false); let actor = actor_from(payload); let mut new_version = current .get("current_version") .and_then(Value::as_i64) .ok_or_else(|| anyhow!("current_version missing"))?; let mut new_checksum = required_string(current.get("checksum"), "checksum")?; let mut new_status = current .get("status") .and_then(Value::as_str) .unwrap_or("draft") .to_string(); let conn = self.connect()?; let tx = conn.unchecked_transaction()?; if let Some(policy) = payload.get("policy").filter(|value| !value.is_null()) { new_version += 1; new_checksum = checksum_policy(policy); new_status = "draft".to_string(); tx.execute( r#" INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) VALUES(?, ?, ?, ?, ?, ?, NULL) "#, params![ policy_id, new_version, canonical_json(policy)?, new_checksum, utc_now(), actor ], )?; } if activate { tx.execute("UPDATE policies SET is_active = 0", [])?; new_status = "deployed".to_string(); } tx.execute( r#" UPDATE policies SET name = ?, description = ?, status = ?, is_active = ?, current_version = ?, checksum = ?, updated_at = ? WHERE id = ? "#, params![ name, description, new_status, if activate { 1 } else { current .get("is_active") .and_then(Value::as_i64) .unwrap_or(0) }, new_version, new_checksum, utc_now(), policy_id, ], )?; audit( &tx, Some(policy_id), "update", actor.as_deref(), None, Some(json!({"activate": activate, "status": new_status})), )?; tx.commit()?; self.get_policy(policy_id) } fn activate_policy(&self, policy_id: i64, actor: Option) -> Result> { let current = match self.get_policy(policy_id)? { Some(item) => item, None => return Ok(None), }; if current.get("status").and_then(Value::as_str) != Some("approved") { bail!("policy must be approved before deploy"); } let conn = self.connect()?; let tx = conn.unchecked_transaction()?; tx.execute("UPDATE policies SET is_active = 0", [])?; tx.execute( "UPDATE policies SET status = 'deployed', is_active = 1, updated_at = ? WHERE id = ?", params![utc_now(), policy_id], )?; audit(&tx, Some(policy_id), "deploy", actor.as_deref(), None, None)?; tx.commit()?; self.get_policy(policy_id) } fn rollback_active_policy(&self, actor: Option) -> Result> { let active = match self.get_active_policy()? { Some(item) => item, None => return Ok(None), }; let policy_id = active .get("id") .and_then(Value::as_i64) .ok_or_else(|| anyhow!("active policy id missing"))?; let current_version = active .get("current_version") .and_then(Value::as_i64) .ok_or_else(|| anyhow!("active current_version missing"))?; let conn = self.connect()?; let rows = { let mut stmt = conn.prepare( r#" SELECT version, policy_json FROM policy_versions WHERE policy_id = ? ORDER BY version DESC LIMIT 2 "#, )?; stmt.query_map([policy_id], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) })? .collect::, _>>()? }; if rows.len() < 2 { return Ok(Some(active)); } let previous_version = rows[1].0; let previous_policy: Value = serde_json::from_str(&rows[1].1)?; let rollback_version = current_version + 1; let rollback_checksum = checksum_policy(&previous_policy); let now = utc_now(); let tx = conn.unchecked_transaction()?; tx.execute( r#" INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) VALUES(?, ?, ?, ?, ?, ?, ?) "#, params![ policy_id, rollback_version, canonical_json(&previous_policy)?, rollback_checksum, now, actor, previous_version ], )?; tx.execute( r#" UPDATE policies SET status = 'draft', current_version = ?, checksum = ?, updated_at = ? WHERE id = ? "#, params![rollback_version, rollback_checksum, now, policy_id], )?; audit( &tx, Some(policy_id), "rollback", actor.as_deref(), None, Some(json!({"rollback_to": previous_version})), )?; tx.commit()?; self.get_policy(policy_id) } fn delete_policy(&self, policy_id: i64) -> Result { let current = match self.get_policy(policy_id)? { Some(item) => item, None => return Ok(false), }; if current .get("is_active") .and_then(Value::as_i64) .unwrap_or(0) != 0 { bail!("cannot delete active policy"); } let conn = self.connect()?; let tx = conn.unchecked_transaction()?; audit(&tx, Some(policy_id), "delete", None, None, None)?; tx.execute( "DELETE FROM policy_versions WHERE policy_id = ?", [policy_id], )?; tx.execute("DELETE FROM policies WHERE id = ?", [policy_id])?; tx.commit()?; Ok(true) } fn set_policy_status( &self, policy_id: i64, status: &str, actor: Option, comment: Option, ) -> Result> { if !matches!( status, "draft" | "pending_approval" | "approved" | "deployed" ) { bail!("unsupported status: {status}"); } if self.get_policy(policy_id)?.is_none() { return Ok(None); } validate_description(comment.as_deref())?; let conn = self.connect()?; let tx = conn.unchecked_transaction()?; tx.execute( "UPDATE policies SET status = ?, updated_at = ? WHERE id = ?", params![status, utc_now(), policy_id], )?; audit( &tx, Some(policy_id), "status_change", actor.as_deref(), comment.as_deref(), Some(json!({"status": status})), )?; tx.commit()?; self.get_policy(policy_id) } fn list_audit(&self, policy_id: Option, limit: i64) -> Result> { let conn = self.connect()?; let limit = limit.clamp(1, 1000); let mut items = Vec::new(); if let Some(policy_id) = policy_id { let mut stmt = conn.prepare( r#" SELECT id, policy_id, action, actor, comment, details_json, created_at FROM policy_audit WHERE policy_id = ? ORDER BY id DESC LIMIT ? "#, )?; let rows = stmt.query_map(params![policy_id, limit], audit_from_row)?; for row in rows { items.push(row?); } } else { let mut stmt = conn.prepare( r#" SELECT id, policy_id, action, actor, comment, details_json, created_at FROM policy_audit ORDER BY id DESC LIMIT ? "#, )?; let rows = stmt.query_map([limit], audit_from_row)?; for row in rows { items.push(row?); } } Ok(items) } } fn audit( conn: &Connection, policy_id: Option, action: &str, actor: Option<&str>, comment: Option<&str>, details: Option, ) -> Result<()> { let details_json = details .as_ref() .map(canonical_json) .transpose() .context("serialize audit details")?; conn.execute( r#" INSERT INTO policy_audit(policy_id, action, actor, comment, details_json, created_at) VALUES(?, ?, ?, ?, ?, ?) "#, params![policy_id, action, actor, comment, details_json, utc_now()], )?; Ok(()) } fn policy_summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(json!({ "id": row.get::<_, i64>(0)?, "name": row.get::<_, String>(1)?, "description": row.get::<_, Option>(2)?, "status": row.get::<_, String>(3)?, "is_active": row.get::<_, i64>(4)?, "current_version": row.get::<_, i64>(5)?, "checksum": row.get::<_, String>(6)?, "created_at": row.get::<_, String>(7)?, "updated_at": row.get::<_, String>(8)?, })) } fn audit_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let details_json: Option = row.get(5)?; let details = details_json .as_deref() .and_then(|text| serde_json::from_str(text).ok()) .unwrap_or(Value::Null); Ok(json!({ "id": row.get::<_, i64>(0)?, "policy_id": row.get::<_, Option>(1)?, "action": row.get::<_, String>(2)?, "actor": row.get::<_, Option>(3)?, "comment": row.get::<_, Option>(4)?, "created_at": row.get::<_, String>(6)?, "details": details, })) } fn build_policy_bundle(record: &Value) -> Value { json!({ "active": true, "policyId": record.get("id").cloned().unwrap_or(Value::Null), "name": record.get("name").cloned().unwrap_or(Value::Null), "version": record.get("current_version").cloned().unwrap_or(Value::Null), "checksum": record.get("checksum").cloned().unwrap_or(Value::Null), "updatedAtUtc": record.get("updated_at").cloned().unwrap_or(Value::Null), "policy": record.get("policy").cloned().unwrap_or(Value::Null), }) } fn desired_policy(agent_id: &str, current: &Value, active: &Value) -> Value { let current_version = current.get("version").cloned().unwrap_or(Value::Null); let current_checksum = current.get("checksum").cloned().unwrap_or(Value::Null); let desired_version = active .get("current_version") .cloned() .unwrap_or(Value::Null); let desired_checksum = active.get("checksum").cloned().unwrap_or(Value::Null); let refresh_now = value_to_string(¤t_version) != value_to_string(&desired_version) || value_to_string(¤t_checksum) != value_to_string(&desired_checksum); json!({ "agentId": agent_id, "refreshNow": refresh_now, "reason": if refresh_now { "mismatch" } else { "up-to-date" }, "current": { "version": current_version, "checksum": current_checksum, }, "desired": { "policyId": active.get("id").cloned().unwrap_or(Value::Null), "version": desired_version, "checksum": desired_checksum, "updatedAtUtc": active.get("updated_at").cloned().unwrap_or(Value::Null), }, }) } fn agent_heartbeat(agent_id: &str, payload: &Value) -> Value { json!({ "agentId": agent_id, "hostname": payload.get("hostname").and_then(Value::as_str).unwrap_or(agent_id), "version": payload.get("version").cloned().unwrap_or(Value::Null), "checksum": payload.get("checksum").cloned().unwrap_or(Value::Null), "updatedAtUtc": payload.get("updatedAtUtc").cloned().unwrap_or(Value::Null), }) } fn split_url(url: &str) -> (String, BTreeMap) { let (path, query) = url.split_once('?').unwrap_or((url, "")); let mut params = BTreeMap::new(); for pair in query.split('&').filter(|item| !item.is_empty()) { let (key, value) = pair.split_once('=').unwrap_or((pair, "")); params.insert(url_decode(key), url_decode(value)); } (path.to_string(), params) } fn path_segments(path: &str) -> Vec<&str> { path.trim_matches('/') .split('/') .filter(|segment| !segment.is_empty()) .collect() } fn query_limit(query: &BTreeMap) -> i64 { query .get("limit") .and_then(|value| value.parse().ok()) .unwrap_or(200) .clamp(1, 1000) } fn read_json_body(request: &mut Request) -> Result { let mut body = String::new(); request .as_reader() .read_to_string(&mut body) .context("read request body")?; if body.trim().is_empty() { Ok(json!({})) } else { serde_json::from_str(&body).context("decode JSON body") } } fn json_response(status: StatusCode, payload: Value) -> Response>> { let body = serde_json::to_vec_pretty(&payload).unwrap_or_else(|_| b"{\"detail\":\"json\"}".to_vec()); let mut response = Response::from_data(body).with_status_code(status); response.add_header( Header::from_bytes( &b"Content-Type"[..], &b"application/json; charset=utf-8"[..], ) .expect("valid header"), ); response } fn parse_id(value: &str) -> Result { value .parse::() .with_context(|| format!("invalid policy id: {value}")) } fn actor_from(payload: &Value) -> Option { match payload.get("actor") { None => Some("api".to_string()), Some(Value::Null) => None, Some(value) => value.as_str().map(ToOwned::to_owned), } } fn required_string(value: Option<&Value>, field: &str) -> Result { value .and_then(Value::as_str) .map(ToOwned::to_owned) .ok_or_else(|| anyhow!("{field} is required")) } fn optional_string(value: Option<&Value>) -> Option { match value { Some(Value::String(text)) => Some(text.clone()), _ => None, } } fn validate_name(value: &str) -> Result<()> { if value.is_empty() || value.chars().count() > 128 { bail!("name length must be 1..128"); } Ok(()) } fn validate_description(value: Option<&str>) -> Result<()> { if value.is_some_and(|text| text.chars().count() > 2048) { bail!("description/comment length must be <= 2048"); } Ok(()) } fn utc_now() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } fn checksum_policy(policy: &Value) -> String { let mut hasher = Sha256::new(); hasher.update( canonical_json(policy) .expect("policy JSON serializable") .as_bytes(), ); format!("{:x}", hasher.finalize()) } fn canonical_json(value: &Value) -> Result { match value { Value::Null => Ok("null".to_string()), Value::Bool(value) => Ok(value.to_string()), Value::Number(value) => Ok(value.to_string()), Value::String(value) => serde_json::to_string(value).map_err(Into::into), Value::Array(items) => { let mut out = String::from("["); for (idx, item) in items.iter().enumerate() { if idx > 0 { out.push(','); } out.push_str(&canonical_json(item)?); } out.push(']'); Ok(out) } Value::Object(map) => { let mut out = String::from("{"); let mut first = true; let mut sorted = BTreeMap::new(); for (key, value) in map { sorted.insert(key, value); } for (key, value) in sorted { if !first { out.push(','); } first = false; out.push_str(&serde_json::to_string(key)?); out.push(':'); out.push_str(&canonical_json(value)?); } out.push('}'); Ok(out) } } } fn value_to_string(value: &Value) -> String { match value { Value::Null => "None".to_string(), Value::String(text) => text.clone(), other => other.to_string(), } } fn url_decode(value: &str) -> String { decode(value) .map(|value| value.into_owned()) .unwrap_or_else(|_| value.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn canonical_json_sorts_keys_without_spaces() { let value = json!({"b": 2, "a": {"z": true, "m": "тест"}}); assert_eq!( canonical_json(&value).unwrap(), r#"{"a":{"m":"тест","z":true},"b":2}"# ); } #[test] fn checksum_matches_python_contract_fixture() { let value = json!({ "version": 1, "defaults": {"enabled": true, "action": "alert"}, "endpoint": {"clipboard": []} }); assert_eq!( checksum_policy(&value), "88d9966b1517b45b009dbf8aca7260cf67b64201ea91bc85f743ab8ba88da0cf" ); } #[test] fn desired_policy_detects_string_equivalent_version() { let current = json!({"version": "6", "checksum": "abc"}); let active = json!({"id": 1, "current_version": 6, "checksum": "abc", "updated_at": "now"}); assert_eq!(desired_policy("a", ¤t, &active)["refreshNow"], false); } }