use crate::error::{Error, Result};
use crate::hash::Algorithm;
pub const MAGIC: &[u8; 4] = b"CAFS";
pub const VERSION: u8 = 2;
pub const HEADER_SIZE: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum ObjectType {
Blob = 1,
Tree = 2,
ChunkList = 3,
}
impl ObjectType {
pub fn to_u8(self) -> u8 {
self as u8
}
pub fn from_u8(value: u8) -> Result<Self> {
match value {
1 => Ok(ObjectType::Blob),
2 => Ok(ObjectType::Tree),
3 => Ok(ObjectType::ChunkList),
_ => Err(Error::invalid_hash(format!(
"Invalid object type: {}",
value
))),
}
}
pub fn as_str(&self) -> &'static str {
match self {
ObjectType::Blob => "blob",
ObjectType::Tree => "tree",
ObjectType::ChunkList => "chunk_list",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionType {
None = 0,
Zstd = 1,
}
impl CompressionType {
pub fn to_u8(self) -> u8 {
self as u8
}
pub fn from_u8(value: u8) -> Result<Self> {
match value {
0 => Ok(CompressionType::None),
1 => Ok(CompressionType::Zstd),
_ => Err(Error::invalid_hash(format!(
"Invalid compression type: {}",
value
))),
}
}
pub fn as_str(&self) -> &'static str {
match self {
CompressionType::None => "none",
CompressionType::Zstd => "zstd",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectHeader {
pub version: u8,
pub object_type: ObjectType,
pub algorithm: Algorithm,
pub compression: CompressionType,
pub payload_len: u64,
}
impl ObjectHeader {
pub fn new(
object_type: ObjectType,
algorithm: Algorithm,
compression: CompressionType,
payload_len: u64,
) -> Self {
Self {
version: VERSION,
object_type,
algorithm,
compression,
payload_len,
}
}
pub fn encode(&self) -> [u8; HEADER_SIZE] {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(MAGIC);
buf[4] = self.version;
buf[5] = self.object_type.to_u8();
buf[6] = self.algorithm.id();
buf[7] = self.compression.to_u8();
buf[8..16].copy_from_slice(&self.payload_len.to_le_bytes());
buf
}
pub fn decode(buf: &[u8]) -> Result<Self> {
if buf.len() < HEADER_SIZE {
return Err(Error::invalid_hash(format!(
"Header too short: {} bytes (expected {})",
buf.len(),
HEADER_SIZE
)));
}
if &buf[0..4] != MAGIC {
return Err(Error::invalid_hash(format!(
"Invalid magic: expected {:?}, got {:?}",
MAGIC,
&buf[0..4]
)));
}
let version = buf[4];
if version != 2 {
return Err(Error::invalid_hash(format!(
"Unsupported version: {} (expected 2)",
version
)));
}
let object_type = ObjectType::from_u8(buf[5])?;
let algorithm = Algorithm::from_id(buf[6])?;
let compression = CompressionType::from_u8(buf[7])?;
let mut len_bytes = [0u8; 8];
len_bytes.copy_from_slice(&buf[8..16]);
let payload_len = u64::from_le_bytes(len_bytes);
Ok(Self {
version,
object_type,
algorithm,
compression,
payload_len,
})
}
pub fn validate(&self) -> Result<()> {
if self.version != 2 {
return Err(Error::invalid_hash(format!(
"Unsupported version: {}",
self.version
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkEntry {
pub hash: crate::hash::Hash,
pub size: u64,
}
pub const CHUNK_ENTRY_SIZE: usize = 40;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkList {
pub chunks: Vec<ChunkEntry>,
}
impl ChunkList {
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(self.chunks.len() * CHUNK_ENTRY_SIZE);
for chunk in &self.chunks {
buf.extend_from_slice(chunk.hash.as_bytes());
buf.extend_from_slice(&chunk.size.to_le_bytes());
}
buf
}
pub fn decode(bytes: &[u8]) -> Result<Self> {
if !bytes.len().is_multiple_of(CHUNK_ENTRY_SIZE) {
return Err(Error::invalid_chunk_list(format!(
"ChunkList payload size {} is not a multiple of {}",
bytes.len(),
CHUNK_ENTRY_SIZE
)));
}
let mut chunks = Vec::new();
for chunk_bytes in bytes.chunks_exact(CHUNK_ENTRY_SIZE) {
let hash_bytes: [u8; 32] = chunk_bytes[0..32]
.try_into()
.map_err(|_| Error::invalid_chunk_list("Failed to parse chunk hash"))?;
let hash = crate::hash::Hash::from_bytes(hash_bytes);
let size = u64::from_le_bytes(
chunk_bytes[32..40]
.try_into()
.map_err(|_| Error::invalid_chunk_list("Failed to parse chunk size"))?,
);
chunks.push(ChunkEntry { hash, size });
}
Ok(ChunkList { chunks })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_object_type_conversions() {
assert_eq!(ObjectType::Blob.to_u8(), 1);
assert_eq!(ObjectType::Tree.to_u8(), 2);
assert_eq!(ObjectType::ChunkList.to_u8(), 3);
assert_eq!(ObjectType::from_u8(1).unwrap(), ObjectType::Blob);
assert_eq!(ObjectType::from_u8(2).unwrap(), ObjectType::Tree);
assert_eq!(ObjectType::from_u8(3).unwrap(), ObjectType::ChunkList);
assert!(ObjectType::from_u8(0).is_err());
assert!(ObjectType::from_u8(4).is_err());
}
#[test]
fn test_header_encode_decode_blob() {
let header = ObjectHeader::new(
ObjectType::Blob,
Algorithm::Blake3,
CompressionType::None,
1024,
);
let encoded = header.encode();
assert_eq!(encoded.len(), HEADER_SIZE);
assert_eq!(&encoded[0..4], MAGIC);
let decoded = ObjectHeader::decode(&encoded).unwrap();
assert_eq!(decoded, header);
}
#[test]
fn test_header_encode_decode_tree() {
let header = ObjectHeader::new(
ObjectType::Tree,
Algorithm::Blake3,
CompressionType::None,
512,
);
let encoded = header.encode();
let decoded = ObjectHeader::decode(&encoded).unwrap();
assert_eq!(decoded, header);
}
#[test]
fn test_header_decode_invalid_magic() {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(b"XXXX");
buf[4] = VERSION;
buf[5] = ObjectType::Blob.to_u8();
buf[6] = Algorithm::Blake3.id();
assert!(ObjectHeader::decode(&buf).is_err());
}
#[test]
fn test_header_decode_invalid_version() {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(MAGIC);
buf[4] = 99; buf[5] = ObjectType::Blob.to_u8();
buf[6] = Algorithm::Blake3.id();
assert!(ObjectHeader::decode(&buf).is_err());
buf[4] = 1;
assert!(ObjectHeader::decode(&buf).is_err());
}
#[test]
fn test_header_decode_invalid_type() {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(MAGIC);
buf[4] = VERSION;
buf[5] = 99; buf[6] = Algorithm::Blake3.id();
assert!(ObjectHeader::decode(&buf).is_err());
}
#[test]
fn test_header_decode_v2_compression() {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(MAGIC);
buf[4] = 2; buf[5] = ObjectType::Blob.to_u8();
buf[6] = Algorithm::Blake3.id();
buf[7] = 1;
let header = ObjectHeader::decode(&buf).unwrap();
assert_eq!(header.version, 2);
assert_eq!(header.compression, CompressionType::Zstd);
}
#[test]
fn test_header_decode_invalid_compression() {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(MAGIC);
buf[4] = 2; buf[5] = ObjectType::Blob.to_u8();
buf[6] = Algorithm::Blake3.id();
buf[7] = 99;
assert!(ObjectHeader::decode(&buf).is_err());
}
#[test]
fn test_header_payload_len() {
let header = ObjectHeader::new(
ObjectType::Blob,
Algorithm::Blake3,
CompressionType::None,
0x123456789ABCDEF0,
);
let encoded = header.encode();
let decoded = ObjectHeader::decode(&encoded).unwrap();
assert_eq!(decoded.payload_len, 0x123456789ABCDEF0);
}
#[test]
fn test_header_too_short() {
let buf = [0u8; 10]; assert!(ObjectHeader::decode(&buf).is_err());
}
#[test]
fn test_header_validate() {
let header = ObjectHeader::new(
ObjectType::Blob,
Algorithm::Blake3,
CompressionType::None,
100,
);
assert!(header.validate().is_ok());
}
use proptest::prelude::*;
fn arb_object_header() -> impl Strategy<Value = ObjectHeader> {
(
prop::sample::select(vec![
ObjectType::Blob,
ObjectType::Tree,
ObjectType::ChunkList,
]),
prop::sample::select(vec![Algorithm::Blake3]),
prop::sample::select(vec![CompressionType::None, CompressionType::Zstd]),
any::<u64>(),
)
.prop_map(|(object_type, algorithm, compression, payload_len)| {
ObjectHeader::new(object_type, algorithm, compression, payload_len)
})
}
fn arb_chunk_list() -> impl Strategy<Value = ChunkList> {
prop::collection::vec((prop::array::uniform32(any::<u8>()), any::<u64>()), 0..20).prop_map(
|chunks| ChunkList {
chunks: chunks
.into_iter()
.map(|(hash_bytes, size)| ChunkEntry {
hash: crate::hash::Hash::from_bytes(hash_bytes),
size,
})
.collect(),
},
)
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
max_shrink_iters: 10000,
..ProptestConfig::default()
})]
#[test]
fn prop_header_roundtrip(header in arb_object_header()) {
let encoded = header.encode();
prop_assert_eq!(encoded.len(), HEADER_SIZE);
let decoded = ObjectHeader::decode(&encoded)?;
prop_assert_eq!(decoded, header);
}
#[test]
fn prop_chunk_list_roundtrip(chunk_list in arb_chunk_list()) {
let encoded = chunk_list.encode();
prop_assert!(encoded.len().is_multiple_of(CHUNK_ENTRY_SIZE));
let decoded = ChunkList::decode(&encoded)?;
prop_assert_eq!(decoded, chunk_list);
}
#[test]
fn prop_invalid_chunk_size_rejected(
bad_len in (1usize..400).prop_filter("not multiple of 40", |n| !n.is_multiple_of(CHUNK_ENTRY_SIZE))
) {
let bad_bytes = vec![0u8; bad_len];
prop_assert!(ChunkList::decode(&bad_bytes).is_err());
}
#[test]
fn prop_object_type_roundtrip(
obj_type in prop::sample::select(vec![
ObjectType::Blob,
ObjectType::Tree,
ObjectType::ChunkList,
])
) {
let byte = obj_type.to_u8();
prop_assert_eq!(ObjectType::from_u8(byte)?, obj_type);
}
}
}