diff --git a/README.md b/README.md index 74f1d105..56785b8e 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,15 @@ Same engine under all of them (it cross-compiles to WASM for the browser path), ```bash # 1. install cargo install sanctifier-cli +# (skip Z3 with: cargo install sanctifier-cli --no-default-features) # 2. scan -sanctifier analyze ./contracts/my-token +sanctifier analyze ./contracts -# 3. ship a badge for your README +# 3. integrate into CI — exit 1 on high/critical findings +sanctifier analyze ./contracts --exit-code --format sarif > sanctifier.sarif + +# 4. ship a security badge for your README sanctifier analyze . --format json > report.json sanctifier badge --report report.json --svg-output sanctifier.svg ``` @@ -188,21 +192,32 @@ Skip Z3 entirely with `cargo install sanctifier-cli --no-default-features` — e ## CLI reference ```bash -sanctifier analyze [PATH] [--format text|json] [--limit BYTES] [--webhook-url URL]... -sanctifier diff [PATH] --baseline +# Full analysis (most flags shown; all have defaults) +sanctifier analyze [PATH] + --format text|json|sarif|ndjson # output format (default: text) + --limit BYTES # ledger entry size cap (default: 64000) + --timeout SECS # per-file timeout, 0 = none (default: 30) + --exit-code # exit 1 when findings meet threshold + --min-severity critical|high|medium|low # threshold for --exit-code (default: high) + --profile strict|lenient|ci|audit # preset overrides --exit-code/--min-severity + --webhook-url URL # POST results here on completion (repeatable) + --no-cache # skip incremental analysis cache + +# Other commands +sanctifier diff [PATH] --baseline # new/resolved findings vs baseline sanctifier watch [PATH] # re-runs on file change -sanctifier workspace [PATH] # cargo-workspace–aware scan +sanctifier workspace [PATH] # cargo-workspace-aware scan sanctifier callgraph [PATH] --output callgraph.dot sanctifier badge --report report.json --svg-output sanctifier.svg sanctifier fix [PATH] --rule S003 # apply patcher fixes sanctifier verify [PATH] # Z3-only invariant pass -sanctifier deploy ... # ship the runtime guard +sanctifier deploy [PATH] --network testnet|futurenet|mainnet sanctifier doctor # environment diagnostics -sanctifier init # generate .sanctify.toml +sanctifier init [PATH] # scaffold project + .sanctify.toml sanctifier update # self-update with checksum check ``` -Every subcommand respects `--format json` for machine consumption. +Every subcommand accepts `--format json` for machine consumption. Use `--format ndjson` with `analyze` for streaming line-delimited output (one JSON object per finding, final `{"event":"done"}`). --- @@ -212,14 +227,20 @@ Every subcommand respects `--format json` for machine consumption. ```jsonc { - "metadata": { "version": "0.1.0", "format": "sanctifier-ci-v1", "timestamp": "…" }, - "summary": { "critical": 0, "high": 0, "medium": 2, "low": 0 }, - "findings": { "auth_gaps": [...], "arithmetic_issues": [...], "storage_collisions": [...] }, - "vuln_db_matches": [{ "id": "SOL-2024-002", "severity": "CRITICAL", "matched_at": "…" }], - "schema_version": "1.0.0" + "metadata": { "version": "0.1.0", "format": "sanctifier-ci-v1", "timestamp": "…" }, + "summary": { "critical": 0, "high": 1, "medium": 2, "low": 0 }, + "error_codes": ["S001", "S003"], + "auth_gaps": [{ "location": "src/lib.rs:42", "message": "missing require_auth" }], + "arithmetic_issues": [{ "location": "src/lib.rs:30", "operator": "-" }], + "rule_violations": [{ "rule_name": "require_auth_for_args", "severity": "Error", + "location": "src/lib.rs:set_admin", "message": "…" }], + "vuln_db_matches": [{ "id": "SOL-2024-002", "severity": "CRITICAL", "matched_at": "src/lib.rs:55" }], + "schema_version": "1.0.0" } ``` +`--format sarif` produces a SARIF 2.1.0 document compatible with GitHub code-scanning. `--format ndjson` streams one object per finding so large scans can be processed incrementally. + SARIF 2.1.0 output is canonical for GitHub code-scanning and any SAST aggregator. --- diff --git a/tooling/sanctifier-cli/src/commands/deploy.rs b/tooling/sanctifier-cli/src/commands/deploy.rs index c328534a..c2f94050 100644 --- a/tooling/sanctifier-cli/src/commands/deploy.rs +++ b/tooling/sanctifier-cli/src/commands/deploy.rs @@ -1,11 +1,14 @@ #![allow(dead_code)] use crate::commands::color as c; +use crate::errors::SanctifierError; use clap::Args; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +const VALID_NETWORKS: &[&str] = &["testnet", "futurenet", "mainnet"]; + #[derive(Args, Debug)] pub struct DeployArgs { /// Path to the contract to deploy @@ -43,26 +46,26 @@ pub struct DeploymentConfig { pub fn exec(args: DeployArgs) -> anyhow::Result<()> { let is_json = args.output_format == "json"; + // Validate network name before doing any I/O. + if !VALID_NETWORKS.contains(&args.network.as_str()) { + return Err(SanctifierError::invalid_network(&args.network).into()); + } + // Validate contract path if !args.contract_path.exists() { - eprintln!( - "{} Error: Contract path not found: {}", - c::red("❌"), - args.contract_path.display() - ); - std::process::exit(1); + return Err(SanctifierError::path_not_found(&args.contract_path).into()); } - // Get secret key from argument or environment - let secret_key = match args.secret_key { - Some(key) => key, - None => std::env::var("SOROBAN_SECRET_KEY").unwrap_or_default(), - }; - - if secret_key.is_empty() { - eprintln!(" Set via --secret-key or SOROBAN_SECRET_KEY environment variable"); - std::process::exit(1); - } + // Resolve secret key: explicit flag wins, then env var. + let secret_key = args + .secret_key + .filter(|k| !k.is_empty()) + .or_else(|| { + std::env::var("SOROBAN_SECRET_KEY") + .ok() + .filter(|k| !k.is_empty()) + }) + .ok_or_else(SanctifierError::missing_credentials)?; let _ = is_json; diff --git a/tooling/sanctifier-cli/src/errors.rs b/tooling/sanctifier-cli/src/errors.rs index 8d2ef81e..9c9acb40 100644 --- a/tooling/sanctifier-cli/src/errors.rs +++ b/tooling/sanctifier-cli/src/errors.rs @@ -28,6 +28,10 @@ pub enum ErrorCode { E008, /// Dry-run mode: no changes were made (informational, not a failure). E009, + /// Deploy target network is not a recognised Soroban network handle. + E010, + /// Soroban credentials are missing or cannot be sourced from the environment. + E011, } impl ErrorCode { @@ -42,6 +46,8 @@ impl ErrorCode { ErrorCode::E007 => "E007", ErrorCode::E008 => "E008", ErrorCode::E009 => "E009", + ErrorCode::E010 => "E010", + ErrorCode::E011 => "E011", } } } @@ -189,6 +195,28 @@ impl SanctifierError { ) } + pub fn invalid_network(network: &str) -> Self { + Self::new( + ErrorCode::E010, + format!("'{}' is not a recognised Soroban network", network), + "Use one of: testnet, futurenet, mainnet. \ + See https://developers.stellar.org/network/soroban-rpc for RPC endpoints." + .to_string(), + ) + } + + pub fn missing_credentials() -> Self { + Self::new( + ErrorCode::E011, + "Soroban secret key not provided", + "Pass --secret-key or set the SOROBAN_SECRET_KEY environment variable. \ + Generate a funded testnet key with: \ + `stellar keys generate --global my-key --network testnet && \ + stellar keys fund my-key --network testnet`." + .to_string(), + ) + } + pub fn dry_run_no_changes(command: &str) -> Self { Self::new( ErrorCode::E009, @@ -306,9 +334,46 @@ mod tests { (ErrorCode::E007, "E007"), (ErrorCode::E008, "E008"), (ErrorCode::E009, "E009"), + (ErrorCode::E010, "E010"), + (ErrorCode::E011, "E011"), ]; for (code, expected) in codes { assert_eq!(code.as_str(), expected); } } + + #[test] + fn invalid_network_hint_lists_valid_options() { + let err = SanctifierError::invalid_network("devnet"); + assert!(err.message.contains("devnet"), "message should echo the bad value"); + assert!(err.hint.contains("testnet"), "hint should list valid networks"); + assert!(err.hint.contains("futurenet"), "hint should list valid networks"); + assert!(err.hint.contains("mainnet"), "hint should list valid networks"); + assert_eq!(err.code, ErrorCode::E010); + } + + #[test] + fn missing_credentials_hint_mentions_env_var_and_generate_command() { + let err = SanctifierError::missing_credentials(); + assert!(err.hint.contains("SOROBAN_SECRET_KEY")); + assert!(err.hint.contains("--secret-key")); + assert!(err.hint.contains("generate")); + assert_eq!(err.code, ErrorCode::E011); + } + + #[test] + fn e010_display_includes_code() { + let err = SanctifierError::invalid_network("staging"); + let s = err.to_string(); + assert!(s.contains("[E010]")); + assert!(s.contains("hint:")); + } + + #[test] + fn e011_display_includes_code() { + let err = SanctifierError::missing_credentials(); + let s = err.to_string(); + assert!(s.contains("[E011]")); + assert!(s.contains("hint:")); + } } diff --git a/tooling/sanctifier-cli/src/logging.rs b/tooling/sanctifier-cli/src/logging.rs index 699fbf65..5f78e537 100644 --- a/tooling/sanctifier-cli/src/logging.rs +++ b/tooling/sanctifier-cli/src/logging.rs @@ -6,6 +6,16 @@ pub enum LogOutput { Json, } +/// Build an `EnvFilter` from a verbosity string without touching global state. +/// +/// Accepts standard `tracing` directive syntax: `"warn"`, `"debug"`, +/// `"sanctifier=trace,warn"`, etc. Useful for measuring filter-build overhead +/// in benchmarks without side-effects. +pub fn make_filter(verbosity: &str) -> anyhow::Result { + EnvFilter::try_new(verbosity) + .map_err(|e| anyhow::anyhow!("invalid log filter '{}': {}", verbosity, e)) +} + pub fn init(output: LogOutput) -> anyhow::Result<()> { let env_filter = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new("warn")) @@ -33,3 +43,62 @@ pub fn init(output: LogOutput) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + /// Budget for building a single `EnvFilter` — should be well under 50 ms + /// even on a heavily loaded CI runner. + const FILTER_BUILD_BUDGET_MS: u128 = 50; + + #[test] + fn make_filter_accepts_standard_levels() { + for level in &["error", "warn", "info", "debug", "trace"] { + assert!( + make_filter(level).is_ok(), + "make_filter('{}') should succeed", + level + ); + } + } + + #[test] + fn make_filter_accepts_crate_qualified_directives() { + assert!(make_filter("sanctifier=debug,warn").is_ok()); + assert!(make_filter("sanctifier_cli=trace").is_ok()); + } + + #[test] + fn make_filter_builds_within_budget_for_all_modes() { + let cases = [ + ("error", "quiet/error mode"), + ("warn", "default mode"), + ("info", "info mode"), + ("debug", "verbose mode"), + ("sanctifier=debug,warn", "crate-scoped verbose"), + ]; + for (directive, label) in cases { + let start = Instant::now(); + let _ = make_filter(directive).expect("filter must build"); + let elapsed = start.elapsed().as_millis(); + assert!( + elapsed < FILTER_BUILD_BUDGET_MS, + "make_filter for {label} took {elapsed}ms, budget {FILTER_BUILD_BUDGET_MS}ms" + ); + } + } + + #[test] + fn make_filter_json_log_mode_builds_within_budget() { + // JSON log mode uses the same EnvFilter; verify it meets the same budget. + let start = Instant::now(); + let _ = make_filter("debug").expect("filter must build"); + let elapsed = start.elapsed().as_millis(); + assert!( + elapsed < FILTER_BUILD_BUDGET_MS, + "JSON-mode filter build took {elapsed}ms, budget {FILTER_BUILD_BUDGET_MS}ms" + ); + } +} diff --git a/tooling/sanctifier-cli/tests/callgraph_tests.rs b/tooling/sanctifier-cli/tests/callgraph_tests.rs new file mode 100644 index 00000000..ede5c3fe --- /dev/null +++ b/tooling/sanctifier-cli/tests/callgraph_tests.rs @@ -0,0 +1,259 @@ +/// Integration / e2e tests for `sanctifier callgraph` (#526). +/// +/// Covers: empty contract, invoke_contract_check variant, multiple callers, +/// nonexistent path, default output file, and DOT syntax correctness. +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::tempdir; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn write_contract(dir: &tempfile::TempDir, name: &str, src: &str) -> std::path::PathBuf { + let path = dir.path().join(name); + fs::write(&path, src).unwrap(); + path +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +/// A contract with no `invoke_contract` calls produces a graph with 0 edges. +#[test] +fn callgraph_empty_contract_has_no_edges() { + let dir = tempdir().unwrap(); + let src = write_contract(&dir, "empty.rs", "pub struct MyContract;\nimpl MyContract {}"); + let dot = dir.path().join("out.dot"); + + Command::cargo_bin("sanctifier") + .unwrap() + .args(["callgraph"]) + .arg(&src) + .arg("--output") + .arg(&dot) + .assert() + .success() + .stdout(predicate::str::contains("0 edge(s)")); + + let content = fs::read_to_string(&dot).unwrap(); + assert!(content.contains("digraph ContractCallGraph"), "must have digraph header"); + assert!( + !content.contains(" -> "), + "empty contract must not produce edges" + ); +} + +/// `invoke_contract_check` is a second call variant that must also be detected. +#[test] +fn callgraph_detects_invoke_contract_check_variant() { + let dir = tempdir().unwrap(); + let src = write_contract( + &dir, + "checked.rs", + r#" +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; + +#[contract] +pub struct Checker; + +#[contractimpl] +impl Checker { + pub fn safe_call(env: Env, target: Address) { + let _result = env.invoke_contract_check::<()>(target, &Symbol::new(&env, "ping"), ()); + } +} +"#, + ); + let dot = dir.path().join("checked.dot"); + + Command::cargo_bin("sanctifier") + .unwrap() + .args(["callgraph"]) + .arg(&src) + .arg("--output") + .arg(&dot) + .assert() + .success() + .stdout(predicate::str::contains("1 edge(s)")); + + let content = fs::read_to_string(&dot).unwrap(); + assert!(content.contains("\"Checker\" -> \"target\"")); +} + +/// Two functions in the same contract each calling `invoke_contract` produces +/// two separate edges. +#[test] +fn callgraph_multiple_callers_in_one_contract_produce_multiple_edges() { + let dir = tempdir().unwrap(); + let src = write_contract( + &dir, + "multi.rs", + r#" +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; + +#[contract] +pub struct Hub; + +#[contractimpl] +impl Hub { + pub fn call_a(env: Env, target: Address) { + env.invoke_contract::<()>(target, &Symbol::new(&env, "fn_a"), ()); + } + pub fn call_b(env: Env, target: Address) { + env.invoke_contract::<()>(target, &Symbol::new(&env, "fn_b"), ()); + } +} +"#, + ); + let dot = dir.path().join("multi.dot"); + + Command::cargo_bin("sanctifier") + .unwrap() + .args(["callgraph"]) + .arg(&src) + .arg("--output") + .arg(&dot) + .assert() + .success() + .stdout(predicate::str::contains("2 edge(s)")); + + let content = fs::read_to_string(&dot).unwrap(); + assert_eq!( + content.matches("\"Hub\" -> \"target\"").count(), + 2, + "both callers must appear as edges" + ); +} + +/// A nonexistent source path must cause a non-zero exit. +#[test] +fn callgraph_nonexistent_path_exits_with_error() { + Command::cargo_bin("sanctifier") + .unwrap() + .args(["callgraph", "/no/such/file.rs"]) + .assert() + .failure(); +} + +/// Without `--output`, the DOT file is written to `callgraph.dot` in the +/// current working directory. +#[test] +fn callgraph_default_output_is_callgraph_dot_in_cwd() { + let dir = tempdir().unwrap(); + let src = write_contract( + &dir, + "contract.rs", + r#" +#[contractimpl] +impl MyContract { + pub fn noop(_env: Env) {} +} +"#, + ); + + Command::cargo_bin("sanctifier") + .unwrap() + .current_dir(dir.path()) + .args(["callgraph"]) + .arg(&src) + .assert() + .success(); + + assert!( + dir.path().join("callgraph.dot").exists(), + "default output file callgraph.dot must be created in cwd" + ); +} + +/// The generated DOT file must have valid DOT syntax: starts with `digraph`, +/// closes with `}`, and edges carry `[label=…]` attributes. +#[test] +fn callgraph_dot_output_is_structurally_valid() { + let dir = tempdir().unwrap(); + let src = write_contract( + &dir, + "router.rs", + r#" +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; + +#[contract] +pub struct Router; + +#[contractimpl] +impl Router { + pub fn route(env: Env, target: Address) { + env.invoke_contract::<()>(target, &Symbol::new(&env, "execute"), ()); + } +} +"#, + ); + let dot = dir.path().join("router.dot"); + + Command::cargo_bin("sanctifier") + .unwrap() + .args(["callgraph"]) + .arg(&src) + .arg("--output") + .arg(&dot) + .assert() + .success(); + + let content = fs::read_to_string(&dot).unwrap(); + assert!( + content.starts_with("digraph"), + "DOT file must start with 'digraph'" + ); + assert!( + content.trim_end().ends_with('}'), + "DOT file must end with closing brace" + ); + assert!(content.contains("[label="), "edges must carry label attributes"); +} + +/// Two separate impl blocks (two contracts) in one file each produce edges +/// attributed to their respective contract name. +#[test] +fn callgraph_two_contracts_in_one_file_are_attributed_correctly() { + let dir = tempdir().unwrap(); + let src = write_contract( + &dir, + "two_contracts.rs", + r#" +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; + +#[contract] +pub struct Alpha; + +#[contractimpl] +impl Alpha { + pub fn go(env: Env, target: Address) { + env.invoke_contract::<()>(target, &Symbol::new(&env, "alpha_fn"), ()); + } +} + +#[contract] +pub struct Beta; + +#[contractimpl] +impl Beta { + pub fn go(env: Env, target: Address) { + env.invoke_contract::<()>(target, &Symbol::new(&env, "beta_fn"), ()); + } +} +"#, + ); + let dot = dir.path().join("two.dot"); + + Command::cargo_bin("sanctifier") + .unwrap() + .args(["callgraph"]) + .arg(&src) + .arg("--output") + .arg(&dot) + .assert() + .success() + .stdout(predicate::str::contains("2 edge(s)")); + + let content = fs::read_to_string(&dot).unwrap(); + assert!(content.contains("\"Alpha\" -> \"target\""), "Alpha edge missing"); + assert!(content.contains("\"Beta\" -> \"target\""), "Beta edge missing"); +} diff --git a/tooling/sanctifier-cli/tests/deploy_validation_tests.rs b/tooling/sanctifier-cli/tests/deploy_validation_tests.rs new file mode 100644 index 00000000..d0286d93 --- /dev/null +++ b/tooling/sanctifier-cli/tests/deploy_validation_tests.rs @@ -0,0 +1,113 @@ +/// Input validation tests for `sanctifier deploy` (#527). +/// +/// Tests that runtime guard deploy UX returns structured E010/E011 errors +/// before attempting any I/O (build, WASM lookup, soroban-cli invocation). +use assert_cmd::Command; +use predicates::prelude::*; + +// ── E010 — network validation ───────────────────────────────────────────────── + +#[test] +fn deploy_rejects_unknown_network_with_e010() { + Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", "devnet"]) + .env_remove("SOROBAN_SECRET_KEY") + .assert() + .failure() + .stderr(predicate::str::contains("E010")); +} + +#[test] +fn deploy_rejects_empty_network_string_with_e010() { + Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", ""]) + .env_remove("SOROBAN_SECRET_KEY") + .assert() + .failure() + .stderr(predicate::str::contains("E010")); +} + +#[test] +fn deploy_rejects_staging_network_with_e010() { + Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", "staging"]) + .env_remove("SOROBAN_SECRET_KEY") + .assert() + .failure() + .stderr(predicate::str::contains("E010")); +} + +#[test] +fn deploy_e010_hint_lists_valid_networks() { + let out = Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", "badnet"]) + .env_remove("SOROBAN_SECRET_KEY") + .output() + .unwrap(); + + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains("testnet"), "hint must mention testnet"); + assert!(stderr.contains("futurenet"), "hint must mention futurenet"); + assert!(stderr.contains("mainnet"), "hint must mention mainnet"); +} + +// ── E011 — credentials validation ───────────────────────────────────────────── + +#[test] +fn deploy_rejects_missing_credentials_with_e011() { + // Use a valid network so we get past E010 and reach the credentials check. + // Current dir exists, so the path check (E001) passes too. + Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", "testnet"]) + .env_remove("SOROBAN_SECRET_KEY") + .assert() + .failure() + .stderr(predicate::str::contains("E011")); +} + +#[test] +fn deploy_e011_hint_mentions_env_var() { + let out = Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", "testnet"]) + .env_remove("SOROBAN_SECRET_KEY") + .output() + .unwrap(); + + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("SOROBAN_SECRET_KEY"), + "hint must reference the env var; got: {stderr}" + ); + assert!( + stderr.contains("--secret-key"), + "hint must reference the flag; got: {stderr}" + ); +} + +// ── valid networks pass validation ──────────────────────────────────────────── + +/// testnet/futurenet/mainnet should all pass network validation and proceed to +/// the next check (credentials). We verify they do NOT produce E010. +#[test] +fn deploy_valid_networks_do_not_produce_e010() { + for network in &["testnet", "futurenet", "mainnet"] { + let out = Command::cargo_bin("sanctifier") + .unwrap() + .args(["deploy", "--network", network]) + .env_remove("SOROBAN_SECRET_KEY") + .output() + .unwrap(); + + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + !stderr.contains("E010"), + "network '{network}' is valid and must not produce E010; stderr: {stderr}" + ); + } +} diff --git a/tooling/sanctifier-cli/tests/logging_bench_tests.rs b/tooling/sanctifier-cli/tests/logging_bench_tests.rs new file mode 100644 index 00000000..bbda9c76 --- /dev/null +++ b/tooling/sanctifier-cli/tests/logging_bench_tests.rs @@ -0,0 +1,179 @@ +/// Performance benchmarks + budget enforcement for logging modes (#521). +/// +/// Covers: quiet (error), default (warn), verbose (debug), and JSON log modes. +/// Each test asserts that a full `sanctifier analyze` run on a trivial contract +/// completes within a generous wall-clock budget. The budget is intentionally +/// large so the tests are stable under CI load — the point is to catch +/// catastrophic regressions, not sub-millisecond differences. +use assert_cmd::Command; +use std::fs; +use std::time::Instant; +use tempfile::tempdir; + +/// Wall-clock budget per logging-mode test (seconds). +const LOG_BUDGET_SECS: u64 = 30; + +fn write_trivial_contract(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("contract.rs"); + fs::write( + &path, + r#" +use soroban_sdk::{contractimpl, Env}; +#[contractimpl] +impl MyContract { + pub fn add(_env: Env, a: u32, b: u32) -> u32 { a + b } +} +"#, + ) + .unwrap(); + path +} + +// ── budget tests ────────────────────────────────────────────────────────────── + +/// `RUST_LOG=error` (quiet / production mode) must finish within budget. +#[test] +fn quiet_mode_analysis_within_budget() { + let dir = tempdir().unwrap(); + let path = write_trivial_contract(&dir); + + let start = Instant::now(); + Command::cargo_bin("sanctifier") + .unwrap() + .args(["analyze", "--format", "text"]) + .arg(&path) + .env("RUST_LOG", "error") + .assert() + .success(); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_secs() < LOG_BUDGET_SECS, + "quiet-mode analysis took {elapsed:?}, budget {LOG_BUDGET_SECS}s" + ); +} + +/// `RUST_LOG=debug` (verbose mode) must finish within budget. +#[test] +fn verbose_mode_analysis_within_budget() { + let dir = tempdir().unwrap(); + let path = write_trivial_contract(&dir); + + let start = Instant::now(); + Command::cargo_bin("sanctifier") + .unwrap() + .args(["analyze", "--format", "text"]) + .arg(&path) + .env("RUST_LOG", "debug") + .assert() + .success(); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_secs() < LOG_BUDGET_SECS, + "verbose-mode analysis took {elapsed:?}, budget {LOG_BUDGET_SECS}s" + ); +} + +/// JSON output + `RUST_LOG=debug` (json-logs mode) must finish within budget. +#[test] +fn json_logs_mode_analysis_within_budget() { + let dir = tempdir().unwrap(); + let path = write_trivial_contract(&dir); + + let start = Instant::now(); + Command::cargo_bin("sanctifier") + .unwrap() + .args(["analyze", "--format", "json"]) + .arg(&path) + .env("RUST_LOG", "debug") + .assert() + .success(); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_secs() < LOG_BUDGET_SECS, + "json-logs-mode analysis took {elapsed:?}, budget {LOG_BUDGET_SECS}s" + ); +} + +// ── correctness: logs go to the right stream ────────────────────────────────── + +/// Debug log lines must arrive on stderr, not stdout, in text format. +#[test] +fn text_mode_debug_logs_go_to_stderr() { + let dir = tempdir().unwrap(); + let path = write_trivial_contract(&dir); + + let out = Command::cargo_bin("sanctifier") + .unwrap() + .args(["analyze", "--format", "text"]) + .arg(&path) + .env("RUST_LOG", "sanctifier=debug") + .output() + .unwrap(); + + let stderr = String::from_utf8(out.stderr).unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + + // The progress line "Analyzing" goes to stderr in text mode. + assert!(stderr.contains("Analyzing"), "debug logs should arrive on stderr"); + // Stdout should not contain raw log lines. + assert!( + !stdout.contains("DEBUG"), + "DEBUG log lines must not leak onto stdout" + ); +} + +/// Debug log lines must be valid JSON and arrive on stderr in json-logs mode. +#[test] +fn json_mode_logs_are_valid_json_on_stderr() { + let dir = tempdir().unwrap(); + let path = write_trivial_contract(&dir); + + let out = Command::cargo_bin("sanctifier") + .unwrap() + .args(["analyze", "--format", "json"]) + .arg(&path) + .env("RUST_LOG", "sanctifier=debug") + .output() + .unwrap(); + + assert!(out.status.success(), "command must succeed"); + + // Stdout must be valid JSON (the analysis result). + let stdout = String::from_utf8(out.stdout).unwrap(); + serde_json::from_str::(&stdout) + .expect("stdout must be valid JSON analysis output"); + + // Stderr log lines must themselves be valid JSON objects. + let stderr = String::from_utf8(out.stderr).unwrap(); + for line in stderr.lines().filter(|l| !l.is_empty()) { + serde_json::from_str::(line) + .unwrap_or_else(|_| panic!("stderr log line is not valid JSON: {line}")); + } +} + +/// Quiet mode (`RUST_LOG=error`) produces no output on stderr for a clean file. +#[test] +fn quiet_mode_produces_no_stderr_on_clean_file() { + let dir = tempdir().unwrap(); + let path = write_trivial_contract(&dir); + + let out = Command::cargo_bin("sanctifier") + .unwrap() + .args(["analyze", "--format", "text"]) + .arg(&path) + .env_remove("RUST_LOG") // use the default "warn" filter via logging::init + .env("RUST_LOG", "error") + .output() + .unwrap(); + + let stderr = String::from_utf8(out.stderr).unwrap(); + // In error-only mode no warn/info/debug lines should appear for a trivial file. + // The "Analyzing …" progress line uses tracing::info!, so it should be suppressed. + assert!( + !stderr.contains("ERROR"), + "no ERROR-level log expected for a clean contract: {stderr}" + ); +}