rskim 2.3.1

The most intelligent context optimization engine for coding agents. Code-aware AST parsing, command rewriting, output compression.
//! Integration tests for hook integrity verification (#57).
//!
//! Tests the full lifecycle: install creates SHA-256 manifest, uninstall checks
//! integrity, tampered scripts require --force, and hook mode logs warnings
//! to file (NEVER stderr).

use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;

// ============================================================================
// Helper: build an isolated `skim init` command with CLAUDE_CONFIG_DIR override
// ============================================================================

fn skim_init_cmd(config_dir: &std::path::Path) -> Command {
    let mut cmd = Command::cargo_bin("skim").unwrap();
    cmd.arg("init")
        .env("CLAUDE_CONFIG_DIR", config_dir.as_os_str());
    cmd
}

fn skim_rewrite_hook_cmd(config_dir: &std::path::Path) -> Command {
    let mut cmd = Command::cargo_bin("skim").unwrap();
    cmd.args(["rewrite", "--hook"])
        .env("CLAUDE_CONFIG_DIR", config_dir.as_os_str());
    cmd
}

// ============================================================================
// Install creates SHA-256 file
// ============================================================================

#[test]
fn test_install_creates_sha256_file() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();

    skim_init_cmd(config).args(["--yes"]).assert().success();

    // Verify the SHA-256 manifest was created
    let manifest_path = config.join("hooks/skim-claude-code.sha256");
    assert!(
        manifest_path.exists(),
        "SHA-256 manifest should be created on install"
    );

    // Verify manifest format: sha256:<hex>  skim-rewrite.sh
    let content = fs::read_to_string(&manifest_path).unwrap();
    assert!(
        content.starts_with("sha256:"),
        "Manifest should start with sha256: prefix, got: {content}"
    );
    assert!(
        content.contains("skim-rewrite.sh"),
        "Manifest should reference the script name, got: {content}"
    );

    // Verify hash is valid hex (64 chars for SHA-256)
    let hash = content
        .strip_prefix("sha256:")
        .unwrap()
        .split_whitespace()
        .next()
        .unwrap();
    assert_eq!(hash.len(), 64, "SHA-256 hash should be 64 hex chars");
    assert!(
        hash.chars().all(|c| c.is_ascii_hexdigit()),
        "Hash should be valid hex"
    );
}

// ============================================================================
// Upgrade recomputes hash
// ============================================================================

#[test]
fn test_upgrade_recomputes_hash() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();

    // First install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    let manifest_path = config.join("hooks/skim-claude-code.sha256");
    let _hash1 = fs::read_to_string(&manifest_path).unwrap();

    // Modify the hook script version to simulate an upgrade scenario
    let script_path = config.join("hooks/skim-rewrite.sh");
    let content = fs::read_to_string(&script_path).unwrap();
    let modified = content.replace("skim-hook v", "skim-hook v0.0.0-old-");
    fs::write(&script_path, &modified).unwrap();

    // Re-run init (upgrade) -- should recompute hash
    skim_init_cmd(config).args(["--yes"]).assert().success();

    let hash2 = fs::read_to_string(&manifest_path).unwrap();
    // The hash should be different because the script content changed during upgrade
    // (Actually, the install flow writes a NEW script with the current version,
    // so the hash will match the freshly-written script)
    assert!(
        hash2.starts_with("sha256:"),
        "After upgrade, manifest should still be valid"
    );
}

// ============================================================================
// Uninstall tampered requires --force
// ============================================================================

#[test]
fn test_uninstall_tampered_requires_force() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();

    // Install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    // Tamper with the hook script
    let script_path = config.join("hooks/skim-rewrite.sh");
    fs::write(&script_path, "#!/bin/bash\necho 'tampered'\n").unwrap();
    // Keep it executable
    let perms = std::fs::Permissions::from_mode(0o755);
    fs::set_permissions(&script_path, perms).unwrap();

    // Uninstall WITHOUT --force should fail
    skim_init_cmd(config)
        .args(["--uninstall", "--yes"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("modified since installation"))
        .stderr(predicate::str::contains("--force"));
}

#[test]
fn test_uninstall_with_force_bypasses_warning() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();

    // Install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    // Tamper with the hook script
    let script_path = config.join("hooks/skim-rewrite.sh");
    fs::write(&script_path, "#!/bin/bash\necho 'tampered'\n").unwrap();
    let perms = std::fs::Permissions::from_mode(0o755);
    fs::set_permissions(&script_path, perms).unwrap();

    // Uninstall WITH --force should succeed
    skim_init_cmd(config)
        .args(["--uninstall", "--yes", "--force"])
        .assert()
        .success()
        .stderr(predicate::str::contains("proceeding with --force"));

    // Script should be deleted
    assert!(
        !script_path.exists(),
        "Hook script should be deleted after forced uninstall"
    );

    // Hash manifest should also be cleaned up
    let manifest_path = config.join("hooks/skim-claude-code.sha256");
    assert!(
        !manifest_path.exists(),
        "Hash manifest should be cleaned up after uninstall"
    );
}

// ============================================================================
// Uninstall clean script proceeds normally
// ============================================================================

#[test]
fn test_uninstall_clean_script_proceeds() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();

    // Install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    // Uninstall without tampering -- should succeed without --force
    skim_init_cmd(config)
        .args(["--uninstall", "--yes"])
        .assert()
        .success();

    // Everything should be cleaned up
    let script_path = config.join("hooks/skim-rewrite.sh");
    assert!(!script_path.exists(), "Script should be deleted");
    let manifest_path = config.join("hooks/skim-claude-code.sha256");
    assert!(!manifest_path.exists(), "Manifest should be deleted");
}

// ============================================================================
// Hook mode: tamper warning goes to log, NOT stderr
// ============================================================================

#[test]
fn test_hook_mode_tamper_warning_goes_to_log_not_stderr() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();
    let cache_dir = TempDir::new().unwrap();

    // Install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    // Tamper with the hook script
    let script_path = config.join("hooks/skim-rewrite.sh");
    fs::write(&script_path, "#!/bin/bash\necho 'tampered'\n").unwrap();

    // Run hook mode with a simple command
    let hook_input = serde_json::json!({
        "tool_input": {
            "command": "cargo test"
        }
    });

    // Override SKIM_CACHE_DIR so we can find the log file
    skim_rewrite_hook_cmd(config)
        .env("SKIM_CACHE_DIR", cache_dir.path().as_os_str())
        .write_stdin(hook_input.to_string())
        .assert()
        .success()
        // CRITICAL: stderr must NOT contain the tamper warning
        .stderr(predicate::str::contains("tampered").not());

    // The warning SHOULD appear in the log file.
    // SKIM_CACHE_DIR points directly to the skim cache dir.
    let log_path = cache_dir.path().join("hook.log");
    assert!(
        log_path.exists(),
        "Hook log file should exist at {}",
        log_path.display()
    );
    let log_content = fs::read_to_string(&log_path).unwrap();
    assert!(
        log_content.contains("tampered"),
        "Hook log should contain tamper warning, got: {log_content}"
    );
}

// ============================================================================
// Cleanup removes SHA-256 on uninstall
// ============================================================================

#[test]
fn test_cleanup_removes_sha256() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();

    // Install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    let manifest_path = config.join("hooks/skim-claude-code.sha256");
    assert!(
        manifest_path.exists(),
        "Manifest should exist after install"
    );

    // Uninstall
    skim_init_cmd(config)
        .args(["--uninstall", "--yes"])
        .assert()
        .success();

    assert!(
        !manifest_path.exists(),
        "Manifest should be removed after uninstall"
    );
}

// ============================================================================
// Integrity suppresses version mismatch
// ============================================================================

#[test]
fn test_integrity_suppresses_version_mismatch() {
    let dir = TempDir::new().unwrap();
    let config = dir.path();
    let cache_dir = TempDir::new().unwrap();

    // Install
    skim_init_cmd(config).args(["--yes"]).assert().success();

    // Tamper with the hook script
    let script_path = config.join("hooks/skim-rewrite.sh");
    fs::write(&script_path, "#!/bin/bash\necho 'tampered'\n").unwrap();

    // Run hook mode with a MISMATCHED version env
    let hook_input = serde_json::json!({
        "tool_input": {
            "command": "cargo test"
        }
    });

    // Set a mismatched hook version -- integrity warning should subsume it
    skim_rewrite_hook_cmd(config)
        .env("SKIM_HOOK_VERSION", "0.0.0-fake")
        .env("SKIM_CACHE_DIR", cache_dir.path().as_os_str())
        .write_stdin(hook_input.to_string())
        .assert()
        .success()
        // CRITICAL: stderr must NOT contain version mismatch warning
        // (integrity warning subsumes it)
        .stderr(predicate::str::contains("version mismatch").not());
}