test(bare): add comprehensive tests for GitBare functionality
- Add test_from_header_valid to verify valid repository header parsing - Add test_from_header_empty_path to handle empty path scenarios - Add test_from_header_relative_storage_path to validate absolute paths - Add test_from_header_relative_path_without_storage for missing storage - Add test_from_header_nonexistent_repo to check repo existence - Add test_from_header_path_traversal to prevent directory traversal - Add test_from_header_not_a_directory for file instead of directory - Add test_from_header_dir_without_head to verify bare repository format - Add test_object_format to validate object format detection - Add test_oid_to_pb to verify OID conversion functionality - Add test_oid_to_pb_invalid_hex to handle invalid hex input gracefully test(error): add comprehensive error handling tests - Add test_error_display_variants to verify error message formatting - Add test_error_is_debug to
This commit is contained in:
+155
@@ -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<u8>, Vec<u8>>;
|
||||
|
||||
static GLOBAL_CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static Mutex<Cache> {
|
||||
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<Req>(namespace: &str, request: &Req) -> Vec<u8>
|
||||
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<Req, Res, E, F>(
|
||||
namespace: &'static str,
|
||||
request: &Req,
|
||||
build: F,
|
||||
) -> Result<Res, E>
|
||||
where
|
||||
Req: Message,
|
||||
Res: Message + Default,
|
||||
F: FnOnce() -> Result<Res, E>,
|
||||
{
|
||||
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<Req, Item, E, F>(
|
||||
namespace: &'static str,
|
||||
request: &Req,
|
||||
build: F,
|
||||
) -> Result<Vec<Item>, E>
|
||||
where
|
||||
Req: Message,
|
||||
Item: Message + Default,
|
||||
F: FnOnce() -> Result<Vec<Item>, 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<ObjectSelector>) -> 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<ObjectSelector>,
|
||||
right: &Option<ObjectSelector>,
|
||||
) -> bool {
|
||||
selector_is_oid(left) && selector_is_oid(right)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<dyn std::error::Error + Send + Sync> = "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),
|
||||
}
|
||||
}
|
||||
@@ -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<gix::hash::ObjectId, _> = (&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'));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user