use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use kmp_domain::{NodeDetailProjection, PortError};
use super::engine::{Key, ReadTx, Table, WriteTx};
use super::node_detail::size_batch;
use super::serdes::{decode, encode};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct DetailHeaderRecord {
pub(super) node_id: String,
pub(super) revision: u64,
pub(super) content_hash: String,
pub(super) record_bytes: u64,
pub(super) body_bytes: u64,
pub(super) record_digest: String,
}
impl DetailHeaderRecord {
pub(super) fn describe(detail: &NodeDetailProjection, record: &[u8]) -> Self {
Self {
node_id: detail.node_id.clone(),
revision: detail.revision,
content_hash: detail.content_hash.clone(),
record_bytes: record.len() as u64,
body_bytes: detail.detail.len() as u64,
record_digest: format!("sha256:{:x}", Sha256::digest(record)),
}
}
}
pub(super) fn write(tx: &mut dyn WriteTx, header: &DetailHeaderRecord) -> Result<(), PortError> {
let bytes = encode("node detail header", header)?;
tx.insert(Table::DetailHeaders, Key::Str(&header.node_id), &bytes)
}
#[allow(dead_code)]
pub(super) fn read_batch(
tx: &dyn ReadTx,
node_ids: &[String],
) -> Result<Vec<Option<DetailHeaderRecord>>, PortError> {
let record_bytes = size_batch(tx, node_ids)?;
node_ids
.iter()
.zip(record_bytes)
.map(|(id, stored)| {
let Some(stored) = stored else {
return Ok(None);
};
let raw = tx
.get(Table::DetailHeaders, Key::Str(id))?
.ok_or_else(|| inconsistent(id, "has no stored header"))?;
let header =
decode::<DetailHeaderRecord>("node detail header", &raw).map_err(|error| {
inconsistent(id, &format!("has an unreadable header ({error})"))
})?;
validated(id, header, stored).map(Some)
})
.collect()
}
fn validated(
id: &str,
header: DetailHeaderRecord,
stored: u64,
) -> Result<DetailHeaderRecord, PortError> {
if header.node_id != id {
return Err(inconsistent(
id,
&format!("has a header naming `{}`", header.node_id),
));
}
if header.record_bytes != stored {
return Err(inconsistent(
id,
&format!(
"has a header of {} bytes against a stored record of {stored}",
header.record_bytes
),
));
}
if header.body_bytes > header.record_bytes {
return Err(inconsistent(
id,
&format!(
"has a header claiming {} canonical bytes inside a record of {}",
header.body_bytes, header.record_bytes
),
));
}
if !is_record_digest(&header.record_digest) {
return Err(inconsistent(
id,
&format!(
"has a header whose digest `{}` is not sha256 followed by 64 lowercase hex digits",
header.record_digest
),
));
}
Ok(header)
}
fn is_record_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|digit| digit.is_ascii_digit() || matches!(digit, b'a'..=b'f'))
})
}
fn inconsistent(node_id: &str, what: &str) -> PortError {
PortError::InvalidState(format!(
"embedded store: canonical body `{node_id}` {what}; rebuild the projections before \
reading bodies in bounded mode"
))
}
#[cfg(test)]
#[path = "detail_header_tests.rs"]
mod tests;