ccboard-core 0.16.3

Core library for ccboard - parsers, models, store, watcher
Documentation
//! Security tests for ccboard-core
//!
//! Tests for:
//! - Path traversal prevention (symlinks, ..)
//! - Input size limits (OOM protection)
//! - Credential masking
//!
//! Run with:
//! ```bash
//! cargo test --test security_tests
//! ```

use ccboard_core::parsers::SessionIndexParser;
use std::fs;

// NOTE: Path validation is now implemented and tested in SessionIndexParser
// These tests are kept for documentation but test the real implementation
mod path_validation {

    // NOTE: These tests are now covered by SessionIndexParser unit tests
    // Path sanitization is implemented in extract_project_path()

    #[test]
    #[ignore = "Covered by SessionIndexParser tests"]
    fn test_rejects_path_traversal_dotdot() {
        // Path traversal protection is in SessionIndexParser::sanitize_project_path
        // which strips .. components
    }

    #[test]
    #[ignore = "Covered by SessionIndexParser tests"]
    fn test_rejects_absolute_paths() {
        // Absolute path normalization is in SessionIndexParser::sanitize_project_path
    }

    #[test]
    #[ignore = "Covered by SessionIndexParser tests"]
    fn test_rejects_symlinks() {
        // Symlink rejection is tested in SessionIndexParser::sanitize_project_path
    }

    #[test]
    #[ignore = "Covered by SessionIndexParser tests"]
    fn test_accepts_valid_paths() {
        // Valid path acceptance is tested in session_index.rs unit tests
    }

    #[test]
    #[ignore = "Covered by SessionIndexParser tests"]
    fn test_normalizes_multiple_slashes() {
        // Path normalization is tested in session_index.rs unit tests
    }
}

mod input_size_limits {
    use super::*;

    #[tokio::test]
    async fn test_rejects_oversized_lines() {
        let dir = tempfile::tempdir().unwrap();
        let projects_dir = dir.path().join("projects/-Users-test");
        fs::create_dir_all(&projects_dir).unwrap();

        // Create session with 15MB single line (exceeds 10MB limit)
        let huge_line = "a".repeat(15 * 1024 * 1024);
        let session_path = projects_dir.join("huge.jsonl");
        fs::write(
            &session_path,
            format!(r#"{{"type":"system","text":"{}"}}"#, huge_line),
        )
        .unwrap();

        let parser = SessionIndexParser::new();
        let result = parser.scan_session(&session_path).await;

        // Should gracefully handle, not crash
        // Either return error or skip the line
        match result {
            Ok(meta) => {
                // If parsed, should have skipped the huge line
                assert!(meta.message_count == 0, "Should skip oversized lines");
            }
            Err(_) => {
                // Graceful error is also acceptable
            }
        }
    }

    #[tokio::test]
    async fn test_handles_many_small_lines() {
        let dir = tempfile::tempdir().unwrap();
        let projects_dir = dir.path().join("projects/-Users-test");
        fs::create_dir_all(&projects_dir).unwrap();

        // Create session with 100K small lines (stress test)
        let session_path = projects_dir.join("many-lines.jsonl");
        let mut content = String::new();
        for i in 0..100_000 {
            content.push_str(&format!(
                r#"{{"type":"user","text":"Message {}","timestamp":"2025-01-01T00:00:00Z"}}
"#,
                i
            ));
        }
        fs::write(&session_path, content).unwrap();

        let parser = SessionIndexParser::new();
        let start = std::time::Instant::now();
        let result = parser.scan_session(&session_path).await;
        let elapsed = start.elapsed();

        // Should complete within reasonable time (not hang)
        assert!(
            elapsed < std::time::Duration::from_secs(5),
            "100K lines took {:?}, potential performance issue",
            elapsed
        );

        assert!(result.is_ok(), "Should handle many small lines");
    }

    #[tokio::test]
    async fn test_empty_file_no_panic() {
        let dir = tempfile::tempdir().unwrap();
        let projects_dir = dir.path().join("projects/-Users-test");
        fs::create_dir_all(&projects_dir).unwrap();

        let session_path = projects_dir.join("empty.jsonl");
        fs::write(&session_path, "").unwrap();

        let parser = SessionIndexParser::new();
        let result = parser.scan_session(&session_path).await;

        // Should handle gracefully
        assert!(
            result.is_ok() || result.is_err(),
            "Should not panic on empty file"
        );
    }

    #[tokio::test]
    async fn test_malformed_json_no_panic() {
        let dir = tempfile::tempdir().unwrap();
        let projects_dir = dir.path().join("projects/-Users-test");
        fs::create_dir_all(&projects_dir).unwrap();

        let session_path = projects_dir.join("malformed.jsonl");
        fs::write(
            &session_path,
            r#"{"type":"user","text":"valid"}
{invalid json here}
{"type":"user","text":"another valid"}
"#,
        )
        .unwrap();

        let parser = SessionIndexParser::new();
        let result = parser.scan_session(&session_path).await;

        // Should skip malformed lines and continue
        assert!(result.is_ok(), "Should gracefully handle malformed JSON");
    }
}

mod credential_masking {
    use ccboard_core::models::Settings;

    #[test]
    fn test_api_key_masking() {
        let config = Settings {
            api_key: Some("sk-ant-1234567890abcdef1234567890abcdef".to_string()),
            ..Default::default()
        };

        let masked = config.masked_api_key();

        assert!(masked.is_some());
        let masked = masked.unwrap();

        // Should show prefix and suffix only
        assert!(masked.starts_with("sk-"));
        assert!(masked.contains("••••"));
        assert!(!masked.contains("1234567890abcdef"));
        assert!(masked.len() < config.api_key.as_ref().unwrap().len());
    }

    #[test]
    fn test_none_api_key() {
        let config = Settings {
            api_key: None,
            ..Default::default()
        };

        let masked = config.masked_api_key();

        assert!(masked.is_none());
    }

    #[test]
    fn test_short_api_key() {
        let config = Settings {
            api_key: Some("short".to_string()),
            ..Default::default()
        };

        let masked = config.masked_api_key();

        assert!(masked.is_some());
        // Should handle short keys gracefully
        assert!(masked.unwrap().contains("••••"));
    }
}

mod timing_attacks {
    use std::time::Instant;

    #[tokio::test]
    async fn test_file_discovery_timing_consistent() {
        // Test that file existence checks don't leak timing information
        let dir = tempfile::tempdir().unwrap();
        let projects_dir = dir.path().join("projects");
        std::fs::create_dir_all(&projects_dir).unwrap();

        // Create one session
        let existing = projects_dir.join("-Users-test-exists.jsonl");
        std::fs::write(&existing, r#"{"type":"system","text":"test"}"#).unwrap();

        let parser = ccboard_core::parsers::SessionIndexParser::new();

        // Time scanning existing file
        let start = Instant::now();
        let _ = parser.scan_session(&existing).await;
        let time_exists = start.elapsed();

        // Time scanning non-existing file
        let non_existing = projects_dir.join("-Users-test-nonexist.jsonl");
        let start = Instant::now();
        let _ = parser.scan_session(&non_existing).await;
        let time_not_exists = start.elapsed();

        // Timing difference should be consistent (not revealing)
        // Allow for some variance, but should be in same order of magnitude
        let ratio =
            time_exists.as_micros().max(1) as f64 / time_not_exists.as_micros().max(1) as f64;

        // If ratio > 100, there's a significant timing leak
        assert!(
            ratio < 100.0 && ratio > 0.01,
            "Timing difference too large (ratio: {:.2}), potential timing attack vector",
            ratio
        );
    }
}