lix 0.12.2

Embeddable version control for apps and AI agents.
Documentation
use musli::{Decode, Encode};

use super::types::BinaryCasChunkView;
use crate::LixError;
use crate::storage_codec;

const HASH_BYTES: usize = 32;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
pub(crate) enum BinaryChunkCodec {
    Raw,
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub(crate) enum BinaryCasManifest {
    Empty {
        size_bytes: u64,
    },
    SingleChunk {
        size_bytes: u64,
        chunk_hash: [u8; HASH_BYTES],
    },
    Chunked {
        size_bytes: u64,
        chunk_count: u32,
    },
    Delta {
        size_bytes: u64,
        base_blob_hash: [u8; HASH_BYTES],
        base_size_bytes: u64,
        base_layout: StorageBinaryCasDeltaBaseLayout,
        segments: Vec<StorageBinaryCasDeltaSegment>,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub(crate) enum StorageBinaryCasDeltaBaseLayout {
    SingleChunk { chunk_hash: [u8; HASH_BYTES] },
    Chunked { chunk_count: u32 },
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub(crate) enum StorageBinaryCasDeltaSegment {
    Copy { offset: u64, length: u64 },
    Insert { bytes: Vec<u8> },
}

#[derive(Encode, Decode)]
#[musli(packed)]
struct StorageBinaryCasManifestChunk {
    chunk_hash: [u8; HASH_BYTES],
    chunk_size: u64,
}

#[derive(Encode)]
#[musli(packed)]
struct BinaryCasChunkRef<'a> {
    codec: BinaryChunkCodec,
    uncompressed_len: u64,
    #[musli(bytes)]
    payload: &'a [u8],
}

impl BinaryCasManifest {
    pub(crate) fn size_bytes(&self) -> u64 {
        match self {
            Self::Empty { size_bytes }
            | Self::SingleChunk { size_bytes, .. }
            | Self::Chunked { size_bytes, .. }
            | Self::Delta { size_bytes, .. } => *size_bytes,
        }
    }
}

#[cfg(test)]
pub(crate) fn binary_blob_hash_hex(data: &[u8]) -> String {
    hash_bytes_to_hex(&binary_blob_hash_bytes(data))
}

pub(crate) fn binary_blob_hash_bytes(data: &[u8]) -> [u8; HASH_BYTES] {
    *blake3::hash(data).as_bytes()
}

pub(crate) fn hash_hex_to_bytes(hash_hex: &str, label: &str) -> Result<[u8; HASH_BYTES], LixError> {
    if hash_hex.len() != HASH_BYTES * 2 {
        return Err(codec_error(format!(
            "{label} hash must be {} hex characters, got {}",
            HASH_BYTES * 2,
            hash_hex.len()
        )));
    }

    let mut out = [0u8; HASH_BYTES];
    let bytes = hash_hex.as_bytes();
    for index in 0..HASH_BYTES {
        out[index] =
            (hex_value(bytes[index * 2], label)? << 4) | hex_value(bytes[index * 2 + 1], label)?;
    }
    Ok(out)
}

pub(crate) fn hash_bytes_to_hex(bytes: &[u8; HASH_BYTES]) -> String {
    blake3::Hash::from_bytes(*bytes).to_hex().to_string()
}

pub(crate) fn encode_binary_cas_manifest(manifest: &BinaryCasManifest) -> Vec<u8> {
    storage_codec::encode("binary CAS manifest", manifest)
        .expect("binary CAS manifest storage encoding should not fail")
}

pub(crate) fn decode_binary_cas_manifest(bytes: &[u8]) -> Result<BinaryCasManifest, LixError> {
    storage_codec::decode("binary CAS manifest", bytes)
}

pub(crate) fn encode_binary_cas_manifest_chunk(
    chunk_hash: &[u8; HASH_BYTES],
    chunk_size: u64,
) -> Vec<u8> {
    storage_codec::encode(
        "binary CAS manifest chunk",
        &StorageBinaryCasManifestChunk {
            chunk_hash: *chunk_hash,
            chunk_size,
        },
    )
    .expect("binary CAS manifest chunk storage encoding should not fail")
}

pub(crate) fn decode_binary_cas_manifest_chunk(
    bytes: &[u8],
) -> Result<([u8; HASH_BYTES], u64), LixError> {
    let StorageBinaryCasManifestChunk {
        chunk_hash,
        chunk_size,
    } = storage_codec::decode("binary CAS manifest chunk", bytes)?;
    Ok((chunk_hash, chunk_size))
}

pub(crate) fn encode_binary_cas_chunk(
    codec: BinaryChunkCodec,
    uncompressed_len: u64,
    payload: &[u8],
) -> Vec<u8> {
    storage_codec::encode(
        "binary CAS chunk",
        &BinaryCasChunkRef {
            codec,
            uncompressed_len,
            payload,
        },
    )
    .expect("binary CAS chunk storage encoding should not fail")
}

pub(crate) fn decode_binary_cas_chunk(
    bytes: &[u8],
) -> Result<(BinaryChunkCodec, u64, &[u8]), LixError> {
    let BinaryCasChunkView {
        codec,
        uncompressed_len,
        payload,
    } = storage_codec::decode("binary CAS chunk", bytes)?;
    Ok((codec, uncompressed_len, payload))
}

fn hex_value(byte: u8, label: &str) -> Result<u8, LixError> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'a'..=b'f' => Ok(byte - b'a' + 10),
        b'A'..=b'F' => Ok(byte - b'A' + 10),
        _ => Err(codec_error(format!("{label} hash contains non-hex bytes"))),
    }
}

fn codec_error(message: String) -> LixError {
    LixError::new("LIX_ERROR_UNKNOWN", message)
}

#[cfg(test)]
mod tests {
    use super::*;

    const EMPTY_MANIFEST_STORAGE_BYTES: usize = 4;
    const SINGLE_CHUNK_MANIFEST_STORAGE_BYTES: usize = 38;
    const CHUNKED_MANIFEST_STORAGE_BYTES: usize = 6;
    const MANIFEST_CHUNK_STORAGE_BYTES: usize = 35;
    const CHUNK_STORAGE_OVERHEAD_BYTES: usize = 3;

    #[test]
    fn manifests_roundtrip_fixed_binary_rows() {
        let chunk_hash = binary_blob_hash_bytes(b"chunk");
        let cases = [
            (
                BinaryCasManifest::Empty { size_bytes: 0 },
                EMPTY_MANIFEST_STORAGE_BYTES,
            ),
            (
                BinaryCasManifest::SingleChunk {
                    size_bytes: 42,
                    chunk_hash,
                },
                SINGLE_CHUNK_MANIFEST_STORAGE_BYTES,
            ),
            (
                BinaryCasManifest::Chunked {
                    size_bytes: 42,
                    chunk_count: 7,
                },
                CHUNKED_MANIFEST_STORAGE_BYTES,
            ),
        ];
        for (manifest, expected_len) in cases {
            let encoded = encode_binary_cas_manifest(&manifest);
            assert_eq!(encoded.len(), expected_len);
            assert_eq!(decode_binary_cas_manifest(&encoded).unwrap(), manifest);
        }
    }

    #[test]
    fn delta_manifest_roundtrips_copy_insert_program() {
        let base_blob_hash = binary_blob_hash_bytes(b"base blob");
        let chunk_hash = binary_blob_hash_bytes(b"base chunk");
        let manifest = BinaryCasManifest::Delta {
            size_bytes: 12,
            base_blob_hash,
            base_size_bytes: 10,
            base_layout: StorageBinaryCasDeltaBaseLayout::SingleChunk { chunk_hash },
            segments: vec![
                StorageBinaryCasDeltaSegment::Copy {
                    offset: 0,
                    length: 5,
                },
                StorageBinaryCasDeltaSegment::Insert {
                    bytes: b"delta".to_vec(),
                },
                StorageBinaryCasDeltaSegment::Copy {
                    offset: 8,
                    length: 2,
                },
            ],
        };

        let encoded = encode_binary_cas_manifest(&manifest);
        assert_eq!(decode_binary_cas_manifest(&encoded).unwrap(), manifest);
    }

    #[test]
    fn manifest_chunk_roundtrips_fixed_binary_row() {
        let hash = binary_blob_hash_bytes(b"chunk");
        let encoded = encode_binary_cas_manifest_chunk(&hash, 1024);
        assert_eq!(encoded.len(), MANIFEST_CHUNK_STORAGE_BYTES);
        assert_eq!(
            decode_binary_cas_manifest_chunk(&encoded).unwrap(),
            (hash, 1024)
        );
    }

    #[test]
    fn chunk_roundtrips_payload_as_remaining_bytes() {
        let payload = b"hello payload";
        let encoded = encode_binary_cas_chunk(BinaryChunkCodec::Raw, payload.len() as u64, payload);
        assert_eq!(encoded.len(), CHUNK_STORAGE_OVERHEAD_BYTES + payload.len());
        let (decoded_codec, uncompressed_len, decoded_payload) =
            decode_binary_cas_chunk(&encoded).unwrap();
        assert_eq!(decoded_codec, BinaryChunkCodec::Raw);
        assert_eq!(uncompressed_len, payload.len() as u64);
        assert_eq!(decoded_payload, payload);
    }

    #[test]
    fn malformed_storage_bytes_are_rejected() {
        let mut encoded = encode_binary_cas_manifest(&BinaryCasManifest::Empty { size_bytes: 0 });
        encoded.truncate(encoded.len() - 1);
        let error = decode_binary_cas_manifest(&encoded).unwrap_err();
        assert!(
            error
                .message
                .contains("failed to decode binary CAS manifest")
        );
    }

    #[test]
    fn hex_hashes_roundtrip_to_32_byte_keys() {
        let hash_hex = binary_blob_hash_hex(b"blob");
        let hash_bytes = hash_hex_to_bytes(&hash_hex, "test").unwrap();
        assert_eq!(hash_bytes.len(), 32);
        assert_eq!(hash_bytes_to_hex(&hash_bytes), hash_hex);
    }
}