use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use qail_core::ast::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 {
driver
.execute_simple(&receipt_column_add_sql(name, ty))
.await
.map_err(|e| {
anyhow!(
"Failed to ensure migration receipt column '{}': {}",
name,
e
)
})?;
}
Ok(())
}
fn receipt_column_add_sql(name: &str, ty: &str) -> String {
format!(
"ALTER TABLE public._qail_migrations ADD COLUMN IF NOT EXISTS \"{}\" {}",
name.replace('"', "\"\""),
ty
)
}
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;
};
verify_stored_receipt_signature_with_key(stored, &key)
}
fn verify_stored_receipt_signature_with_key(
stored: &StoredMigrationReceipt,
key: &str,
) -> ReceiptSignatureStatus {
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;
};
let legacy_expected = compute_legacy_receipt_hmac(&material, key);
if expected.eq_ignore_ascii_case(sig)
|| legacy_expected
.as_deref()
.is_some_and(|expected| expected.eq_ignore_ascii_case(sig))
{
ReceiptSignatureStatus::Valid
} else {
ReceiptSignatureStatus::Invalid
}
}
fn compute_receipt_hmac(receipt: &MigrationReceipt, key: &str) -> Option<String> {
compute_receipt_hmac_for_material(&canonical_receipt_material(receipt), key)
}
fn compute_legacy_receipt_hmac(receipt: &MigrationReceipt, key: &str) -> Option<String> {
compute_receipt_hmac_for_material(&legacy_receipt_material(receipt), key)
}
fn compute_receipt_hmac_for_material(material: &str, 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(material.as_bytes());
let digest = mac.finalize().into_bytes();
Some(digest.iter().map(|b| format!("{:02x}", b)).collect())
}
fn push_receipt_str_field(material: &mut String, name: &str, value: Option<&str>) {
material.push_str(name);
material.push(':');
match value {
Some(value) => {
material.push_str("str:");
material.push_str(&value.len().to_string());
material.push('\n');
material.push_str(value);
}
None => material.push_str("null"),
}
material.push('\n');
}
fn push_receipt_i64_field(material: &mut String, name: &str, value: Option<i64>) {
material.push_str(name);
material.push(':');
match value {
Some(value) => {
material.push_str("i64:");
material.push_str(&value.to_string());
}
None => material.push_str("null"),
}
material.push('\n');
}
fn canonical_receipt_material(receipt: &MigrationReceipt) -> String {
let mut material = String::new();
material.push_str("qail-receipt-v2\n");
push_receipt_str_field(&mut material, "version", Some(&receipt.version));
push_receipt_str_field(&mut material, "name", Some(&receipt.name));
push_receipt_str_field(&mut material, "checksum", Some(&receipt.checksum));
push_receipt_str_field(&mut material, "sql_up", Some(&receipt.sql_up));
push_receipt_str_field(&mut material, "git_sha", receipt.git_sha.as_deref());
push_receipt_str_field(&mut material, "qail_version", Some(&receipt.qail_version));
push_receipt_str_field(&mut material, "actor", receipt.actor.as_deref());
push_receipt_i64_field(&mut material, "started_at_ms", receipt.started_at_ms);
push_receipt_i64_field(&mut material, "finished_at_ms", receipt.finished_at_ms);
push_receipt_i64_field(&mut material, "duration_ms", receipt.duration_ms);
push_receipt_i64_field(
&mut material,
"affected_rows_est",
receipt.affected_rows_est,
);
push_receipt_str_field(
&mut material,
"risk_summary",
receipt.risk_summary.as_deref(),
);
push_receipt_str_field(
&mut material,
"shadow_checksum",
receipt.shadow_checksum.as_deref(),
);
material
}
fn legacy_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_legacy_receipt_hmac, compute_receipt_hmac,
receipt_column_add_sql, verify_stored_receipt_signature,
verify_stored_receipt_signature_with_key,
};
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,
}
}
fn sample_stored_receipt(receipt_sig: Option<String>) -> StoredMigrationReceipt {
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,
}
}
#[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 receipt_hmac_distinguishes_null_and_empty_optional_fields() {
let mut null_field = sample_receipt();
null_field.shadow_checksum = None;
let mut empty_field = sample_receipt();
empty_field.shadow_checksum = Some(String::new());
let null_sig = compute_receipt_hmac(&null_field, "top-secret").expect("hmac");
let empty_sig = compute_receipt_hmac(&empty_field, "top-secret").expect("hmac");
assert_ne!(
null_sig, empty_sig,
"v2 receipt signatures must distinguish NULL from empty string"
);
let legacy_null =
compute_legacy_receipt_hmac(&null_field, "top-secret").expect("legacy hmac");
let legacy_empty =
compute_legacy_receipt_hmac(&empty_field, "top-secret").expect("legacy hmac");
assert_eq!(
legacy_null, legacy_empty,
"legacy material collapsed NULL and empty string"
);
}
#[test]
fn stored_receipt_verification_accepts_v2_and_legacy_signatures() {
let receipt = sample_receipt();
let v2_sig = compute_receipt_hmac(&receipt, "top-secret").expect("hmac");
let legacy_sig = compute_legacy_receipt_hmac(&receipt, "top-secret").expect("legacy hmac");
assert_eq!(
verify_stored_receipt_signature_with_key(
&sample_stored_receipt(Some(v2_sig)),
"top-secret",
),
ReceiptSignatureStatus::Valid
);
assert_eq!(
verify_stored_receipt_signature_with_key(
&sample_stored_receipt(Some(legacy_sig)),
"top-secret",
),
ReceiptSignatureStatus::Valid
);
assert_eq!(
verify_stored_receipt_signature_with_key(
&sample_stored_receipt(Some("not-a-valid-signature".to_string())),
"top-secret",
),
ReceiptSignatureStatus::Invalid
);
}
#[test]
fn canonical_material_contains_core_fields() {
let material = canonical_receipt_material(&sample_receipt());
assert!(material.starts_with("qail-receipt-v2\n"));
assert!(material.contains("version:str:21\n001_add_users.up.qail"));
assert!(material.contains("checksum:str:6\nabc123"));
assert!(material.contains("sql_up:str:28\nCREATE TABLE users (id int);"));
}
#[test]
fn receipt_column_bootstrap_sql_is_idempotent_and_quoted() {
let sql = receipt_column_add_sql("receipt_sig", "text");
assert!(sql.contains("ADD COLUMN IF NOT EXISTS"));
assert!(sql.contains("\"receipt_sig\" text"));
let quoted = receipt_column_add_sql("receipt\"sig", "text");
assert!(quoted.contains("\"receipt\"\"sig\" text"));
}
#[test]
fn verify_signature_returns_disabled_without_key() {
let stored = sample_stored_receipt(None);
assert_eq!(
verify_stored_receipt_signature(&stored),
ReceiptSignatureStatus::DisabledNoKey
);
}
}