use std::fmt;
use prikk_error::{PrikkError, Result};
use prikk_object::{CanonicalWriter, NodeId, NodeKind, ObjectId, WireType};
use crate::byte_cursor::ByteCursor;
use crate::node_lifecycle::{LiveNode, NodeContent, NodeLifecycleState, Tombstone};
use crate::object_store::ObjectReader;
use super::{
BlobKindResolver, ReplayDerivedLifecycleState, StoreBackedResolver, replay,
replay_derived_state,
};
const LIFECYCLE_CACHE_MAGIC: &[u8] = b"PRIKK-NODE-LIFECYCLE-CACHE-v1\0";
const WINDOW_HASH_DOMAIN: &[u8] = b"PRIKK-LIFECYCLE-CACHE-WINDOW-v1";
pub(crate) const CACHE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ParentPolicy {
SingleParent,
Dc13MergeAware,
}
impl ParentPolicy {
const fn code(self) -> u16 {
match self {
Self::SingleParent => 1,
Self::Dc13MergeAware => 2,
}
}
fn from_code(code: u16) -> Result<Self> {
match code {
1 => Ok(Self::SingleParent),
2 => Ok(Self::Dc13MergeAware),
other => Err(malformed(format!("unknown parent_policy code {other}"))),
}
}
}
pub(crate) fn compute_window_hash(ordered_block_ids: &[ObjectId]) -> [u8; 32] {
let mut preimage =
Vec::with_capacity(WINDOW_HASH_DOMAIN.len() + 8 + ordered_block_ids.len() * 32);
preimage.extend_from_slice(WINDOW_HASH_DOMAIN);
preimage.extend_from_slice(&(ordered_block_ids.len() as u64).to_be_bytes());
for block_id in ordered_block_ids {
preimage.extend_from_slice(block_id.as_bytes());
}
prikk_hash::sha256(&preimage)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DecodedLifecycleCache {
pub(crate) schema_version: u32,
pub(crate) baseline_block_id: ObjectId,
pub(crate) lineage_horizon_id: ObjectId,
pub(crate) parent_policy: ParentPolicy,
pub(crate) replay_window_hash: [u8; 32],
pub(crate) snapshot_blob_id: Option<ObjectId>,
pub(crate) live_entries: Vec<(NodeId, LiveNode)>,
pub(crate) tombstones: Vec<(NodeId, Tombstone)>,
pub(crate) seen_ids: Vec<NodeId>,
}
impl DecodedLifecycleCache {
pub(crate) fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
self.serialize()
}
fn serialize(&self) -> Result<Vec<u8>> {
let mut writer = CanonicalWriter::new();
writer.field_u32(1, self.schema_version)?;
writer.field_object_id(2, &self.baseline_block_id)?;
writer.field_object_id(3, &self.lineage_horizon_id)?;
writer.field_enum_u16(4, self.parent_policy.code())?;
writer.field_bytes(5, &self.replay_window_hash)?;
if let Some(snapshot) = &self.snapshot_blob_id {
writer.field_object_id(6, snapshot)?;
}
for (node_id, node) in &self.live_entries {
let record = encode_node_record(node_id, &node.path, node.kind, &node.content)?;
writer.field_raw(10, WireType::RecordListItem, &record)?;
}
for (node_id, tombstone) in &self.tombstones {
let record =
encode_node_record(node_id, &tombstone.path, tombstone.kind, &tombstone.content)?;
writer.field_raw(11, WireType::RecordListItem, &record)?;
}
let mut seen_bytes = Vec::with_capacity(self.seen_ids.len() * 32);
for id in &self.seen_ids {
seen_bytes.extend_from_slice(id.as_bytes());
}
writer.field_bytes(12, &seen_bytes)?;
let mut out = LIFECYCLE_CACHE_MAGIC.to_vec();
out.extend_from_slice(&writer.finish());
Ok(out)
}
pub(super) fn encode_unchecked(&self) -> Result<Vec<u8>> {
self.serialize()
}
pub(crate) fn decode(bytes: &[u8]) -> Result<Self> {
let Some(body) = bytes.strip_prefix(LIFECYCLE_CACHE_MAGIC) else {
return Err(malformed(
"missing or wrong lifecycle-cache magic".to_string(),
));
};
let mut cursor = ByteCursor::new(body);
let mut schema_version: Option<u32> = None;
let mut baseline_block_id: Option<ObjectId> = None;
let mut lineage_horizon_id: Option<ObjectId> = None;
let mut parent_policy: Option<ParentPolicy> = None;
let mut replay_window_hash: Option<[u8; 32]> = None;
let mut snapshot_blob_id: Option<ObjectId> = None;
let mut seen_raw: Option<Vec<u8>> = None;
let mut live_entries: Vec<(NodeId, LiveNode)> = Vec::new();
let mut tombstones: Vec<(NodeId, Tombstone)> = Vec::new();
let mut last_tag: Option<u16> = None;
while let Some(field) = next_field(&mut cursor)? {
ensure_nondecreasing_tag(&mut last_tag, field.tag)?;
match field.tag {
1 => set_once(&mut schema_version, read_u32(&field)?, "schema_version")?,
2 => set_once(&mut baseline_block_id, read_object_id(&field)?, "baseline")?,
3 => set_once(&mut lineage_horizon_id, read_object_id(&field)?, "horizon")?,
4 => set_once(
&mut parent_policy,
ParentPolicy::from_code(read_enum_u16(&field)?)?,
"parent_policy",
)?,
5 => set_once(&mut replay_window_hash, read_hash32(&field)?, "window_hash")?,
6 => set_once(&mut snapshot_blob_id, read_object_id(&field)?, "snapshot")?,
10 => {
require_wire(&field, WireType::RecordListItem)?;
live_entries.push(decode_live_entry(field.value)?);
}
11 => {
require_wire(&field, WireType::RecordListItem)?;
tombstones.push(decode_tombstone(field.value)?);
}
12 => {
require_wire(&field, WireType::Bytes)?;
set_once(&mut seen_raw, field.value.to_vec(), "seen_ids")?;
}
other => return Err(malformed(format!("unknown lifecycle-cache tag {other}"))),
}
}
let cache = Self {
schema_version: schema_version
.ok_or_else(|| malformed("missing schema_version".to_string()))?,
baseline_block_id: baseline_block_id
.ok_or_else(|| malformed("missing baseline_block_id".to_string()))?,
lineage_horizon_id: lineage_horizon_id
.ok_or_else(|| malformed("missing lineage_horizon_id".to_string()))?,
parent_policy: parent_policy
.ok_or_else(|| malformed("missing parent_policy".to_string()))?,
replay_window_hash: replay_window_hash
.ok_or_else(|| malformed("missing replay_window_hash".to_string()))?,
snapshot_blob_id,
live_entries,
tombstones,
seen_ids: decode_seen_ids(
&seen_raw.ok_or_else(|| malformed("missing seen_ids".to_string()))?,
)?,
};
cache.validate()?;
Ok(cache)
}
fn validate(&self) -> Result<()> {
if self.schema_version != CACHE_SCHEMA_VERSION {
return Err(stale_provenance(format!(
"unsupported lifecycle-cache schema_version {}",
self.schema_version
)));
}
if self.parent_policy != ParentPolicy::SingleParent {
return Err(stale_provenance(
"lifecycle cache parent_policy is not single_parent (merge deferred to DC-13)"
.to_string(),
));
}
let mut prev_path: Option<&str> = None;
let mut live_ids = std::collections::BTreeSet::new();
for (node_id, node) in &self.live_entries {
validate_node_record_shape(*node_id, node.kind, &node.content)?;
let path = node.path.as_str();
if let Some(previous) = prev_path {
if path <= previous {
return Err(malformed(
"live_entries not strictly sorted by repo_path".to_string(),
));
}
}
prev_path = Some(path);
if !live_ids.insert(*node_id) {
return Err(malformed("duplicate live node_id".to_string()));
}
}
let mut prev_tomb: Option<NodeId> = None;
let mut tomb_ids = std::collections::BTreeSet::new();
for (node_id, tombstone) in &self.tombstones {
validate_node_record_shape(*node_id, tombstone.kind, &tombstone.content)?;
if let Some(previous) = prev_tomb {
if node_id.as_bytes() <= previous.as_bytes() {
return Err(malformed(
"tombstones not strictly sorted by node_id".to_string(),
));
}
}
prev_tomb = Some(*node_id);
if !tomb_ids.insert(*node_id) {
return Err(malformed("duplicate tombstone node_id".to_string()));
}
}
if live_ids.intersection(&tomb_ids).next().is_some() {
return Err(malformed(
"node_id appears in both live and tombstone sets".to_string(),
));
}
let mut prev_seen: Option<NodeId> = None;
let mut seen_set = std::collections::BTreeSet::new();
for id in &self.seen_ids {
crate::node_lifecycle::ensure_node_id_nonzero(*id)?;
if let Some(previous) = prev_seen {
if id.as_bytes() <= previous.as_bytes() {
return Err(malformed("seen_ids not strictly ascending".to_string()));
}
}
prev_seen = Some(*id);
seen_set.insert(*id);
}
let union: std::collections::BTreeSet<NodeId> =
live_ids.union(&tomb_ids).copied().collect();
if seen_set != union {
return Err(malformed(
"seen_ids must equal exactly live ∪ tombstoned".to_string(),
));
}
Ok(())
}
fn verify_window_against_chain(&self, resolver: &impl BlockParentResolver) -> Result<()> {
let walked = replay::walk_single_parent_chain(
&ResolverLineage(resolver),
self.baseline_block_id,
self.lineage_horizon_id,
)
.map_err(|e| stale_provenance(e.to_string()))?;
let chain: Vec<ObjectId> = walked.into_iter().map(|(id, _parents)| id).collect();
if compute_window_hash(&chain) != self.replay_window_hash {
return Err(stale_provenance(
"replay_window_hash does not match the walked single-parent chain".to_string(),
));
}
Ok(())
}
}
struct ResolverLineage<'a, P: BlockParentResolver>(&'a P);
impl<P: BlockParentResolver> replay::LineageBlockReader for ResolverLineage<'_, P> {
type Block = Vec<ObjectId>;
fn read_lineage_block(
&self,
block_id: ObjectId,
) -> std::result::Result<Vec<ObjectId>, replay::LifecycleReplayError> {
self.0.parent_block_ids(&block_id).map_err(|e| {
replay::LifecycleReplayError::UnreadableBlockInLineage {
block_id,
detail: e.to_string(),
}
})
}
fn parents_of(block: &Vec<ObjectId>) -> Vec<ObjectId> {
block.clone()
}
}
struct Field<'a> {
tag: u16,
wire: u8,
value: &'a [u8],
}
fn next_field<'a>(cursor: &mut ByteCursor<'a>) -> Result<Option<Field<'a>>> {
if cursor.is_finished() {
return Ok(None);
}
let tag = cursor.read_u16()?;
let wire = cursor.read_array::<1>()?[0];
let len = usize::try_from(cursor.read_u64()?)
.map_err(|_| malformed("field length exceeds usize".to_string()))?;
let value = cursor.read_exact(len)?;
Ok(Some(Field { tag, wire, value }))
}
fn validate_node_record_shape(
node_id: NodeId,
kind: NodeKind,
content: &NodeContent,
) -> Result<()> {
crate::node_lifecycle::ensure_node_id_nonzero(node_id)?;
crate::node_lifecycle::validate_kind_content_shape(kind, content)?;
Ok(())
}
fn ensure_nondecreasing_tag(last: &mut Option<u16>, tag: u16) -> Result<()> {
if let Some(prev) = *last {
if tag < prev {
return Err(malformed(format!(
"non-canonical TLV tag order: {tag} after {prev}"
)));
}
}
*last = Some(tag);
Ok(())
}
fn require_wire(field: &Field<'_>, expected: WireType) -> Result<()> {
if field.wire == expected as u8 {
Ok(())
} else {
Err(malformed(format!(
"tag {} has wire type 0x{:02x}, expected 0x{:02x}",
field.tag, field.wire, expected as u8
)))
}
}
fn set_once<T>(slot: &mut Option<T>, value: T, name: &str) -> Result<()> {
if slot.is_some() {
return Err(malformed(format!("duplicate singleton field {name}")));
}
*slot = Some(value);
Ok(())
}
fn read_u32(field: &Field<'_>) -> Result<u32> {
require_wire(field, WireType::U32)?;
let array: [u8; 4] = field
.value
.try_into()
.map_err(|_| malformed("u32 field wrong length".to_string()))?;
Ok(u32::from_be_bytes(array))
}
fn read_enum_u16(field: &Field<'_>) -> Result<u16> {
require_wire(field, WireType::EnumU16)?;
let array: [u8; 2] = field
.value
.try_into()
.map_err(|_| malformed("enum_u16 field wrong length".to_string()))?;
Ok(u16::from_be_bytes(array))
}
fn read_object_id(field: &Field<'_>) -> Result<ObjectId> {
require_wire(field, WireType::ObjectId)?;
let array: [u8; 32] = field
.value
.try_into()
.map_err(|_| malformed("object_id field wrong length".to_string()))?;
Ok(ObjectId::from_bytes(array))
}
fn read_hash32(field: &Field<'_>) -> Result<[u8; 32]> {
require_wire(field, WireType::Bytes)?;
field
.value
.try_into()
.map_err(|_| malformed("expected 32-byte hash".to_string()))
}
fn read_node_id_bytes(field: &Field<'_>) -> Result<NodeId> {
require_wire(field, WireType::Bytes)?;
let array: [u8; 32] = field
.value
.try_into()
.map_err(|_| malformed("node_id field wrong length".to_string()))?;
NodeId::try_from_bytes(array)
}
fn read_repo_path(field: &Field<'_>) -> Result<crate::path::RepoPath> {
require_wire(field, WireType::RepoPath)?;
let text = core::str::from_utf8(field.value)
.map_err(|_| malformed("repo_path is not UTF-8".to_string()))?;
crate::path::RepoPath::parse(text)
}
fn read_symlink_target(field: &Field<'_>) -> Result<String> {
require_wire(field, WireType::String)?;
let text = core::str::from_utf8(field.value)
.map_err(|_| malformed("symlink target is not UTF-8".to_string()))?;
Ok(text.to_string())
}
fn decode_node_record(
bytes: &[u8],
) -> Result<(NodeId, crate::path::RepoPath, NodeKind, NodeContent)> {
let mut cursor = ByteCursor::new(bytes);
let mut path: Option<crate::path::RepoPath> = None;
let mut node_id: Option<NodeId> = None;
let mut node_kind: Option<NodeKind> = None;
let mut blob_id: Option<ObjectId> = None;
let mut mode: Option<u32> = None;
let mut target: Option<String> = None;
let mut last_tag: Option<u16> = None;
while let Some(field) = next_field(&mut cursor)? {
ensure_nondecreasing_tag(&mut last_tag, field.tag)?;
match field.tag {
1 => set_once(&mut path, read_repo_path(&field)?, "path")?,
2 => set_once(&mut node_id, read_node_id_bytes(&field)?, "node_id")?,
3 => set_once(
&mut node_kind,
NodeKind::from_code(read_enum_u16(&field)?)?,
"node_kind",
)?,
4 => set_once(&mut blob_id, read_object_id(&field)?, "blob_id")?,
5 => set_once(&mut mode, read_u32(&field)?, "normalized_mode")?,
6 => set_once(&mut target, read_symlink_target(&field)?, "symlink_target")?,
other => return Err(malformed(format!("unknown node-record tag {other}"))),
}
}
let path = path.ok_or_else(|| malformed("node record missing path".to_string()))?;
let node_id = node_id.ok_or_else(|| malformed("node record missing node_id".to_string()))?;
let node_kind =
node_kind.ok_or_else(|| malformed("node record missing node_kind".to_string()))?;
let content = match node_kind {
NodeKind::TextFile | NodeKind::BinaryFile => {
if target.is_some() {
return Err(malformed(
"file node record must not carry a symlink target".to_string(),
));
}
let blob_id =
blob_id.ok_or_else(|| malformed("file node record missing blob_id".to_string()))?;
let mode = mode
.ok_or_else(|| malformed("file node record missing normalized_mode".to_string()))?;
NodeContent::File { blob_id, mode }
}
NodeKind::Symlink => {
if blob_id.is_some() || mode.is_some() {
return Err(malformed(
"symlink node record must not carry blob_id or normalized_mode".to_string(),
));
}
let target = target
.ok_or_else(|| malformed("symlink node record missing target".to_string()))?;
NodeContent::Symlink { target }
}
};
Ok((node_id, path, node_kind, content))
}
fn decode_live_entry(bytes: &[u8]) -> Result<(NodeId, LiveNode)> {
let (node_id, path, kind, content) = decode_node_record(bytes)?;
Ok((
node_id,
LiveNode {
path,
kind,
content,
},
))
}
fn decode_tombstone(bytes: &[u8]) -> Result<(NodeId, Tombstone)> {
let (node_id, path, kind, content) = decode_node_record(bytes)?;
Ok((
node_id,
Tombstone {
kind,
content,
path,
},
))
}
fn decode_seen_ids(raw: &[u8]) -> Result<Vec<NodeId>> {
if raw.len() % 32 != 0 {
return Err(malformed(
"seen_ids length is not a multiple of 32".to_string(),
));
}
let mut ids = Vec::with_capacity(raw.len() / 32);
for chunk in raw.chunks_exact(32) {
let array: [u8; 32] = chunk
.try_into()
.map_err(|_| malformed("seen_id chunk wrong length".to_string()))?;
ids.push(NodeId::try_from_bytes(array)?);
}
Ok(ids)
}
fn encode_node_record(
node_id: &NodeId,
path: &crate::path::RepoPath,
kind: NodeKind,
content: &NodeContent,
) -> Result<Vec<u8>> {
let mut writer = CanonicalWriter::new();
writer.field_repo_path(1, path.as_str())?;
writer.field_bytes(2, node_id.as_bytes())?;
writer.field_enum_u16(3, kind.code())?;
match content {
NodeContent::File { blob_id, mode } => {
writer.field_object_id(4, blob_id)?;
writer.field_u32(5, *mode)?;
}
NodeContent::Symlink { target } => {
writer.field_string(6, target)?;
}
}
Ok(writer.finish())
}
fn malformed(detail: String) -> PrikkError {
PrikkError::MalformedData(format!("lifecycle cache: {detail}"))
}
fn stale_provenance(detail: String) -> PrikkError {
PrikkError::Integrity(format!("lifecycle cache stale provenance: {detail}"))
}
pub(crate) trait BlockParentResolver {
fn parent_block_ids(&self, block_id: &ObjectId) -> Result<Vec<ObjectId>>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ValidatedLifecycleCache {
decoded: DecodedLifecycleCache,
}
impl ValidatedLifecycleCache {
pub(crate) fn from_decoded(
decoded: DecodedLifecycleCache,
blob_resolver: &impl BlobKindResolver,
parent_resolver: &impl BlockParentResolver,
) -> Result<Self> {
decoded.validate()?;
decoded.verify_window_against_chain(parent_resolver)?;
for (_, node) in &decoded.live_entries {
verify_file_blob_kind(node.kind, &node.content, blob_resolver)?;
}
for (_, tombstone) in &decoded.tombstones {
verify_file_blob_kind(tombstone.kind, &tombstone.content, blob_resolver)?;
}
Ok(Self { decoded })
}
pub(crate) fn from_decoded_for_baseline(
decoded: DecodedLifecycleCache,
expected_baseline_block_id: ObjectId,
blob_resolver: &impl BlobKindResolver,
parent_resolver: &impl BlockParentResolver,
) -> Result<Self> {
if decoded.baseline_block_id != expected_baseline_block_id {
return Err(stale_provenance(
"cache baseline does not match the caller's intended baseline".to_string(),
));
}
Self::from_decoded(decoded, blob_resolver, parent_resolver)
}
fn to_node_lifecycle_state(&self) -> Result<NodeLifecycleState> {
let mut state = NodeLifecycleState::new();
for (node_id, node) in &self.decoded.live_entries {
state.seed_live_node(*node_id, node.clone())?;
}
for (node_id, tombstone) in &self.decoded.tombstones {
state.seed_tombstone(*node_id, tombstone.clone())?;
}
Ok(state)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ComparedLifecycleCache {
validated: ValidatedLifecycleCache,
}
#[derive(Debug)]
pub(crate) enum CacheCertificationError {
BaselineMismatch { expected: ObjectId, found: ObjectId },
HorizonMismatch { expected: ObjectId, found: ObjectId },
CacheRejected(PrikkError),
ReplayUnavailable(PrikkError),
ContentMismatch,
}
impl fmt::Display for CacheCertificationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BaselineMismatch { expected, found } => write!(
f,
"lifecycle cache certification failed: baseline mismatch \
(caller expected {expected}, cache declares {found})"
),
Self::HorizonMismatch { expected, found } => write!(
f,
"lifecycle cache certification failed: horizon mismatch \
(caller expected {expected}, cache declares {found})"
),
Self::CacheRejected(e) => write!(
f,
"lifecycle cache certification failed: cache rejected by validation: {e}"
),
Self::ReplayUnavailable(e) => write!(
f,
"lifecycle cache certification failed: authoritative replay unavailable: {e}"
),
Self::ContentMismatch => write!(
f,
"lifecycle cache certification failed: cache contents disagree with \
authoritative replay"
),
}
}
}
impl std::error::Error for CacheCertificationError {}
impl From<CacheCertificationError> for PrikkError {
fn from(e: CacheCertificationError) -> Self {
PrikkError::Integrity(e.to_string())
}
}
impl ComparedLifecycleCache {
pub(crate) fn from_validated_and_replay(
validated: ValidatedLifecycleCache,
replay: &ReplayDerivedLifecycleState,
) -> std::result::Result<Self, CacheCertificationError> {
if validated.decoded.baseline_block_id != replay.baseline_block_id {
return Err(CacheCertificationError::BaselineMismatch {
expected: replay.baseline_block_id,
found: validated.decoded.baseline_block_id,
});
}
let cache_state = validated
.to_node_lifecycle_state()
.map_err(CacheCertificationError::CacheRejected)?;
if cache_state != replay.state {
return Err(CacheCertificationError::ContentMismatch);
}
Ok(Self { validated })
}
}
pub(crate) fn certified_compared_cache<R: ObjectReader>(
reader: &R,
decoded: DecodedLifecycleCache,
expected_baseline_block_id: ObjectId,
expected_lineage_horizon_id: ObjectId,
) -> std::result::Result<ComparedLifecycleCache, CacheCertificationError> {
if decoded.baseline_block_id != expected_baseline_block_id {
return Err(CacheCertificationError::BaselineMismatch {
expected: expected_baseline_block_id,
found: decoded.baseline_block_id,
});
}
if decoded.lineage_horizon_id != expected_lineage_horizon_id {
return Err(CacheCertificationError::HorizonMismatch {
expected: expected_lineage_horizon_id,
found: decoded.lineage_horizon_id,
});
}
let resolver = StoreBackedResolver::new(reader);
let validated = ValidatedLifecycleCache::from_decoded_for_baseline(
decoded,
expected_baseline_block_id,
&resolver,
&resolver,
)
.map_err(CacheCertificationError::CacheRejected)?;
let replay = replay_derived_state(
reader,
expected_baseline_block_id,
expected_lineage_horizon_id,
)
.map_err(CacheCertificationError::ReplayUnavailable)?;
ComparedLifecycleCache::from_validated_and_replay(validated, &replay)
}
fn verify_file_blob_kind(
kind: NodeKind,
content: &NodeContent,
resolver: &impl BlobKindResolver,
) -> Result<()> {
let NodeContent::File { blob_id, .. } = content else {
return Ok(());
};
match resolver.blob_kind(blob_id)? {
None => Err(PrikkError::Integrity(format!(
"lifecycle cache: blob required for kind verification is missing: {blob_id}"
))),
Some(blob_kind) => {
let implied = NodeKind::from_file_blob_kind(blob_kind)?;
if implied == kind {
Ok(())
} else {
Err(PrikkError::Integrity(format!(
"lifecycle cache: file node kind {kind:?} disagrees with referenced \
blob kind (implies {implied:?})"
)))
}
}
}
}