Files
nx9-url-shortener/src/services/destination_audit.rs
T

259 lines
8.7 KiB
Rust

//! Read-only audit of stored redirect destinations.
//!
//! Scans tenant content databases and classifies each `urls.destination` using
//! the same rules as write-path validation. Never rewrites or deletes data.
use crate::db::Db;
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
use rusqlite::Connection;
use tracing::{error, info, warn};
/// Summary counters for a destination audit run.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DestinationAuditReport {
pub scanned_users: usize,
pub total_urls: usize,
pub valid_http: usize,
pub valid_https: usize,
pub invalid: usize,
pub control_characters: usize,
pub unsupported_scheme: usize,
pub malformed: usize,
pub empty: usize,
pub too_long: usize,
pub non_ascii: usize,
/// Safe sample of invalid records: (owner_user_id, code, class_label).
/// Destination bodies are never included (may contain control chars / secrets).
pub invalid_samples: Vec<InvalidDestinationSample>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidDestinationSample {
pub owner_user_id: i64,
pub code: String,
pub url_id: String,
pub class: &'static str,
pub destination_len: usize,
}
const MAX_SAMPLES: usize = 50;
fn class_label(c: DestinationClass) -> &'static str {
match c {
DestinationClass::ValidHttp => "valid_http",
DestinationClass::ValidHttps => "valid_https",
DestinationClass::Empty => "empty",
DestinationClass::TooLong => "too_long",
DestinationClass::ControlCharacters => "control_characters",
DestinationClass::NonAscii => "non_ascii",
DestinationClass::UnsupportedScheme => "unsupported_scheme",
DestinationClass::Malformed => "malformed",
}
}
/// Classify a single destination and update report counters.
pub fn record_destination(
report: &mut DestinationAuditReport,
owner_user_id: i64,
code: &str,
url_id: &str,
destination: &str,
) {
report.total_urls += 1;
let class = classify_redirect_destination(destination);
match class {
DestinationClass::ValidHttp => report.valid_http += 1,
DestinationClass::ValidHttps => report.valid_https += 1,
DestinationClass::Empty => {
report.empty += 1;
report.invalid += 1;
}
DestinationClass::TooLong => {
report.too_long += 1;
report.invalid += 1;
}
DestinationClass::ControlCharacters => {
report.control_characters += 1;
report.invalid += 1;
}
DestinationClass::NonAscii => {
report.non_ascii += 1;
report.invalid += 1;
}
DestinationClass::UnsupportedScheme => {
report.unsupported_scheme += 1;
report.invalid += 1;
}
DestinationClass::Malformed => {
report.malformed += 1;
report.invalid += 1;
}
}
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
report.invalid_samples.push(InvalidDestinationSample {
owner_user_id,
code: code.to_string(),
url_id: url_id.to_string(),
class: class_label(class),
destination_len: destination.len(),
});
}
}
/// Scan one content database connection for URL destinations.
pub fn audit_content_conn(
conn: &Connection,
owner_user_id: i64,
report: &mut DestinationAuditReport,
) -> rusqlite::Result<()> {
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (id, code, destination) = row?;
record_destination(report, owner_user_id, &code, &id, &destination);
}
Ok(())
}
fn open_user_content(db: &Db, user_id: i64) -> Result<Connection, rusqlite::Error> {
let users = db
.users
.lock()
.map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?;
let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?;
let conn = Connection::open(path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
Ok(conn)
}
/// Audit all tenant content databases found under the configured data directory.
///
/// Read-only: does not modify any records.
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
let mut report = DestinationAuditReport::default();
let user_ids: Vec<i64> = {
let users = db
.users
.lock()
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
let mut stmt = users
.prepare("SELECT id FROM users;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for user_id in user_ids {
match open_user_content(db, user_id) {
Ok(conn) => {
report.scanned_users += 1;
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
error!(
owner_user_id = user_id,
error = %e,
"destination audit failed for user content.db"
);
return Err(format!("audit user {} content.db: {}", user_id, e));
}
}
Err(rusqlite::Error::InvalidPath(_)) => {
// User has no content DB yet — skip.
}
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"could not open user content.db for destination audit"
);
}
}
}
info!(
total_urls = report.total_urls,
valid = report.valid_http + report.valid_https,
invalid = report.invalid,
"destination audit complete"
);
Ok(report)
}
/// Format a human-readable report for CLI output.
pub fn format_report(report: &DestinationAuditReport) -> String {
let mut out = String::new();
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
out.push_str("===========================================\n");
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
out.push_str(&format!(
" control characters: {}\n",
report.control_characters
));
out.push_str(&format!(
" unsupported scheme: {}\n",
report.unsupported_scheme
));
out.push_str(&format!(" malformed: {}\n", report.malformed));
out.push_str(&format!(" empty: {}\n", report.empty));
out.push_str(&format!(" too long: {}\n", report.too_long));
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
if !report.invalid_samples.is_empty() {
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
for s in &report.invalid_samples {
out.push_str(&format!(
" user={} code={} id={} class={} dest_len={}\n",
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_control_character_destination() {
let mut report = DestinationAuditReport::default();
record_destination(
&mut report,
1,
"ab12cd",
"id-1",
"https://evil.example/\r\nX:1",
);
assert_eq!(report.total_urls, 1);
assert_eq!(report.invalid, 1);
assert_eq!(report.control_characters, 1);
assert_eq!(report.invalid_samples.len(), 1);
assert_eq!(report.invalid_samples[0].class, "control_characters");
// Ensure we never store the destination body in the sample.
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
}
#[test]
fn records_valid_https() {
let mut report = DestinationAuditReport::default();
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 0);
assert!(report.invalid_samples.is_empty());
}
}