use crate::crypto::AUTH_TAG_SIZE;
use crate::double_write::slot_record_max;
use crate::error::{Result, WalError};
use crate::record::HEADER_SIZE;
const U32_SIZE: usize = 4;
const ENTRY_FIXED_SIZE: usize = 2 * U32_SIZE;
pub const SURROGATE_BIND_PAYLOAD_MAX: usize = slot_record_max() - HEADER_SIZE - AUTH_TAG_SIZE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SurrogateBindBatchPayload {
pub collection: String,
pub entries: Vec<(u32, Vec<u8>)>,
}
impl SurrogateBindBatchPayload {
pub fn new(collection: impl Into<String>, entries: Vec<(u32, Vec<u8>)>) -> Self {
Self {
collection: collection.into(),
entries,
}
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let entries: Vec<(u32, &[u8])> = self
.entries
.iter()
.map(|(surrogate, pk)| (*surrogate, pk.as_slice()))
.collect();
encode_one(self.collection.as_bytes(), &entries)
}
pub fn encode_chunks(
collection: &str,
entries: &[(u32, &[u8])],
max_payload: usize,
) -> Result<Vec<Vec<u8>>> {
let name = collection.as_bytes();
let prefix_size = U32_SIZE + name.len() + U32_SIZE;
let mut payloads = Vec::new();
let mut start = 0;
let mut size = prefix_size;
for (idx, &(_, pk)) in entries.iter().enumerate() {
let entry_size = ENTRY_FIXED_SIZE + pk.len();
if idx > start && size + entry_size > max_payload {
payloads.push(encode_one(name, &entries[start..idx])?);
start = idx;
size = prefix_size;
}
size += entry_size;
}
if start < entries.len() {
payloads.push(encode_one(name, &entries[start..])?);
}
Ok(payloads)
}
pub fn from_bytes(buf: &[u8]) -> Result<Self> {
let mut cursor = Cursor { buf, off: 0 };
let name_len = cursor.read_u32()? as usize;
let name = cursor.take(name_len)?;
let collection = std::str::from_utf8(name)
.map_err(|e| invalid(format!("SurrogateBindBatch collection not utf8: {e}")))?
.to_string();
let count = cursor.read_u32()? as usize;
let remaining = cursor.remaining();
let Some(capacity) = nodedb_types::decode_bounds::checked_decode_capacity(
count,
std::mem::size_of::<(u32, Vec<u8>)>(),
remaining,
ENTRY_FIXED_SIZE,
remaining / ENTRY_FIXED_SIZE,
remaining.saturating_mul(std::mem::size_of::<(u32, Vec<u8>)>()),
) else {
return Err(invalid(format!(
"SurrogateBindBatch count {count} exceeds the {remaining} bytes left"
)));
};
let mut entries = Vec::with_capacity(capacity);
for _ in 0..count {
let surrogate = cursor.read_u32()?;
let pk_len = cursor.read_u32()? as usize;
let pk = cursor.take(pk_len)?.to_vec();
entries.push((surrogate, pk));
}
if cursor.remaining() != 0 {
return Err(invalid(format!(
"SurrogateBindBatch trailing bytes: parsed {}, buffer {}",
cursor.off,
buf.len()
)));
}
Ok(Self {
collection,
entries,
})
}
}
fn encode_one(name: &[u8], entries: &[(u32, &[u8])]) -> Result<Vec<u8>> {
let size = U32_SIZE
+ name.len()
+ U32_SIZE
+ entries
.iter()
.map(|(_, pk)| ENTRY_FIXED_SIZE + pk.len())
.sum::<usize>();
let mut buf = Vec::with_capacity(size);
buf.extend_from_slice(&len_u32(name.len(), "collection name")?.to_le_bytes());
buf.extend_from_slice(name);
buf.extend_from_slice(&len_u32(entries.len(), "entry count")?.to_le_bytes());
for &(surrogate, pk) in entries {
buf.extend_from_slice(&surrogate.to_le_bytes());
buf.extend_from_slice(&len_u32(pk.len(), "pk")?.to_le_bytes());
buf.extend_from_slice(pk);
}
Ok(buf)
}
fn len_u32(len: usize, what: &str) -> Result<u32> {
u32::try_from(len).map_err(|_| invalid(format!("SurrogateBindBatch {what} too long: {len}")))
}
fn invalid(detail: String) -> WalError {
WalError::InvalidPayload { detail }
}
struct Cursor<'a> {
buf: &'a [u8],
off: usize,
}
impl<'a> Cursor<'a> {
fn remaining(&self) -> usize {
self.buf.len() - self.off
}
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
if n > self.remaining() {
return Err(invalid(format!(
"SurrogateBindBatch truncated: need {n} bytes at offset {}, have {}",
self.off,
self.buf.len()
)));
}
let slice = &self.buf[self.off..self.off + n];
self.off += n;
Ok(slice)
}
fn read_u32(&mut self) -> Result<u32> {
let b = self.take(U32_SIZE)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(entries: &[(u32, &[u8])]) -> Vec<(u32, Vec<u8>)> {
entries.iter().map(|&(s, pk)| (s, pk.to_vec())).collect()
}
#[test]
fn batch_roundtrip() {
let p = SurrogateBindBatchPayload::new(
"users",
vec![(42, b"alice".to_vec()), (43, b"bob".to_vec())],
);
let bytes = p.to_bytes().unwrap();
assert_eq!(SurrogateBindBatchPayload::from_bytes(&bytes).unwrap(), p);
}
#[test]
fn single_entry_and_binary_pk_roundtrip() {
let pk: Vec<u8> = (0..=255u8).collect();
let p = SurrogateBindBatchPayload::new("binary", vec![(u32::MAX, pk)]);
let bytes = p.to_bytes().unwrap();
assert_eq!(SurrogateBindBatchPayload::from_bytes(&bytes).unwrap(), p);
}
#[test]
fn empty_pk_roundtrip() {
let p = SurrogateBindBatchPayload::new("c", vec![(1, Vec::new())]);
let bytes = p.to_bytes().unwrap();
assert_eq!(SurrogateBindBatchPayload::from_bytes(&bytes).unwrap(), p);
}
#[test]
fn empty_batch_encodes_no_payload() {
let chunks =
SurrogateBindBatchPayload::encode_chunks("users", &[], SURROGATE_BIND_PAYLOAD_MAX)
.unwrap();
assert!(chunks.is_empty());
}
#[test]
fn small_batch_is_one_payload() {
let entries: Vec<(u32, &[u8])> = vec![
(1, b"a".as_slice()),
(2, b"b".as_slice()),
(3, b"c".as_slice()),
];
let chunks =
SurrogateBindBatchPayload::encode_chunks("users", &entries, SURROGATE_BIND_PAYLOAD_MAX)
.unwrap();
assert_eq!(chunks.len(), 1);
let decoded = SurrogateBindBatchPayload::from_bytes(&chunks[0]).unwrap();
assert_eq!(decoded.collection, "users");
assert_eq!(decoded.entries, owned(&entries));
}
#[test]
fn large_batch_splits_into_slot_sized_payloads_in_order() {
let pks: Vec<Vec<u8>> = (0..20_000u32)
.map(|i| format!("pk-{i:010}").into_bytes())
.collect();
let entries: Vec<(u32, &[u8])> = pks
.iter()
.enumerate()
.map(|(i, pk)| (i as u32 + 1, pk.as_slice()))
.collect();
let chunks =
SurrogateBindBatchPayload::encode_chunks("users", &entries, SURROGATE_BIND_PAYLOAD_MAX)
.unwrap();
assert!(chunks.len() > 1, "a 20k-entry batch must split");
assert!(
chunks.len() < 20,
"a split keeps many entries per payload, got {} payloads",
chunks.len()
);
let mut decoded = Vec::new();
for chunk in &chunks {
assert!(chunk.len() <= SURROGATE_BIND_PAYLOAD_MAX);
let payload = SurrogateBindBatchPayload::from_bytes(chunk).unwrap();
assert_eq!(payload.collection, "users");
assert!(!payload.entries.is_empty());
decoded.extend(payload.entries);
}
assert_eq!(decoded, owned(&entries));
}
#[test]
fn oversized_entry_is_encoded_alone() {
let big = vec![7u8; 256];
let entries: Vec<(u32, &[u8])> = vec![
(1, b"a".as_slice()),
(2, big.as_slice()),
(3, b"c".as_slice()),
];
let chunks = SurrogateBindBatchPayload::encode_chunks("t", &entries, 64).unwrap();
assert_eq!(chunks.len(), 3);
let middle = SurrogateBindBatchPayload::from_bytes(&chunks[1]).unwrap();
assert_eq!(middle.entries, vec![(2, big.clone())]);
}
#[test]
fn rejects_garbage_truncation_and_trailing_bytes() {
assert!(SurrogateBindBatchPayload::from_bytes(&[0xFFu8; 7]).is_err());
let bytes = SurrogateBindBatchPayload::new("users", vec![(9, b"x".to_vec())])
.to_bytes()
.unwrap();
assert!(SurrogateBindBatchPayload::from_bytes(&bytes[..bytes.len() - 1]).is_err());
let mut trailing = bytes.clone();
trailing.push(0);
assert!(SurrogateBindBatchPayload::from_bytes(&trailing).is_err());
}
#[test]
fn corrupt_count_does_not_overallocate() {
let mut bytes = SurrogateBindBatchPayload::new("u", Vec::new())
.to_bytes()
.unwrap();
let count_at = bytes.len() - U32_SIZE;
bytes[count_at..].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(SurrogateBindBatchPayload::from_bytes(&bytes).is_err());
}
}