Skip to main content

qail_core/build/
validate.rs

1//! Validation pipeline: schema validation, RLS audit, N+1 detection, SQL policy.
2
3use std::collections::HashSet;
4use std::path::Path;
5
6use super::scanner::{QailUsage, scan_source_files};
7use super::schema::Schema;
8
9fn has_explicit_tenant_scope(cmd: &crate::ast::Qail) -> bool {
10    cmd.cages.iter().any(|cage| {
11        let cage_can_scope = match cage.kind {
12            crate::ast::CageKind::Filter => true,
13            crate::ast::CageKind::Payload => {
14                matches!(
15                    cmd.action,
16                    crate::ast::Action::Add | crate::ast::Action::Put
17                )
18            }
19            _ => false,
20        };
21
22        cage_can_scope
23            && cage
24                .conditions
25                .iter()
26                .any(is_explicit_tenant_scope_condition)
27    })
28}
29
30fn is_explicit_tenant_scope_condition(cond: &crate::ast::Condition) -> bool {
31    let crate::ast::Expr::Named(raw_left) = &cond.left else {
32        return false;
33    };
34    if !is_tenant_identifier(raw_left) {
35        return false;
36    }
37    matches!(
38        cond.op,
39        crate::ast::Operator::Eq | crate::ast::Operator::IsNull
40    )
41}
42
43fn is_tenant_identifier(raw_ident: &str) -> bool {
44    let without_cast = raw_ident.split("::").next().unwrap_or(raw_ident).trim();
45    let last_segment = without_cast.rsplit('.').next().unwrap_or(without_cast);
46    let normalized = last_segment
47        .trim_matches('"')
48        .trim_matches('`')
49        .to_ascii_lowercase();
50    normalized == "tenant_id"
51}
52
53/// Validation diagnostic category.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ValidationDiagnosticKind {
56    /// Hard schema validation failure (must fail build).
57    SchemaError,
58    /// Advisory RLS audit warning.
59    RlsWarning,
60}
61
62/// Structured diagnostic emitted by build validation.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ValidationDiagnostic {
65    pub kind: ValidationDiagnosticKind,
66    pub message: String,
67}
68
69impl ValidationDiagnostic {
70    fn schema_error(message: String) -> Self {
71        Self {
72            kind: ValidationDiagnosticKind::SchemaError,
73            message,
74        }
75    }
76
77    fn rls_warning(message: String) -> Self {
78        Self {
79            kind: ValidationDiagnosticKind::RlsWarning,
80            message,
81        }
82    }
83}
84
85#[cold]
86#[inline(never)]
87fn fail_build(message: impl AsRef<str>) -> ! {
88    let msg = message.as_ref();
89    println!("cargo:warning={}", msg);
90    eprintln!("{}", msg);
91    std::process::exit(1);
92}
93
94/// Provides "Did you mean?" suggestions for typos, type validation, and RLS audit
95pub fn validate_against_schema_diagnostics(
96    schema: &Schema,
97    usages: &[QailUsage],
98) -> Vec<ValidationDiagnostic> {
99    use crate::validator::Validator;
100
101    // Build Validator from Schema with column types
102    let mut validator = Validator::new();
103    for (table_name, table_schema) in &schema.tables {
104        if table_schema.columns.is_empty() {
105            validator.add_table_name(table_name);
106            continue;
107        }
108
109        // Convert HashMap<String, ColumnType> to Vec<(&str, &str)> for validator
110        let type_strings: Vec<(String, String)> = table_schema
111            .columns
112            .iter()
113            .map(|(name, typ)| (name.clone(), typ.to_pg_type()))
114            .collect();
115        let cols_with_types: Vec<(&str, &str)> = type_strings
116            .iter()
117            .map(|(name, typ)| (name.as_str(), typ.as_str()))
118            .collect();
119        validator.add_table_with_types(table_name, &cols_with_types);
120    }
121    for view_name in &schema.views {
122        validator.add_table_name(view_name);
123    }
124
125    let mut diagnostics = Vec::new();
126    let mut seen_diagnostics: HashSet<String> = HashSet::new();
127    let mut push_unique = |diag: ValidationDiagnostic| {
128        let key = format!("{:?}|{}", diag.kind, diag.message);
129        if seen_diagnostics.insert(key) {
130            diagnostics.push(diag);
131        }
132    };
133    let (query_ir, query_ir_errors) = super::query_ir::build_query_ir(usages);
134
135    for err in query_ir_errors {
136        push_unique(ValidationDiagnostic::schema_error(err));
137    }
138
139    // Qualifier-scope violations found at scan time (GROUP BY / DISTINCT ON
140    // entries referencing relations the query never joins). These pass
141    // schema-existence checks but fail at runtime, so they are hard errors.
142    for usage in usages {
143        for err in &usage.scope_errors {
144            push_unique(ValidationDiagnostic::schema_error(format!(
145                "{}:{}: {}",
146                usage.file, usage.line, err
147            )));
148        }
149    }
150
151    for query in query_ir {
152        // Skip CTE alias refs — but only if the name doesn't also exist as a
153        // real schema table. If there's a collision (CTE alias == real table name),
154        // always validate to avoid false negatives.
155        if query.is_cte_ref && !schema.has_table(&query.table) {
156            continue;
157        }
158
159        // Skip unresolvable dynamic table names only.
160        // Static literals must be validated so typos are caught reliably.
161        if query.is_dynamic_table && !schema.has_table(&query.table) {
162            continue;
163        }
164
165        // ── Validate canonical query IR ───────────────────────────────
166        let mut cmd = query.cmd.clone();
167        if let Some(resolved_table) = schema.resolve_table_name(&query.table) {
168            cmd.table = resolved_table.to_string();
169        }
170        match validator.validate_command(&cmd) {
171            Ok(()) => {}
172            Err(validation_errors) => {
173                for e in validation_errors {
174                    push_unique(ValidationDiagnostic::schema_error(format!(
175                        "{}:{}: {}",
176                        query.file, query.line, e
177                    )));
178                }
179            }
180        }
181        for related_table in &query.related_tables {
182            let related_table_for_validation = schema
183                .resolve_table_name(related_table)
184                .unwrap_or(related_table);
185            if let Err(e) = validator.validate_table(related_table_for_validation) {
186                push_unique(ValidationDiagnostic::schema_error(format!(
187                    "{}:{}: {}",
188                    query.file, query.line, e
189                )));
190            }
191        }
192
193        // RLS Audit: warn if query targets RLS-enabled table without .with_rls()
194        if schema.is_rls_table(&query.table) && !query.has_rls {
195            push_unique(ValidationDiagnostic::rls_warning(format!(
196                "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
197                query.file,
198                query.line,
199                query.action.to_lowercase(),
200                query.table
201            )));
202        }
203
204        // RLS Audit (false-green): `.with_rls()` is present but the table
205        // registers NEITHER a tenant column NOR an `owner=` column, so the
206        // call injects nothing at runtime. `.with_rls_policy()` is the
207        // explicit opt-out for policy-delegated isolation and is exempt.
208        if schema.is_rls_table(&query.table) && query.has_rls && !query.rls_policy_delegated {
209            let scopes_nothing = schema
210                .table(&query.table)
211                .is_some_and(|t| !t.has_column("tenant_id") && t.owner_column.is_none());
212            if scopes_nothing {
213                push_unique(ValidationDiagnostic::rls_warning(format!(
214                    "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\").with_rls() scopes NOTHING — table is `rls` but declares no tenant_id column and no owner=<column>; isolation rests entirely on DB policies (use .with_rls_policy() to declare that, or add owner=)",
215                    query.file,
216                    query.line,
217                    query.action.to_lowercase(),
218                    query.table
219                )));
220            }
221        }
222
223        // SuperAdmin Audit: warn if file uses for_system_process() and queries
224        // a table that has tenant_id (tenant-scoped). This catches cases where
225        // acquire_with_rls(SuperAdmin) bypasses tenant isolation at the
226        // connection level — invisible to the per-command .with_rls() check.
227        if query.file_uses_super_admin {
228            let table_has_tenant_id = schema
229                .table(&query.table)
230                .map(|t| t.has_column("tenant_id"))
231                .unwrap_or(false);
232            if table_has_tenant_id
233                && !(query.has_explicit_tenant_scope || has_explicit_tenant_scope(&query.cmd))
234            {
235                push_unique(ValidationDiagnostic::rls_warning(format!(
236                    "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") in file using SuperAdminToken::for_system_process() \
237   — query has no explicit tenant scope (`tenant_id = ...` or `tenant_id IS NULL`) and may bypass tenant isolation. \
238Use claims-based scoping, `RlsContext::global()` for shared data, or add explicit tenant scope. If intentional, add `// qail:allow(super_admin)`.",
239                    query.file,
240                    query.line,
241                    query.action.to_lowercase(),
242                    query.table
243                )));
244            }
245        }
246    }
247
248    diagnostics
249}
250
251/// Run N+1 compile-time check.
252///
253/// Controlled by environment variables:
254/// - `QAIL_NPLUS1`: `off` | `warn` (default) | `deny`
255/// - `QAIL_NPLUS1_MAX_WARNINGS`: max warnings before truncation (default 50)
256fn run_nplus1_check(src_dir: &str) {
257    use super::nplus1_semantic::{NPlusOneSeverity, detect_n_plus_one_in_dir};
258
259    println!("cargo:rerun-if-env-changed=QAIL_NPLUS1");
260    println!("cargo:rerun-if-env-changed=QAIL_NPLUS1_MAX_WARNINGS");
261
262    let mode = std::env::var("QAIL_NPLUS1").unwrap_or_else(|_| "warn".to_string());
263
264    if mode == "off" || mode == "false" || mode == "0" {
265        return;
266    }
267
268    let max_warnings: usize = std::env::var("QAIL_NPLUS1_MAX_WARNINGS")
269        .ok()
270        .and_then(|s| s.parse().ok())
271        .unwrap_or(50);
272
273    let diagnostics = detect_n_plus_one_in_dir(Path::new(src_dir));
274
275    if diagnostics.is_empty() {
276        println!("cargo:warning=QAIL: N+1 scan clean ✓");
277        return;
278    }
279
280    let total = diagnostics.len();
281    let shown = total.min(max_warnings);
282
283    for diag in diagnostics.iter().take(shown) {
284        let prefix = match diag.severity {
285            NPlusOneSeverity::Error => "QAIL N+1 ERROR",
286            NPlusOneSeverity::Warning => "QAIL N+1",
287        };
288        println!("cargo:warning={}: {}", prefix, diag);
289    }
290
291    if total > shown {
292        println!(
293            "cargo:warning=QAIL N+1: ... and {} more (set QAIL_NPLUS1_MAX_WARNINGS to see all)",
294            total - shown
295        );
296    }
297
298    if mode == "deny" {
299        fail_build(format!(
300            "QAIL N+1: {} diagnostic(s) found. Fix N+1 patterns or set QAIL_NPLUS1=warn",
301            total
302        ));
303    }
304}
305
306fn configured_scan_roots() -> Vec<String> {
307    println!("cargo:rerun-if-env-changed=QAIL_SCAN_DIRS");
308
309    let raw = std::env::var("QAIL_SCAN_DIRS").unwrap_or_else(|_| "src".to_string());
310    let mut roots: Vec<String> = raw
311        .split(',')
312        .map(str::trim)
313        .filter(|s| !s.is_empty())
314        .map(ToOwned::to_owned)
315        .collect();
316
317    roots.sort();
318    roots.dedup();
319
320    if roots.is_empty() {
321        vec!["src".to_string()]
322    } else {
323        roots
324    }
325}
326
327fn scan_all_roots(scan_roots: &[String]) -> Vec<QailUsage> {
328    let mut usages = Vec::new();
329    for root in scan_roots {
330        usages.extend(scan_source_files(root));
331    }
332    usages
333}
334
335fn emit_scan_watchers(scan_roots: &[String]) {
336    for root in scan_roots {
337        println!("cargo:rerun-if-changed={}", root);
338    }
339}
340
341fn emit_validation_results(
342    diagnostics: &[ValidationDiagnostic],
343    usage_count: usize,
344    mode_label: &str,
345) {
346    let schema_errors: Vec<_> = diagnostics
347        .iter()
348        .filter(|d| matches!(d.kind, ValidationDiagnosticKind::SchemaError))
349        .collect();
350    let rls_warnings: Vec<_> = diagnostics
351        .iter()
352        .filter(|d| matches!(d.kind, ValidationDiagnosticKind::RlsWarning))
353        .collect();
354
355    for warning in &rls_warnings {
356        println!("cargo:warning=QAIL RLS: {}", warning.message);
357    }
358
359    if schema_errors.is_empty() {
360        println!(
361            "cargo:warning=QAIL: Validated {} queries against {} ✓",
362            usage_count, mode_label
363        );
364        return;
365    }
366
367    for error in &schema_errors {
368        println!("cargo:warning=QAIL ERROR: {}", error.message);
369    }
370
371    fail_build(format!(
372        "QAIL validation failed with {} errors",
373        schema_errors.len()
374    ));
375}
376
377fn run_nplus1_checks(scan_roots: &[String]) {
378    for root in scan_roots {
379        run_nplus1_check(root);
380    }
381}
382
383/// Run raw SQL policy check.
384///
385/// Controlled by environment variables:
386/// - `QAIL_SQL`: `off` (default) | `warn` | `deny`
387/// - `QAIL_SQL_MAX_WARNINGS`: max warnings before truncation (default 50)
388fn run_sql_policy_checks(scan_roots: &[String]) {
389    println!("cargo:rerun-if-env-changed=QAIL_SQL");
390    println!("cargo:rerun-if-env-changed=QAIL_SQL_MAX_WARNINGS");
391
392    let mode = std::env::var("QAIL_SQL").unwrap_or_else(|_| "off".to_string());
393    if mode == "off" || mode == "false" || mode == "0" {
394        return;
395    }
396
397    let max_warnings: usize = std::env::var("QAIL_SQL_MAX_WARNINGS")
398        .ok()
399        .and_then(|s| s.parse().ok())
400        .unwrap_or(50);
401
402    let mut all = Vec::new();
403    for root in scan_roots {
404        all.extend(super::sql_guard::detect_sql_usage_in_dir(Path::new(root)));
405    }
406
407    if all.is_empty() {
408        println!("cargo:warning=QAIL: SQL policy scan clean ✓");
409        return;
410    }
411
412    let total = all.len();
413    let shown = total.min(max_warnings);
414
415    for diag in all.iter().take(shown) {
416        println!(
417            "cargo:warning=QAIL SQL {}: {}:{}:{}: {}",
418            diag.code, diag.file, diag.line, diag.column, diag.message
419        );
420    }
421    if total > shown {
422        println!(
423            "cargo:warning=QAIL SQL: ... and {} more (set QAIL_SQL_MAX_WARNINGS to see all)",
424            total - shown
425        );
426    }
427
428    if mode == "deny" {
429        fail_build(format!(
430            "QAIL SQL policy failed with {} diagnostic(s). Migrate raw SQL to QAIL DSL or set QAIL_SQL=warn",
431            total
432        ));
433    }
434}
435
436/// The migrations directory the build merges into the pulled schema.
437///
438/// Mirrors the CLI's rule so a crate's build and its `qail migrate` runs read
439/// ONE set of files: walking up from the crate root, the nearest `qail.toml`
440/// that declares `[project].migrations_dir` wins, resolved against that file's
441/// directory; a nearer file that declares nothing does not mask an ancestor
442/// that does. Without any declaration the `migrations/` directory beside
443/// `schema.qail` is used, as before this rule existed.
444pub fn resolve_migrations_dir() -> String {
445    let start = std::env::var("CARGO_MANIFEST_DIR")
446        .map(std::path::PathBuf::from)
447        .or_else(|_| std::env::current_dir())
448        .unwrap_or_else(|_| std::path::PathBuf::from("."));
449    resolve_migrations_dir_from(&start)
450}
451
452/// [`resolve_migrations_dir`], starting the walk at `start`.
453pub fn resolve_migrations_dir_from(start: &Path) -> String {
454    for dir in start.ancestors() {
455        let config = dir.join("qail.toml");
456        if !config.is_file() {
457            continue;
458        }
459        match declared_migrations_dir(&config) {
460            Ok(Some(declared)) => return dir.join(declared).to_string_lossy().into_owned(),
461            Ok(None) => continue,
462            Err(e) => {
463                println!("cargo:warning=QAIL: {}: {}", config.display(), e);
464                continue;
465            }
466        }
467    }
468    "migrations".to_string()
469}
470
471/// `[project].migrations_dir` from one `qail.toml`, read without expanding
472/// `${VAR}` references: a build script has no DATABASE_URL to expand, and the
473/// key it needs never carries one.
474fn declared_migrations_dir(config: &Path) -> Result<Option<String>, String> {
475    let raw = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
476    let value: toml::Value = toml::from_str(&raw).map_err(|e| e.to_string())?;
477    match value
478        .get("project")
479        .and_then(|project| project.get("migrations_dir"))
480    {
481        None => Ok(None),
482        Some(declared) => declared
483            .as_str()
484            .map(|s| Some(s.to_string()))
485            .ok_or_else(|| "project.migrations_dir must be a string".to_string()),
486    }
487}
488
489/// Build validation entrypoint for build.rs.
490/// Failures are reported via `cargo:warning` and process exit code 1.
491pub fn validate() {
492    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
493        if Path::new("schema.qail").exists() || Path::new("schema").is_dir() {
494            "schema".to_string()
495        } else {
496            "false".to_string()
497        }
498    });
499    let migrations_dir = resolve_migrations_dir();
500
501    match mode.as_str() {
502        "schema" => {
503            let scan_roots = configured_scan_roots();
504            if let Ok(source) = crate::schema_source::resolve_schema_source("schema.qail") {
505                for path in source.watch_paths() {
506                    println!("cargo:rerun-if-changed={}", path.display());
507                }
508            } else {
509                // Keep backward-compatible watcher even if resolution fails;
510                // parse step below will emit the concrete error.
511                println!("cargo:rerun-if-changed=schema.qail");
512                println!("cargo:rerun-if-changed=schema");
513            }
514            println!("cargo:rerun-if-changed={}", migrations_dir);
515            println!("cargo:rerun-if-changed=qail.toml");
516            println!("cargo:rerun-if-env-changed=QAIL");
517            emit_scan_watchers(&scan_roots);
518
519            match Schema::parse_file("schema.qail") {
520                Ok(mut schema) => {
521                    // Merge pending migrations with pulled schema
522                    let merged = match schema.merge_migrations(&migrations_dir) {
523                        Ok(n) => n,
524                        Err(e) => {
525                            println!("cargo:warning=QAIL: Migration merge failed: {}", e);
526                            0
527                        }
528                    };
529                    if merged > 0 {
530                        println!(
531                            "cargo:warning=QAIL: Merged {} schema changes from migrations",
532                            merged
533                        );
534                    }
535
536                    let usages = scan_all_roots(&scan_roots);
537                    let diagnostics = validate_against_schema_diagnostics(&schema, &usages);
538                    emit_validation_results(&diagnostics, usages.len(), "schema source");
539
540                    // ── N+1 detection ──────────────────────────────────────
541                    run_nplus1_checks(&scan_roots);
542                    run_sql_policy_checks(&scan_roots);
543                }
544                Err(e) => {
545                    fail_build(format!("QAIL: Failed to parse schema source: {}", e));
546                }
547            }
548        }
549        "live" => {
550            let scan_roots = configured_scan_roots();
551            println!("cargo:rerun-if-env-changed=QAIL");
552            println!("cargo:rerun-if-env-changed=DATABASE_URL");
553            emit_scan_watchers(&scan_roots);
554
555            // Get DATABASE_URL for qail pull
556            let db_url = match std::env::var("DATABASE_URL") {
557                Ok(url) => url,
558                Err(_) => {
559                    fail_build("QAIL=live requires DATABASE_URL environment variable");
560                }
561            };
562
563            // Step 1: Run qail pull to update schema.qail
564            println!("cargo:warning=QAIL: Pulling schema from live database...");
565
566            let pull_result = std::process::Command::new("qail")
567                .args(["pull", &db_url])
568                .output();
569
570            match pull_result {
571                Ok(output) => {
572                    if !output.status.success() {
573                        let stderr = String::from_utf8_lossy(&output.stderr);
574                        fail_build(format!("QAIL: Failed to pull schema: {}", stderr));
575                    }
576                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
577                }
578                Err(e) => {
579                    // qail CLI not found, try using cargo run
580                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
581
582                    let cargo_result = std::process::Command::new("cargo")
583                        .args(["run", "-p", "qail", "--", "pull", &db_url])
584                        .current_dir(
585                            std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()),
586                        )
587                        .output();
588
589                    match cargo_result {
590                        Ok(output) if output.status.success() => {
591                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
592                        }
593                        _ => {
594                            fail_build(format!(
595                                "QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema",
596                                e
597                            ));
598                        }
599                    }
600                }
601            }
602
603            // Step 2: Parse the updated schema and validate
604            match Schema::parse_file("schema.qail") {
605                Ok(mut schema) => {
606                    // Merge pending migrations (in case live DB doesn't have them yet)
607                    let merged = match schema.merge_migrations(&migrations_dir) {
608                        Ok(n) => n,
609                        Err(e) => {
610                            println!("cargo:warning=QAIL: Migration merge failed: {}", e);
611                            0
612                        }
613                    };
614                    if merged > 0 {
615                        println!(
616                            "cargo:warning=QAIL: Merged {} schema changes from pending migrations",
617                            merged
618                        );
619                    }
620
621                    let usages = scan_all_roots(&scan_roots);
622                    let diagnostics = validate_against_schema_diagnostics(&schema, &usages);
623                    emit_validation_results(&diagnostics, usages.len(), "live database");
624
625                    // ── N+1 detection ──────────────────────────────────────
626                    run_nplus1_checks(&scan_roots);
627                    run_sql_policy_checks(&scan_roots);
628                }
629                Err(e) => {
630                    fail_build(format!("QAIL: Failed to parse schema after pull: {}", e));
631                }
632            }
633        }
634        "false" | "off" | "0" => {
635            println!("cargo:rerun-if-env-changed=QAIL");
636            // Silently skip validation
637        }
638        _ => {
639            fail_build(format!(
640                "QAIL: Unknown mode '{}'. Use: schema, live, or false",
641                mode
642            ));
643        }
644    }
645}