snip-it 1.3.1

Fast terminal snippet manager with fuzzy search, TUI, variable expansion, and end-to-end encrypted cross-device sync
Documentation
use crate::commands::expand_snippet_command;
use crate::commands::run_snippet_selection;
use crate::error::{SnipError, SnipResult};
use crate::library::Snippet;
use crate::logging::{audit_log, log_command_execution};
use std::fs;
use std::process::Command;
use std::time::Duration;

const DEFAULT_TIMEOUT_SECONDS: u64 = 300;

#[derive(Clone, Copy)]
enum TimeoutPolicy {
    NoDefault,
    Default(Duration),
}

fn get_timeout(policy: TimeoutPolicy) -> Option<Duration> {
    if let Some(secs) = std::env::var("SNP_COMMAND_TIMEOUT")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
    {
        return (secs > 0).then(|| Duration::from_secs(secs));
    }

    match policy {
        TimeoutPolicy::NoDefault => None,
        TimeoutPolicy::Default(timeout) => Some(timeout),
    }
}

fn wait_for_command(
    child: &mut std::process::Child,
    timeout: Option<Duration>,
) -> SnipResult<std::process::ExitStatus> {
    let start = std::time::Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(status)) => return Ok(status),
            Ok(None) => {}
            Err(e) => {
                return Err(SnipError::runtime_error(
                    "Failed to check command status",
                    Some(&e.to_string()),
                ));
            }
        }

        if let Some(timeout) = timeout
            && start.elapsed() >= timeout
        {
            let _ = child.kill();
            let _ = child.wait();
            return Err(SnipError::runtime_error(
                "Command timed out",
                Some(&format!(
                    "Command exceeded timeout of {} seconds",
                    timeout.as_secs()
                )),
            ));
        }
        std::thread::sleep(Duration::from_millis(50));
    }
}

fn shell_arg_flag() -> &'static str {
    if cfg!(windows) { "/C" } else { "-c" }
}

fn spawn_shell_command(shell: &str, command: &str) -> SnipResult<std::process::Child> {
    Command::new(shell)
        .arg(shell_arg_flag())
        .arg(command)
        .spawn()
        .map_err(|e| SnipError::command_error(shell, vec![command.to_string()], e))
}

fn run_shell_command(
    shell: &str,
    command: &str,
    timeout: Option<Duration>,
) -> SnipResult<std::process::ExitStatus> {
    let mut child = spawn_shell_command(shell, command)?;
    wait_for_command(&mut child, timeout)
}

fn get_shell() -> String {
    if cfg!(windows) {
        std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
    } else {
        std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
    }
}

fn handle_command_result(
    command: &str,
    result: std::process::ExitStatus,
    snippet: &Snippet,
    working_dir: Option<&std::path::Path>,
) -> crate::ProcessResult {
    let result_str: Result<(), String> = if result.success() {
        if let Err(e) = audit_log("execute", snippet, None) {
            tracing::debug!("Audit log write failed: {}", e);
        }
        Ok(())
    } else {
        Err(format!("exit code: {result}"))
    };
    log_command_execution(command, &[], &result_str, working_dir);

    if result.success() {
        crate::ProcessResult::Done("Executed".to_string())
    } else {
        crate::ProcessResult::Done(format!("Executed with exit code: {result}"))
    }
}

fn process_snippet(snippet: &Snippet, copy: bool) -> SnipResult<crate::ProcessResult> {
    let final_command = match expand_snippet_command(snippet)? {
        crate::commands::ExpandedCommand::Cancel => return Ok(crate::ProcessResult::Cancel),
        crate::commands::ExpandedCommand::Skip => return Ok(crate::ProcessResult::Continue),
        crate::commands::ExpandedCommand::Expanded(cmd) => cmd,
    };

    if copy {
        crate::clipboard::copy_to_clipboard_auto(&final_command)?;
        if let Err(e) = audit_log("copy", snippet, None) {
            tracing::debug!("Audit log write failed: {}", e);
        }
        let ok_result: std::result::Result<(), String> = Ok(());
        log_command_execution(&final_command, &[], &ok_result, None);
        Ok(crate::ProcessResult::Done(
            "Copied to clipboard".to_string(),
        ))
    } else if !snippet.output.is_empty() {
        let cwd = std::env::current_dir().map_err(|e| {
            SnipError::runtime_error(
                "Failed to get current directory",
                Some(&format!("Cannot create output file: {e}")),
            )
        })?;

        let output_path = cwd.join(&snippet.output);

        let canonical_cwd = cwd.canonicalize().map_err(|e| {
            SnipError::runtime_error(
                "Failed to verify current directory",
                Some(&format!("Cannot canonicalize CWD: {e}")),
            )
        })?;

        if output_path.exists() {
            let canonical_path = output_path.canonicalize().map_err(|e| {
                SnipError::runtime_error(
                    "Failed to verify output path",
                    Some(&format!("Cannot canonicalize output path: {e}")),
                )
            })?;
            if !canonical_path.starts_with(&canonical_cwd) {
                return Err(SnipError::runtime_error(
                    "Invalid output path",
                    Some(
                        "Output path resolves outside of working directory (possible symlink attack)",
                    ),
                ));
            }
        } else {
            let parent = output_path
                .parent()
                .unwrap_or(&output_path)
                .canonicalize()
                .map_err(|e| {
                    SnipError::runtime_error(
                        "Failed to verify output directory",
                        Some(&format!("Cannot canonicalize parent directory: {e}")),
                    )
                })?;
            if !parent.starts_with(&canonical_cwd) {
                return Err(SnipError::runtime_error(
                    "Invalid output path",
                    Some(
                        "Output path resolves outside of working directory (possible symlink attack)",
                    ),
                ));
            }
        }

        let output_file = fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&output_path)
            .map_err(|e| SnipError::io_error("create output file", snippet.output.clone(), e))?;

        let shell = get_shell();
        let timeout = get_timeout(TimeoutPolicy::Default(Duration::from_secs(
            DEFAULT_TIMEOUT_SECONDS,
        )));
        let mut child = Command::new(&shell)
            .arg(shell_arg_flag())
            .arg(&final_command)
            .stdout(output_file)
            .spawn()
            .map_err(|e| SnipError::command_error(&shell, vec![final_command.clone()], e))?;
        let status = wait_for_command(&mut child, timeout)?;

        Ok(handle_command_result(
            &final_command,
            status,
            snippet,
            Some(&cwd),
        ))
    } else {
        let shell = get_shell();
        let timeout = get_timeout(TimeoutPolicy::NoDefault);
        let status = run_shell_command(&shell, &final_command, timeout)?;

        Ok(handle_command_result(&final_command, status, snippet, None))
    }
}

/// Executes the selected snippet's command in the user's shell.
pub fn run(
    filter: Option<String>,
    do_sync: bool,
    library: Option<String>,
    runtime: &tokio::runtime::Runtime,
) -> SnipResult<()> {
    run_snippet_selection(filter, library, do_sync, runtime, |snippet, copy_flag| {
        process_snippet(snippet, copy_flag.is_some())
    })
}

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

    #[test]
    fn test_get_shell() {
        let shell = get_shell();
        if cfg!(windows) {
            assert!(
                shell.ends_with("cmd.exe") || shell.ends_with("CMD.EXE"),
                "Expected cmd.exe on Windows, got: {shell}"
            );
        } else {
            assert!(
                shell.contains("/bin/sh") || std::env::var("SHELL").is_ok(),
                "Expected /bin/sh or $SHELL on Unix, got: {shell}"
            );
        }
    }

    #[test]
    fn test_shell_arg_flag_matches_platform() {
        let flag = shell_arg_flag();
        if cfg!(windows) {
            assert_eq!(flag, "/C", "cmd.exe expects /C, not the unix-style {flag}");
        } else {
            assert_eq!(flag, "-c", "Unix shells expect -c, not {flag}");
        }
    }
}