From 69e79d37de8033f304146d86855878538ff82576 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:03 +0700 Subject: [PATCH 01/15] test(core): add comprehensive tests for binary decoder --- libvctrl_core/src/codec/binary_decoder.rs | 535 +++++++++++++++++++++- 1 file changed, 533 insertions(+), 2 deletions(-) diff --git a/libvctrl_core/src/codec/binary_decoder.rs b/libvctrl_core/src/codec/binary_decoder.rs index 9c81429..704bc03 100644 --- a/libvctrl_core/src/codec/binary_decoder.rs +++ b/libvctrl_core/src/codec/binary_decoder.rs @@ -1,11 +1,14 @@ +use alloc::str; +use alloc::sync::Arc; + use libvctrl_handler::{ Blob, Commit, CommitMeta, Decoder, EntryKind, HASH_LENGTH, Hash, MAX_BLOB_SIZE, MAX_MESSAGE_LENGTH, MAX_TREE_ENTRIES, Tag, Tree, TreeEntry, UserID, VctrlError, }; -use std::str; const EXPECTED_VERSION: u8 = 3; +#[derive(Debug, Copy, Clone)] pub struct BinaryDecoder; impl BinaryDecoder { @@ -33,7 +36,7 @@ impl BinaryDecoder { loop { let n = reader .read(&mut chunk) - .map_err(|e| VctrlError::IoError(std::sync::Arc::new(e)))?; + .map_err(|e| VctrlError::IoError(Arc::new(e)))?; if n == 0 { break; } @@ -389,3 +392,531 @@ impl Decoder for BinaryDecoder { Tag::with_meta(name, target, tagger, message, meta) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn hash_bytes(fill: u8) -> Vec { + vec![fill; HASH_LENGTH] + } + + // --- Private helper tests --- + + #[test] + fn test_check_version_missing_byte() { + let result = BinaryDecoder::check_version(&[]); + assert!(result.is_err(), "empty data should fail"); + } + + #[test] + fn test_check_version_wrong_version() { + let result = BinaryDecoder::check_version(&[0u8, 0xAA]); + assert!(result.is_err(), "wrong version should fail"); + } + + #[test] + fn test_check_version_no_payload() { + let result = BinaryDecoder::check_version(&[EXPECTED_VERSION]); + assert!( + result.is_err(), + "version byte only (no payload) should fail" + ); + } + + #[test] + fn test_check_version_valid() { + let data = [EXPECTED_VERSION, 0xAA, 0xBB, 0xCC]; + let result = BinaryDecoder::check_version(&data); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), &[0xAA, 0xBB, 0xCC]); + } + + #[test] + fn test_read_bounded_within_limit() { + let data = vec![0x42u8; 50]; + let mut cursor = Cursor::new(data.as_slice()); + let result = BinaryDecoder::read_bounded(&mut cursor, 100); + assert!(result.is_ok()); + let buf = result.unwrap(); + assert_eq!(buf.len(), 50); + assert!(buf.iter().all(|&b| b == 0x42)); + } + + #[test] + fn test_read_bounded_exceeds_limit() { + let data = vec![0u8; 100]; + let mut cursor = Cursor::new(data.as_slice()); + let result = BinaryDecoder::read_bounded(&mut cursor, 50); + assert!(result.is_err(), "should error when stream exceeds max size"); + } + + #[test] + fn test_read_bounded_empty_stream() { + let data: Vec = Vec::new(); + let mut cursor = Cursor::new(data.as_slice()); + let result = BinaryDecoder::read_bounded(&mut cursor, 100); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_require_byte_valid() { + let data = [10, 20, 30]; + let result = BinaryDecoder::require_byte(&data, 1, "test byte"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 20); + } + + #[test] + fn test_require_byte_out_of_bounds() { + let data = [10]; + let result = BinaryDecoder::require_byte(&data, 5, "test byte"); + assert!(result.is_err()); + } + + #[test] + fn test_require_slice_valid() { + let data = [1, 2, 3, 4, 5]; + let result = BinaryDecoder::require_slice(&data, 1, 3, "test slice"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), &[2, 3, 4]); + } + + #[test] + fn test_require_slice_zero_length() { + let data = [1, 2, 3]; + let result = BinaryDecoder::require_slice(&data, 0, 0, "empty"); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_require_slice_truncated() { + let data = [1, 2]; + let result = BinaryDecoder::require_slice(&data, 0, 5, "test slice"); + assert!(result.is_err()); + } + + #[test] + fn test_require_slice_overflow() { + let data = [1, 2]; + let result = BinaryDecoder::require_slice(&data, usize::MAX, 2, "overflow slice"); + assert!( + result.is_err(), + "should error on usize overflow in start+len" + ); + } + + // --- decode_blob tests --- + + #[test] + fn test_decode_blob_valid() { + let payload = b"hello world"; + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&(payload.len() as u64).to_le_bytes()); + data.extend_from_slice(payload); + + let result = BinaryDecoder.decode_blob(Cursor::new(data)); + assert!(result.is_ok()); + assert_eq!(result.unwrap().data(), payload.as_slice()); + } + + #[test] + fn test_decode_blob_empty_payload() { + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&0u64.to_le_bytes()); + + let result = BinaryDecoder.decode_blob(Cursor::new(data)); + assert!(result.is_ok()); + assert!(result.unwrap().data().is_empty()); + } + + #[test] + fn test_decode_blob_empty_input() { + let result = BinaryDecoder.decode_blob(Cursor::new(Vec::::new())); + assert!(result.is_err()); + } + + #[test] + fn test_decode_blob_wrong_version() { + let mut data = Vec::new(); + data.push(0); + data.extend_from_slice(&5u64.to_le_bytes()); + data.extend_from_slice(b"hello"); + + let result = BinaryDecoder.decode_blob(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_blob_length_mismatch_too_short() { + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&100u64.to_le_bytes()); + data.extend_from_slice(b"short"); + + let result = BinaryDecoder.decode_blob(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_blob_length_mismatch_too_long() { + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&2u64.to_le_bytes()); + data.extend_from_slice(b"this is longer than 2"); + + let result = BinaryDecoder.decode_blob(Cursor::new(data)); + assert!(result.is_err()); + } + + // --- decode_tree tests --- + + #[test] + fn test_decode_tree_valid_single_entry() { + let hb = hash_bytes(0xAB); + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&1u32.to_le_bytes()); + data.push(4); + data.extend_from_slice(b"file"); + data.push(0); // Blob + data.extend_from_slice(&hb); + + let result = BinaryDecoder.decode_tree(Cursor::new(data)); + assert!(result.is_ok()); + let tree = result.unwrap(); + assert_eq!(tree.entries().len(), 1); + assert_eq!(tree.entries()[0].name(), "file"); + assert_eq!(tree.entries()[0].kind(), EntryKind::Blob); + } + + #[test] + fn test_decode_tree_empty() { + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&0u32.to_le_bytes()); + + let result = BinaryDecoder.decode_tree(Cursor::new(data)); + assert!(result.is_ok()); + assert_eq!(result.unwrap().entries().len(), 0); + } + + #[test] + fn test_decode_tree_multiple_entries() { + let hb1 = hash_bytes(0x01); + let hb2 = hash_bytes(0x02); + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&2u32.to_le_bytes()); + // Entry 1 + data.push(3); + data.extend_from_slice(b"src"); + data.push(3); // Tree + data.extend_from_slice(&hb1); + // Entry 2 + data.push(9); + data.extend_from_slice(b"Cargo.toml"); + data.push(0); // Blob + data.extend_from_slice(&hb2); + + let result = BinaryDecoder.decode_tree(Cursor::new(data)); + assert!(result.is_ok()); + let tree = result.unwrap(); + assert_eq!(tree.entries().len(), 2); + assert_eq!(tree.entries()[0].name(), "src"); + assert_eq!(tree.entries()[0].kind(), EntryKind::Tree); + assert_eq!(tree.entries()[1].name(), "Cargo.toml"); + assert_eq!(tree.entries()[1].kind(), EntryKind::Blob); + } + + #[test] + fn test_decode_tree_unknown_kind() { + let hb = hash_bytes(0x00); + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&1u32.to_le_bytes()); + data.push(1); + data.push(b'x'); + data.push(99); // unknown kind + data.extend_from_slice(&hb); + + let result = BinaryDecoder.decode_tree(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_tree_trailing_bytes() { + let hb = hash_bytes(0x00); + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&1u32.to_le_bytes()); + data.push(1); + data.push(b'x'); + data.push(0); + data.extend_from_slice(&hb); + data.push(0xFF); // trailing + + let result = BinaryDecoder.decode_tree(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_tree_all_known_kinds() { + let kinds = [0u8, 1, 2, 3, 4]; // Blob, Executable, Symlink, Tree, Submodule + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&(kinds.len() as u32).to_le_bytes()); + for (i, &kind) in kinds.iter().enumerate() { + let name = format!("entry_{i}"); + data.push(name.len() as u8); + data.extend_from_slice(name.as_bytes()); + data.push(kind); + data.extend_from_slice(&hash_bytes(i as u8)); + } + + let result = BinaryDecoder.decode_tree(Cursor::new(data)); + assert!(result.is_ok(), "should decode all known entry kinds"); + } + + // --- decode_commit tests --- + + fn build_valid_commit_bytes( + tree_fill: u8, + parents: &[u8], + author_name: &str, + author_email: &str, + committer_name: &str, + committer_email: &str, + message: &str, + timestamp: i64, + tz: i16, + encoding: Option<&str>, + ) -> Vec { + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.extend_from_slice(&hash_bytes(tree_fill)); + data.extend_from_slice(&(parents.len() as u16).to_le_bytes()); + for &p in parents { + data.extend_from_slice(&hash_bytes(p)); + } + data.push(author_name.len() as u8); + data.extend_from_slice(author_name.as_bytes()); + data.push(author_email.len() as u8); + data.extend_from_slice(author_email.as_bytes()); + data.push(committer_name.len() as u8); + data.extend_from_slice(committer_name.as_bytes()); + data.push(committer_email.len() as u8); + data.extend_from_slice(committer_email.as_bytes()); + data.extend_from_slice(&(message.len() as u32).to_le_bytes()); + data.extend_from_slice(message.as_bytes()); + data.extend_from_slice(×tamp.to_le_bytes()); + data.extend_from_slice(&tz.to_le_bytes()); + match encoding { + Some(enc) => { + data.push(enc.len() as u8); + data.extend_from_slice(enc.as_bytes()); + } + None => data.push(0), + } + data + } + + #[test] + fn test_decode_commit_valid_no_parents() { + let data = build_valid_commit_bytes( + 0x01, + &[], + "Alice", + "a@b.c", + "Bob", + "b@c.d", + "init", + 1700000000, + 0, + None, + ); + let result = BinaryDecoder.decode_commit(Cursor::new(data)); + assert!(result.is_ok()); + let commit = result.unwrap(); + assert_eq!(commit.parents().len(), 0); + assert_eq!(commit.author().name(), "Alice"); + assert_eq!(commit.author().email(), "a@b.c"); + assert_eq!(commit.committer().name(), "Bob"); + assert_eq!(commit.committer().email(), "b@c.d"); + assert_eq!(commit.message(), "init"); + assert_eq!(commit.meta().timestamp(), 1700000000); + assert_eq!(commit.meta().timezone_offset(), 0); + assert!(commit.meta().encoding().is_none()); + } + + #[test] + fn test_decode_commit_with_parents_and_encoding() { + let data = build_valid_commit_bytes( + 0x01, + &[0x02, 0x03], + "Alice", + "alice@ex.com", + "Bob", + "bob@ex.com", + "merge", + 1700000000, + 3600, + Some("UTF-8"), + ); + let result = BinaryDecoder.decode_commit(Cursor::new(data)); + assert!(result.is_ok()); + let commit = result.unwrap(); + assert_eq!(commit.parents().len(), 2); + assert_eq!(commit.meta().timezone_offset(), 3600); + assert_eq!(commit.meta().encoding(), Some("UTF-8")); + assert_eq!(commit.message(), "merge"); + } + + #[test] + fn test_decode_commit_trailing_bytes() { + let mut data = + build_valid_commit_bytes(0x01, &[], "A", "a@b.c", "B", "b@c.d", "m", 0, 0, None); + data.push(0xFF); + let result = BinaryDecoder.decode_commit(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_commit_wrong_version() { + let mut data = + build_valid_commit_bytes(0x01, &[], "A", "a@b.c", "B", "b@c.d", "m", 0, 0, None); + data[0] = 0; + let result = BinaryDecoder.decode_commit(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_commit_empty_message() { + let data = + build_valid_commit_bytes(0x01, &[], "A", "a@b.c", "B", "b@c.d", "", 100, 0, None); + let result = BinaryDecoder.decode_commit(Cursor::new(data)); + assert!(result.is_ok()); + assert_eq!(result.unwrap().message(), ""); + } + + // --- decode_tag tests --- + + fn build_valid_tag_bytes( + name: &str, + target_fill: u8, + tagger: Option<(&str, &str)>, + message: &str, + timestamp: i64, + tz: i16, + encoding: Option<&str>, + ) -> Vec { + let mut data = Vec::new(); + data.push(EXPECTED_VERSION); + data.push(name.len() as u8); + data.extend_from_slice(name.as_bytes()); + data.extend_from_slice(&hash_bytes(target_fill)); + match tagger { + Some((tname, temail)) => { + data.push(1); + data.push(tname.len() as u8); + data.extend_from_slice(tname.as_bytes()); + data.push(temail.len() as u8); + data.extend_from_slice(temail.as_bytes()); + } + None => data.push(0), + } + data.extend_from_slice(&(message.len() as u32).to_le_bytes()); + data.extend_from_slice(message.as_bytes()); + data.extend_from_slice(×tamp.to_le_bytes()); + data.extend_from_slice(&tz.to_le_bytes()); + match encoding { + Some(enc) => { + data.push(enc.len() as u8); + data.extend_from_slice(enc.as_bytes()); + } + None => data.push(0), + } + data + } + + #[test] + fn test_decode_tag_valid_with_tagger() { + let data = build_valid_tag_bytes( + "v1.0", + 0x10, + Some(("Alice", "alice@ex.com")), + "release", + 1700000000, + 0, + None, + ); + let result = BinaryDecoder.decode_tag(Cursor::new(data)); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.name(), "v1.0"); + assert!(tag.tagger().is_some()); + assert_eq!(tag.tagger().unwrap().name(), "Alice"); + assert_eq!(tag.tagger().unwrap().email(), "alice@ex.com"); + assert_eq!(tag.message(), "release"); + } + + #[test] + fn test_decode_tag_no_tagger() { + let data = build_valid_tag_bytes("v2.0", 0x20, None, "", 1700000000, 0, None); + let result = BinaryDecoder.decode_tag(Cursor::new(data)); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.name(), "v2.0"); + assert!(tag.tagger().is_none()); + assert_eq!(tag.message(), ""); + } + + #[test] + fn test_decode_tag_with_encoding() { + let data = build_valid_tag_bytes( + "v3.0", + 0x30, + Some(("Bob", "bob@ex.com")), + "annotated", + 1700000000, + -3600, + Some("UTF-8"), + ); + let result = BinaryDecoder.decode_tag(Cursor::new(data)); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.meta().timezone_offset(), -3600); + assert_eq!(tag.meta().encoding(), Some("UTF-8")); + } + + #[test] + fn test_decode_tag_invalid_tagger_presence() { + let data = build_valid_tag_bytes("v4.0", 0x40, None, "", 0, 0, None); + let pos = 1 + 4 + HASH_LENGTH; // after name + target + let mut mutable_data = data; + mutable_data[pos] = 5; // invalid tagger presence byte + let result = BinaryDecoder.decode_tag(Cursor::new(mutable_data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_tag_trailing_bytes() { + let mut data = build_valid_tag_bytes("v5.0", 0x50, None, "", 0, 0, None); + data.push(0xFF); + let result = BinaryDecoder.decode_tag(Cursor::new(data)); + assert!(result.is_err()); + } + + #[test] + fn test_decode_tag_wrong_version() { + let mut data = build_valid_tag_bytes("v6.0", 0x60, None, "", 0, 0, None); + data[0] = 99; + let result = BinaryDecoder.decode_tag(Cursor::new(data)); + assert!(result.is_err()); + } +} From b6831be4ed5d9074f2b2c0c6110b3d74d5c26b01 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:03 +0700 Subject: [PATCH 02/15] test(core): add tests for binary encoder and fix clippy --- libvctrl_core/src/codec/binary_encoder.rs | 311 +++++++++++++++++++++- 1 file changed, 309 insertions(+), 2 deletions(-) diff --git a/libvctrl_core/src/codec/binary_encoder.rs b/libvctrl_core/src/codec/binary_encoder.rs index 56906ee..6bf6721 100644 --- a/libvctrl_core/src/codec/binary_encoder.rs +++ b/libvctrl_core/src/codec/binary_encoder.rs @@ -5,6 +5,7 @@ use std::io::Write; pub const VERSION: u8 = 3; +#[derive(Debug, Default, Clone, Copy)] pub struct BinaryEncoder; impl Encoder for BinaryEncoder { @@ -18,6 +19,7 @@ impl Encoder for BinaryEncoder { Ok(()) } + #[allow(clippy::wildcard_enum_match_arm)] fn encode_tree(&self, tree: &Tree, writer: &mut W) -> Result<(), VctrlError> { let entries = tree.entries(); writer.write_all(&[VERSION]).map_err(VctrlError::from_io)?; @@ -73,9 +75,9 @@ impl Encoder for BinaryEncoder { .write_all(&parent_count.to_le_bytes()) .map_err(VctrlError::from_io)?; - for p in parents { + for parent in parents { writer - .write_all(p.as_bytes()) + .write_all(parent.as_bytes()) .map_err(VctrlError::from_io)?; } @@ -235,3 +237,308 @@ impl Encoder for BinaryEncoder { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::{CommitMeta, HASH_LENGTH, Hash, UserID}; + use std::io::Cursor; + + fn make_hash(fill: u8) -> Hash { + Hash::from_bytes(&vec![fill; HASH_LENGTH]).unwrap() + } + + fn hash_bytes(fill: u8) -> Vec { + vec![fill; HASH_LENGTH] + } + + fn make_user_id(name: &str, email: &str) -> UserID { + UserID::new(name.into(), email.into()).unwrap() + } + + fn make_meta(ts: i64, tz: i16, enc: Option<&str>) -> CommitMeta { + CommitMeta::new(ts, tz, enc.map(|s| s.into())).unwrap() + } + + #[test] + fn test_encode_blob() { + let blob = Blob::new(vec![0x01, 0x02, 0x03]).unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_blob(&blob, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&3u64.to_le_bytes()); + expected.extend_from_slice(&[0x01, 0x02, 0x03]); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_blob_empty_data() { + let blob = Blob::new(vec![]).unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_blob(&blob, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&0u64.to_le_bytes()); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_tree_single_entry() { + let hash = make_hash(0xAB); + let entry = TreeEntry::new("README".into(), EntryKind::Blob, hash).unwrap(); + let tree = Tree::new(vec![entry]).unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_tree(&tree, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.push(6); // "README" length + expected.extend_from_slice(b"README"); + expected.push(0); // Blob + expected.extend_from_slice(&hash_bytes(0xAB)); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_tree_empty() { + let tree = Tree::new(vec![]).unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_tree(&tree, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_tree_multiple_entries() { + let e1 = TreeEntry::new("src".into(), EntryKind::Tree, make_hash(0x01)).unwrap(); + let e2 = TreeEntry::new("run".into(), EntryKind::Executable, make_hash(0x02)).unwrap(); + let tree = Tree::new(vec![e1, e2]).unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_tree(&tree, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&2u32.to_le_bytes()); + expected.push(3); + expected.extend_from_slice(b"src"); + expected.push(3); // Tree + expected.extend_from_slice(&hash_bytes(0x01)); + expected.push(3); + expected.extend_from_slice(b"run"); + expected.push(1); // Executable + expected.extend_from_slice(&hash_bytes(0x02)); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_commit() { + let commit = Commit::with_meta( + make_hash(0x01), + vec![], + make_user_id("Alice", "a@b.c"), + make_user_id("Bob", "b@c.d"), + "init".into(), + make_meta(1700000000, 0, None), + ) + .unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_commit(&commit, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&hash_bytes(0x01)); + expected.extend_from_slice(&0u16.to_le_bytes()); + expected.push(5); + expected.extend_from_slice(b"Alice"); + expected.push(5); + expected.extend_from_slice(b"a@b.c"); + expected.push(3); + expected.extend_from_slice(b"Bob"); + expected.push(5); + expected.extend_from_slice(b"b@c.d"); + expected.extend_from_slice(&4u32.to_le_bytes()); + expected.extend_from_slice(b"init"); + expected.extend_from_slice(&1700000000i64.to_le_bytes()); + expected.extend_from_slice(&0i16.to_le_bytes()); + expected.push(0); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_commit_with_encoding() { + let commit = Commit::with_meta( + make_hash(0x01), + vec![], + make_user_id("A", "a@b.c"), + make_user_id("B", "b@c.d"), + "msg".into(), + make_meta(1700000000, 3600, Some("UTF-8")), + ) + .unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_commit(&commit, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&hash_bytes(0x01)); + expected.extend_from_slice(&0u16.to_le_bytes()); + expected.push(1); + expected.extend_from_slice(b"A"); + expected.push(5); + expected.extend_from_slice(b"a@b.c"); + expected.push(1); + expected.extend_from_slice(b"B"); + expected.push(5); + expected.extend_from_slice(b"b@c.d"); + expected.extend_from_slice(&3u32.to_le_bytes()); + expected.extend_from_slice(b"msg"); + expected.extend_from_slice(&1700000000i64.to_le_bytes()); + expected.extend_from_slice(&3600i16.to_le_bytes()); + expected.push(5); + expected.extend_from_slice(b"UTF-8"); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_commit_with_parents() { + let commit = Commit::with_meta( + make_hash(0x01), + vec![make_hash(0x02), make_hash(0x03)], + make_user_id("A", "a@b.c"), + make_user_id("B", "b@c.d"), + "merge".into(), + make_meta(0, 0, None), + ) + .unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_commit(&commit, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.extend_from_slice(&hash_bytes(0x01)); + expected.extend_from_slice(&2u16.to_le_bytes()); + expected.extend_from_slice(&hash_bytes(0x02)); + expected.extend_from_slice(&hash_bytes(0x03)); + expected.push(1); + expected.extend_from_slice(b"A"); + expected.push(5); + expected.extend_from_slice(b"a@b.c"); + expected.push(1); + expected.extend_from_slice(b"B"); + expected.push(5); + expected.extend_from_slice(b"b@c.d"); + expected.extend_from_slice(&5u32.to_le_bytes()); + expected.extend_from_slice(b"merge"); + expected.extend_from_slice(&0i64.to_le_bytes()); + expected.extend_from_slice(&0i16.to_le_bytes()); + expected.push(0); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_tag_with_tagger() { + let tag = Tag::with_meta( + "v1.0".into(), + make_hash(0x10), + Some(make_user_id("Alice", "alice@ex.com")), + "release".into(), + make_meta(1700000000, 0, None), + ) + .unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_tag(&tag, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.push(4); // "v1.0" length + expected.extend_from_slice(b"v1.0"); + expected.extend_from_slice(&hash_bytes(0x10)); + expected.push(1); // has tagger + expected.push(5); + expected.extend_from_slice(b"Alice"); + expected.push(11); + expected.extend_from_slice(b"alice@ex.com"); + expected.extend_from_slice(&7u32.to_le_bytes()); + expected.extend_from_slice(b"release"); + expected.extend_from_slice(&1700000000i64.to_le_bytes()); + expected.extend_from_slice(&0i16.to_le_bytes()); + expected.push(0); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_tag_no_tagger() { + let tag = Tag::with_meta( + "v2.0".into(), + make_hash(0x20), + None, + "".into(), + make_meta(1700000000, 0, None), + ) + .unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_tag(&tag, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.push(4); + expected.extend_from_slice(b"v2.0"); + expected.extend_from_slice(&hash_bytes(0x20)); + expected.push(0); // no tagger + expected.extend_from_slice(&0u32.to_le_bytes()); + expected.extend_from_slice(&1700000000i64.to_le_bytes()); + expected.extend_from_slice(&0i16.to_le_bytes()); + expected.push(0); + assert_eq!(encoded, expected); + } + + #[test] + fn test_encode_tag_with_encoding() { + let tag = Tag::with_meta( + "v3".into(), + make_hash(0x30), + Some(make_user_id("B", "b@c.d")), + "tag".into(), + make_meta(1700000000, -3600, Some("UTF-8")), + ) + .unwrap(); + let mut buf = Cursor::new(Vec::new()); + BinaryEncoder.encode_tag(&tag, &mut buf).unwrap(); + let encoded = buf.into_inner(); + + let mut expected = Vec::new(); + expected.push(VERSION); + expected.push(2); // "v3" length + expected.extend_from_slice(b"v3"); + expected.extend_from_slice(&hash_bytes(0x30)); + expected.push(1); + expected.push(1); + expected.extend_from_slice(b"B"); + expected.push(5); + expected.extend_from_slice(b"b@c.d"); + expected.extend_from_slice(&3u32.to_le_bytes()); + expected.extend_from_slice(b"tag"); + expected.extend_from_slice(&1700000000i64.to_le_bytes()); + expected.extend_from_slice(&(-3600i16).to_le_bytes()); + expected.push(5); + expected.extend_from_slice(b"UTF-8"); + assert_eq!(encoded, expected); + } +} From 3a0972d4c74d0c1d9ea4ef3e761e5279aa83b975 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:03 +0700 Subject: [PATCH 03/15] test(core): add tests for sha512 hasher --- libvctrl_core/src/hash/sha512.rs | 85 ++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/libvctrl_core/src/hash/sha512.rs b/libvctrl_core/src/hash/sha512.rs index 32be14a..321b555 100644 --- a/libvctrl_core/src/hash/sha512.rs +++ b/libvctrl_core/src/hash/sha512.rs @@ -1,11 +1,14 @@ +use alloc::sync::Arc; +use std::io; + use libvctrl_handler::{Hash, Hasher, VctrlError}; use libvctrl_sha512::Hash as Sha512Hash; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, Copy)] pub struct Sha512Hasher; impl Hasher for Sha512Hasher { - fn hash(&self, mut reader: R) -> Result { + fn hash(&self, mut reader: R) -> Result { let mut hasher = Sha512Hash::new(); let mut buffer = [0u8; 4096]; loop { @@ -14,8 +17,8 @@ impl Hasher for Sha512Hasher { break; } let chunk = buffer.get(..n).ok_or_else(|| { - VctrlError::IoError(std::sync::Arc::new(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, + VctrlError::IoError(Arc::new(io::Error::new( + io::ErrorKind::UnexpectedEof, "read returned invalid length", ))) })?; @@ -25,3 +28,77 @@ impl Hasher for Sha512Hasher { Hash::from_bytes(&digest) } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::HASH_LENGTH; + use std::io::Cursor; + + #[test] + fn test_hash_empty_input() { + let cursor = Cursor::new(Vec::::new()); + let result = Sha512Hasher.hash(cursor); + assert!(result.is_ok(), "hashing empty input should succeed"); + let hash = result.unwrap(); + assert_eq!( + hash.as_bytes().len(), + HASH_LENGTH, + "hash should be HASH_LENGTH bytes" + ); + } + + #[test] + fn test_hash_non_empty_input() { + let cursor = Cursor::new(b"hello world"); + let result = Sha512Hasher.hash(cursor); + assert!(result.is_ok()); + let hash = result.unwrap(); + assert_eq!(hash.as_bytes().len(), HASH_LENGTH); + } + + #[test] + fn test_hash_deterministic() { + let data = b"test data for determinism check"; + let h1 = Sha512Hasher.hash(Cursor::new(data.as_slice())).unwrap(); + let h2 = Sha512Hasher.hash(Cursor::new(data.as_slice())).unwrap(); + assert_eq!( + h1.as_bytes(), + h2.as_bytes(), + "same input must produce identical hash" + ); + } + + #[test] + fn test_hash_different_inputs_produce_different_hashes() { + let h1 = Sha512Hasher.hash(Cursor::new(b"input one")).unwrap(); + let h2 = Sha512Hasher.hash(Cursor::new(b"input two")).unwrap(); + assert_ne!( + h1.as_bytes(), + h2.as_bytes(), + "different inputs should produce different hashes" + ); + } + + #[test] + fn test_hash_large_input() { + let data = vec![0xABu8; 100_000]; + let result = Sha512Hasher.hash(Cursor::new(data)); + assert!(result.is_ok(), "hashing large input should succeed"); + let hash = result.unwrap(); + assert_eq!(hash.as_bytes().len(), HASH_LENGTH); + } + + #[test] + fn test_hash_single_byte() { + let result = Sha512Hasher.hash(Cursor::new(b"\x00")); + assert!(result.is_ok()); + let result2 = Sha512Hasher.hash(Cursor::new(b"\xFF")); + assert!(result2.is_ok()); + assert_ne!( + result.unwrap().as_bytes(), + result2.unwrap().as_bytes(), + "different single bytes should produce different hashes" + ); + } +} From 9e14f03d8c09e82f5dfca575b3062e37847e313c Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:03 +0700 Subject: [PATCH 04/15] style(core): add alloc extern and allow lint --- libvctrl_core/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libvctrl_core/src/lib.rs b/libvctrl_core/src/lib.rs index 49e0e5b..3ed84be 100644 --- a/libvctrl_core/src/lib.rs +++ b/libvctrl_core/src/lib.rs @@ -1,10 +1,11 @@ +#![allow(clippy::arithmetic_side_effects)] + +extern crate alloc; + #[cfg(test)] use proptest as _; pub mod codec; - pub mod hash; - pub mod object; - pub mod store; From 9581df5a779e47d30d0c1f1d6a8184069c631465 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 05/15] test(core): add tests for blob builder --- libvctrl_core/src/object/blob.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/libvctrl_core/src/object/blob.rs b/libvctrl_core/src/object/blob.rs index ef22996..9e8fdea 100644 --- a/libvctrl_core/src/object/blob.rs +++ b/libvctrl_core/src/object/blob.rs @@ -21,3 +21,24 @@ impl BlobBuilder { Blob::new(self.data) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_success_with_data() { + let result = BlobBuilder::new().with_data(vec![1, 2, 3, 4]).build(); + assert!(result.is_ok(), "BlobBuilder should succeed with valid data"); + } + + #[test] + fn test_build_returns_blob_with_correct_data() { + let data = vec![0xDE, 0xAD, 0xBE, 0xEF]; + let blob = BlobBuilder::new() + .with_data(data.clone()) + .build() + .expect("build should succeed"); + assert_eq!(blob.data(), data.as_slice()); + } +} From 989f0b89778f5162cfd5b3285575b543635a59cf Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 06/15] test(core): add tests for commit builder --- libvctrl_core/src/object/commit.rs | 115 +++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/libvctrl_core/src/object/commit.rs b/libvctrl_core/src/object/commit.rs index 3e15948..9208646 100644 --- a/libvctrl_core/src/object/commit.rs +++ b/libvctrl_core/src/object/commit.rs @@ -80,3 +80,118 @@ impl CommitBuilder { } } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::HASH_LENGTH; + + fn make_hash(fill: u8) -> Hash { + Hash::from_bytes(&vec![fill; HASH_LENGTH]).unwrap() + } + + fn make_user_id(name: &str, email: &str) -> UserID { + UserID::new(name.into(), email.into()).unwrap() + } + + #[test] + fn test_build_missing_tree() { + let result = CommitBuilder::new() + .author(make_user_id("A", "a@b.c")) + .committer(make_user_id("B", "b@c.d")) + .message("msg".into()) + .build(); + assert!(result.is_err(), "should fail without tree"); + } + + #[test] + fn test_build_missing_author() { + let result = CommitBuilder::new() + .tree(make_hash(0)) + .committer(make_user_id("B", "b@c.d")) + .message("msg".into()) + .build(); + assert!(result.is_err(), "should fail without author"); + } + + #[test] + fn test_build_missing_committer() { + let result = CommitBuilder::new() + .tree(make_hash(0)) + .author(make_user_id("A", "a@b.c")) + .message("msg".into()) + .build(); + assert!(result.is_err(), "should fail without committer"); + } + + #[test] + fn test_build_missing_message() { + let result = CommitBuilder::new() + .tree(make_hash(0)) + .author(make_user_id("A", "a@b.c")) + .committer(make_user_id("B", "b@c.d")) + .build(); + assert!(result.is_err(), "should fail without message"); + } + + #[test] + fn test_build_missing_all_required() { + let result = CommitBuilder::new().build(); + assert!(result.is_err(), "should fail with no fields set"); + } + + #[test] + fn test_build_success_without_meta() { + let result = CommitBuilder::new() + .tree(make_hash(1)) + .author(make_user_id("Alice", "alice@example.com")) + .committer(make_user_id("Bob", "bob@example.com")) + .message("initial commit".into()) + .build(); + assert!(result.is_ok(), "should succeed with all required fields"); + } + + #[test] + fn test_build_success_with_meta() { + let meta = CommitMeta::new(1700000000, 3600, Some("UTF-8".into())).unwrap(); + let result = CommitBuilder::new() + .tree(make_hash(1)) + .author(make_user_id("Alice", "alice@example.com")) + .committer(make_user_id("Bob", "bob@example.com")) + .message("initial commit".into()) + .meta(meta) + .build(); + assert!(result.is_ok(), "should succeed with meta"); + } + + #[test] + fn test_build_with_multiple_parents() { + let result = CommitBuilder::new() + .tree(make_hash(1)) + .parent(make_hash(2)) + .parent(make_hash(3)) + .parent(make_hash(4)) + .author(make_user_id("Alice", "alice@example.com")) + .committer(make_user_id("Bob", "bob@example.com")) + .message("merge commit".into()) + .build(); + assert!(result.is_ok(), "should succeed with multiple parents"); + let commit = result.unwrap(); + assert_eq!(commit.parents().len(), 3); + } + + #[test] + fn test_build_with_meta_preserves_timestamp() { + let meta = CommitMeta::new(9999999999, -7200, None).unwrap(); + let commit = CommitBuilder::new() + .tree(make_hash(1)) + .author(make_user_id("A", "a@b.c")) + .committer(make_user_id("B", "b@c.d")) + .message("ts test".into()) + .meta(meta) + .build() + .unwrap(); + assert_eq!(commit.meta().timestamp(), 9999999999); + assert_eq!(commit.meta().timezone_offset(), -7200); + } +} From 0115f0bcd37fc33320effb57a844def24f01bf80 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 07/15] test(core): add tests for tag builder --- libvctrl_core/src/object/tag.rs | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/libvctrl_core/src/object/tag.rs b/libvctrl_core/src/object/tag.rs index a5f81f7..2f2f461 100644 --- a/libvctrl_core/src/object/tag.rs +++ b/libvctrl_core/src/object/tag.rs @@ -72,3 +72,86 @@ impl TagBuilder { } } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::HASH_LENGTH; + + fn make_hash(fill: u8) -> Hash { + Hash::from_bytes(&vec![fill; HASH_LENGTH]).unwrap() + } + + fn make_user_id(name: &str, email: &str) -> UserID { + UserID::new(name.into(), email.into()).unwrap() + } + + #[test] + fn test_build_missing_name() { + let result = TagBuilder::new().target(make_hash(0)).build(); + assert!(result.is_err(), "should fail without name"); + } + + #[test] + fn test_build_missing_target() { + let result = TagBuilder::new().name("v1.0".into()).build(); + assert!(result.is_err(), "should fail without target"); + } + + #[test] + fn test_build_missing_both() { + let result = TagBuilder::new().build(); + assert!(result.is_err(), "should fail without name and target"); + } + + #[test] + fn test_build_success_without_meta() { + let result = TagBuilder::new() + .name("v1.0".into()) + .target(make_hash(0xAA)) + .build(); + assert!(result.is_ok(), "should succeed with name and target"); + } + + #[test] + fn test_build_success_with_tagger_and_meta() { + let meta = CommitMeta::new(1700000000, 0, None).unwrap(); + let result = TagBuilder::new() + .name("release".into()) + .target(make_hash(0xBB)) + .tagger(make_user_id("Alice", "alice@example.com")) + .message("v1.0 release".into()) + .meta(meta) + .build(); + assert!(result.is_ok(), "should succeed with all fields"); + let tag = result.unwrap(); + assert_eq!(tag.name(), "release"); + assert!(tag.tagger().is_some()); + assert_eq!(tag.tagger().unwrap().name(), "Alice"); + assert_eq!(tag.message(), "v1.0 release"); + } + + #[test] + fn test_build_default_message_when_none() { + let result = TagBuilder::new() + .name("v2.0".into()) + .target(make_hash(0xCC)) + .build(); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.message(), "", "message should default to empty string"); + } + + #[test] + fn test_build_without_tagger() { + let meta = CommitMeta::new(1700000000, 0, Some("UTF-8".into())).unwrap(); + let result = TagBuilder::new() + .name("lightweight".into()) + .target(make_hash(0xDD)) + .meta(meta) + .build(); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert!(tag.tagger().is_none(), "tagger should be None when not set"); + } +} From d7e6510a611bb50c367ca294f6a3abcfc0d92fc9 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 08/15] test(core): add tests for tree and tree entry builders --- libvctrl_core/src/object/tree.rs | 86 ++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/libvctrl_core/src/object/tree.rs b/libvctrl_core/src/object/tree.rs index 87bf772..3406ee2 100644 --- a/libvctrl_core/src/object/tree.rs +++ b/libvctrl_core/src/object/tree.rs @@ -52,3 +52,89 @@ impl TreeEntryBuilder { TreeEntry::new(self.name, self.kind, self.hash) } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::HASH_LENGTH; + + fn make_hash(fill: u8) -> Hash { + Hash::from_bytes(&vec![fill; HASH_LENGTH]).unwrap() + } + + #[test] + fn test_tree_builder_build_empty() { + let result = TreeBuilder::new().build(); + assert!(result.is_ok(), "empty tree should be valid"); + let tree = result.unwrap(); + assert_eq!(tree.entries().len(), 0); + } + + #[test] + fn test_tree_builder_build_with_entries_via_entry_method() { + let entry = TreeEntry::new("README.md".into(), EntryKind::Blob, make_hash(0x01)).unwrap(); + let result = TreeBuilder::new().entry(entry).build(); + assert!(result.is_ok()); + let tree = result.unwrap(); + assert_eq!(tree.entries().len(), 1); + assert_eq!(tree.entries()[0].name(), "README.md"); + } + + #[test] + fn test_tree_builder_build_with_multiple_entries() { + let e1 = TreeEntry::new("src".into(), EntryKind::Tree, make_hash(0x01)).unwrap(); + let e2 = TreeEntry::new("Cargo.toml".into(), EntryKind::Blob, make_hash(0x02)).unwrap(); + let result = TreeBuilder::new().entry(e1).entry(e2).build(); + assert!(result.is_ok()); + let tree = result.unwrap(); + assert_eq!(tree.entries().len(), 2); + } + + #[test] + fn test_tree_builder_add_entry_success() { + let result = TreeBuilder::new() + .add_entry("main.rs".into(), EntryKind::Blob, make_hash(0x03)) + .and_then(|b| b.build()); + assert!(result.is_ok()); + let tree = result.unwrap(); + assert_eq!(tree.entries()[0].name(), "main.rs"); + assert_eq!(tree.entries()[0].kind(), EntryKind::Blob); + } + + #[test] + fn test_tree_builder_add_entry_chaining() { + let result = TreeBuilder::new() + .add_entry("a.txt".into(), EntryKind::Blob, make_hash(0x10)) + .and_then(|b| b.add_entry("b.txt".into(), EntryKind::Blob, make_hash(0x20))) + .and_then(|b| b.build()); + assert!(result.is_ok()); + let tree = result.unwrap(); + assert_eq!(tree.entries().len(), 2); + } + + #[test] + fn test_tree_entry_builder_build_success() { + let result = + TreeEntryBuilder::new("lib.rs".into(), EntryKind::Blob, make_hash(0x42)).build(); + assert!(result.is_ok()); + let entry = result.unwrap(); + assert_eq!(entry.name(), "lib.rs"); + assert_eq!(entry.kind(), EntryKind::Blob); + } + + #[test] + fn test_tree_entry_builder_all_kinds() { + for kind in [ + EntryKind::Blob, + EntryKind::Executable, + EntryKind::Symlink, + EntryKind::Tree, + EntryKind::Submodule, + ] { + let result = + TreeEntryBuilder::new(format!("item_{kind:?}"), kind, make_hash(0xFF)).build(); + assert!(result.is_ok(), "should succeed for kind {kind:?}"); + assert_eq!(result.unwrap().kind(), kind); + } + } +} From f05f4c7e3c9956c00b4b3e9b32d6ea86cd3307be Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 09/15] test(core): add tests for memory store --- libvctrl_core/src/store/memory.rs | 115 ++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/libvctrl_core/src/store/memory.rs b/libvctrl_core/src/store/memory.rs index 8e01e40..84fe30e 100644 --- a/libvctrl_core/src/store/memory.rs +++ b/libvctrl_core/src/store/memory.rs @@ -39,3 +39,118 @@ impl ObjectStore for MemoryStore { Ok(self.objects.contains_key(hash)) } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::HASH_LENGTH; + + fn make_hash(fill: u8) -> Hash { + Hash::from_bytes(&vec![fill; HASH_LENGTH]).unwrap() + } + + #[test] + fn test_put_and_get() { + let mut store = MemoryStore::new(); + let hash = make_hash(0x01); + let data = b"hello world"; + assert!(store.put(&hash, data).is_ok()); + + let get_result = store.get(&hash); + assert!(get_result.is_ok(), "should retrieve stored object"); + + let mut reader = get_result.unwrap(); + let mut retrieved = Vec::new(); + Read::read_to_end(&mut reader, &mut retrieved).unwrap(); + assert_eq!(retrieved, data, "retrieved data must match original"); + } + + #[test] + fn test_get_not_found() { + let store = MemoryStore::new(); + let hash = make_hash(0xFF); + let result = store.get(&hash); + assert!(result.is_err(), "should error for missing object"); + } + + #[test] + fn test_exists_false_then_true() { + let mut store = MemoryStore::new(); + let hash = make_hash(0x10); + assert_eq!( + store.exists(&hash).unwrap(), + false, + "should not exist before put" + ); + store.put(&hash, b"data").unwrap(); + assert_eq!(store.exists(&hash).unwrap(), true, "should exist after put"); + } + + #[test] + fn test_delete_existing() { + let mut store = MemoryStore::new(); + let hash = make_hash(0x20); + store.put(&hash, b"to delete").unwrap(); + assert!(store.exists(&hash).unwrap()); + store.delete(&hash).unwrap(); + assert!( + !store.exists(&hash).unwrap(), + "should not exist after delete" + ); + } + + #[test] + fn test_delete_nonexistent() { + let mut store = MemoryStore::new(); + let hash = make_hash(0x30); + let result = store.delete(&hash); + assert!(result.is_ok(), "deleting nonexistent key should not error"); + } + + #[test] + fn test_overwrite() { + let mut store = MemoryStore::new(); + let hash = make_hash(0x40); + store.put(&hash, b"first version").unwrap(); + store.put(&hash, b"second version").unwrap(); + + let mut reader = store.get(&hash).unwrap(); + let mut retrieved = Vec::new(); + Read::read_to_end(&mut reader, &mut retrieved).unwrap(); + assert_eq!( + retrieved, b"second version", + "should return the most recently put data" + ); + } + + #[test] + fn test_put_empty_data() { + let mut store = MemoryStore::new(); + let hash = make_hash(0x50); + store.put(&hash, b"").unwrap(); + let mut reader = store.get(&hash).unwrap(); + let mut retrieved = Vec::new(); + Read::read_to_end(&mut reader, &mut retrieved).unwrap(); + assert_eq!(retrieved, b"", "empty data should be stored and retrieved"); + } + + #[test] + fn test_multiple_objects() { + let mut store = MemoryStore::new(); + let h1 = make_hash(0x01); + let h2 = make_hash(0x02); + let h3 = make_hash(0x03); + store.put(&h1, b"aaa").unwrap(); + store.put(&h2, b"bbb").unwrap(); + store.put(&h3, b"ccc").unwrap(); + + assert!(store.exists(&h1).unwrap()); + assert!(store.exists(&h2).unwrap()); + assert!(store.exists(&h3).unwrap()); + + store.delete(&h2).unwrap(); + assert!(store.exists(&h1).unwrap()); + assert!(!store.exists(&h2).unwrap()); + assert!(store.exists(&h3).unwrap()); + } +} From 2bfbe1f8ad6150ac372943aedefdf9ecd48bd8c3 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 10/15] test(core): add tests for memory ref store --- libvctrl_core/src/store/ref_store.rs | 131 ++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/libvctrl_core/src/store/ref_store.rs b/libvctrl_core/src/store/ref_store.rs index ca511c0..3c9491b 100644 --- a/libvctrl_core/src/store/ref_store.rs +++ b/libvctrl_core/src/store/ref_store.rs @@ -1,6 +1,8 @@ -use libvctrl_handler::{Hash, RefStore, VctrlError}; +use alloc::vec::IntoIter; use std::collections::HashMap; +use libvctrl_handler::{Hash, RefStore, VctrlError}; + #[derive(Debug, Default)] pub struct MemoryRefStore { refs: HashMap, @@ -16,7 +18,7 @@ impl MemoryRefStore { } impl RefStore for MemoryRefStore { - type RefsIterator = std::vec::IntoIter>; + type RefsIterator = IntoIter>; fn set_ref(&mut self, name: &str, hash: &Hash) -> Result<(), VctrlError> { libvctrl_handler::validate_ref_name(name)?; @@ -42,3 +44,128 @@ impl RefStore for MemoryRefStore { Ok(names.into_iter().map(Ok).collect::>().into_iter()) } } + +#[cfg(test)] +mod tests { + use super::*; + use libvctrl_handler::HASH_LENGTH; + + fn make_hash(fill: u8) -> Hash { + Hash::from_bytes(&vec![fill; HASH_LENGTH]).unwrap() + } + + #[test] + fn test_set_and_get() { + let mut store = MemoryRefStore::new(); + let hash = make_hash(0x01); + assert!(store.set_ref("refs/heads/main", &hash).is_ok()); + + let result = store.get_ref("refs/heads/main"); + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + hash, + "retrieved hash must match stored hash" + ); + } + + #[test] + fn test_get_not_found() { + let store = MemoryRefStore::new(); + let result = store.get_ref("refs/heads/nonexistent"); + assert!(result.is_err(), "should error for missing ref"); + } + + #[test] + fn test_delete_existing() { + let mut store = MemoryRefStore::new(); + let hash = make_hash(0x10); + store.set_ref("refs/tags/v1", &hash).unwrap(); + assert!(store.get_ref("refs/tags/v1").is_ok()); + store.delete_ref("refs/tags/v1").unwrap(); + assert!(store.get_ref("refs/tags/v1").is_err()); + } + + #[test] + fn test_delete_nonexistent() { + let mut store = MemoryRefStore::new(); + let result = store.delete_ref("refs/heads/nope"); + assert!(result.is_ok(), "deleting nonexistent ref should not error"); + } + + #[test] + fn test_list_refs_empty() { + let store = MemoryRefStore::new(); + let refs: Vec = store + .list_refs() + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(refs.is_empty(), "new store should have no refs"); + } + + #[test] + fn test_list_refs_sorted() { + let mut store = MemoryRefStore::new(); + let h1 = make_hash(0x01); + let h2 = make_hash(0x02); + let h3 = make_hash(0x03); + store.set_ref("refs/heads/main", &h1).unwrap(); + store.set_ref("refs/heads/feature", &h2).unwrap(); + store.set_ref("refs/tags/v1.0", &h3).unwrap(); + + let refs: Vec = store + .list_refs() + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + refs, + vec![ + "refs/heads/feature".to_string(), + "refs/heads/main".to_string(), + "refs/tags/v1.0".to_string(), + ], + "refs should be returned in sorted order" + ); + } + + #[test] + fn test_set_overwrite() { + let mut store = MemoryRefStore::new(); + let h1 = make_hash(0xAA); + let h2 = make_hash(0xBB); + store.set_ref("refs/heads/main", &h1).unwrap(); + store.set_ref("refs/heads/main", &h2).unwrap(); + assert_eq!( + store.get_ref("refs/heads/main").unwrap(), + h2, + "should return the most recently set hash" + ); + } + + #[test] + fn test_set_invalid_ref_name() { + let mut store = MemoryRefStore::new(); + let hash = make_hash(0x00); + let result = store.set_ref("invalid name with spaces", &hash); + assert!(result.is_err(), "ref name with spaces should be rejected"); + } + + #[test] + fn test_set_multiple_refs_independent() { + let mut store = MemoryRefStore::new(); + let h_main = make_hash(0x01); + let h_dev = make_hash(0x02); + store.set_ref("refs/heads/main", &h_main).unwrap(); + store.set_ref("refs/heads/dev", &h_dev).unwrap(); + + assert_eq!(store.get_ref("refs/heads/main").unwrap(), h_main); + assert_eq!(store.get_ref("refs/heads/dev").unwrap(), h_dev); + assert_eq!( + store.list_refs().unwrap().count(), + 2, + "should have exactly 2 refs" + ); + } +} From 178c33a181ea1d14f04bc79d78115be82a01b76d Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 11/15] test(core): remove obsolete codec_test --- libvctrl_core/tests/codec_test.rs | 424 ------------------------------ 1 file changed, 424 deletions(-) delete mode 100644 libvctrl_core/tests/codec_test.rs diff --git a/libvctrl_core/tests/codec_test.rs b/libvctrl_core/tests/codec_test.rs deleted file mode 100644 index a1881ae..0000000 --- a/libvctrl_core/tests/codec_test.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! # Codec Round-Trip and Limit Tests -//! -//! This test module validates the binary encoder and decoder for all core -//! object types: [`Blob`], [`Tree`], [`Commit`], and [`Tag`]. -//! -//! The tests verify: -//! -//! - Successful round-trip serialization for valid objects. -//! - Malformed byte streams are rejected with [`VctrlError`]. -//! - System limits (`MAX_BLOB_SIZE`, `MAX_TREE_ENTRIES`, -//! `MAX_PARENT_COUNT`, `MAX_MESSAGE_LENGTH`) are enforced. -//! - Version byte is checked. -//! - All [`EntryKind`] variants survive encoding and decoding. -//! -//! These tests are integration-style but located within the same crate. -//! They help ensure the codec remains backward-compatible and robust against -//! corrupted or malicious input. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] -#![allow(missing_docs)] -#![allow(unused_crate_dependencies)] - -use libvctrl_core::codec::{BinaryDecoder, BinaryEncoder}; -use libvctrl_handler::{ - Blob, Commit, CommitMeta, Decoder, Encoder, EntryKind, Hash, MAX_BLOB_SIZE, MAX_MESSAGE_LENGTH, - MAX_PARENT_COUNT, MAX_TREE_ENTRIES, Tag, Tree, TreeEntry, UserID, -}; -use libvctrl_sha512 as _; -use proptest as _; -use std::io::Cursor; - -/// Returns a hash filled with the byte `0xAB`. -/// -/// This is useful as a placeholder for an arbitrary valid object ID. -fn dummy_hash() -> Hash { - Hash::from_bytes(&[0xAB; 64]).unwrap() -} - -/// Returns a hash filled with the given byte. -/// -/// The byte `b` is repeated 64 times to form a valid [`Hash`]. This helper -/// creates distinguishable hashes for testing equality and ordering. -fn hash_from_byte(b: u8) -> Hash { - Hash::from_bytes(&[b; 64]).unwrap() -} - -/// Creates a [`Blob`] of the specified size, filled with `0x42`. -/// -/// The size must not exceed [`MAX_BLOB_SIZE`]. The resulting blob is used to -/// test size limits and round-trip behavior. -fn blob_of_size(size: usize) -> Blob { - Blob::new(vec![0x42; size]).unwrap() -} - -/// Creates a [`Tree`] with `n` entries. -/// -/// Each entry is named `entry_XXX` (zero-padded) and points to -/// [`dummy_hash`]. The entries are sorted by name to satisfy [`Tree`] -/// ordering requirements. -fn tree_with_n_entries(n: usize) -> Tree { - let mut entries = Vec::with_capacity(n); - for i in 0..n { - let name = format!("entry_{i:03}"); - entries.push(TreeEntry::new(name, EntryKind::Blob, dummy_hash()).unwrap()); - } - Tree::new(entries).unwrap() -} - -/// Creates a minimal, parentless commit with a fixed author and message. -/// -/// The tree is [`dummy_hash`], the author and committer are both -/// "author ", and the message is "message". -fn minimal_commit() -> Commit { - let user = UserID::new("author".into(), "author@example.com".into()).unwrap(); - Commit::new(dummy_hash(), vec![], user.clone(), user, "message".into()).unwrap() -} - -/// Creates a lightweight tag (no tagger, empty message) with the given name. -/// -/// The target is [`dummy_hash`]. -fn lightweight_tag(name: &str) -> Tag { - Tag::new(name.into(), dummy_hash(), None, String::new()).unwrap() -} - -/// Tests blob encoding/decoding and blob size limits. -/// -/// Checks: -/// - Empty blob round-trips. -/// - Small blob round-trips. -/// - Blob of exactly `MAX_BLOB_SIZE` round-trips. -/// - Blob exceeding `MAX_BLOB_SIZE` fails at construction. -#[test] -fn test_blob_roundtrip_and_limits() { - // 1. Empty blob - let b = Blob::new(vec![]).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_blob(&b, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_blob(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.data(), b.data()); - - // 2. Small blob - let b = Blob::new(b"hello world".to_vec()).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_blob(&b, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_blob(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.data(), b.data()); - - // 3. Max size blob - let max_size = usize::try_from(MAX_BLOB_SIZE).unwrap(); - let b = blob_of_size(max_size); - let mut enc = Vec::new(); - BinaryEncoder.encode_blob(&b, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_blob(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.size(), max_size); - - // 4. Exceeds max size (should fail at Blob::new) - let over_size = max_size + 1; - assert!(Blob::new(vec![0; over_size]).is_err()); -} - -/// Tests that malformed blob inputs are rejected. -/// -/// Covers: -/// - Empty input. -/// - Correct version but missing length prefix. -/// - Wrong version byte. -/// - Length mismatch (trailing byte). -/// - Declared length exceeding `MAX_BLOB_SIZE`. -#[test] -fn test_blob_malformed_data() { - // Empty input - assert!(BinaryDecoder.decode_blob(Cursor::new(&[])).is_err()); - - // Correct version but missing length prefix - let data = vec![0x03]; - assert!(BinaryDecoder.decode_blob(Cursor::new(&data)).is_err()); - - // Wrong version - let data = vec![0x02]; - assert!(BinaryDecoder.decode_blob(Cursor::new(&data)).is_err()); - - // Length mismatch (trailing byte) - let b = Blob::new(vec![0; 5]).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_blob(&b, &mut enc).unwrap(); - enc.push(0x00); - assert!(BinaryDecoder.decode_blob(Cursor::new(&enc)).is_err()); - - // Declared length exceeds MAX_BLOB_SIZE - let over_size = usize::try_from(MAX_BLOB_SIZE).unwrap() + 1; - let mut bytes = vec![0x03u8]; - bytes.extend_from_slice(&(over_size as u64).to_le_bytes()); - bytes.extend(vec![0x00; over_size]); - assert!(BinaryDecoder.decode_blob(Cursor::new(&bytes)).is_err()); -} - -/// Tests tree encoding/decoding and limit enforcement. -/// -/// Verifies: -/// - Empty tree round-trips. -/// - Tree with multiple entries round-trips. -/// - All [`EntryKind`] variants survive round-trip. -#[test] -fn test_tree_roundtrip_and_limits() { - // Empty tree - let t = Tree::new(vec![]).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&t, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_tree(Cursor::new(&enc)).unwrap(); - assert!(dec.entries().is_empty()); - - // Multiple entries - let t = tree_with_n_entries(5); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&t, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_tree(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.entries().len(), 5); - - // All entry kinds roundtrip - let entries = vec![ - TreeEntry::new("blob".into(), EntryKind::Blob, hash_from_byte(1)).unwrap(), - TreeEntry::new("dir".into(), EntryKind::Tree, hash_from_byte(4)).unwrap(), - TreeEntry::new("exec".into(), EntryKind::Executable, hash_from_byte(2)).unwrap(), - TreeEntry::new("link".into(), EntryKind::Symlink, hash_from_byte(3)).unwrap(), - TreeEntry::new("sub".into(), EntryKind::Submodule, hash_from_byte(5)).unwrap(), - ]; - let t = Tree::new(entries).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&t, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_tree(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.entries().len(), 5); -} - -/// Tests that malformed tree inputs are rejected. -/// -/// Covers: -/// - Empty input. -/// - Missing entry count. -/// - Wrong version. -/// - Entry count exceeding `MAX_TREE_ENTRIES`. -/// - Truncated name. -/// - Invalid entry kind byte. -/// - Truncated hash. -/// - Trailing bytes. -#[test] -fn test_tree_malformed_data() { - // Empty input - assert!(BinaryDecoder.decode_tree(Cursor::new(&[])).is_err()); - - // Correct version but missing entry count bytes - let data = vec![0x03]; - assert!(BinaryDecoder.decode_tree(Cursor::new(&data)).is_err()); - - // Wrong version - let data = vec![0x02]; - assert!(BinaryDecoder.decode_tree(Cursor::new(&data)).is_err()); - - // Entry count exceeds MAX_TREE_ENTRIES - let over = usize::try_from(MAX_TREE_ENTRIES).unwrap() + 1; - let mut enc = vec![0x03u8]; - enc.extend_from_slice(&u32::try_from(over).unwrap().to_le_bytes()); - assert!(BinaryDecoder.decode_tree(Cursor::new(&enc)).is_err()); - - // Truncated entry name - let tree = Tree::new(vec![]).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&tree, &mut enc).unwrap(); - enc[1..5].copy_from_slice(&1u32.to_le_bytes()); - enc.push(50); // Name length 50, but no data - assert!(BinaryDecoder.decode_tree(Cursor::new(&enc)).is_err()); - - // Invalid entry kind - let tree = tree_with_n_entries(1); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&tree, &mut enc).unwrap(); - let kind_pos = 6 + 9; // version + count + name_len + name - enc[kind_pos] = 99; - assert!(BinaryDecoder.decode_tree(Cursor::new(&enc)).is_err()); - - // Truncated hash - let tree = tree_with_n_entries(1); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&tree, &mut enc).unwrap(); - enc.truncate(enc.len() - 4); - assert!(BinaryDecoder.decode_tree(Cursor::new(&enc)).is_err()); - - // Trailing bytes - let tree = tree_with_n_entries(1); - let mut enc = Vec::new(); - BinaryEncoder.encode_tree(&tree, &mut enc).unwrap(); - enc.push(0x00); - assert!(BinaryDecoder.decode_tree(Cursor::new(&enc)).is_err()); -} - -/// Tests commit encoding/decoding and limit enforcement. -/// -/// Verifies: -/// - Minimal commit round-trips. -/// - Commits with 0–256 parents round-trip. -/// - Duplicate parents are rejected. -/// - Parent count exceeding `MAX_PARENT_COUNT` is rejected. -/// - Metadata encoding survives round-trip. -/// - Invalid timezone offset is rejected. -/// - Message exceeding `MAX_MESSAGE_LENGTH` is rejected. -#[test] -fn test_commit_roundtrip_and_limits() { - let user = UserID::new("author".into(), "author@example.com".into()).unwrap(); - - // Minimal commit - let c = minimal_commit(); - let mut enc = Vec::new(); - BinaryEncoder.encode_commit(&c, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_commit(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.tree(), c.tree()); - assert!(dec.parents().is_empty()); - assert_eq!(dec.author().name(), "author"); - assert_eq!(dec.message(), "message"); - - // With parents - let parents = vec![hash_from_byte(1), hash_from_byte(2), hash_from_byte(3)]; - let c = Commit::new( - dummy_hash(), - parents, - user.clone(), - user.clone(), - "merge".into(), - ) - .unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_commit(&c, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_commit(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.parents().len(), 3); - - // With many parents (u16 range — test 256 which exceeds old u8 limit) - let many_parents: Vec = (0u8..=255).map(hash_from_byte).collect(); - let c = Commit::new( - dummy_hash(), - many_parents.clone(), - user.clone(), - user.clone(), - "octopus".into(), - ) - .unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_commit(&c, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_commit(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.parents().len(), 256); - assert_eq!(dec.parents(), many_parents); - - // Duplicate parent rejected - let dup = vec![dummy_hash(), dummy_hash()]; - assert!(Commit::new(dummy_hash(), dup, user.clone(), user.clone(), "dup".into()).is_err()); - - // Exceeds MAX_PARENT_COUNT rejected - let too_many = vec![dummy_hash(); usize::try_from(MAX_PARENT_COUNT).unwrap() + 1]; - assert!( - Commit::new( - dummy_hash(), - too_many, - user.clone(), - user.clone(), - "toomany".into() - ) - .is_err() - ); - - // With meta - let meta = CommitMeta::new(1, 2, Some("UTF-8".into())).unwrap(); - let c = Commit::with_meta( - dummy_hash(), - vec![], - user.clone(), - user.clone(), - "msg".into(), - meta, - ) - .unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_commit(&c, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_commit(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.meta().encoding(), Some("UTF-8")); - - // Invalid timezone offset - assert!(CommitMeta::new(1, 1441, None).is_err()); - - // Message too long - let msg_len = usize::try_from(MAX_MESSAGE_LENGTH).unwrap() + 1; - let msg = "A".repeat(msg_len); - assert!(Commit::new(dummy_hash(), vec![], user.clone(), user, msg).is_err()); -} - -/// Tests tag encoding/decoding and limit enforcement. -/// -/// Verifies: -/// - Lightweight tag round-trips. -/// - Annotated tag (with tagger and message) round-trips. -/// - Metadata encoding survives round-trip. -/// - Tag name longer than 255 bytes is rejected. -/// - Message exceeding `MAX_MESSAGE_LENGTH` is rejected. -#[test] -fn test_tag_roundtrip_and_limits() { - let tagger = UserID::new("tagger".into(), "tag@example.com".into()).unwrap(); - - // Lightweight tag - let t = lightweight_tag("v0.1"); - let mut enc = Vec::new(); - BinaryEncoder.encode_tag(&t, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_tag(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.name(), "v0.1"); - assert!(dec.tagger().is_none()); - - // Annotated tag - let t = Tag::new( - "v1.0".into(), - dummy_hash(), - Some(tagger.clone()), - "Release".into(), - ) - .unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_tag(&t, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_tag(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.tagger().unwrap().name(), "tagger"); - assert_eq!(dec.message(), "Release"); - - // Tag with meta - let meta = CommitMeta::new(3, 4, Some("ISO-8859-1".into())).unwrap(); - let t = Tag::with_meta( - "v2.0".into(), - dummy_hash(), - Some(tagger), - "msg".into(), - meta, - ) - .unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_tag(&t, &mut enc).unwrap(); - let dec = BinaryDecoder.decode_tag(Cursor::new(&enc)).unwrap(); - assert_eq!(dec.meta().encoding(), Some("ISO-8859-1")); - - // Tag name too long - let long_name = "a".repeat(256); - assert!(Tag::new(long_name, dummy_hash(), None, String::new()).is_err()); - - // Message too long - let msg_len = usize::try_from(MAX_MESSAGE_LENGTH).unwrap() + 1; - let msg = "A".repeat(msg_len); - assert!(Tag::new("v".into(), dummy_hash(), None, msg).is_err()); -} - -/// Tests that a corrupted version byte is rejected. -/// -/// The version byte is the first byte of every encoded object. Changing it -/// to an unsupported value must cause decoding to fail with -/// [`VctrlError::CorruptedData`]. -#[test] -fn test_wrong_version_rejected() { - // Version 2 is no longer supported - let b = Blob::new(vec![]).unwrap(); - let mut enc = Vec::new(); - BinaryEncoder.encode_blob(&b, &mut enc).unwrap(); - enc[0] = 0x02; // Corrupt version byte - assert!(BinaryDecoder.decode_blob(Cursor::new(&enc)).is_err()); -} From ca72779f16fecc6e65eaecfd1921826ce24215b0 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 12/15] test(core): remove obsolete store_test --- libvctrl_core/tests/store_test.rs | 171 ------------------------------ 1 file changed, 171 deletions(-) delete mode 100644 libvctrl_core/tests/store_test.rs diff --git a/libvctrl_core/tests/store_test.rs b/libvctrl_core/tests/store_test.rs deleted file mode 100644 index bb6e432..0000000 --- a/libvctrl_core/tests/store_test.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! # Store and RefStore Integration Tests -//! -//! This module contains integration-style tests for the in-memory object and -//! reference store implementations: -//! -//! - `MemoryStore` implements `ObjectStore` and provides CRUD operations plus -//! streaming reads via `Box`. -//! - `MemoryRefStore` implements `RefStore` and manages named references with -//! strict name validation and deterministic sorted iteration. -//! -//! The tests verify both normal behavior and defensive handling of malformed -//! or potentially hostile inputs. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] -#![allow(missing_docs)] -#![allow(unused_crate_dependencies)] - -use libvctrl_core::hash::Sha512Hasher; -use libvctrl_core::store::{MemoryRefStore, MemoryStore}; -use libvctrl_handler::{Hash, Hasher, MAX_NAME_LENGTH, ObjectStore, RefStore}; -use libvctrl_sha512 as _; -use proptest as _; -use std::io::Read; - -/// Computes a SHA-512 content hash for the given data. -/// -/// This helper uses `Sha512Hasher` to derive a stable, content-addressed -/// identifier. It is used to generate distinct `Hash` values for objects and -/// references in the tests. -fn dummy_hash_from_data(data: &[u8]) -> Hash { - let hasher = Sha512Hasher; - hasher.hash(data).unwrap() -} - -/// Tests CRUD operations and streaming reads for `MemoryStore`. -/// -/// Verifies: -/// - `put` stores data and `exists` reports it correctly. -/// - `get` returns a stream that yields the exact stored bytes. -/// - `delete` removes the object and subsequent `get` fails. -/// - Deleting or reading a non-existent object does not panic. -#[test] -fn test_memory_store_crud_and_streaming() { - let mut store = MemoryStore::new(); - let data = b"hello world"; - let hash = dummy_hash_from_data(data); - - // Put - store.put(&hash, data).unwrap(); - - // Exists - assert!(store.exists(&hash).unwrap()); - assert!(!store.exists(&dummy_hash_from_data(b"other")).unwrap()); - - // Get and verify (zero-clone streaming) - { - let mut reader = store.get(&hash).unwrap(); - let mut buf = Vec::new(); - reader.read_to_end(&mut buf).unwrap(); - assert_eq!(buf, data); - } // reader is dropped here, releasing the immutable borrow - - // Delete - store.delete(&hash).unwrap(); - assert!(!store.exists(&hash).unwrap()); - - // Delete non-existent - assert!(store.delete(&hash).is_ok()); - - // Get non-existent - assert!(store.get(&hash).is_err()); -} - -/// Tests that `MemoryStore` can stream a large object without requiring a -/// full contiguous copy beyond the stored data. -/// -/// The object is 10 MiB; reading it back through the returned reader must -/// yield the exact original bytes. -#[test] -fn test_memory_store_large_object_streaming() { - let mut store = MemoryStore::new(); - // 10 MB object to test zero-copy cursor limits - let data = vec![0x42u8; 10 * 1024 * 1024]; - let hash = dummy_hash_from_data(&data); - - store.put(&hash, &data).unwrap(); - - let mut reader = store.get(&hash).unwrap(); - let mut buf = Vec::new(); - reader.read_to_end(&mut buf).unwrap(); - - assert_eq!(buf.len(), data.len()); - assert_eq!(buf, data); -} - -/// Tests CRUD operations and sorted iteration for `MemoryRefStore`. -/// -/// Verifies: -/// - References can be set and retrieved. -/// - `list_refs` returns names in sorted order. -/// - Deleting a reference removes it from the store and from the listing. -#[test] -fn test_memory_ref_store_crud_and_sorting() { - let mut store = MemoryRefStore::new(); - let hash1 = dummy_hash_from_data(b"1"); - let hash2 = dummy_hash_from_data(b"2"); - - // Set refs - store.set_ref("refs/heads/main", &hash1).unwrap(); - store.set_ref("refs/heads/feature", &hash2).unwrap(); - - // Get - assert_eq!(store.get_ref("refs/heads/main").unwrap(), hash1); - - // List (should be sorted) - let refs: Vec = store - .list_refs() - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!(refs, vec!["refs/heads/feature", "refs/heads/main"]); - - // Delete - store.delete_ref("refs/heads/main").unwrap(); - assert!(store.get_ref("refs/heads/main").is_err()); - - let refs: Vec = store - .list_refs() - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!(refs, vec!["refs/heads/feature"]); -} - -/// Tests that `MemoryRefStore` enforces strict reference name validation. -/// -/// The following invalid names are rejected: -/// - Empty string. -/// - Names exceeding `MAX_NAME_LENGTH`. -/// - Path traversal attempts (`../`, `..\\`, `..`). -/// - Git illegal characters (`~`, `^`, `:`, space, `@{`, ending with `.lock`). -/// -/// A normal valid name is accepted. -#[test] -fn test_memory_ref_store_strict_validation() { - let mut store = MemoryRefStore::new(); - let hash = dummy_hash_from_data(b"1"); - - // Empty name - assert!(store.set_ref("", &hash).is_err()); - - // Too long name - let long_name = "a".repeat(usize::try_from(MAX_NAME_LENGTH).unwrap() + 1); - assert!(store.set_ref(&long_name, &hash).is_err()); - - // Path traversal attempts (Security) - assert!(store.set_ref("../config", &hash).is_err()); - assert!(store.set_ref("..\\config", &hash).is_err()); - assert!(store.set_ref("refs/heads/..", &hash).is_err()); - - // Git illegal characters - assert!(store.set_ref("refs/heads/foo~bar", &hash).is_err()); - assert!(store.set_ref("refs/heads/foo^bar", &hash).is_err()); - assert!(store.set_ref("refs/heads/foo:bar", &hash).is_err()); - assert!(store.set_ref("refs/heads/foo bar", &hash).is_err()); // Space - assert!(store.set_ref("refs/heads/@{upstream}", &hash).is_err()); - assert!(store.set_ref("refs/heads/foo.lock", &hash).is_err()); - - // Valid name - assert!(store.set_ref("refs/heads/valid_name", &hash).is_ok()); -} From 922bc548b1ad01549c27c905f69e5cfa50014311 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:04 +0700 Subject: [PATCH 13/15] test(core): add builder API integration tests --- libvctrl_core/tests/builder_api.rs | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 libvctrl_core/tests/builder_api.rs diff --git a/libvctrl_core/tests/builder_api.rs b/libvctrl_core/tests/builder_api.rs new file mode 100644 index 0000000..4821d39 --- /dev/null +++ b/libvctrl_core/tests/builder_api.rs @@ -0,0 +1,48 @@ +use libvctrl_core::object::{BlobBuilder, CommitBuilder, TagBuilder, TreeBuilder}; + +mod common; + +#[test] +fn test_blob_builder_build_success_via_public_api() { + let result = BlobBuilder::new().with_data(vec![1, 2, 3]).build(); + assert!( + result.is_ok(), + "BlobBuilder should succeed with valid data via public API" + ); +} + +#[test] +fn test_commit_builder_missing_tree_via_public_api() { + let result = CommitBuilder::new().build(); + assert!( + result.is_err(), + "CommitBuilder should fail without tree via public API" + ); +} + +#[test] +fn test_tag_builder_missing_name_via_public_api() { + let result = TagBuilder::new().build(); + assert!( + result.is_err(), + "TagBuilder should fail without name via public API" + ); +} + +#[test] +fn test_tag_builder_missing_target_via_public_api() { + let result = TagBuilder::new().name("v1.0").build(); + assert!( + result.is_err(), + "TagBuilder should fail without target via public API" + ); +} + +#[test] +fn test_tree_builder_build_empty_via_public_api() { + let result = TreeBuilder::new().build(); + assert!( + result.is_ok(), + "TreeBuilder should succeed with empty entries via public API" + ); +} From f15c43c70fceebb29356407e6ccb28713ae8385f Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:05 +0700 Subject: [PATCH 14/15] test(core): add codec roundtrip integration tests --- libvctrl_core/tests/codec_roundtrip.rs | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 libvctrl_core/tests/codec_roundtrip.rs diff --git a/libvctrl_core/tests/codec_roundtrip.rs b/libvctrl_core/tests/codec_roundtrip.rs new file mode 100644 index 0000000..2e5938c --- /dev/null +++ b/libvctrl_core/tests/codec_roundtrip.rs @@ -0,0 +1,68 @@ +use libvctrl_core::codec::{BinaryDecoder, BinaryEncoder, VERSION}; +use libvctrl_core::object::BlobBuilder; +use std::io::Cursor; + +mod common; + +fn encode_to_vec(encode_fn: F) -> Vec +where + W: std::io::Write + Send, + F: FnOnce(&mut W) -> Result<(), libvctrl_core::codec::binary_encoder::VctrlError>, +{ + let mut buf = Cursor::new(Vec::new()); + encode_fn(&mut buf).unwrap(); + buf.into_inner() +} + +#[test] +fn test_blob_roundtrip() { + let original_data = vec![0x01, 0x02, 0x03, 0x04, 0x05]; + let blob = BlobBuilder::new() + .with_data(original_data.clone()) + .build() + .expect("blob build should succeed"); + + let encoded = encode_to_vec(|w| BinaryEncoder.encode_blob(&blob, w)); + assert_eq!(encoded[0], VERSION, "first byte should be version"); + + let decoded = BinaryDecoder + .decode_blob(Cursor::new(encoded)) + .expect("decode should succeed"); + assert_eq!( + decoded.data(), + original_data.as_slice(), + "roundtrip blob data should match original" + ); +} + +#[test] +fn test_blob_empty_roundtrip() { + let blob = BlobBuilder::new() + .with_data(vec![]) + .build() + .expect("empty blob build should succeed"); + + let encoded = encode_to_vec(|w| BinaryEncoder.encode_blob(&blob, w)); + let decoded = BinaryDecoder + .decode_blob(Cursor::new(encoded)) + .expect("decode empty blob should succeed"); + assert!( + decoded.data().is_empty(), + "roundtrip empty blob should have empty data" + ); +} + +#[test] +fn test_blob_large_roundtrip() { + let original_data = vec![0x42u8; 8192]; + let blob = BlobBuilder::new() + .with_data(original_data.clone()) + .build() + .expect("large blob build should succeed"); + + let encoded = encode_to_vec(|w| BinaryEncoder.encode_blob(&blob, w)); + let decoded = BinaryDecoder + .decode_blob(Cursor::new(encoded)) + .expect("decode large blob should succeed"); + assert_eq!(decoded.data(), original_data.as_slice()); +} From 8d4037490bb1041c8f779adf9cb234782e178f04 Mon Sep 17 00:00:00 2001 From: mroczect Date: Thu, 20 Aug 2026 21:45:05 +0700 Subject: [PATCH 15/15] test(core): add common test utilities --- libvctrl_core/tests/common/mod.rs | 1 + 1 file changed, 1 insertion(+) create mode 100644 libvctrl_core/tests/common/mod.rs diff --git a/libvctrl_core/tests/common/mod.rs b/libvctrl_core/tests/common/mod.rs new file mode 100644 index 0000000..f371760 --- /dev/null +++ b/libvctrl_core/tests/common/mod.rs @@ -0,0 +1 @@ +pub fn setup() {}