pub(crate) const PROTOCOL_VERSION: u16 = 6;
pub(crate) const TRANSFER_FILE: u8 = 0x00;
pub(crate) const TRANSFER_DIR: u8 = 0x01;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransferKind {
File = 0x00,
Directory = 0x01,
}
impl TransferKind {
#[must_use]
pub fn from_u8(value: u8) -> Option<Self> {
match value {
TRANSFER_FILE => Some(Self::File),
TRANSFER_DIR => Some(Self::Directory),
_ => None,
}
}
#[must_use]
#[allow(clippy::as_conversions)]
pub fn as_u8(self) -> u8 {
self as u8
}
}
pub(crate) const FRAME_RAW: u8 = 0x00;
pub(crate) const FRAME_ZSTD: u8 = 0x01;
pub(crate) const MAX_FILENAME_BYTES: usize = 4096;
pub(crate) const MAX_METADATA_ENCRYPTED: usize =
4 + MAX_FILENAME_BYTES + 8 + 1 + 1 + 256 + 12 + 16 + 16;
pub(crate) const CHUNK_SIZE: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Metadata {
pub filename: String,
pub total_size: u64,
pub transfer_type: TransferKind,
pub hash_algo: String,
}
impl Metadata {
#[must_use]
pub fn new(
filename: String,
total_size: u64,
transfer_type: TransferKind,
hash_algo: String,
) -> Self {
Self { filename, total_size, transfer_type, hash_algo }
}
#[inline]
pub fn validate(&self) -> Result<(), crate::EngineError> {
let name_len = self.filename.len();
if name_len == 0 || name_len > MAX_FILENAME_BYTES {
return Err(crate::EngineError::InvalidFrame(format!(
"invalid filename length: {name_len}"
)));
}
let algo = self.hash_algo.as_str();
if !is_known_hash_algo(algo) {
return Err(crate::EngineError::InvalidFrame(format!(
"unsupported hash algorithm: {algo}"
)));
}
Ok(())
}
}
pub const KNOWN_HASH_ALGOS: &[&str] = &["blake3", "sha256"];
#[must_use]
#[inline]
pub fn is_known_hash_algo(algo: &str) -> bool {
KNOWN_HASH_ALGOS.contains(&algo)
}
impl Metadata {
pub fn encode(&self) -> Vec<u8> {
let name_bytes = self.filename.as_bytes();
let algo_bytes = self.hash_algo.as_bytes();
let mut buf = Vec::with_capacity(2 + name_bytes.len() + 8 + 1 + 1 + algo_bytes.len());
buf.extend_from_slice(&(name_bytes.len() as u16).to_be_bytes());
buf.extend_from_slice(name_bytes);
buf.extend_from_slice(&self.total_size.to_be_bytes());
buf.push(self.transfer_type.as_u8());
buf.push(algo_bytes.len() as u8);
buf.extend_from_slice(algo_bytes);
buf
}
pub fn decode(raw: &[u8]) -> Result<Self, crate::EngineError> {
if raw.len() < 12 {
return Err(crate::EngineError::InvalidFrame("metadata too short".into()));
}
let name_len = u16::from_be_bytes([raw[0], raw[1]]) as usize;
if name_len == 0 || name_len > MAX_FILENAME_BYTES {
return Err(crate::EngineError::InvalidFrame(format!(
"invalid filename length: {name_len}"
)));
}
if raw.len() < 2 + name_len + 8 + 1 + 1 {
return Err(crate::EngineError::InvalidFrame(
"metadata truncated before hash algorithm".into(),
));
}
let filename = std::str::from_utf8(&raw[2..2 + name_len])
.map_err(|_| crate::EngineError::InvalidFrame("filename not UTF-8".into()))?
.to_owned();
let total_size = u64::from_be_bytes(
raw[2 + name_len..2 + name_len + 8]
.try_into()
.map_err(|_| crate::EngineError::InvalidFrame("metadata truncated".into()))?,
);
let transfer_type = TransferKind::from_u8(raw[2 + name_len + 8]).ok_or_else(|| {
crate::EngineError::InvalidFrame(format!(
"invalid transfer type: 0x{:02x}",
raw[2 + name_len + 8]
))
})?;
let algo_len = raw[2 + name_len + 9] as usize;
if raw.len() < 2 + name_len + 10 + algo_len {
return Err(crate::EngineError::InvalidFrame(
"metadata truncated for hash algorithm name".into(),
));
}
let hash_algo = std::str::from_utf8(&raw[2 + name_len + 10..2 + name_len + 10 + algo_len])
.map_err(|_| crate::EngineError::InvalidFrame("hash algorithm not UTF-8".into()))?
.to_owned();
if !is_known_hash_algo(&hash_algo) {
return Err(crate::EngineError::InvalidFrame(format!(
"unsupported hash algorithm: {hash_algo}"
)));
}
Ok(Self::new(filename, total_size, transfer_type, hash_algo))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_rejects_unknown_transfer_type() {
let mut raw = Vec::new();
raw.extend_from_slice(&4u16.to_be_bytes());
raw.extend_from_slice(b"name");
raw.extend_from_slice(&123u64.to_be_bytes());
raw.push(0xff);
let err = Metadata::decode(&raw).unwrap_err();
assert!(matches!(err, crate::EngineError::InvalidFrame(_)));
}
#[test]
fn validate_rejects_empty_filename() {
let meta = Metadata::new(String::new(), 0, TransferKind::File, "blake3".to_owned());
let err = meta.validate().unwrap_err();
assert!(matches!(err, crate::EngineError::InvalidFrame(_)));
}
#[test]
fn validate_rejects_unknown_hash_algo() {
let meta = Metadata::new("file.bin".to_owned(), 0, TransferKind::File, "md5".to_owned());
let err = meta.validate().unwrap_err();
assert!(matches!(err, crate::EngineError::InvalidFrame(_)));
}
#[test]
fn validate_accepts_known_hash_algos() {
for algo in KNOWN_HASH_ALGOS {
let meta =
Metadata::new("file.bin".to_owned(), 0, TransferKind::File, (*algo).to_owned());
assert!(meta.validate().is_ok(), "expected {algo} to validate");
}
}
}