vflight 0.9.2

Share files over the Veilid distributed network with content-addressable storage
Documentation
//! Network protocol definitions for file sharing.
//!
//! Defines the request/response types used for communication between
//! seeder and fetcher nodes over Veilid private routes.

use anyhow::Result;
use serde::{Deserialize, Serialize};

/// Default chunk size for file transfers (30 KB).
/// Balances network efficiency with memory usage.
pub const CHUNK_SIZE: usize = 30_000;

/// Metadata about a file available for download.
///
/// # Example
///
/// ```
/// let metadata = vflight::FileMetadata {
///     name: "document.pdf".to_string(),
///     size: 102400,
///     total_chunks: 4,
///     chunk_hashes: vec!["hash1".to_string()],
///     route_blob: "route_data".to_string(),
///     encryption_salt: None,
///     encryption_nonce: None,
///     compressed: false,
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadata {
    /// Original filename
    pub name: String,
    /// Original (uncompressed) file size in bytes
    pub size: u64,
    /// Number of chunks this file is split into
    pub total_chunks: u64,
    /// BLAKE3 hashes of all chunks in order
    pub chunk_hashes: Vec<String>,
    /// Base64-encoded Veilid route blob for accessing this file
    pub route_blob: String,
    /// Base64-encoded encryption salt (None if unencrypted)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub encryption_salt: Option<String>,
    /// Base64-encoded session nonce for encryption (None if unencrypted)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub encryption_nonce: Option<String>,
    /// Whether the file data was zstd-compressed before chunking
    pub compressed: bool,
}

/// Request message sent by fetcher to seeder.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Request {
    /// Request file metadata
    GetMetadata,
    /// Request a specific chunk by index
    GetChunk { index: u64 },
}

/// Response message sent by seeder to fetcher.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Response {
    /// Chunk data with integrity information.
    /// Transmitted via binary layout (see [`encode_response`] / [`decode_response`]),
    /// not JSON.
    ChunkData {
        /// Chunk index
        index: u64,
        /// Raw chunk data bytes
        data: Vec<u8>,
        /// BLAKE3 hash for verification (64-char hex)
        hash: String,
    },
    /// Complete file metadata
    Metadata {
        /// Original filename
        name: String,
        /// File size in bytes
        size: u64,
        /// Total number of chunks
        total_chunks: u64,
        /// BLAKE3 hashes of all chunks
        chunk_hashes: Vec<String>,
        /// Whether the file is zstd-compressed
        compressed: bool,
    },
    /// Error response
    Error {
        /// Error message
        message: String,
    },
}

/// Length of a BLAKE3 hash in hex encoding (always 64 characters).
const HASH_HEX_LEN: usize = 64;

/// Wire-format tag: binary-encoded `ChunkData`.
const WIRE_CHUNK_DATA: u8 = 0x00;
/// Wire-format tag: JSON-encoded response (`Metadata`, `Error`).
const WIRE_JSON: u8 = 0x01;

/// Encode a [`Response`] for transmission over a Veilid AppCall.
///
/// `ChunkData` uses a compact binary layout to avoid the ~33 % size overhead
/// that base64 + JSON would add.  All other variants are prefixed with a tag
/// byte and JSON-serialised.
///
/// Binary layout for `ChunkData`:
/// ```text
/// [1 byte  : 0x00 tag        ]
/// [8 bytes : index (u64 LE)  ]
/// [64 bytes: BLAKE3 hex hash ]
/// [N bytes : raw chunk data  ]
/// ```
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)
        }
    }
}

/// Decode a [`Response`] received over a Veilid AppCall.
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() {
        // Metadata without encryption fields should deserialize fine
        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); // BLAKE3 hex hashes are always 64 chars
        let response = Response::ChunkData {
            index: 42,
            data: vec![10, 20, 30, 40, 50],
            hash: hash.clone(),
        };

        let encoded = encode_response(&response).unwrap();
        // tag(1) + index(8) + hash(64) + data(5) = 78
        assert_eq!(encoded.len(), 78);
        assert_eq!(encoded[0], 0x00); // WIRE_CHUNK_DATA

        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); // WIRE_JSON

        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() {
        // Tag + partial index only — need at least 73 bytes for a valid ChunkData
        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);
    }
}