use crate::bytecode::{decode_bytecode, encode_bytecode};
use crate::consts::SINGLE_STRING_LONG_BYTES;
use crate::error::{Error, Result};
use crate::key_card::KeyCard;
use crate::string_layer::bch::{
bytes_to_5bit, decode_string, encode_5bit_to_string, five_bit_to_bytes,
};
use crate::string_layer::chunk::{ChunkFragment, reassemble_from_chunks, split_into_chunks};
use crate::string_layer::header::{MAX_CHUNK_SET_ID, StringLayerHeader, VERSION_V0_1};
fn fresh_chunk_set_id() -> u32 {
let mut buf = [0u8; 4];
getrandom::getrandom(&mut buf).expect("OS CSPRNG must be available for mk1 encode");
u32::from_be_bytes(buf) & MAX_CHUNK_SET_ID
}
pub fn encode(card: &KeyCard) -> Result<Vec<String>> {
let bytecode = encode_bytecode(card)?;
encode_bytecode_stream(&bytecode, None)
}
pub fn encode_with_chunk_set_id(card: &KeyCard, chunk_set_id: u32) -> Result<Vec<String>> {
let bytecode = encode_bytecode(card)?;
encode_bytecode_stream(&bytecode, Some(chunk_set_id))
}
fn encode_bytecode_stream(bytecode: &[u8], chunk_set_id: Option<u32>) -> Result<Vec<String>> {
if bytecode.len() <= SINGLE_STRING_LONG_BYTES {
let header = StringLayerHeader::SingleString {
version: VERSION_V0_1,
};
let mut data_5bit = header.to_5bit_symbols();
data_5bit.extend(bytes_to_5bit(bytecode));
let s = encode_5bit_to_string(&data_5bit)?;
return Ok(vec![s]);
}
let csid = match chunk_set_id {
Some(v) => {
if v > MAX_CHUNK_SET_ID {
return Err(Error::ChunkedHeaderMalformed(format!(
"chunk_set_id {v:#x} exceeds 20-bit field"
)));
}
v
}
None => fresh_chunk_set_id(),
};
let chunks = split_into_chunks(bytecode, csid)?;
let mut strings = Vec::with_capacity(chunks.len());
for chunk in chunks {
let mut data_5bit = chunk.header.to_5bit_symbols();
data_5bit.extend(bytes_to_5bit(&chunk.fragment));
strings.push(encode_5bit_to_string(&data_5bit)?);
}
Ok(strings)
}
pub fn decode(strings: &[&str]) -> Result<KeyCard> {
if strings.is_empty() {
return Err(Error::ChunkedHeaderMalformed(
"empty input string list".to_string(),
));
}
let mut parsed: Vec<(StringLayerHeader, Vec<u8>)> = Vec::with_capacity(strings.len());
for s in strings {
let decoded = decode_string(s)?;
let data_5bit = decoded.data();
let (header, consumed) = StringLayerHeader::from_5bit_symbols(data_5bit)?;
let payload_5bit = &data_5bit[consumed..];
let fragment = five_bit_to_bytes(payload_5bit).ok_or(Error::MalformedPayloadPadding)?;
parsed.push((header, fragment));
}
let first_is_single = matches!(parsed[0].0, StringLayerHeader::SingleString { .. });
if first_is_single {
if parsed.len() != 1 {
return Err(Error::MixedHeaderTypes);
}
let (_, bytecode) = parsed.into_iter().next().expect("len == 1");
return decode_bytecode(&bytecode);
}
let chunks: Vec<ChunkFragment> = parsed
.into_iter()
.map(|(header, fragment)| ChunkFragment { header, fragment })
.collect();
let bytecode = reassemble_from_chunks(chunks)?;
decode_bytecode(&bytecode)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bytecode::test_helpers::synthetic_xpub;
use bitcoin::bip32::{DerivationPath, Fingerprint};
use std::str::FromStr;
fn fixture_card_typical_chunked() -> KeyCard {
let path = DerivationPath::from_str("48'/0'/0'/2'").unwrap();
KeyCard {
policy_id_stubs: vec![[0x11, 0x22, 0x33, 0x44]],
origin_fingerprint: Some(Fingerprint::from([0xAA, 0xBB, 0xCC, 0xDD])),
origin_path: path.clone(),
xpub: synthetic_xpub(&path),
}
}
fn fixture_card_explicit_path_long() -> KeyCard {
let path = DerivationPath::from_str("9999'/1234'/56'/7'/0/1/2/3").unwrap();
KeyCard {
policy_id_stubs: vec![[0xDE, 0xAD, 0xBE, 0xEF]],
origin_fingerprint: Some(Fingerprint::from([0x01, 0x02, 0x03, 0x04])),
origin_path: path.clone(),
xpub: synthetic_xpub(&path),
}
}
#[test]
fn round_trip_typical_card_chunked() {
let card = fixture_card_typical_chunked();
let strings = encode_with_chunk_set_id(&card, 0x12345).unwrap();
let parts: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
let recovered = decode(&parts).unwrap();
assert_eq!(recovered, card);
}
#[test]
fn round_trip_explicit_path_chunked() {
let card = fixture_card_explicit_path_long();
let strings = encode_with_chunk_set_id(&card, 0xABCDE).unwrap();
assert!(strings.len() >= 2, "explicit-path card must chunk");
let parts: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
let recovered = decode(&parts).unwrap();
assert_eq!(recovered, card);
}
#[test]
fn deterministic_encoding_with_explicit_chunk_set_id() {
let card = fixture_card_typical_chunked();
let s1 = encode_with_chunk_set_id(&card, 0x12345).unwrap();
let s2 = encode_with_chunk_set_id(&card, 0x12345).unwrap();
assert_eq!(s1, s2);
}
#[test]
fn random_chunk_set_id_decodes_round_trip() {
let card = fixture_card_typical_chunked();
let strings = encode(&card).unwrap();
let parts: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
let recovered = decode(&parts).unwrap();
assert_eq!(recovered, card);
}
#[test]
fn random_chunk_set_id_fits_20_bits() {
let card = fixture_card_typical_chunked();
let strings = encode(&card).unwrap();
let s0 = &strings[0];
let decoded = decode_string(s0).unwrap();
let (header, _consumed) = StringLayerHeader::from_5bit_symbols(decoded.data()).unwrap();
match header {
StringLayerHeader::Chunked { chunk_set_id, .. } => {
assert!(
chunk_set_id <= MAX_CHUNK_SET_ID,
"chunk_set_id {chunk_set_id:#x} > 20-bit max"
);
}
StringLayerHeader::SingleString { .. } => {
}
}
}
#[test]
fn encode_with_chunk_set_id_rejects_oversized_value() {
let card = fixture_card_typical_chunked();
let r = encode_with_chunk_set_id(&card, 0x10_0000);
assert!(matches!(r, Err(Error::ChunkedHeaderMalformed(_))));
}
#[test]
fn decode_rejects_chunk_set_id_mismatch() {
let card = fixture_card_typical_chunked();
let strings = encode_with_chunk_set_id(&card, 0x12345).unwrap();
let other = encode_with_chunk_set_id(&card, 0x67890).unwrap();
let mixed: Vec<&str> = vec![strings[0].as_str(), other[1].as_str()];
assert!(matches!(decode(&mixed), Err(Error::ChunkSetIdMismatch)));
}
#[test]
fn decode_rejects_5_symbol_burst_in_last_chunk_data_part() {
let card = fixture_card_typical_chunked();
let strings = encode_with_chunk_set_id(&card, 0).unwrap();
assert!(
strings.len() >= 2,
"fixture must produce a multi-chunk encoding"
);
let mut perturbed = strings.last().expect("multi-chunk fixture").clone();
let mut chars: Vec<char> = perturbed.chars().collect();
for c in chars.iter_mut().take(16).skip(11) {
*c = if *c == 'q' { 'p' } else { 'q' };
}
perturbed = chars.into_iter().collect();
let mut perturbed_strings: Vec<String> = strings[..strings.len() - 1].to_vec();
perturbed_strings.push(perturbed);
let parts: Vec<&str> = perturbed_strings.iter().map(|s| s.as_str()).collect();
match decode(&parts) {
Err(Error::CrossChunkHashMismatch) | Err(Error::BchUncorrectable(_)) => (),
other => panic!(
"5-symbol burst must produce CrossChunkHashMismatch or BchUncorrectable, \
got {other:?}"
),
}
}
fn synthetic_singlestring(bytecode: &[u8]) -> String {
let header = StringLayerHeader::SingleString {
version: VERSION_V0_1,
};
let mut data_5bit = header.to_5bit_symbols();
data_5bit.extend(bytes_to_5bit(bytecode));
encode_5bit_to_string(&data_5bit).expect("synthetic singlestring encode")
}
#[test]
fn decode_rejects_singlestring_then_chunked() {
let single = synthetic_singlestring(&[0x42u8; 8]);
let card = fixture_card_typical_chunked();
let chunked = encode_with_chunk_set_id(&card, 0).unwrap();
let parts: Vec<&str> = vec![single.as_str(), chunked[0].as_str()];
assert!(matches!(decode(&parts), Err(Error::MixedHeaderTypes)));
}
#[test]
fn decode_rejects_chunked_then_singlestring() {
let card = fixture_card_typical_chunked();
let mut strings = encode_with_chunk_set_id(&card, 0).unwrap();
assert!(strings.len() >= 2, "fixture must produce ≥ 2 chunks");
strings[1] = synthetic_singlestring(&[0xAAu8; 8]);
let parts: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
assert!(matches!(decode(&parts), Err(Error::MixedHeaderTypes)));
}
#[test]
fn decode_rejects_singlestring_padding_bits_nonzero() {
let header = StringLayerHeader::SingleString {
version: VERSION_V0_1,
};
let mut data_5bit = header.to_5bit_symbols();
data_5bit.extend([0u8, 0u8, 0b00011u8]); let s = encode_5bit_to_string(&data_5bit).unwrap();
let r = decode(&[&s]);
assert!(matches!(r, Err(Error::MalformedPayloadPadding)));
}
#[test]
fn decode_rejects_empty_input() {
assert!(matches!(decode(&[]), Err(Error::ChunkedHeaderMalformed(_))));
}
}