aptu-cli 0.10.11

CLI for Aptu - Gamified OSS issue triage with AI assistance
// SPDX-License-Identifier: Apache-2.0

//! `scan-security` subcommand: scan a local file or directory for security issues.

use std::io::Read;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use aptu_core::{AppConfig, Finding, PatternEngine, SarifReport, SecurityScanner};
use walkdir::WalkDir;

use crate::cli::OutputFormat;

/// Maximum allowed size for a diff input (5 MiB).
const DIFF_SIZE_LIMIT: usize = 5_242_880;

/// Run the `scan-security` subcommand.
///
/// When `diff` is provided, reads a unified diff from a file path or stdin (`-`),
/// enforces a 5 MiB size limit, and calls `scanner.scan_diff()`.
/// When `path` is provided, walks the file or directory and calls `scanner.scan_file()`.
///
/// Findings are emitted in the requested `output_format`. When `sarif_output` is
/// provided, a SARIF report is additionally written to that file (before the
/// `--fail-on` exit evaluation) so the report survives a non-zero exit.
#[allow(clippy::unused_async)]
pub async fn run_scan_security_command(
    path: Option<PathBuf>,
    diff: Option<PathBuf>,
    fail_on: Vec<String>,
    exclude: Vec<String>,
    output_format: OutputFormat,
    sarif_output: Option<PathBuf>,
    _config: &AppConfig,
) -> Result<()> {
    let scanner = SecurityScanner::default();
    let mut findings: Vec<Finding> = Vec::new();

    if let Some(diff_path) = diff {
        // Diff mode: read from file or stdin
        let content = if diff_path == Path::new("-") {
            let mut buf = String::new();
            std::io::stdin()
                .take((DIFF_SIZE_LIMIT + 1) as u64)
                .read_to_string(&mut buf)
                .map_err(|e| anyhow::anyhow!("Failed to read stdin: {e}"))?;
            buf
        } else {
            let meta = std::fs::metadata(&diff_path)
                .map_err(|e| anyhow::anyhow!("Cannot stat '{}': {e}", diff_path.display()))?;
            if meta.len() > DIFF_SIZE_LIMIT as u64 {
                return Err(anyhow::anyhow!(
                    "Diff file '{}' exceeds the 5 MiB limit ({} bytes)",
                    diff_path.display(),
                    meta.len()
                ));
            }
            std::fs::read_to_string(&diff_path)
                .map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", diff_path.display()))?
        };

        if content.len() > DIFF_SIZE_LIMIT {
            return Err(anyhow::anyhow!(
                "Diff input exceeds the 5 MiB limit ({} bytes)",
                content.len()
            ));
        }

        findings.extend(scanner.scan_diff(&content));
    } else {
        // Walk mode: path is guaranteed present by Clap (required_unless_present = "diff")
        let scan_path = path
            .ok_or_else(|| anyhow::anyhow!("internal: path required when --diff not provided"))?;

        for entry in WalkDir::new(&scan_path)
            .follow_links(false)
            .into_iter()
            .filter_map(std::result::Result::ok)
        {
            if entry.file_type().is_dir() {
                continue;
            }

            let file_path = entry.path();
            let file_path_str = file_path.to_string_lossy();

            // Apply --exclude prefix filter
            if exclude
                .iter()
                .any(|prefix| file_path_str.starts_with(prefix.as_str()))
            {
                continue;
            }

            // Read file content; skip files that cannot be read (binary, permission denied, etc.)
            let Ok(content) = std::fs::read_to_string(file_path) else {
                continue;
            };

            let file_findings = scanner.scan_file(&content, &file_path_str);
            findings.extend(file_findings);
        }
    }

    // Emit findings in the requested format; SARIF report file is written
    // before the --fail-on evaluation below so the report survives a non-zero exit.
    emit_output(output_format, sarif_output, &findings)?;

    // Exit 1 if any finding severity matches --fail-on list
    if !fail_on.is_empty() {
        let fail_severities: Vec<String> = fail_on.iter().map(|s| s.to_lowercase()).collect();

        let should_fail = findings
            .iter()
            .any(|f| fail_severities.iter().any(|s| s == f.severity.as_str()));

        if should_fail {
            return Err(anyhow::Error::new(crate::errors::ScanFindingsExit));
        }
    }

    Ok(())
}

/// Emit findings in the requested output format and write a SARIF report
/// to `sarif_output` if provided.
fn emit_output(
    output_format: OutputFormat,
    sarif_output: Option<PathBuf>,
    findings: &[Finding],
) -> Result<()> {
    // Build the SARIF report exactly once, whether it is the requested
    // output format or only written to the `sarif_output` file.
    let sarif_json = if matches!(output_format, OutputFormat::Sarif) || sarif_output.is_some() {
        let engine = PatternEngine::from_embedded_json()?;
        let patterns = engine.definitions();
        let report = SarifReport::with_rules(findings.to_vec(), &patterns);
        Some(
            serde_json::to_string_pretty(&report)
                .map_err(|e| anyhow::anyhow!("Failed to serialize SARIF: {e}"))?,
        )
    } else {
        None
    };

    match output_format {
        OutputFormat::Sarif => {
            // Guaranteed present because output_format is Sarif.
            if let Some(json) = &sarif_json {
                println!("{json}");
            }
        }
        OutputFormat::GithubAnnotations => {
            for f in findings {
                println!(
                    "::error file={},line={},title={}::{}",
                    f.file_path, f.line_number, f.pattern_id, f.description
                );
            }
        }
        OutputFormat::Json => {
            let json = serde_json::to_string_pretty(findings)
                .map_err(|e| anyhow::anyhow!("Failed to serialize findings to JSON: {e}"))?;
            println!("{json}");
        }
        OutputFormat::Yaml => {
            let yaml = serde_saphyr::to_string(&findings.to_vec())
                .map_err(|e| anyhow::anyhow!("Failed to serialize findings to YAML: {e}"))?;
            println!("{yaml}");
        }
        OutputFormat::Text | OutputFormat::Markdown => {
            if findings.is_empty() {
                println!("No security findings.");
            } else {
                println!("Security findings ({}):", findings.len());
                for f in findings {
                    println!(
                        "  [{}] {} ({}:{}): {}",
                        f.severity.as_str().to_uppercase(),
                        f.pattern_id,
                        f.file_path,
                        f.line_number,
                        f.description
                    );
                }
            }
        }
    }

    // Write the SARIF report to the requested file (before --fail-on evaluation).
    if let Some(sarif_path) = sarif_output {
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(json) = &sarif_json {
            std::fs::write(&sarif_path, json)
                .with_context(|| format!("failed to write SARIF to {}", sarif_path.display()))?;
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The walk-mode path is guaranteed present by Clap (`required_unless_present`),
    /// so this branch is outside Clap validation. Caller-facing None must still
    /// surface as an error rather than a panic.
    #[tokio::test]
    async fn run_scan_security_errors_when_path_and_diff_missing() {
        // Arrange / Act: no diff and no path supplied
        let result = run_scan_security_command(
            None,
            None,
            Vec::new(),
            Vec::new(),
            OutputFormat::Text,
            None,
            &AppConfig::default(),
        )
        .await;

        // Assert
        let err = result.expect_err("expected an error when both path and diff are missing");
        assert!(
            err.to_string()
                .contains("path required when --diff not provided"),
            "unexpected error: {err}"
        );
    }
}