aurora-lint 0.4.336

aurora-lint - a fast CERT C static analyzer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
#![allow(clippy::only_used_in_recursion)]
#![allow(clippy::needless_borrow)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::collapsible_if)]

pub mod prelude;

mod analyze;
mod export;
mod files;
mod manifest;
mod parser;
mod progress;
mod rules;
mod toolchain;
#[cfg(feature = "tui")]
mod ui;
mod utility;

use crate::manifest::Severity;
use crate::prelude::*;
use anyhow::Context;
use clap::{Arg, Command};

use analyze::{analyze_project, handle_generate_suppression};
use export::export_all_violations;
use files::ProjectSource;
use progress::CLIProgressReporter;
#[cfg(feature = "tui")]
use ui::TerminalUI;

use std::collections::HashSet;
use std::fs;
use std::path::Path;

/// Embedded at compile time so `aurora-lint` works when installed outside the repo
/// checkout (e.g. via `cargo install`), where `rules_templates/rules-all.toml`
/// doesn't exist on disk relative to the binary.
const DEFAULT_MANIFEST_TOML: &str = include_str!("../rules_templates/rules-all.toml");

fn load_manifest(manifest_path: Option<&String>) -> Result<RuleManifest> {
    match manifest_path {
        Some(path) => RuleManifest::load(path),
        None => RuleManifest::from_toml_str(DEFAULT_MANIFEST_TOML)
            .context("Failed to parse built-in default manifest"),
    }
}

fn main() {
    let result = run();
    match result {
        Ok(exit_code) => std::process::exit(exit_code),
        Err(e) => {
            eprintln!("Error: {:#}", e);
            std::process::exit(2);
        }
    }
}

fn run() -> Result<i32> {
    let matches = Command::new("aurora-lint")
        .about("aurora-lint - a fast CERT C static analyzer")
        .version(env!("CARGO_PKG_VERSION"))
        .arg(
            Arg::new("path")
                .help("Path to the file, directory, or git repository to analyze")
                .value_name("PATH")
                .default_value(".")
                .index(1),
        )
        .arg(
            Arg::new("manifest")
                .long("manifest")
                .short('m')
                .help("Path to the rules manifest file (defaults to the built-in manifest)")
                .value_name("FILE"),
        )
        .arg(
            Arg::new("interactive")
                .long("interactive")
                .short('i')
                .help("Run in interactive terminal UI mode (requires building with `--features tui`)")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("export")
                .long("export")
                .short('e')
                .help("Export violations to file (CSV, Excel, JSON, or SARIF based on extension)")
                .value_name("FILE"),
        )
        .arg(
            Arg::new("generate_suppression")
                .long("generate-suppression")
                .help("Generate suppression comment for a specific file:line:rule")
                .value_name("FILE:LINE:RULE")
                .conflicts_with("interactive")
                .conflicts_with("export"),
        )
        .arg(
            Arg::new("directories")
                .long("directories")
                .short('d')
                .help("Additional directories to pre-scan for function definitions (cross-file context)")
                .value_name("DIR")
                .action(clap::ArgAction::Append),
        )
        .arg(
            Arg::new("include_paths")
                .long("include-path")
                .short('I')
                .help("Include search paths for resolving #include directives (like compiler -I flag)")
                .value_name("DIR")
                .action(clap::ArgAction::Append),
        )
        .arg(
            Arg::new("compile_commands")
                .long("compile-commands")
                .help("Read include search paths and -D macros from a compile_commands.json (optional; improves cross-file macro/header coverage for projects that already have a compile database)")
                .value_name("FILE"),
        )
        .arg(
            Arg::new("system_includes")
                .long("system-includes")
                .help("Also search the compiler's own built-in system header directories, found by asking it (cc -E -Wp,-v -). Off by default: it spawns a compiler. Works with or without --compile-commands, which can never contain these paths")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("exclude")
                .long("exclude")
                .help("Exclude files matching this path glob from analysis (repeatable, e.g. --exclude '**/onelua.c' --exclude 'testes/**')")
                .value_name("GLOB")
                .action(clap::ArgAction::Append),
        )
        .arg(
            Arg::new("fail_on_violation")
                .long("fail-on-violation")
                .help("Exit with code 1 if any violations are found")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("fail_on_severity")
                .long("fail-on-severity")
                .help("Exit with code 1 if any violation meets or exceeds this severity")
                .value_name("LEVEL")
                .value_parser(["Low", "Medium", "High", "Critical"]),
        )
        .arg(
            Arg::new("min_severity")
                .long("min-severity")
                .help("Only report violations at or above this severity")
                .value_name("LEVEL")
                .value_parser(["Low", "Medium", "High", "Critical"]),
        )
        .arg(
            Arg::new("rules")
                .long("rules")
                .help("Only report violations from these rules (comma-separated)")
                .value_name("RULE1,RULE2,..."),
        )
        .arg(
            Arg::new("diff")
                .long("diff")
                .help("Only analyze modified/new C files (git diff)")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("suppress_file")
                .long("suppress-file")
                .help("Path to suppress.toml file (auto-detected in project root as suppress.toml, then the legacy .aurora-lint-suppress.toml / .sqc-suppress.toml, if not specified)")
                .value_name("FILE"),
        )
        .arg(
            Arg::new("verbose")
                .long("verbose")
                .short('v')
                .help("Increase output verbosity (-v: per-rule scanning progress)")
                .action(clap::ArgAction::Count),
        )
        .arg(
            Arg::new("save_prescan")
                .long("save-prescan")
                .help("Save prescan context to a binary cache file (for CI/CD caching)")
                .value_name("FILE"),
        )
        .arg(
            Arg::new("load_prescan")
                .long("load-prescan")
                .help("Load prescan context from cache instead of scanning -d directories")
                .value_name("FILE"),
        )
        .arg(
            Arg::new("jobs")
                .long("jobs")
                .short('j')
                .help("Number of parallel analysis threads (0 = auto-detect, 1 = sequential)")
                .value_name("N")
                .default_value("0")
                .value_parser(clap::value_parser!(usize)),
        )
        .arg(
            Arg::new("detect_relevance")
                .long("detect-relevance")
                .help("Detect categorically-inapplicable rule classes (CON*/WIN*) in PATH and -d directories, then write a relevance-gated manifest with --write-manifest. Does not run an analysis.")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("write_manifest")
                .long("write-manifest")
                .help("With --detect-relevance: write the generated manifest here (requires --detect-relevance)")
                .value_name("FILE")
                .requires("detect_relevance"),
        )
        .get_matches();

    let path = matches.get_one::<String>("path").unwrap();
    let manifest_path = matches.get_one::<String>("manifest");
    let interactive = matches.get_flag("interactive");
    let export_file = matches.get_one::<String>("export");
    let generate_suppression = matches.get_one::<String>("generate_suppression");
    let directories: Vec<String> = matches
        .get_many::<String>("directories")
        .map(|vals| vals.cloned().collect())
        .unwrap_or_default();
    let mut include_paths: Vec<String> = matches
        .get_many::<String>("include_paths")
        .map(|vals| vals.cloned().collect())
        .unwrap_or_default();
    // A compile database contributes its build's search paths *after* any
    // explicit -I, so a hand-passed path keeps priority in the search order.
    let compile_db = match matches.get_one::<String>("compile_commands") {
        Some(db_path) => {
            let db = analyze::compile_commands::CompileDb::load(std::path::Path::new(db_path))?;
            eprintln!(
                "Loaded compile database: {} {}, {} include paths, {} macro definitions ({})",
                db.entry_count,
                if db.entry_count == 1 {
                    "entry"
                } else {
                    "entries"
                },
                db.include_paths.len(),
                db.defines.len(),
                db_path,
            );
            // A compile database stores absolute paths from the machine that
            // built the project. If it was generated elsewhere, those paths are
            // not here, and resolve_includes would silently skip every header
            // rather than fail — an expensive no-op that still looks like it
            // worked. Say so loudly instead.
            let missing = db.missing_include_paths();
            if !missing.is_empty() {
                eprintln!(
                    "Warning: {} of {} compile-database include paths do not exist on this \
                     machine (e.g. {}). If this database was generated on another host or in a \
                     container, its paths need remapping — header resolution will silently skip \
                     them.",
                    missing.len(),
                    db.include_paths.len(),
                    missing[0],
                );
            }
            for p in &db.include_paths {
                if !include_paths.contains(p) {
                    include_paths.push(p.clone());
                }
            }
            Some(db)
        }
        None => None,
    };
    // The compiler's built-in directories go last: they are the lowest-priority
    // half of a real compiler's search order, and anything the user or the
    // build named explicitly should still win.
    if matches.get_flag("system_includes") {
        // A compile database records which compiler built each file, which is
        // the right one to ask. Without one there is nothing to go on but the
        // platform default.
        let compilers: Vec<String> = match &compile_db {
            Some(db) if !db.compilers.is_empty() => db.compilers.clone(),
            _ => vec![analyze::system_includes::DEFAULT_COMPILER.to_string()],
        };
        let sys = analyze::system_includes::query(&compilers);
        let mut added = 0usize;
        for p in &sys.paths {
            if !include_paths.contains(p) {
                include_paths.push(p.clone());
                added += 1;
            }
        }
        eprintln!(
            "System include directories: {} from {} ({})",
            added,
            if sys.queried.len() == 1 {
                "compiler".to_string()
            } else {
                format!("{} compilers", sys.queried.len())
            },
            if sys.queried.is_empty() {
                "none answered".to_string()
            } else {
                sys.queried.join(", ")
            },
        );
        // A compiler that could not be asked is reported rather than swallowed:
        // otherwise a cross-compiler missing from the analysis host looks
        // identical to one that genuinely has no system directories.
        for (compiler, reason) in &sys.failed {
            eprintln!("Warning: could not query '{compiler}' for its system include directories ({reason}); its built-in headers stay out of reach.");
        }
    }
    let excludes: Vec<String> = matches
        .get_many::<String>("exclude")
        .map(|vals| vals.cloned().collect())
        .unwrap_or_default();
    let fail_on_violation = matches.get_flag("fail_on_violation");
    let fail_on_severity: Option<Severity> = matches
        .get_one::<String>("fail_on_severity")
        .map(|s| s.parse().expect("clap validated severity"));
    let min_severity: Option<Severity> = matches
        .get_one::<String>("min_severity")
        .map(|s| s.parse().expect("clap validated severity"));
    let rule_filter: Option<HashSet<String>> = matches
        .get_one::<String>("rules")
        .map(|s| s.split(',').map(|r| r.trim().to_string()).collect());
    let diff_only = matches.get_flag("diff");
    let suppress_file = matches.get_one::<String>("suppress_file");
    let verbosity = matches.get_count("verbose");
    let save_prescan = matches.get_one::<String>("save_prescan");
    let load_prescan = matches.get_one::<String>("load_prescan");
    let jobs = *matches.get_one::<usize>("jobs").unwrap();
    let detect_relevance = matches.get_flag("detect_relevance");
    let write_manifest = matches.get_one::<String>("write_manifest");

    if detect_relevance {
        let mut corpus = vec![path.clone()];
        corpus.extend(directories.iter().cloned());
        let profile = analyze::relevance::detect(&corpus)?;
        println!(
            "Detected: threading={}, windows={}, max_c_standard={:?}",
            profile.has_threading, profile.has_windows, profile.max_c_standard
        );

        let base_manifest = load_manifest(manifest_path)?;
        let generated = analyze::relevance::generate_manifest_toml(&base_manifest, &profile);

        match write_manifest {
            Some(out_path) => {
                fs::write(out_path, &generated)?;
                println!("Wrote relevance-gated manifest to: {}", out_path);
            }
            None => print!("{}", generated),
        }
        return Ok(0);
    }

    // Verify the path and determine source type
    let project_source = ProjectSource::open(path)?;
    println!("Detected {} at: {}", project_source.source_type(), path);

    let mut manifest = load_manifest(manifest_path)?;
    if let Some(ref rules) = rule_filter {
        manifest.restrict_to(rules);
    }

    // Handle suppression generation
    if let Some(gen_spec) = generate_suppression {
        handle_generate_suppression(gen_spec)?;
        return Ok(0);
    }

    if interactive {
        #[cfg(feature = "tui")]
        {
            let mut ui = TerminalUI::new(path, manifest, &directories, &include_paths)?;
            ui.run()?;
            return Ok(0);
        }
        #[cfg(not(feature = "tui"))]
        {
            anyhow::bail!(
                "aurora-lint was built without the `tui` feature; rebuild with `cargo build --features tui` to use --interactive"
            );
        }
    }

    println!("Analyzing {} at: {}", project_source.source_type(), path);
    println!(
        "Using manifest: {}",
        manifest_path
            .map(String::as_str)
            .unwrap_or("<built-in default>")
    );

    if diff_only {
        println!("Mode: diff-only (analyzing modified files)");
    }

    // Create progress reporter for CLI
    let progress_reporter = CLIProgressReporter::new(verbosity);

    // Perform analysis with progress reporting
    let results = analyze_project(
        &project_source,
        &manifest,
        Some(&progress_reporter),
        &directories,
        &include_paths,
        &excludes,
        diff_only,
        suppress_file.map(|s| s.as_str()),
        save_prescan.map(|s| s.as_str()),
        load_prescan.map(|s| s.as_str()),
        compile_db.as_ref(),
        jobs,
    )?;

    let mut violations = results.violations;
    let suppressed = results.suppressed;

    // Post-analysis filtering
    if let Some(ref min_sev) = min_severity {
        violations.retain(|v| v.severity >= *min_sev);
    }

    // Print violations to stdout
    let cwd = std::env::current_dir().unwrap_or_default();
    for v in &violations {
        let display_path = Path::new(&v.file_path)
            .strip_prefix(&cwd)
            .map(|p| p.to_string_lossy().into_owned())
            .unwrap_or_else(|_| v.file_path.clone());
        let sev = if v.needs_manual_review() {
            format!("{}?", v.severity.to_string().to_lowercase())
        } else {
            v.severity.to_string().to_lowercase()
        };
        println!(
            "{}:{}:{}: [{}] {}: {}",
            display_path, v.line, v.column, sev, v.rule_id, v.message
        );
        if let Some(ref hint) = v.suggestion {
            println!("  note: {}", hint);
        }
    }

    // Export to file if requested (includes both active and suppressed violations)
    if let Some(export_path) = export_file {
        export_all_violations(&violations, &suppressed, export_path, &manifest)?;
        println!(
            "Exported {} violations ({} suppressed) to: {}",
            violations.len(),
            suppressed.len(),
            export_path
        );
    }

    // Print summary
    println!(
        "Total violations: {} ({} suppressed)",
        violations.len(),
        suppressed.len()
    );

    // Determine exit code (only unsuppressed violations count)
    if fail_on_violation && !violations.is_empty() {
        return Ok(1);
    }
    if let Some(ref threshold) = fail_on_severity {
        if violations.iter().any(|v| v.severity >= *threshold) {
            return Ok(1);
        }
    }

    Ok(0)
}