syncular-protocol 0.1.0

Wire protocol and integrity types for Rust-first Syncular clients.
Documentation
use crate::{ProtocolError, Result, SnapshotChunkRef, SNAPSHOT_CHUNK_ENCODING_BINARY_TABLE_V1};

pub const SNAPSHOT_CHUNK_COMPRESSION_GZIP: &str = "gzip";

pub fn validate_snapshot_chunk_format(chunk: &SnapshotChunkRef) -> Result<()> {
    if chunk.compression != SNAPSHOT_CHUNK_COMPRESSION_GZIP {
        return Err(ProtocolError::message(format!(
            "unsupported snapshot chunk compression: {}",
            chunk.compression
        )));
    }
    if chunk.encoding != SNAPSHOT_CHUNK_ENCODING_BINARY_TABLE_V1 {
        return Err(ProtocolError::message(format!(
            "unsupported snapshot chunk encoding: {}",
            chunk.encoding
        )));
    }
    Ok(())
}

pub fn validate_snapshot_chunk_hash_hex(chunk: &SnapshotChunkRef, actual_hash: &str) -> Result<()> {
    if actual_hash != chunk.sha256 {
        return Err(snapshot_chunk_hash_mismatch(chunk, actual_hash));
    }
    Ok(())
}

pub fn validate_snapshot_chunk_hash_bytes(
    chunk: &SnapshotChunkRef,
    actual_hash: &[u8],
) -> Result<()> {
    let expected_hash = decode_snapshot_chunk_sha256(chunk)?;
    if actual_hash != expected_hash.as_slice() {
        return Err(snapshot_chunk_hash_mismatch(
            chunk,
            &hex::encode(actual_hash),
        ));
    }
    Ok(())
}

pub fn decode_snapshot_chunk_sha256(chunk: &SnapshotChunkRef) -> Result<[u8; 32]> {
    let decoded = hex::decode(&chunk.sha256).map_err(|err| {
        ProtocolError::message(format!("decode snapshot chunk expected hash: {err}"))
    })?;
    decoded.try_into().map_err(|_| {
        ProtocolError::message(format!(
            "snapshot chunk expected hash must decode to 32 bytes: {}",
            chunk.sha256
        ))
    })
}

fn snapshot_chunk_hash_mismatch(chunk: &SnapshotChunkRef, actual_hash: &str) -> ProtocolError {
    ProtocolError::message(format!(
        "snapshot chunk hash mismatch: expected {}, got {}",
        chunk.sha256, actual_hash
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use sha2::{Digest, Sha256};

    #[test]
    fn validates_snapshot_chunk_format_and_hash() {
        let compressed = b"chunk-body";
        let chunk = SnapshotChunkRef {
            id: "chunk-1".to_string(),
            byte_length: compressed.len() as i64,
            sha256: hex::encode(Sha256::digest(compressed)),
            encoding: SNAPSHOT_CHUNK_ENCODING_BINARY_TABLE_V1.to_string(),
            compression: SNAPSHOT_CHUNK_COMPRESSION_GZIP.to_string(),
        };

        validate_snapshot_chunk_format(&chunk).expect("format");
        validate_snapshot_chunk_hash_bytes(&chunk, &Sha256::digest(compressed)).expect("bytes");
        validate_snapshot_chunk_hash_hex(&chunk, &hex::encode(Sha256::digest(compressed)))
            .expect("hex");
    }
}