use anyhow::Result;
use serde::{Deserialize, Serialize};
pub const CHUNK_SIZE: usize = 30_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadata {
pub name: String,
pub size: u64,
pub total_chunks: u64,
pub chunk_hashes: Vec<String>,
pub route_blob: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub encryption_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encryption_nonce: Option<String>,
pub compressed: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Request {
GetMetadata,
GetChunk { index: u64 },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Response {
ChunkData {
index: u64,
data: Vec<u8>,
hash: String,
},
Metadata {
name: String,
size: u64,
total_chunks: u64,
chunk_hashes: Vec<String>,
compressed: bool,
},
Error {
message: String,
},
}
const HASH_HEX_LEN: usize = 64;
const WIRE_CHUNK_DATA: u8 = 0x00;
const WIRE_JSON: u8 = 0x01;
pub fn encode_response(response: &Response) -> Result<Vec<u8>> {
match response {
Response::ChunkData { index, data, hash } => {
debug_assert_eq!(hash.len(), HASH_HEX_LEN);
let mut buf = Vec::with_capacity(1 + 8 + HASH_HEX_LEN + data.len());
buf.push(WIRE_CHUNK_DATA);
buf.extend_from_slice(&index.to_le_bytes());
buf.extend_from_slice(hash.as_bytes());
buf.extend_from_slice(data);
Ok(buf)
}
_ => {
let json = serde_json::to_vec(response)?;
let mut buf = Vec::with_capacity(1 + json.len());
buf.push(WIRE_JSON);
buf.extend_from_slice(&json);
Ok(buf)
}
}
}
pub fn decode_response(bytes: &[u8]) -> Result<Response> {
if bytes.is_empty() {
anyhow::bail!("Empty response");
}
match bytes[0] {
WIRE_CHUNK_DATA => {
if bytes.len() < 1 + 8 + HASH_HEX_LEN {
anyhow::bail!("ChunkData response too short ({} bytes)", bytes.len());
}
let index = u64::from_le_bytes(bytes[1..9].try_into()?);
let hash = std::str::from_utf8(&bytes[9..9 + HASH_HEX_LEN])?.to_string();
let data = bytes[9 + HASH_HEX_LEN..].to_vec();
Ok(Response::ChunkData { index, data, hash })
}
WIRE_JSON => Ok(serde_json::from_slice(&bytes[1..])?),
tag => anyhow::bail!("Unknown response tag: 0x{:02x}", tag),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_metadata_serialization() {
let metadata = FileMetadata {
name: "test.txt".to_string(),
size: 1024,
total_chunks: 1,
chunk_hashes: vec!["abc123".to_string()],
route_blob: "route_data".to_string(),
encryption_salt: None,
encryption_nonce: None,
compressed: false,
};
let json = serde_json::to_string(&metadata).unwrap();
let deserialized: FileMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "test.txt");
assert_eq!(deserialized.size, 1024);
assert_eq!(deserialized.total_chunks, 1);
assert!(!deserialized.compressed);
}
#[test]
fn test_file_metadata_with_encryption() {
let metadata = FileMetadata {
name: "encrypted.txt".to_string(),
size: 2048,
total_chunks: 2,
chunk_hashes: vec!["hash1".to_string(), "hash2".to_string()],
route_blob: "route_data".to_string(),
encryption_salt: Some("c2FsdF9kYXRh".to_string()),
encryption_nonce: Some("bm9uY2VfZGF0YQ".to_string()),
compressed: false,
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains("encryption_salt"));
assert!(json.contains("encryption_nonce"));
let deserialized: FileMetadata = serde_json::from_str(&json).unwrap();
assert!(deserialized.encryption_salt.is_some());
assert!(deserialized.encryption_nonce.is_some());
}
#[test]
fn test_file_metadata_without_encryption() {
let json = r#"{
"name": "old_file.txt",
"size": 512,
"total_chunks": 1,
"chunk_hashes": ["hash1"],
"route_blob": "route",
"compressed": false
}"#;
let metadata: FileMetadata = serde_json::from_str(json).unwrap();
assert_eq!(metadata.name, "old_file.txt");
assert!(metadata.encryption_salt.is_none());
assert!(metadata.encryption_nonce.is_none());
assert!(!metadata.compressed);
}
#[test]
fn test_request_get_chunk_serialization() {
let request = Request::GetChunk { index: 42 };
let json = serde_json::to_string(&request).unwrap();
let deserialized: Request = serde_json::from_str(&json).unwrap();
match deserialized {
Request::GetChunk { index } => assert_eq!(index, 42),
_ => panic!("Expected GetChunk request"),
}
}
#[test]
fn test_request_get_metadata_serialization() {
let request = Request::GetMetadata;
let json = serde_json::to_string(&request).unwrap();
let deserialized: Request = serde_json::from_str(&json).unwrap();
match deserialized {
Request::GetMetadata => {}
_ => panic!("Expected GetMetadata request"),
}
}
#[test]
fn test_response_chunk_data_binary_roundtrip() {
let hash = "a".repeat(64); let response = Response::ChunkData {
index: 42,
data: vec![10, 20, 30, 40, 50],
hash: hash.clone(),
};
let encoded = encode_response(&response).unwrap();
assert_eq!(encoded.len(), 78);
assert_eq!(encoded[0], 0x00);
let decoded = decode_response(&encoded).unwrap();
match decoded {
Response::ChunkData {
index,
data,
hash: decoded_hash,
} => {
assert_eq!(index, 42);
assert_eq!(data, vec![10, 20, 30, 40, 50]);
assert_eq!(decoded_hash, hash);
}
_ => panic!("Expected ChunkData response"),
}
}
#[test]
fn test_response_error_roundtrip() {
let response = Response::Error {
message: "Test error".to_string(),
};
let encoded = encode_response(&response).unwrap();
assert_eq!(encoded[0], 0x01);
let decoded = decode_response(&encoded).unwrap();
match decoded {
Response::Error { message } => assert_eq!(message, "Test error"),
_ => panic!("Expected Error response"),
}
}
#[test]
fn test_decode_response_empty() {
assert!(decode_response(&[]).is_err());
}
#[test]
fn test_decode_response_unknown_tag() {
assert!(decode_response(&[0xFF, 1, 2, 3]).is_err());
}
#[test]
fn test_decode_response_truncated_chunk_data() {
let bytes = vec![0x00, 0, 0, 0];
assert!(decode_response(&bytes).is_err());
}
#[test]
fn test_chunk_size_value() {
assert_eq!(CHUNK_SIZE, 30_000);
assert!(CHUNK_SIZE > 0);
}
#[test]
fn test_file_metadata_clone() {
let metadata = FileMetadata {
name: "original.txt".to_string(),
size: 2048,
total_chunks: 2,
chunk_hashes: vec!["hash1".to_string(), "hash2".to_string()],
route_blob: "route".to_string(),
encryption_salt: None,
encryption_nonce: None,
compressed: false,
};
let cloned = metadata.clone();
assert_eq!(metadata.name, cloned.name);
assert_eq!(metadata.size, cloned.size);
}
}