foxguard 0.12.0

A security scanner as fast as a linter, written in Rust. 200+ built-in rules across 12 source languages.
Documentation
mod input;
mod state;
#[cfg(test)]
mod tests;
mod views;
mod widgets;

use self::input::ControlFlow;
use self::state::{SourceContextCacheKey, TuiApp};
use self::widgets::{has_stashed_event, pop_stashed_event_or_read, render_source_context};
use crate::app::{execute_tui, TuiExecution};
use crate::cli::TuiArgs;
use crate::Finding;
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::Terminal;
use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Component, Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::time::Duration;

pub fn run_scan_tui(args: &TuiArgs) -> Result<i32, String> {
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        return Err("foxguard tui requires an interactive terminal".to_string());
    }

    let mut session = TerminalSession::enter()?;
    let (tx, rx) = mpsc::channel();
    let mut app = TuiApp::new(args.clone());

    loop {
        app.handle_worker_messages(&rx);
        if let Some((request_id, key, finding)) = app.prepare_source_context_load() {
            start_source_context_load(request_id, key, finding, tx.clone());
        }

        session
            .terminal
            .draw(|frame| app.draw(frame))
            .map_err(|e| e.to_string())?;

        if has_stashed_event()
            || event::poll(Duration::from_millis(100)).map_err(|e| e.to_string())?
        {
            let ev = pop_stashed_event_or_read().map_err(|e| e.to_string())?;

            if let Event::Mouse(mouse) = ev {
                if app.can_handle_finding_mouse() {
                    app.handle_mouse(mouse);
                }
                continue;
            }

            let Event::Key(key) = ev else {
                continue;
            };

            if key.kind != KeyEventKind::Press {
                continue;
            }

            match app.handle_key(key) {
                ControlFlow::Continue => {}
                ControlFlow::Rescan => {
                    let request_id = app.begin_scan();
                    start_tui_execution(request_id, app.request.clone(), tx.clone())
                }
                ControlFlow::OpenSelected => {
                    if let Err(error) = app.open_selected_finding(&mut session) {
                        app.push_runtime_notice(format!("open failed: {}", error));
                    }
                }
                ControlFlow::ApplyAction(action) => match app.apply_action(action) {
                    Ok(true) => {
                        let request_id = app.begin_scan();
                        start_tui_execution(request_id, app.request.clone(), tx.clone())
                    }
                    Ok(false) => {}
                    Err(error) => app.push_runtime_notice(format!("action failed: {}", error)),
                },
                ControlFlow::Exit => break,
            }
        }

        if app.scanning {
            app.advance_spinner();
        }
    }

    if let Some(error) = app.error.take() {
        return Err(error);
    }

    let finding_count = app
        .result
        .as_ref()
        .map(|result| result.findings.len())
        .unwrap_or(0);
    Ok(if finding_count > 0 { 1 } else { 0 })
}

enum WorkerMessage {
    Scan {
        request_id: u64,
        result: Result<TuiExecution, String>,
    },
    SourceContext {
        request_id: u64,
        key: SourceContextCacheKey,
        lines: Vec<Line<'static>>,
    },
}

struct TerminalSession {
    terminal: Terminal<CrosstermBackend<io::Stdout>>,
    active: bool,
}

impl TerminalSession {
    fn enter() -> Result<Self, String> {
        enable_raw_mode().map_err(|e| e.to_string())?;
        let mut stdout = io::stdout();
        if let Err(error) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
            rollback_terminal_setup();
            return Err(error.to_string());
        }
        let backend = CrosstermBackend::new(stdout);
        let terminal = match Terminal::new(backend) {
            Ok(terminal) => terminal,
            Err(error) => {
                rollback_terminal_setup();
                return Err(error.to_string());
            }
        };
        Ok(Self {
            terminal,
            active: true,
        })
    }

    fn suspend(&mut self) -> Result<(), String> {
        if !self.active {
            return Ok(());
        }

        disable_raw_mode().map_err(|e| e.to_string())?;
        execute!(
            self.terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableMouseCapture
        )
        .map_err(|e| e.to_string())?;
        self.terminal.show_cursor().map_err(|e| e.to_string())?;
        self.active = false;
        Ok(())
    }

    fn resume(&mut self) -> Result<(), String> {
        if self.active {
            return Ok(());
        }

        enable_raw_mode().map_err(|e| e.to_string())?;
        execute!(
            self.terminal.backend_mut(),
            EnterAlternateScreen,
            EnableMouseCapture
        )
        .map_err(|error| {
            rollback_terminal_setup();
            error.to_string()
        })?;
        self.terminal.clear().map_err(|error| {
            rollback_terminal_setup();
            error.to_string()
        })?;
        self.active = true;
        Ok(())
    }
}

fn rollback_terminal_setup() {
    let mut stdout = io::stdout();
    let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture);
    let _ = disable_raw_mode();
}

impl Drop for TerminalSession {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let _ = execute!(
            self.terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableMouseCapture
        );
        let _ = self.terminal.show_cursor();
    }
}

fn start_tui_execution(request_id: u64, args: TuiArgs, tx: Sender<WorkerMessage>) {
    std::thread::spawn(move || {
        let _ = tx.send(WorkerMessage::Scan {
            request_id,
            result: execute_tui(&args),
        });
    });
}

fn start_source_context_load(
    request_id: u64,
    key: SourceContextCacheKey,
    finding: Finding,
    tx: Sender<WorkerMessage>,
) {
    std::thread::spawn(move || {
        let lines = match fs::read_to_string(&key.path) {
            Ok(source) => render_source_context(&source, &finding, 2),
            Err(error) => vec![Line::from(Span::styled(
                format!("Unable to load source context: {}", error),
                Style::default().fg(Color::DarkGray),
            ))],
        };

        let _ = tx.send(WorkerMessage::SourceContext {
            request_id,
            key,
            lines,
        });
    });
}

struct OpenTarget {
    path: PathBuf,
    line: usize,
}

struct CommandSpec {
    program: String,
    args: Vec<String>,
}

fn open_command_spec(target: &OpenTarget) -> Result<CommandSpec, String> {
    open_command_spec_from_editor(
        target,
        std::env::var_os("EDITOR")
            .as_ref()
            .map(|editor| editor.to_string_lossy().into_owned()),
    )
}

fn open_command_spec_from_editor(
    target: &OpenTarget,
    editor: Option<String>,
) -> Result<CommandSpec, String> {
    if let Some(editor) = editor {
        let mut parts =
            shlex::split(&editor).ok_or_else(|| "failed to parse $EDITOR".to_string())?;
        if parts.is_empty() {
            return Err("$EDITOR is set but empty".to_string());
        }

        let program = parts.remove(0);
        let basename = normalized_editor_basename(&program);
        let mut args = parts;

        match basename.as_str() {
            "code" | "code-insiders" | "cursor" | "codium" | "windsurf" => {
                args.push("-g".to_string());
                args.push(format!("{}:{}", target.path.display(), target.line));
            }
            "hx" | "helix" => {
                args.push(format!("{}:{}", target.path.display(), target.line));
            }
            "vim" | "nvim" | "vi" | "nano" | "emacs" => {
                args.push(format!("+{}", target.line));
                args.push(target.path.display().to_string());
            }
            _ => {
                args.push(target.path.display().to_string());
            }
        }

        return Ok(CommandSpec { program, args });
    }

    if cfg!(target_os = "macos") {
        return Ok(CommandSpec {
            program: "open".to_string(),
            args: vec![target.path.display().to_string()],
        });
    }

    if cfg!(target_os = "windows") {
        return Ok(CommandSpec {
            program: "cmd".to_string(),
            args: vec![
                "/C".to_string(),
                "start".to_string(),
                String::new(),
                target.path.display().to_string(),
            ],
        });
    }

    Ok(CommandSpec {
        program: "xdg-open".to_string(),
        args: vec![target.path.display().to_string()],
    })
}

fn normalized_editor_basename(program: &str) -> String {
    let basename = Path::new(program)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(program)
        .rsplit(['/', '\\'])
        .next()
        .unwrap_or(program);
    let basename = basename.to_ascii_lowercase();

    for extension in [".exe", ".cmd", ".bat"] {
        if let Some(stem) = basename.strip_suffix(extension) {
            return stem.to_string();
        }
    }

    basename
}

fn resolve_finding_path(scan_path: &str, finding_file: &str) -> PathBuf {
    let finding_path = Path::new(finding_file);
    if finding_path.is_absolute() {
        return finding_path.to_path_buf();
    }

    if finding_path
        .components()
        .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
    {
        return finding_path.to_path_buf();
    }

    let scan_root = Path::new(scan_path);
    if finding_path.starts_with(scan_root) {
        return finding_path.to_path_buf();
    }

    let scan_root_is_file = scan_root.is_file();
    let base = if scan_root_is_file {
        scan_root.parent().unwrap_or_else(|| Path::new("."))
    } else {
        scan_root
    };

    base.join(finding_path)
}