qail 0.27.9

Schema-first database toolkit - migrations, diff, lint, and query generation
Documentation
//! Migration receipt metadata + persistence.

use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use qail_core::ast::{Action, Constraint, Expr, Qail};
use qail_pg::PgDriver;
use sha2::Sha256;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone)]
pub struct MigrationReceipt {
    pub version: String,
    pub name: String,
    pub checksum: String,
    pub sql_up: String,
    pub git_sha: Option<String>,
    pub qail_version: String,
    pub actor: Option<String>,
    pub started_at_ms: Option<i64>,
    pub finished_at_ms: Option<i64>,
    pub duration_ms: Option<i64>,
    pub affected_rows_est: Option<i64>,
    pub risk_summary: Option<String>,
    pub shadow_checksum: Option<String>,
}

#[derive(Debug, Clone)]
pub struct StoredMigrationReceipt {
    pub version: String,
    pub name: Option<String>,
    pub checksum: Option<String>,
    pub sql_up: Option<String>,
    pub git_sha: Option<String>,
    pub qail_version: Option<String>,
    pub actor: Option<String>,
    pub started_at_ms: Option<i64>,
    pub finished_at_ms: Option<i64>,
    pub duration_ms: Option<i64>,
    pub affected_rows_est: Option<i64>,
    pub risk_summary: Option<String>,
    pub shadow_checksum: Option<String>,
    pub receipt_sig: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReceiptSignatureStatus {
    DisabledNoKey,
    Missing,
    Valid,
    Invalid,
}

pub async fn ensure_migration_receipt_columns(driver: &mut PgDriver) -> Result<()> {
    let columns: &[(&str, &str)] = &[
        ("git_sha", "text"),
        ("qail_version", "text"),
        ("actor", "text"),
        ("started_at_ms", "bigint"),
        ("finished_at_ms", "bigint"),
        ("duration_ms", "bigint"),
        ("affected_rows_est", "bigint"),
        ("risk_summary", "text"),
        ("shadow_checksum", "text"),
        ("receipt_sig", "text"),
    ];

    for (name, ty) in columns {
        let exists_cmd = Qail::get("information_schema.columns")
            .column("1")
            .where_eq("table_schema", "public")
            .where_eq("table_name", "_qail_migrations")
            .where_eq("column_name", *name)
            .limit(1);
        let rows = driver
            .fetch_all(&exists_cmd)
            .await
            .map_err(|e| anyhow!("Failed to check migration receipt column '{}': {}", name, e))?;
        if !rows.is_empty() {
            continue;
        }

        let alter_cmd = Qail {
            action: Action::Mod,
            table: "_qail_migrations".to_string(),
            columns: vec![Expr::Def {
                name: (*name).to_string(),
                data_type: (*ty).to_string(),
                constraints: vec![Constraint::Nullable],
            }],
            ..Default::default()
        };
        driver.execute(&alter_cmd).await.map_err(|e| {
            anyhow!(
                "Failed to ensure migration receipt column '{}': {}",
                name,
                e
            )
        })?;
    }
    Ok(())
}

pub async fn write_migration_receipt(
    driver: &mut PgDriver,
    receipt: &MigrationReceipt,
) -> Result<()> {
    let receipt_sig = runtime_receipt_hmac_key()
        .as_deref()
        .and_then(|key| compute_receipt_hmac(receipt, key));
    let insert_cmd = Qail::add("_qail_migrations")
        .set_value("version", receipt.version.as_str())
        .set_value("name", receipt.name.as_str())
        .set_value("checksum", receipt.checksum.as_str())
        .set_value("sql_up", receipt.sql_up.as_str())
        .set_opt("git_sha", receipt.git_sha.as_deref())
        .set_value("qail_version", receipt.qail_version.as_str())
        .set_opt("actor", receipt.actor.as_deref())
        .set_opt("started_at_ms", receipt.started_at_ms)
        .set_opt("finished_at_ms", receipt.finished_at_ms)
        .set_opt("duration_ms", receipt.duration_ms)
        .set_opt("affected_rows_est", receipt.affected_rows_est)
        .set_opt("risk_summary", receipt.risk_summary.as_deref())
        .set_opt("shadow_checksum", receipt.shadow_checksum.as_deref())
        .set_opt("receipt_sig", receipt_sig.as_deref());

    driver
        .execute(&insert_cmd)
        .await
        .map_err(|e| anyhow!("Failed to write migration receipt: {}", e))?;
    Ok(())
}

fn runtime_receipt_hmac_key() -> Option<String> {
    [
        "QAIL_MIGRATION_RECEIPT_HMAC_KEY",
        "QAIL_RECEIPT_HMAC_KEY",
        "QAIL_MIGRATION_SIGNING_KEY",
    ]
    .iter()
    .find_map(|name| {
        std::env::var(name)
            .ok()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty())
    })
}

pub fn verify_stored_receipt_signature(stored: &StoredMigrationReceipt) -> ReceiptSignatureStatus {
    let Some(key) = runtime_receipt_hmac_key() else {
        return ReceiptSignatureStatus::DisabledNoKey;
    };
    let Some(sig) = stored
        .receipt_sig
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
    else {
        return ReceiptSignatureStatus::Missing;
    };

    let material = MigrationReceipt {
        version: stored.version.clone(),
        name: stored.name.clone().unwrap_or_default(),
        checksum: stored.checksum.clone().unwrap_or_default(),
        sql_up: stored.sql_up.clone().unwrap_or_default(),
        git_sha: stored.git_sha.clone(),
        qail_version: stored.qail_version.clone().unwrap_or_default(),
        actor: stored.actor.clone(),
        started_at_ms: stored.started_at_ms,
        finished_at_ms: stored.finished_at_ms,
        duration_ms: stored.duration_ms,
        affected_rows_est: stored.affected_rows_est,
        risk_summary: stored.risk_summary.clone(),
        shadow_checksum: stored.shadow_checksum.clone(),
    };
    let Some(expected) = compute_receipt_hmac(&material, &key) else {
        return ReceiptSignatureStatus::Invalid;
    };
    if expected.eq_ignore_ascii_case(sig) {
        ReceiptSignatureStatus::Valid
    } else {
        ReceiptSignatureStatus::Invalid
    }
}

fn compute_receipt_hmac(receipt: &MigrationReceipt, key: &str) -> Option<String> {
    if key.trim().is_empty() {
        return None;
    }
    type HmacSha256 = Hmac<Sha256>;
    let mut mac = HmacSha256::new_from_slice(key.as_bytes()).ok()?;
    mac.update(canonical_receipt_material(receipt).as_bytes());
    let digest = mac.finalize().into_bytes();
    Some(digest.iter().map(|b| format!("{:02x}", b)).collect())
}

fn canonical_receipt_material(receipt: &MigrationReceipt) -> String {
    let mut material = String::new();
    material.push_str("version=");
    material.push_str(&receipt.version);
    material.push('\n');
    material.push_str("name=");
    material.push_str(&receipt.name);
    material.push('\n');
    material.push_str("checksum=");
    material.push_str(&receipt.checksum);
    material.push('\n');
    material.push_str("sql_up=");
    material.push_str(&receipt.sql_up);
    material.push('\n');
    material.push_str("git_sha=");
    material.push_str(receipt.git_sha.as_deref().unwrap_or(""));
    material.push('\n');
    material.push_str("qail_version=");
    material.push_str(&receipt.qail_version);
    material.push('\n');
    material.push_str("actor=");
    material.push_str(receipt.actor.as_deref().unwrap_or(""));
    material.push('\n');
    material.push_str("started_at_ms=");
    material.push_str(&receipt.started_at_ms.unwrap_or_default().to_string());
    material.push('\n');
    material.push_str("finished_at_ms=");
    material.push_str(&receipt.finished_at_ms.unwrap_or_default().to_string());
    material.push('\n');
    material.push_str("duration_ms=");
    material.push_str(&receipt.duration_ms.unwrap_or_default().to_string());
    material.push('\n');
    material.push_str("affected_rows_est=");
    material.push_str(&receipt.affected_rows_est.unwrap_or_default().to_string());
    material.push('\n');
    material.push_str("risk_summary=");
    material.push_str(receipt.risk_summary.as_deref().unwrap_or(""));
    material.push('\n');
    material.push_str("shadow_checksum=");
    material.push_str(receipt.shadow_checksum.as_deref().unwrap_or(""));
    material
}

pub fn now_epoch_ms() -> i64 {
    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
}

pub fn runtime_actor() -> Option<String> {
    let candidates = [
        "QAIL_ACTOR",
        "GIT_AUTHOR_NAME",
        "USER",
        "USERNAME",
        "SUDO_USER",
    ];
    for key in candidates {
        if let Ok(v) = std::env::var(key) {
            let trimmed = v.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }
    None
}

pub fn runtime_git_sha() -> Option<String> {
    let candidates = [
        "QAIL_GIT_SHA",
        "GIT_SHA",
        "GITHUB_SHA",
        "CI_COMMIT_SHA",
        "BUILDKITE_COMMIT",
    ];
    for key in candidates {
        if let Ok(v) = std::env::var(key) {
            let trimmed = v.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.chars().take(12).collect());
            }
        }
    }

    if let Ok(out) = Command::new("git")
        .args(["rev-parse", "--short=12", "HEAD"])
        .output()
        && out.status.success()
    {
        let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if !sha.is_empty() {
            return Some(sha);
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::{
        MigrationReceipt, ReceiptSignatureStatus, StoredMigrationReceipt,
        canonical_receipt_material, compute_receipt_hmac, verify_stored_receipt_signature,
    };

    fn sample_receipt() -> MigrationReceipt {
        MigrationReceipt {
            version: "001_add_users.up.qail".to_string(),
            name: "001_add_users.up.qail".to_string(),
            checksum: "abc123".to_string(),
            sql_up: "CREATE TABLE users (id int);".to_string(),
            git_sha: Some("deadbeef".to_string()),
            qail_version: "0.25.0".to_string(),
            actor: Some("tester".to_string()),
            started_at_ms: Some(1000),
            finished_at_ms: Some(1100),
            duration_ms: Some(100),
            affected_rows_est: Some(0),
            risk_summary: Some("source=test".to_string()),
            shadow_checksum: None,
        }
    }

    #[test]
    fn receipt_hmac_is_deterministic_for_same_payload() {
        let receipt = sample_receipt();
        let a = compute_receipt_hmac(&receipt, "top-secret").expect("hmac");
        let b = compute_receipt_hmac(&receipt, "top-secret").expect("hmac");
        assert_eq!(a, b, "same receipt+key must produce same signature");
    }

    #[test]
    fn receipt_hmac_changes_when_payload_changes() {
        let mut receipt = sample_receipt();
        let before = compute_receipt_hmac(&receipt, "top-secret").expect("hmac");
        receipt
            .sql_up
            .push_str("\nALTER TABLE users ADD COLUMN email text;");
        let after = compute_receipt_hmac(&receipt, "top-secret").expect("hmac");
        assert_ne!(
            before, after,
            "signature must change when receipt payload changes"
        );
    }

    #[test]
    fn canonical_material_contains_core_fields() {
        let material = canonical_receipt_material(&sample_receipt());
        assert!(material.contains("version=001_add_users.up.qail"));
        assert!(material.contains("checksum=abc123"));
        assert!(material.contains("sql_up=CREATE TABLE users (id int);"));
    }

    #[test]
    fn verify_signature_returns_disabled_without_key() {
        let stored = StoredMigrationReceipt {
            version: "001_add_users.up.qail".to_string(),
            name: Some("001_add_users.up.qail".to_string()),
            checksum: Some("abc123".to_string()),
            sql_up: Some("CREATE TABLE users (id int);".to_string()),
            git_sha: Some("deadbeef".to_string()),
            qail_version: Some("0.25.0".to_string()),
            actor: Some("tester".to_string()),
            started_at_ms: Some(1000),
            finished_at_ms: Some(1100),
            duration_ms: Some(100),
            affected_rows_est: Some(0),
            risk_summary: Some("source=test".to_string()),
            shadow_checksum: None,
            receipt_sig: None,
        };
        assert_eq!(
            verify_stored_receipt_signature(&stored),
            ReceiptSignatureStatus::DisabledNoKey
        );
    }
}