use crate::__bypass::RawAccessExt as _;
use crate::config::DjogiConfig;
use crate::context::DjogiContext;
use crate::error::DjogiError;
use crate::pg::pool::DjogiPool;
pub const DDL_AUDIT_TABLE_DDL: &str = r#"
CREATE TABLE IF NOT EXISTS djogi_ddl_audit (
id BIGSERIAL PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
target_database TEXT NOT NULL,
app_label TEXT NOT NULL,
ddl_sql TEXT NOT NULL,
snapshot_signature_hex TEXT
);
"#;
pub async fn bootstrap_ddl_audit(audit_ctx: &mut DjogiContext) -> Result<(), DjogiError> {
audit_ctx.raw_ddl(DDL_AUDIT_TABLE_DDL).await
}
pub async fn record_ddl(
audit_ctx: &mut DjogiContext,
target_database: &str,
app_label: &str,
ddl_sql: &str,
snapshot_sig_hex: Option<&str>,
) -> Result<i64, DjogiError> {
let sql = "INSERT INTO djogi_ddl_audit \
(target_database, app_label, ddl_sql, snapshot_signature_hex) \
VALUES ($1, $2, $3, $4) \
RETURNING id";
let row = audit_ctx
.query_one(
sql,
&[&target_database, &app_label, &ddl_sql, &snapshot_sig_hex],
)
.await?;
Ok(row.try_get::<_, i64>(0)?)
}
pub fn signature_to_hex(sig: &[u8; 32]) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(64);
for &byte in sig {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0F) as usize] as char);
}
out
}
pub const AUDIT_DB_DERIVED_NAME: &str = "crud_log";
pub const AUDIT_URL_ENV_VAR: &str = "CRUD_LOG_URL";
pub const DJOGI_AUDIT_URL_ENV_VAR: &str = "DJOGI_CRUD_LOG_URL";
#[cfg(test)]
pub(crate) static AUDIT_URL_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditUrlError {
Unresolvable {
application_url: String,
},
SelfAudit {
application_url: String,
},
}
impl std::fmt::Display for AuditUrlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuditUrlError::Unresolvable { application_url } => write!(
f,
"cannot resolve audit DB URL: set {AUDIT_URL_ENV_VAR}, set \
[database].crud_log_url, or ensure \
Djogi.toml::database.url has a path component to splice (got `{application_url}`)"
),
AuditUrlError::SelfAudit { application_url } => write!(
f,
"audit URL derivation produced the same URL as the app DB (`{application_url}`). \
The audit DB must be a separate database — set {AUDIT_URL_ENV_VAR} \
or [database].crud_log_url explicitly, or rename the app DB so its path does not end in \
`/{AUDIT_DB_DERIVED_NAME}`."
),
}
}
}
impl std::error::Error for AuditUrlError {}
pub fn resolve_audit_url(config: &DjogiConfig) -> Result<String, AuditUrlError> {
if let Ok(url) = std::env::var(AUDIT_URL_ENV_VAR)
&& !url.is_empty()
{
return Ok(url);
}
if let Ok(url) = std::env::var(DJOGI_AUDIT_URL_ENV_VAR)
&& !url.is_empty()
{
return Ok(url);
}
if let Some(url) = config.database.crud_log_url.as_deref()
&& !url.is_empty()
{
return Ok(url.to_string());
}
let derived = super::derive_per_database_url(&config.database.url, AUDIT_DB_DERIVED_NAME)
.ok_or_else(|| AuditUrlError::Unresolvable {
application_url: config.database.url.clone(),
})?;
if derived == config.database.url {
return Err(AuditUrlError::SelfAudit {
application_url: config.database.url.clone(),
});
}
Ok(derived)
}
pub async fn build_audit_pool(url: &str) -> Result<deadpool_postgres::Pool, DjogiError> {
let djogi_pool = DjogiPool::connect(url).await?;
Ok(djogi_pool.inner)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signature_to_hex_all_zero() {
let sig = [0u8; 32];
let hex = signature_to_hex(&sig);
assert_eq!(hex, "0".repeat(64));
assert_eq!(hex.len(), 64);
}
#[test]
fn signature_to_hex_all_ones() {
let sig = [0xFFu8; 32];
let hex = signature_to_hex(&sig);
assert_eq!(hex, "F".repeat(64));
}
#[test]
fn signature_to_hex_known_mixed_bytes() {
let mut sig = [0u8; 32];
for (i, byte) in sig.iter_mut().enumerate() {
*byte = ((i as u32 * 17 + 3) & 0xFF) as u8;
}
let actual = signature_to_hex(&sig);
let expected: String = sig.iter().map(|b| format!("{b:02X}")).collect();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 64);
assert!(
actual
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')),
"non-uppercase-hex byte in {actual:?}"
);
}
use djogi_macros::djogi_test;
#[djogi_test]
async fn bootstrap_is_idempotent(mut ctx: DjogiContext) {
bootstrap_ddl_audit(&mut ctx)
.await
.expect("first bootstrap_ddl_audit");
bootstrap_ddl_audit(&mut ctx)
.await
.expect("second bootstrap_ddl_audit");
let count: i64 = ctx
.query_one("SELECT COUNT(*)::bigint FROM djogi_ddl_audit", &[])
.await
.expect("count djogi_ddl_audit")
.try_get(0)
.expect("decode count");
assert_eq!(count, 0, "bootstrap should create the table but no rows");
}
#[djogi_test]
async fn record_ddl_returns_increasing_ids(mut ctx: DjogiContext) {
bootstrap_ddl_audit(&mut ctx)
.await
.expect("bootstrap audit");
let a = record_ddl(
&mut ctx,
"main",
"",
"CREATE TABLE audit_a (id bigint)",
Some("AA"),
)
.await
.expect("record first ddl");
let b = record_ddl(
&mut ctx,
"main",
"",
"CREATE TABLE audit_b (id bigint)",
Some("BB"),
)
.await
.expect("record second ddl");
let c = record_ddl(
&mut ctx,
"main",
"",
"CREATE TABLE audit_c (id bigint)",
Some("CC"),
)
.await
.expect("record third ddl");
assert!(a < b && b < c, "audit ids should increase: {a}, {b}, {c}");
}
#[djogi_test]
async fn record_ddl_accepts_null_signature(mut ctx: DjogiContext) {
bootstrap_ddl_audit(&mut ctx)
.await
.expect("bootstrap audit");
let id = record_ddl(
&mut ctx,
"main",
"",
"CREATE TABLE audit_null_signature (id bigint)",
None,
)
.await
.expect("record ddl with null signature");
let sig: Option<String> = ctx
.query_one(
"SELECT snapshot_signature_hex FROM djogi_ddl_audit WHERE id = $1",
&[&id],
)
.await
.expect("select audit row")
.try_get(0)
.expect("decode signature");
assert_eq!(sig, None);
}
fn stub_config_with_url(url: &str) -> DjogiConfig {
DjogiConfig {
database: crate::config::DatabaseConfig {
url: url.to_string(),
crud_log_url: None,
event_log_url: None,
max_connections: None,
dev_mode: false,
},
server: crate::config::ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
migrate: crate::config::MigrateConfig {
concurrent_warn_relpages: 128,
strict_concurrent_warnings: false,
pk_flip_long_tx_threshold_secs: 60,
pk_flip_join_table_option: 'A',
},
profile: "development".to_string(),
policy: crate::config::PolicyConfig::default(),
}
}
fn clear_audit_url_env_vars() {
unsafe {
std::env::remove_var(AUDIT_URL_ENV_VAR);
std::env::remove_var(DJOGI_AUDIT_URL_ENV_VAR);
}
}
#[test]
fn resolve_audit_url_env_var_wins() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
unsafe {
std::env::remove_var(DJOGI_AUDIT_URL_ENV_VAR);
std::env::set_var(AUDIT_URL_ENV_VAR, "postgres://override/audit");
}
let cfg = stub_config_with_url("postgres://localhost/main");
let resolved = resolve_audit_url(&cfg);
clear_audit_url_env_vars();
assert_eq!(
resolved.expect("env URL").as_str(),
"postgres://override/audit"
);
}
#[test]
fn resolve_audit_url_compat_env_var_still_works() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
unsafe {
std::env::remove_var(AUDIT_URL_ENV_VAR);
std::env::set_var(DJOGI_AUDIT_URL_ENV_VAR, "postgres://compat/audit");
}
let cfg = stub_config_with_url("postgres://localhost/main");
let resolved = resolve_audit_url(&cfg);
clear_audit_url_env_vars();
assert_eq!(
resolved.expect("compat env URL").as_str(),
"postgres://compat/audit"
);
}
#[test]
fn resolve_audit_url_uses_configured_crud_log_url_before_derive() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
clear_audit_url_env_vars();
let mut cfg = stub_config_with_url("postgres://localhost/main");
cfg.database.crud_log_url = Some("postgres://localhost/myapp_crud_logs".to_string());
let resolved = resolve_audit_url(&cfg).expect("configured crud_log_url");
assert_eq!(resolved, "postgres://localhost/myapp_crud_logs");
}
#[test]
fn resolve_audit_url_falls_back_to_derived() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
clear_audit_url_env_vars();
let cfg = stub_config_with_url("postgres://localhost/main");
let url = resolve_audit_url(&cfg).expect("derived audit URL");
assert!(
url.ends_with("/crud_log"),
"expected derived URL to end in /crud_log, got `{url}`"
);
assert!(
url.contains("localhost"),
"expected derived URL to preserve authority, got `{url}`"
);
}
#[test]
fn resolve_audit_url_empty_env_var_falls_back() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
unsafe {
std::env::remove_var(DJOGI_AUDIT_URL_ENV_VAR);
std::env::set_var(AUDIT_URL_ENV_VAR, "");
}
let cfg = stub_config_with_url("postgres://localhost/main");
let resolved = resolve_audit_url(&cfg);
clear_audit_url_env_vars();
let url = resolved.expect("derived audit URL on empty env");
assert!(
url.ends_with("/crud_log"),
"empty env var should fall back to derived; got `{url}`"
);
}
#[test]
fn resolve_audit_url_rejects_self_audit_via_derive_path() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
clear_audit_url_env_vars();
let cfg = stub_config_with_url("postgres://localhost/crud_log");
match resolve_audit_url(&cfg) {
Err(AuditUrlError::SelfAudit { application_url }) => {
assert_eq!(application_url, "postgres://localhost/crud_log");
let display = format!(
"{}",
AuditUrlError::SelfAudit {
application_url: application_url.clone()
}
);
assert!(
display.contains("audit URL derivation produced the same URL"),
"operator-actionable error message expected, got: {display}"
);
assert!(
display.contains(AUDIT_URL_ENV_VAR),
"error must point at the env-var override, got: {display}"
);
}
other => panic!("expected SelfAudit; got {other:?}"),
}
}
#[test]
fn resolve_audit_url_env_var_bypasses_self_audit_guard() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
unsafe {
std::env::remove_var(DJOGI_AUDIT_URL_ENV_VAR);
std::env::set_var(AUDIT_URL_ENV_VAR, "postgres://localhost/crud_log");
}
let cfg = stub_config_with_url("postgres://localhost/crud_log");
let resolved = resolve_audit_url(&cfg);
clear_audit_url_env_vars();
assert_eq!(
resolved.expect("env URL bypasses guard").as_str(),
"postgres://localhost/crud_log"
);
}
#[test]
fn resolve_audit_url_unresolvable_when_no_path_component() {
let _g = AUDIT_URL_ENV_MUTEX.lock().expect("audit url env mutex");
clear_audit_url_env_vars();
let cfg = stub_config_with_url("postgres://localhost");
match resolve_audit_url(&cfg) {
Err(AuditUrlError::Unresolvable { application_url }) => {
assert_eq!(application_url, "postgres://localhost");
let display = format!(
"{}",
AuditUrlError::Unresolvable {
application_url: application_url.clone()
}
);
assert!(
display.contains("cannot resolve audit DB URL"),
"operator-actionable error message expected, got: {display}"
);
assert!(
display.contains(AUDIT_URL_ENV_VAR),
"error must point at the env-var override, got: {display}"
);
}
other => panic!("expected Unresolvable; got {other:?}"),
}
}
#[tokio::test]
async fn build_audit_pool_malformed_url_surfaces_error() {
let res = build_audit_pool("not a postgres url").await;
match res {
Err(_) => {} Ok(_) => panic!(
"expected build_audit_pool to fail against a malformed URL — \
build() validates the tokio_postgres Config synchronously"
),
}
}
}