use bitcoin::hashes::{Hash, sha256};
use crate::consts::{CHUNKED_FRAGMENT_LONG_BYTES, CROSS_CHUNK_HASH_BYTES, MAX_CHUNKS};
use crate::error::{Error, Result};
use crate::string_layer::header::{MAX_CHUNK_SET_ID, StringLayerHeader, VERSION_V0_1};
pub const MAX_CHUNKABLE_BYTECODE: usize =
(MAX_CHUNKS as usize) * CHUNKED_FRAGMENT_LONG_BYTES - CROSS_CHUNK_HASH_BYTES;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkFragment {
pub header: StringLayerHeader,
pub fragment: Vec<u8>,
}
pub fn split_into_chunks(
canonical_bytecode: &[u8],
chunk_set_id: u32,
) -> Result<Vec<ChunkFragment>> {
if chunk_set_id > MAX_CHUNK_SET_ID {
return Err(Error::ChunkedHeaderMalformed(format!(
"chunk_set_id {chunk_set_id:#x} exceeds 20-bit field"
)));
}
if canonical_bytecode.len() > MAX_CHUNKABLE_BYTECODE {
return Err(Error::CardPayloadTooLarge {
bytecode_len: canonical_bytecode.len(),
max_supported: MAX_CHUNKABLE_BYTECODE,
});
}
let hash = sha256::Hash::hash(canonical_bytecode);
let mut stream = Vec::with_capacity(canonical_bytecode.len() + CROSS_CHUNK_HASH_BYTES);
stream.extend_from_slice(canonical_bytecode);
stream.extend_from_slice(&hash.to_byte_array()[..CROSS_CHUNK_HASH_BYTES]);
let frag_size = CHUNKED_FRAGMENT_LONG_BYTES;
let total: usize = stream.len().div_ceil(frag_size).max(1);
debug_assert!(
total <= MAX_CHUNKS as usize,
"capacity check above guarantees this"
);
let total_chunks_u8: u8 = total as u8;
let mut chunks = Vec::with_capacity(total);
for i in 0..total {
let start = i * frag_size;
let end = ((i + 1) * frag_size).min(stream.len());
let fragment = stream[start..end].to_vec();
let header = StringLayerHeader::Chunked {
version: VERSION_V0_1,
chunk_set_id,
total_chunks: total_chunks_u8,
chunk_index: i as u8,
};
chunks.push(ChunkFragment { header, fragment });
}
Ok(chunks)
}
pub fn reassemble_from_chunks(chunks: Vec<ChunkFragment>) -> Result<Vec<u8>> {
if chunks.is_empty() {
return Err(Error::ChunkedHeaderMalformed(
"empty chunk list".to_string(),
));
}
let (set_id, total) = match chunks[0].header {
StringLayerHeader::Chunked {
chunk_set_id,
total_chunks,
..
} => (chunk_set_id, total_chunks),
StringLayerHeader::SingleString { .. } => {
return Err(Error::ChunkedHeaderMalformed(
"single-string header in multi-chunk reassembly".to_string(),
));
}
};
let total_usize = total as usize;
if chunks.len() != total_usize {
return Err(Error::ChunkedHeaderMalformed(format!(
"received {} chunks, header declares total_chunks = {total}",
chunks.len()
)));
}
let mut slots: Vec<Option<Vec<u8>>> = (0..total_usize).map(|_| None).collect();
for chunk in chunks {
match chunk.header {
StringLayerHeader::Chunked {
version: _,
chunk_set_id,
total_chunks,
chunk_index,
} => {
if chunk_set_id != set_id {
return Err(Error::ChunkSetIdMismatch);
}
if total_chunks != total {
return Err(Error::ChunkedHeaderMalformed(format!(
"total_chunks disagrees across chunks: saw {total} and {total_chunks}"
)));
}
let idx = chunk_index as usize;
if idx >= total_usize {
return Err(Error::ChunkedHeaderMalformed(format!(
"chunk_index {idx} >= total_chunks {total}"
)));
}
if slots[idx].is_some() {
return Err(Error::ChunkedHeaderMalformed(format!(
"duplicate chunk_index {idx}"
)));
}
slots[idx] = Some(chunk.fragment);
}
StringLayerHeader::SingleString { .. } => {
return Err(Error::MixedHeaderTypes);
}
}
}
let mut stream = Vec::new();
for (i, slot) in slots.into_iter().enumerate() {
let frag =
slot.ok_or_else(|| Error::ChunkedHeaderMalformed(format!("missing chunk_index {i}")))?;
stream.extend_from_slice(&frag);
}
if stream.len() < CROSS_CHUNK_HASH_BYTES {
return Err(Error::ChunkedHeaderMalformed(
"reassembled stream shorter than 4-byte cross-chunk hash".to_string(),
));
}
let split = stream.len() - CROSS_CHUNK_HASH_BYTES;
let bytecode = &stream[..split];
let recovered_hash = &stream[split..];
let computed = sha256::Hash::hash(bytecode);
if recovered_hash != &computed.to_byte_array()[..CROSS_CHUNK_HASH_BYTES] {
return Err(Error::CrossChunkHashMismatch);
}
Ok(bytecode.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_bytecode(len: usize) -> Vec<u8> {
(0..len).map(|i| (i & 0xFF) as u8).collect()
}
#[test]
fn split_then_reassemble_round_trip_short() {
let bc = fixture_bytecode(60);
let chunks = split_into_chunks(&bc, 0x12345).unwrap();
assert_eq!(chunks.len(), 2);
let recovered = reassemble_from_chunks(chunks).unwrap();
assert_eq!(recovered, bc);
}
#[test]
fn split_then_reassemble_round_trip_typical_mk1_card_size() {
let bc = fixture_bytecode(84);
let chunks = split_into_chunks(&bc, 0xABCDE).unwrap();
assert_eq!(chunks.len(), 2);
let recovered = reassemble_from_chunks(chunks).unwrap();
assert_eq!(recovered, bc);
}
#[test]
fn split_at_capacity_uses_max_chunks() {
let bc = fixture_bytecode(MAX_CHUNKABLE_BYTECODE);
let chunks = split_into_chunks(&bc, 0x55555).unwrap();
assert_eq!(chunks.len(), MAX_CHUNKS as usize);
let recovered = reassemble_from_chunks(chunks).unwrap();
assert_eq!(recovered, bc);
}
#[test]
fn split_rejects_oversized_bytecode() {
let bc = vec![0u8; MAX_CHUNKABLE_BYTECODE + 1];
let r = split_into_chunks(&bc, 0);
assert!(matches!(r, Err(Error::CardPayloadTooLarge { .. })));
}
#[test]
fn split_rejects_chunk_set_id_above_20_bits() {
let bc = fixture_bytecode(60);
let r = split_into_chunks(&bc, 0x10_0000);
assert!(matches!(r, Err(Error::ChunkedHeaderMalformed(_))));
}
#[test]
fn reassemble_accepts_out_of_order_chunks() {
let bc = fixture_bytecode(150);
let mut chunks = split_into_chunks(&bc, 0).unwrap();
chunks.reverse();
let recovered = reassemble_from_chunks(chunks).unwrap();
assert_eq!(recovered, bc);
}
#[test]
fn reassemble_rejects_chunk_set_id_mismatch() {
let bc = fixture_bytecode(150);
let mut chunks = split_into_chunks(&bc, 0x12345).unwrap();
if let StringLayerHeader::Chunked {
ref mut chunk_set_id,
..
} = chunks[1].header
{
*chunk_set_id = 0x00001;
}
assert!(matches!(
reassemble_from_chunks(chunks),
Err(Error::ChunkSetIdMismatch)
));
}
#[test]
fn reassemble_rejects_cross_chunk_hash_mismatch() {
let bc = fixture_bytecode(150);
let mut chunks = split_into_chunks(&bc, 0).unwrap();
chunks[0].fragment[0] ^= 0x01;
assert!(matches!(
reassemble_from_chunks(chunks),
Err(Error::CrossChunkHashMismatch)
));
}
#[test]
fn reassemble_rejects_duplicate_chunk_index() {
let bc = fixture_bytecode(150);
let mut chunks = split_into_chunks(&bc, 0).unwrap();
if let StringLayerHeader::Chunked {
ref mut chunk_index,
..
} = chunks[1].header
{
*chunk_index = 0;
}
assert!(matches!(
reassemble_from_chunks(chunks),
Err(Error::ChunkedHeaderMalformed(_))
));
}
#[test]
fn reassemble_rejects_missing_chunk() {
let bc = fixture_bytecode(150);
let mut chunks = split_into_chunks(&bc, 0).unwrap();
chunks.pop();
assert!(matches!(
reassemble_from_chunks(chunks),
Err(Error::ChunkedHeaderMalformed(_))
));
}
#[test]
fn reassemble_rejects_empty_chunk_list() {
assert!(matches!(
reassemble_from_chunks(vec![]),
Err(Error::ChunkedHeaderMalformed(_))
));
}
#[test]
fn split_one_chunk_when_stream_fits_in_53_bytes() {
let bc = fixture_bytecode(49);
let chunks = split_into_chunks(&bc, 0).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].fragment.len(), 53);
let recovered = reassemble_from_chunks(chunks).unwrap();
assert_eq!(recovered, bc);
}
#[test]
fn split_handles_empty_bytecode() {
let bc: Vec<u8> = vec![];
let chunks = split_into_chunks(&bc, 0).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].fragment.len(), CROSS_CHUNK_HASH_BYTES);
let recovered = reassemble_from_chunks(chunks).unwrap();
assert_eq!(recovered, bc);
}
}