use std::collections::HashSet;
use std::path::Path;
use super::scanner::{QailUsage, scan_source_files};
use super::schema::Schema;
fn has_explicit_tenant_scope(cmd: &crate::ast::Qail) -> bool {
cmd.cages.iter().any(|cage| {
matches!(
cage.kind,
crate::ast::CageKind::Filter | crate::ast::CageKind::Payload
) && cage
.conditions
.iter()
.any(is_explicit_tenant_scope_condition)
})
}
fn is_explicit_tenant_scope_condition(cond: &crate::ast::Condition) -> bool {
let crate::ast::Expr::Named(raw_left) = &cond.left else {
return false;
};
if !is_tenant_identifier(raw_left) {
return false;
}
matches!(
cond.op,
crate::ast::Operator::Eq | crate::ast::Operator::IsNull
)
}
fn is_tenant_identifier(raw_ident: &str) -> bool {
let without_cast = raw_ident.split("::").next().unwrap_or(raw_ident).trim();
let last_segment = without_cast.rsplit('.').next().unwrap_or(without_cast);
let normalized = last_segment
.trim_matches('"')
.trim_matches('`')
.to_ascii_lowercase();
normalized == "tenant_id"
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationDiagnosticKind {
SchemaError,
RlsWarning,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationDiagnostic {
pub kind: ValidationDiagnosticKind,
pub message: String,
}
impl ValidationDiagnostic {
fn schema_error(message: String) -> Self {
Self {
kind: ValidationDiagnosticKind::SchemaError,
message,
}
}
fn rls_warning(message: String) -> Self {
Self {
kind: ValidationDiagnosticKind::RlsWarning,
message,
}
}
}
#[cold]
#[inline(never)]
fn fail_build(message: impl AsRef<str>) -> ! {
let msg = message.as_ref();
println!("cargo:warning={}", msg);
eprintln!("{}", msg);
std::process::exit(1);
}
pub fn validate_against_schema_diagnostics(
schema: &Schema,
usages: &[QailUsage],
) -> Vec<ValidationDiagnostic> {
use crate::validator::Validator;
let mut validator = Validator::new();
for (table_name, table_schema) in &schema.tables {
if table_schema.columns.is_empty() {
validator.add_table_name(table_name);
continue;
}
let type_strings: Vec<(String, String)> = table_schema
.columns
.iter()
.map(|(name, typ)| (name.clone(), typ.to_pg_type()))
.collect();
let cols_with_types: Vec<(&str, &str)> = type_strings
.iter()
.map(|(name, typ)| (name.as_str(), typ.as_str()))
.collect();
validator.add_table_with_types(table_name, &cols_with_types);
}
for view_name in &schema.views {
validator.add_table_name(view_name);
}
let mut diagnostics = Vec::new();
let mut seen_diagnostics: HashSet<String> = HashSet::new();
let mut push_unique = |diag: ValidationDiagnostic| {
let key = format!("{:?}|{}", diag.kind, diag.message);
if seen_diagnostics.insert(key) {
diagnostics.push(diag);
}
};
let (query_ir, query_ir_errors) = super::query_ir::build_query_ir(usages);
for err in query_ir_errors {
push_unique(ValidationDiagnostic::schema_error(err));
}
for query in query_ir {
if query.is_cte_ref && !schema.has_table(&query.table) {
continue;
}
if query.is_dynamic_table && !schema.has_table(&query.table) {
continue;
}
match validator.validate_command(&query.cmd) {
Ok(()) => {}
Err(validation_errors) => {
for e in validation_errors {
push_unique(ValidationDiagnostic::schema_error(format!(
"{}:{}: {}",
query.file, query.line, e
)));
}
}
}
if schema.is_rls_table(&query.table) && !query.has_rls {
push_unique(ValidationDiagnostic::rls_warning(format!(
"{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
query.file,
query.line,
query.action.to_lowercase(),
query.table
)));
}
if query.file_uses_super_admin {
let table_has_tenant_id = schema
.table(&query.table)
.map(|t| t.has_column("tenant_id"))
.unwrap_or(false);
if table_has_tenant_id
&& !(query.has_explicit_tenant_scope || has_explicit_tenant_scope(&query.cmd))
{
push_unique(ValidationDiagnostic::rls_warning(format!(
"{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") in file using SuperAdminToken::for_system_process() \
— query has no explicit tenant scope (`tenant_id = ...` or `tenant_id IS NULL`) and may bypass tenant isolation. \
Use claims-based scoping, `RlsContext::global()` for shared data, or add explicit tenant scope. If intentional, add `// qail:allow(super_admin)`.",
query.file,
query.line,
query.action.to_lowercase(),
query.table
)));
}
}
}
diagnostics
}
pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
validate_against_schema_diagnostics(schema, usages)
.into_iter()
.map(|d| d.message)
.collect()
}
fn run_nplus1_check(src_dir: &str) {
use super::nplus1_semantic::{NPlusOneSeverity, detect_n_plus_one_in_dir};
println!("cargo:rerun-if-env-changed=QAIL_NPLUS1");
println!("cargo:rerun-if-env-changed=QAIL_NPLUS1_MAX_WARNINGS");
let mode = std::env::var("QAIL_NPLUS1").unwrap_or_else(|_| "warn".to_string());
if mode == "off" || mode == "false" || mode == "0" {
return;
}
let max_warnings: usize = std::env::var("QAIL_NPLUS1_MAX_WARNINGS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50);
let diagnostics = detect_n_plus_one_in_dir(Path::new(src_dir));
if diagnostics.is_empty() {
println!("cargo:warning=QAIL: N+1 scan clean ✓");
return;
}
let total = diagnostics.len();
let shown = total.min(max_warnings);
for diag in diagnostics.iter().take(shown) {
let prefix = match diag.severity {
NPlusOneSeverity::Error => "QAIL N+1 ERROR",
NPlusOneSeverity::Warning => "QAIL N+1",
};
println!("cargo:warning={}: {}", prefix, diag);
}
if total > shown {
println!(
"cargo:warning=QAIL N+1: ... and {} more (set QAIL_NPLUS1_MAX_WARNINGS to see all)",
total - shown
);
}
if mode == "deny" {
fail_build(format!(
"QAIL N+1: {} diagnostic(s) found. Fix N+1 patterns or set QAIL_NPLUS1=warn",
total
));
}
}
fn configured_scan_roots() -> Vec<String> {
println!("cargo:rerun-if-env-changed=QAIL_SCAN_DIRS");
let raw = std::env::var("QAIL_SCAN_DIRS").unwrap_or_else(|_| "src".to_string());
let mut roots: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect();
roots.sort();
roots.dedup();
if roots.is_empty() {
vec!["src".to_string()]
} else {
roots
}
}
fn scan_all_roots(scan_roots: &[String]) -> Vec<QailUsage> {
let mut usages = Vec::new();
for root in scan_roots {
usages.extend(scan_source_files(root));
}
usages
}
fn emit_scan_watchers(scan_roots: &[String]) {
for root in scan_roots {
println!("cargo:rerun-if-changed={}", root);
}
}
fn emit_validation_results(
diagnostics: &[ValidationDiagnostic],
usage_count: usize,
mode_label: &str,
) {
let schema_errors: Vec<_> = diagnostics
.iter()
.filter(|d| matches!(d.kind, ValidationDiagnosticKind::SchemaError))
.collect();
let rls_warnings: Vec<_> = diagnostics
.iter()
.filter(|d| matches!(d.kind, ValidationDiagnosticKind::RlsWarning))
.collect();
for warning in &rls_warnings {
println!("cargo:warning=QAIL RLS: {}", warning.message);
}
if schema_errors.is_empty() {
println!(
"cargo:warning=QAIL: Validated {} queries against {} ✓",
usage_count, mode_label
);
return;
}
for error in &schema_errors {
println!("cargo:warning=QAIL ERROR: {}", error.message);
}
fail_build(format!(
"QAIL validation failed with {} errors",
schema_errors.len()
));
}
fn run_nplus1_checks(scan_roots: &[String]) {
for root in scan_roots {
run_nplus1_check(root);
}
}
fn run_sql_policy_checks(scan_roots: &[String]) {
println!("cargo:rerun-if-env-changed=QAIL_SQL");
println!("cargo:rerun-if-env-changed=QAIL_SQL_MAX_WARNINGS");
let mode = std::env::var("QAIL_SQL").unwrap_or_else(|_| "off".to_string());
if mode == "off" || mode == "false" || mode == "0" {
return;
}
let max_warnings: usize = std::env::var("QAIL_SQL_MAX_WARNINGS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50);
let mut all = Vec::new();
for root in scan_roots {
all.extend(super::sql_guard::detect_sql_usage_in_dir(Path::new(root)));
}
if all.is_empty() {
println!("cargo:warning=QAIL: SQL policy scan clean ✓");
return;
}
let total = all.len();
let shown = total.min(max_warnings);
for diag in all.iter().take(shown) {
println!(
"cargo:warning=QAIL SQL {}: {}:{}:{}: {}",
diag.code, diag.file, diag.line, diag.column, diag.message
);
}
if total > shown {
println!(
"cargo:warning=QAIL SQL: ... and {} more (set QAIL_SQL_MAX_WARNINGS to see all)",
total - shown
);
}
if mode == "deny" {
fail_build(format!(
"QAIL SQL policy failed with {} diagnostic(s). Migrate raw SQL to QAIL DSL or set QAIL_SQL=warn",
total
));
}
}
pub fn validate() {
let mode = std::env::var("QAIL").unwrap_or_else(|_| {
if Path::new("schema.qail").exists() || Path::new("schema").is_dir() {
"schema".to_string()
} else {
"false".to_string()
}
});
match mode.as_str() {
"schema" => {
let scan_roots = configured_scan_roots();
if let Ok(source) = crate::schema_source::resolve_schema_source("schema.qail") {
for path in source.watch_paths() {
println!("cargo:rerun-if-changed={}", path.display());
}
} else {
println!("cargo:rerun-if-changed=schema.qail");
println!("cargo:rerun-if-changed=schema");
}
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-env-changed=QAIL");
emit_scan_watchers(&scan_roots);
match Schema::parse_file("schema.qail") {
Ok(mut schema) => {
let merged = match schema.merge_migrations("migrations") {
Ok(n) => n,
Err(e) => {
println!("cargo:warning=QAIL: Migration merge failed: {}", e);
0
}
};
if merged > 0 {
println!(
"cargo:warning=QAIL: Merged {} schema changes from migrations",
merged
);
}
let usages = scan_all_roots(&scan_roots);
let diagnostics = validate_against_schema_diagnostics(&schema, &usages);
emit_validation_results(&diagnostics, usages.len(), "schema source");
run_nplus1_checks(&scan_roots);
run_sql_policy_checks(&scan_roots);
}
Err(e) => {
fail_build(format!("QAIL: Failed to parse schema source: {}", e));
}
}
}
"live" => {
let scan_roots = configured_scan_roots();
println!("cargo:rerun-if-env-changed=QAIL");
println!("cargo:rerun-if-env-changed=DATABASE_URL");
emit_scan_watchers(&scan_roots);
let db_url = match std::env::var("DATABASE_URL") {
Ok(url) => url,
Err(_) => {
fail_build("QAIL=live requires DATABASE_URL environment variable");
}
};
println!("cargo:warning=QAIL: Pulling schema from live database...");
let pull_result = std::process::Command::new("qail")
.args(["pull", &db_url])
.output();
match pull_result {
Ok(output) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
fail_build(format!("QAIL: Failed to pull schema: {}", stderr));
}
println!("cargo:warning=QAIL: Schema pulled successfully ✓");
}
Err(e) => {
println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
let cargo_result = std::process::Command::new("cargo")
.args(["run", "-p", "qail", "--", "pull", &db_url])
.current_dir(
std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()),
)
.output();
match cargo_result {
Ok(output) if output.status.success() => {
println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
}
_ => {
fail_build(format!(
"QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema",
e
));
}
}
}
}
match Schema::parse_file("schema.qail") {
Ok(mut schema) => {
let merged = match schema.merge_migrations("migrations") {
Ok(n) => n,
Err(e) => {
println!("cargo:warning=QAIL: Migration merge failed: {}", e);
0
}
};
if merged > 0 {
println!(
"cargo:warning=QAIL: Merged {} schema changes from pending migrations",
merged
);
}
let usages = scan_all_roots(&scan_roots);
let diagnostics = validate_against_schema_diagnostics(&schema, &usages);
emit_validation_results(&diagnostics, usages.len(), "live database");
run_nplus1_checks(&scan_roots);
run_sql_policy_checks(&scan_roots);
}
Err(e) => {
fail_build(format!("QAIL: Failed to parse schema after pull: {}", e));
}
}
}
"false" | "off" | "0" => {
println!("cargo:rerun-if-env-changed=QAIL");
}
_ => {
fail_build(format!(
"QAIL: Unknown mode '{}'. Use: schema, live, or false",
mode
));
}
}
}