diff --git a/server/cache.rs b/server/cache.rs new file mode 100644 index 0000000..6bd7559 --- /dev/null +++ b/server/cache.rs @@ -0,0 +1,155 @@ +use std::num::NonZeroUsize; +use std::sync::{Mutex, OnceLock}; + +use clru::CLruCache; +use prost::Message; + +use crate::pb::{ObjectSelector, object_selector}; + +const GLOBAL_CACHE_MAX: usize = 65_545; + +type Cache = CLruCache, Vec>; + +static GLOBAL_CACHE: OnceLock> = OnceLock::new(); + +fn cache() -> &'static Mutex { + GLOBAL_CACHE.get_or_init(|| { + let capacity = + NonZeroUsize::new(GLOBAL_CACHE_MAX).expect("cache capacity must be non-zero"); + Mutex::new(CLruCache::new(capacity)) + }) +} + +fn cache_key(namespace: &str, request: &Req) -> Vec +where + Req: Message, +{ + let mut key = Vec::with_capacity(namespace.len() + 1 + request.encoded_len()); + key.extend_from_slice(namespace.as_bytes()); + key.push(0); + request + .encode(&mut key) + .expect("encoding a prost message into Vec cannot fail"); + key +} + +pub(crate) fn cached_response( + namespace: &'static str, + request: &Req, + build: F, +) -> Result +where + Req: Message, + Res: Message + Default, + F: FnOnce() -> Result, +{ + let key = cache_key(namespace, request); + + if let Some(bytes) = cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&key) + .cloned() + && let Ok(response) = Res::decode(bytes.as_slice()) + { + tracing::debug!( + namespace = %namespace, + key_len = key.len(), + "cache hit" + ); + return Ok(response); + } + + tracing::debug!( + namespace = %namespace, + key_len = key.len(), + "cache miss, building response" + ); + let response = build()?; + let mut bytes = Vec::with_capacity(response.encoded_len()); + response + .encode(&mut bytes) + .expect("encoding a prost message into Vec cannot fail"); + cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .put(key, bytes); + Ok(response) +} + +pub(crate) fn cached_vec_response( + namespace: &'static str, + request: &Req, + build: F, +) -> Result, E> +where + Req: Message, + Item: Message + Default, + F: FnOnce() -> Result, E>, +{ + let key = cache_key(namespace, request); + + if let Some(bytes) = cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&key) + .cloned() + { + let mut remaining = bytes.as_slice(); + let mut items = Vec::new(); + let mut valid = true; + while !remaining.is_empty() { + match Item::decode_length_delimited(&mut remaining) { + Ok(item) => items.push(item), + Err(_) => { + valid = false; + break; + } + } + } + if valid { + tracing::debug!( + namespace = %namespace, + key_len = key.len(), + item_count = items.len(), + "vec cache hit" + ); + return Ok(items); + } + tracing::warn!( + namespace = %namespace, + "vec cache decode failed, rebuilding" + ); + } + + tracing::debug!( + namespace = %namespace, + key_len = key.len(), + "vec cache miss, building response" + ); + let response = build()?; + let mut bytes = Vec::new(); + for item in &response { + item.encode_length_delimited(&mut bytes) + .expect("encoding a prost message into Vec cannot fail"); + } + cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .put(key, bytes); + Ok(response) +} + +pub(crate) fn selector_is_oid(selector: &Option) -> bool { + matches!( + selector.as_ref().and_then(|s| s.selector.as_ref()), + Some(object_selector::Selector::Oid(_)) + ) +} + +pub(crate) fn selectors_are_oid( + left: &Option, + right: &Option, +) -> bool { + selector_is_oid(left) && selector_is_oid(right) +} diff --git a/tests/bare_test.rs b/tests/bare_test.rs new file mode 100644 index 0000000..6af2e7b --- /dev/null +++ b/tests/bare_test.rs @@ -0,0 +1,149 @@ +mod common; + +use gitks::bare::GitBare; +use gitks::error::GitError; +use gitks::pb::RepositoryHeader; + +#[test] +fn test_from_header_valid() { + let (dir, gb) = common::setup_bare_repo(); + let parent = gb.bare_dir.parent().unwrap().to_string_lossy().into_owned(); + let name = gb + .bare_dir + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: parent, + relative_path: name, + ..Default::default() + }); + assert!(result.is_ok()); +} + +#[test] +fn test_from_header_empty_path() { + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: String::new(), + relative_path: String::new(), + ..Default::default() + }); + assert!(result.is_err()); +} + +#[test] +fn test_from_header_relative_storage_path() { + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: "relative/path".into(), + relative_path: String::new(), + ..Default::default() + }); + assert!(result.is_err()); + match result.unwrap_err() { + GitError::InvalidArgument(_) => {} + other => panic!("expected InvalidArgument, got: {:?}", other), + } +} + +#[test] +fn test_from_header_relative_path_without_storage() { + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: String::new(), + relative_path: "some/repo.git".into(), + ..Default::default() + }); + assert!(result.is_err()); +} + +#[test] +fn test_from_header_nonexistent_repo() { + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: "/tmp".into(), + relative_path: "nonexistent_repo_12345".into(), + ..Default::default() + }); + assert!(result.is_err()); + match result.unwrap_err() { + GitError::RepoNotFound => {} + other => panic!("expected RepoNotFound, got: {:?}", other), + } +} + +#[test] +fn test_from_header_path_traversal() { + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: "/tmp".into(), + relative_path: "../../../etc".into(), + ..Default::default() + }); + assert!(result.is_err()); + match result.unwrap_err() { + GitError::InvalidArgument(msg) => { + assert!( + msg.contains("traversal"), + "should detect traversal, got: {}", + msg + ); + } + other => panic!("expected InvalidArgument, got: {:?}", other), + } +} + +#[test] +fn test_from_header_not_a_directory() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("not_a_dir.txt"); + std::fs::write(&file_path, "content").unwrap(); + + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: dir.path().to_string_lossy().into_owned(), + relative_path: "not_a_dir.txt".into(), + ..Default::default() + }); + assert!(result.is_err()); +} + +#[test] +fn test_from_header_dir_without_head() { + let dir = tempfile::tempdir().unwrap(); + let subdir = dir.path().join("empty_dir"); + std::fs::create_dir(&subdir).unwrap(); + + let result = GitBare::from_repository_header(&RepositoryHeader { + storage_path: dir.path().to_string_lossy().into_owned(), + relative_path: "empty_dir".into(), + ..Default::default() + }); + assert!(result.is_err()); + match result.unwrap_err() { + GitError::NotBareRepository => {} + other => panic!("expected NotBareRepository, got: {:?}", other), + } +} + +#[test] +fn test_object_format() { + let (_dir, gb) = common::setup_bare_repo(); + let format = gb.object_format(); + assert_eq!(format, gitks::pb::ObjectFormat::Sha1); +} + +#[test] +fn test_oid_to_pb() { + let (_dir, gb) = common::setup_bare_repo(); + let oid = gb.oid_to_pb("da39a3ee5e6b4b0d3255bfef95601890afd80709"); + assert_eq!(oid.hex, "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + assert_eq!(oid.value.len(), 20); + assert_eq!(oid.format, gitks::pb::ObjectFormat::Sha1 as i32); +} + +#[test] +fn test_oid_to_pb_invalid_hex() { + let (_dir, gb) = common::setup_bare_repo(); + let oid = gb.oid_to_pb("not_hex"); + // Should return default (empty) bytes on invalid hex + assert!(oid.value.is_empty()); + assert_eq!(oid.hex, "not_hex"); +} diff --git a/tests/error_test.rs b/tests/error_test.rs new file mode 100644 index 0000000..f2e1043 --- /dev/null +++ b/tests/error_test.rs @@ -0,0 +1,69 @@ +use gitks::error::GitError; + +#[test] +fn test_error_display_variants() { + let cases: Vec<(GitError, &str)> = vec![ + (GitError::NotBareRepository, "not bare"), + ( + GitError::CommandFailed { + status_code: Some(1), + stderr: "err".into(), + }, + "command failed", + ), + (GitError::UnsafeCommand("rm".into()), "unsafe"), + (GitError::ObjectNotFound("abc".into()), "object not found"), + (GitError::RefNotFound("main".into()), "reference not found"), + (GitError::ParseError("bad".into()), "parse error"), + (GitError::Gix("gix err".into()), "gix error"), + (GitError::RepoNotFound, "repository not found"), + (GitError::Internal("oops".into()), "internal error"), + (GitError::NotFound("x".into()), "not found"), + (GitError::InvalidOid("bad".into()), "invalid oid"), + (GitError::Locked("lock".into()), "locked"), + ( + GitError::PermissionDenied("denied".into()), + "permission denied", + ), + (GitError::AuthFailed("auth".into()), "authentication failed"), + (GitError::PayloadTooLarge("big".into()), "payload too large"), + (GitError::InvalidArgument("arg".into()), "invalid argument"), + ]; + + for (err, keyword) in cases { + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains(keyword), + "error '{}' should contain '{}'", + msg, + keyword + ); + } +} + +#[test] +fn test_error_is_debug() { + let err = GitError::Internal("test".into()); + let debug = format!("{:?}", err); + assert!(debug.contains("Internal")); +} + +#[test] +fn test_io_error_conversion() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let git_err: GitError = io_err.into(); + match git_err { + GitError::Io(_) => {} + other => panic!("expected Io variant, got: {:?}", other), + } +} + +#[test] +fn test_boxed_error_conversion() { + let boxed: Box = "test error".into(); + let git_err: GitError = boxed.into(); + match git_err { + GitError::Gix(msg) => assert!(msg.contains("test error")), + other => panic!("expected Gix variant, got: {:?}", other), + } +} diff --git a/tests/oid_test.rs b/tests/oid_test.rs new file mode 100644 index 0000000..508fe82 --- /dev/null +++ b/tests/oid_test.rs @@ -0,0 +1,78 @@ +use gitks::error::GitError; +use gitks::oid::{ObjectId, ZERO_OID, hex_to_bytes}; + +#[test] +fn test_hex_to_bytes_valid() { + let bytes = hex_to_bytes("abcdef0123456789").unwrap(); + assert_eq!(bytes, vec![0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89]); +} + +#[test] +fn test_hex_to_bytes_sha1() { + // 40-char SHA-1 hex + let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; + let bytes = hex_to_bytes(hex).unwrap(); + assert_eq!(bytes.len(), 20); +} + +#[test] +fn test_hex_to_bytes_odd_length() { + let result = hex_to_bytes("abc"); + assert!(result.is_err()); + match result.unwrap_err() { + GitError::InvalidOid(_) => {} + other => panic!("expected InvalidOid, got: {:?}", other), + } +} + +#[test] +fn test_hex_to_bytes_invalid_hex() { + let result = hex_to_bytes("zzzz"); + assert!(result.is_err()); +} + +#[test] +fn test_hex_to_bytes_with_whitespace() { + let bytes = hex_to_bytes(" abcd ").unwrap(); + assert_eq!(bytes, vec![0xab, 0xcd]); +} + +#[test] +fn test_object_id_new_lowercase() { + let id = ObjectId::new("ABCDEF1234"); + assert_eq!(id.as_str(), "abcdef1234"); +} + +#[test] +fn test_object_id_display() { + let id = ObjectId::new("abcdef"); + assert_eq!(format!("{}", id), "abcdef"); +} + +#[test] +fn test_object_id_as_ref() { + let id = ObjectId::new("123456"); + let s: &str = id.as_ref(); + assert_eq!(s, "123456"); +} + +#[test] +fn test_object_id_try_into_gix() { + let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; + let id = ObjectId::new(hex); + let gix_id: gix::hash::ObjectId = (&id).try_into().unwrap(); + assert_eq!(gix_id.to_string(), hex); +} + +#[test] +fn test_object_id_try_into_invalid() { + let id = ObjectId::new("not_a_valid_hex_oid_at_all_way_too_long_for_any_hash"); + let result: Result = (&id).try_into(); + assert!(result.is_err()); +} + +#[test] +fn test_zero_oid() { + assert_eq!(ZERO_OID.len(), 40); + assert!(ZERO_OID.chars().all(|c| c == '0')); +} diff --git a/tests/refs_test.rs b/tests/refs_test.rs new file mode 100644 index 0000000..6c1f52b --- /dev/null +++ b/tests/refs_test.rs @@ -0,0 +1,64 @@ +mod common; + +fn hdr() -> gitks::pb::RepositoryHeader { + gitks::pb::RepositoryHeader { + relative_path: "test-repo".into(), + ..Default::default() + } +} + +#[tokio::test] +async fn test_list_refs_via_service() { + let (dir, _gb) = common::setup_bare_repo(); + let svc = common::setup_service(dir.path()); + + // list_refs is called internally by advertise_refs + // We test it indirectly through the service + use gitks::pb::pack_service_server::PackService; + let result = svc + .advertise_refs(tonic::Request::new(gitks::pb::AdvertiseRefsRequest { + repository: Some(hdr()), + protocol: None, + service: String::new(), + })) + .await + .unwrap() + .into_inner(); + + assert!(!result.references.is_empty(), "should have refs"); + let names: Vec<&str> = result.references.iter().map(|r| r.name.as_str()).collect(); + assert!( + names.iter().any(|n| n.contains("refs/heads/main")), + "should have main branch, got: {:?}", + names + ); + assert!( + names.iter().any(|n| n.contains("refs/heads/feature")), + "should have feature branch, got: {:?}", + names + ); + assert!( + names.iter().any(|n| n.contains("refs/tags/v0.1.0")), + "should have tag, got: {:?}", + names + ); +} + +#[test] +fn test_list_refs_direct() { + let (_dir, gb) = common::setup_bare_repo(); + let refs = gb.list_refs().expect("list_refs"); + + assert!(!refs.is_empty()); + let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect(); + assert!(names.iter().any(|n| n.contains("refs/heads/main"))); + assert!(names.iter().any(|n| n.contains("refs/heads/feature"))); + assert!(names.iter().any(|n| n.contains("refs/tags/v0.1.0"))); + + // Each ref should have a valid target OID + for r in &refs { + let oid = r.target_oid.as_ref().expect("ref should have target_oid"); + assert!(!oid.hex.is_empty(), "OID hex should not be empty"); + assert_eq!(oid.hex.len(), 40, "SHA-1 hex should be 40 chars"); + } +}