use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::net::atp::transport_common::{flat_merkle_root_from_slices, hex_encode};
use crate::types::symbol::ObjectId as RqObjectId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BondTransferDescriptor {
pub transfer_id: String,
pub root_name: String,
pub is_directory: bool,
pub total_bytes: u64,
pub merkle_root_hex: String,
pub entries: Vec<BondTransferEntry>,
pub symbol_size: u16,
pub max_block_size: u64,
}
impl BondTransferDescriptor {
pub fn new(
transfer_id: impl Into<String>,
root_name: impl Into<String>,
is_directory: bool,
merkle_root_hex: impl Into<String>,
entries: Vec<BondTransferEntry>,
symbol_size: u16,
max_block_size: u64,
) -> Result<Self, BondingError> {
let total_bytes = entries.iter().map(|entry| entry.size).sum();
let descriptor = Self {
transfer_id: transfer_id.into(),
root_name: root_name.into(),
is_directory,
total_bytes,
merkle_root_hex: merkle_root_hex.into(),
entries,
symbol_size,
max_block_size,
};
descriptor.validate()?;
Ok(descriptor)
}
pub fn from_byte_slices(
transfer_id: impl Into<String>,
root_name: impl Into<String>,
is_directory: bool,
symbol_size: u16,
max_block_size: u64,
entries: &[(&str, &[u8])],
) -> Result<Self, BondingError> {
let merkle_root_hex = flat_merkle_root_from_slices(entries.iter().copied());
let entries = entries
.iter()
.enumerate()
.map(|(index, (rel_path, bytes))| BondTransferEntry {
index: index as u32,
rel_path: (*rel_path).to_string(),
size: bytes.len() as u64,
sha256_hex: sha256_hex(bytes),
})
.collect();
Self::new(
transfer_id,
root_name,
is_directory,
merkle_root_hex,
entries,
symbol_size,
max_block_size,
)
}
pub fn validate(&self) -> Result<(), BondingError> {
if self.transfer_id.is_empty() {
return Err(BondingError::EmptyTransferId);
}
if self.root_name.is_empty() {
return Err(BondingError::EmptyRootName);
}
if self.symbol_size == 0 {
return Err(BondingError::InvalidSymbolSize);
}
if self.max_block_size == 0 {
return Err(BondingError::InvalidMaxBlockSize);
}
if !is_lower_hex_64(&self.merkle_root_hex) {
return Err(BondingError::InvalidMerkleRoot {
merkle_root_hex: self.merkle_root_hex.clone(),
});
}
let mut computed_total = 0_u64;
for (position, entry) in self.entries.iter().enumerate() {
let expected_index = position as u32;
if entry.index != expected_index {
return Err(BondingError::EntryIndexMismatch {
expected: expected_index,
actual: entry.index,
});
}
if entry.rel_path.is_empty() {
return Err(BondingError::EmptyRelPath { index: entry.index });
}
if !is_lower_hex_64(&entry.sha256_hex) {
return Err(BondingError::InvalidEntrySha {
index: entry.index,
sha256_hex: entry.sha256_hex.clone(),
});
}
computed_total = computed_total.saturating_add(entry.size);
}
if self.total_bytes != computed_total {
return Err(BondingError::TotalBytesMismatch {
declared: self.total_bytes,
computed: computed_total,
});
}
Ok(())
}
pub fn verify_donor_byte_match(
&self,
proof: &BondDonorByteMatchProof,
) -> Result<(), BondingError> {
self.validate()?;
proof.validate()?;
if proof.merkle_root_hex != self.merkle_root_hex {
return Err(BondingError::ProofMerkleRootMismatch {
expected: self.merkle_root_hex.clone(),
actual: proof.merkle_root_hex.clone(),
});
}
if proof.entries.len() != self.entries.len() {
return Err(BondingError::ProofEntryCountMismatch {
expected: self.entries.len(),
actual: proof.entries.len(),
});
}
for (position, (expected, actual)) in self.entries.iter().zip(&proof.entries).enumerate() {
if actual.index != expected.index {
return Err(BondingError::ProofEntryIndexMismatch {
position,
expected: expected.index,
actual: actual.index,
});
}
if actual.rel_path != expected.rel_path {
return Err(BondingError::ProofEntryPathMismatch {
index: expected.index,
expected: expected.rel_path.clone(),
actual: actual.rel_path.clone(),
});
}
if actual.size != expected.size {
return Err(BondingError::ProofEntrySizeMismatch {
index: expected.index,
expected: expected.size,
actual: actual.size,
});
}
if actual.sha256_hex != expected.sha256_hex {
return Err(BondingError::ProofEntryShaMismatch {
index: expected.index,
expected: expected.sha256_hex.clone(),
actual: actual.sha256_hex.clone(),
});
}
}
Ok(())
}
#[must_use]
pub fn entry_object_id(&self, index: u32) -> RqObjectId {
rq_entry_object_id(&self.transfer_id, index)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BondTransferEntry {
pub index: u32,
pub rel_path: String,
pub size: u64,
pub sha256_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BondDonorByteMatchProof {
pub merkle_root_hex: String,
pub entries: Vec<BondDonorEntryProof>,
}
impl BondDonorByteMatchProof {
pub fn from_byte_slices(entries: &[(&str, &[u8])]) -> Result<Self, BondingError> {
let proof = Self {
merkle_root_hex: flat_merkle_root_from_slices(entries.iter().copied()),
entries: entries
.iter()
.enumerate()
.map(|(index, (rel_path, bytes))| BondDonorEntryProof {
index: index as u32,
rel_path: (*rel_path).to_string(),
size: bytes.len() as u64,
sha256_hex: sha256_hex(bytes),
})
.collect(),
};
proof.validate()?;
Ok(proof)
}
pub fn validate(&self) -> Result<(), BondingError> {
if !is_lower_hex_64(&self.merkle_root_hex) {
return Err(BondingError::InvalidProofMerkleRoot {
merkle_root_hex: self.merkle_root_hex.clone(),
});
}
for (position, entry) in self.entries.iter().enumerate() {
let expected_index = position as u32;
if entry.index != expected_index {
return Err(BondingError::ProofEntryIndexMismatch {
position,
expected: expected_index,
actual: entry.index,
});
}
if entry.rel_path.is_empty() {
return Err(BondingError::EmptyProofRelPath { index: entry.index });
}
if !is_lower_hex_64(&entry.sha256_hex) {
return Err(BondingError::InvalidProofEntrySha {
index: entry.index,
sha256_hex: entry.sha256_hex.clone(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BondDonorEntryProof {
pub index: u32,
pub rel_path: String,
pub size: u64,
pub sha256_hex: String,
}
#[must_use]
pub fn rq_entry_object_id(transfer_id: &str, index: u32) -> RqObjectId {
let mut hasher = Sha256::new();
hasher.update(b"asupersync.atp.rq.entry-object-id.v1\0");
hasher.update(transfer_id.as_bytes());
hasher.update(index.to_be_bytes());
let digest = hasher.finalize();
let high = u64::from_be_bytes([
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6], digest[7],
]);
let low = u64::from_be_bytes([
digest[8], digest[9], digest[10], digest[11], digest[12], digest[13], digest[14],
digest[15],
]);
RqObjectId::new(high, low)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BondingError {
#[error("bond transfer descriptor has empty transfer_id")]
EmptyTransferId,
#[error("bond transfer descriptor has empty root_name")]
EmptyRootName,
#[error("bond transfer descriptor has zero symbol_size")]
InvalidSymbolSize,
#[error("bond transfer descriptor has zero max_block_size")]
InvalidMaxBlockSize,
#[error("bond transfer descriptor has invalid merkle_root_hex {merkle_root_hex:?}")]
InvalidMerkleRoot {
merkle_root_hex: String,
},
#[error("bond transfer descriptor entry index mismatch: expected {expected}, actual {actual}")]
EntryIndexMismatch {
expected: u32,
actual: u32,
},
#[error("bond transfer descriptor entry {index} has empty rel_path")]
EmptyRelPath {
index: u32,
},
#[error("bond transfer descriptor entry {index} has invalid sha256_hex {sha256_hex:?}")]
InvalidEntrySha {
index: u32,
sha256_hex: String,
},
#[error(
"bond transfer descriptor total bytes mismatch: declared {declared}, computed {computed}"
)]
TotalBytesMismatch {
declared: u64,
computed: u64,
},
#[error("bond donor proof has invalid merkle_root_hex {merkle_root_hex:?}")]
InvalidProofMerkleRoot {
merkle_root_hex: String,
},
#[error(
"bond donor proof entry index mismatch at position {position}: expected {expected}, actual {actual}"
)]
ProofEntryIndexMismatch {
position: usize,
expected: u32,
actual: u32,
},
#[error("bond donor proof entry {index} has empty rel_path")]
EmptyProofRelPath {
index: u32,
},
#[error("bond donor proof entry {index} has invalid sha256_hex {sha256_hex:?}")]
InvalidProofEntrySha {
index: u32,
sha256_hex: String,
},
#[error("bond donor proof merkle root mismatch: expected {expected}, actual {actual}")]
ProofMerkleRootMismatch {
expected: String,
actual: String,
},
#[error("bond donor proof entry count mismatch: expected {expected}, actual {actual}")]
ProofEntryCountMismatch {
expected: usize,
actual: usize,
},
#[error(
"bond donor proof path mismatch at entry {index}: expected {expected:?}, actual {actual:?}"
)]
ProofEntryPathMismatch {
index: u32,
expected: String,
actual: String,
},
#[error(
"bond donor proof size mismatch at entry {index}: expected {expected}, actual {actual}"
)]
ProofEntrySizeMismatch {
index: u32,
expected: u64,
actual: u64,
},
#[error(
"bond donor proof sha256 mismatch at entry {index}: expected {expected}, actual {actual}"
)]
ProofEntryShaMismatch {
index: u32,
expected: String,
actual: String,
},
}
fn sha256_hex(bytes: &[u8]) -> String {
hex_encode(&Sha256::digest(bytes))
}
fn is_lower_hex_64(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entries() -> Vec<(&'static str, &'static [u8])> {
vec![("a.txt", b"alpha"), ("dir/b.txt", b"bravo")]
}
#[test]
fn identical_bytes_build_identical_descriptors() {
let entries = sample_entries();
let first = BondTransferDescriptor::from_byte_slices(
"transfer-1",
"root",
true,
1280,
1 << 20,
&entries,
)
.expect("descriptor");
let second = BondTransferDescriptor::from_byte_slices(
"transfer-1",
"root",
true,
1280,
1 << 20,
&entries,
)
.expect("descriptor");
assert_eq!(first, second);
}
#[test]
fn donor_byte_match_accepts_identical_entries() {
let entries = sample_entries();
let descriptor = BondTransferDescriptor::from_byte_slices(
"transfer-1",
"root",
true,
1280,
1 << 20,
&entries,
)
.expect("descriptor");
let proof = BondDonorByteMatchProof::from_byte_slices(&entries).expect("proof");
descriptor
.verify_donor_byte_match(&proof)
.expect("matching proof");
}
#[test]
fn donor_byte_match_rejects_mismatched_content() {
let descriptor_entries = sample_entries();
let donor_entries = vec![("a.txt", b"alpha" as &[u8]), ("dir/b.txt", b"tampered")];
let descriptor = BondTransferDescriptor::from_byte_slices(
"transfer-1",
"root",
true,
1280,
1 << 20,
&descriptor_entries,
)
.expect("descriptor");
let proof = BondDonorByteMatchProof::from_byte_slices(&donor_entries).expect("proof");
let err = descriptor
.verify_donor_byte_match(&proof)
.expect_err("tampered donor must fail closed");
assert!(matches!(err, BondingError::ProofMerkleRootMismatch { .. }));
}
#[test]
fn donor_byte_match_rejects_same_root_but_mismatched_entry_sha() {
let entries = sample_entries();
let descriptor = BondTransferDescriptor::from_byte_slices(
"transfer-1",
"root",
true,
1280,
1 << 20,
&entries,
)
.expect("descriptor");
let mut proof = BondDonorByteMatchProof::from_byte_slices(&entries).expect("proof");
proof.entries[1].sha256_hex = sha256_hex(b"tampered");
let err = descriptor
.verify_donor_byte_match(&proof)
.expect_err("entry sha mismatch must fail closed");
assert!(matches!(err, BondingError::ProofEntryShaMismatch { .. }));
}
#[test]
fn descriptor_rejects_uppercase_sha() {
let err = BondTransferDescriptor::new(
"transfer-1",
"root",
true,
"ab".repeat(32),
vec![BondTransferEntry {
index: 0,
rel_path: "a.txt".to_string(),
size: 1,
sha256_hex: "AA".repeat(32),
}],
1280,
1 << 20,
)
.expect_err("uppercase digest must be rejected");
assert!(matches!(err, BondingError::InvalidEntrySha { .. }));
}
#[test]
fn entry_object_id_uses_rq_derivation() {
let descriptor = BondTransferDescriptor::from_byte_slices(
"transfer-1",
"root",
true,
1280,
1 << 20,
&sample_entries(),
)
.expect("descriptor");
assert_eq!(
descriptor.entry_object_id(1),
rq_entry_object_id("transfer-1", 1)
);
assert_ne!(
descriptor.entry_object_id(0),
rq_entry_object_id("transfer-1", 1)
);
}
}