1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ValidationDiagnosticKind {
56 SchemaError,
58 RlsWarning,
60}
61
62#[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
94pub fn validate_against_schema_diagnostics(
96 schema: &Schema,
97 usages: &[QailUsage],
98) -> Vec<ValidationDiagnostic> {
99 use crate::validator::Validator;
100
101 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 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 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 if query.is_cte_ref && !schema.has_table(&query.table) {
156 continue;
157 }
158
159 if query.is_dynamic_table && !schema.has_table(&query.table) {
162 continue;
163 }
164
165 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 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 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 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
251fn 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
383fn 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
436pub 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
452pub 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
471fn 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
489pub 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 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 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 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 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 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 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 match Schema::parse_file("schema.qail") {
605 Ok(mut schema) => {
606 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 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 }
638 _ => {
639 fail_build(format!(
640 "QAIL: Unknown mode '{}'. Use: schema, live, or false",
641 mode
642 ));
643 }
644 }
645}