use prikk_object::{
BlobKind, BlobPayload, BlockKind, BlockPayload, CanonicalEncode, CreateFile, MerkleRoot,
NodeId, ObjectEnvelope, ObjectId, ObjectType, Operation, OperationKind,
};
use super::{
BlockStateStatus, LineageStateMemo, derive_next_state_root, validate_block_v2_shape,
verify_block_v2_state, verify_blocks_topological,
};
use crate::lifecycle_cache::replay::{TextCache, apply_candidate_patches};
use crate::node_lifecycle::NodeLifecycleState;
use crate::state_root::entries_from_state;
use crate::{MemoryObjectStore, ObjectWriter, compute_state_root};
fn payload(kind: BlockKind, parents: Vec<ObjectId>, root: MerkleRoot) -> BlockPayload {
merge_payload(kind, parents, root, None, None)
}
fn merge_payload(
kind: BlockKind,
parents: Vec<ObjectId>,
root: MerkleRoot,
mainline_parent_id: Option<ObjectId>,
merge_baseline_block_id: Option<ObjectId>,
) -> BlockPayload {
BlockPayload {
parent_block_ids: parents,
kind,
patch_ids: Vec::new(),
state_merkle_root: root,
snapshot_blob_ref: None,
mainline_parent_id,
merge_baseline_block_id,
}
}
#[test]
fn format2_parent_and_kind_matrix_is_closed() -> prikk_error::Result<()> {
let parent = ObjectId::from_bytes([1; 32]);
let root = compute_state_root(&[])?;
assert!(validate_block_v2_shape(&payload(BlockKind::Root, Vec::new(), root)).is_ok());
assert!(validate_block_v2_shape(&payload(BlockKind::Normal, vec![parent], root)).is_ok());
assert!(validate_block_v2_shape(&payload(BlockKind::Root, vec![parent], root)).is_err());
assert!(validate_block_v2_shape(&payload(BlockKind::Normal, Vec::new(), root)).is_err());
assert!(
validate_block_v2_shape(&payload(BlockKind::Normal, vec![parent, parent], root)).is_err()
);
for kind in [BlockKind::Repair, BlockKind::Import] {
assert!(validate_block_v2_shape(&payload(kind, Vec::new(), root)).is_err());
assert!(validate_block_v2_shape(&payload(kind, vec![parent, parent], root)).is_err());
}
assert!(validate_block_v2_shape(&payload(BlockKind::Merge, Vec::new(), root)).is_err());
Ok(())
}
#[test]
fn format2_merge_shape_matrix() -> prikk_error::Result<()> {
let mainline = ObjectId::from_bytes([1; 32]);
let secondary = ObjectId::from_bytes([2; 32]);
let baseline = ObjectId::from_bytes([3; 32]);
let not_a_parent = ObjectId::from_bytes([4; 32]);
let root = compute_state_root(&[])?;
let two_parents = || {
let mut parents = vec![mainline, secondary];
parents.sort();
parents
};
assert!(
validate_block_v2_shape(&merge_payload(
BlockKind::Merge,
vec![mainline],
root,
Some(mainline),
Some(baseline)
))
.is_err()
);
assert!(
validate_block_v2_shape(&merge_payload(
BlockKind::Merge,
two_parents(),
root,
None,
Some(baseline)
))
.is_err()
);
assert!(
validate_block_v2_shape(&merge_payload(
BlockKind::Merge,
two_parents(),
root,
Some(not_a_parent),
Some(baseline)
))
.is_err()
);
assert!(
validate_block_v2_shape(&merge_payload(
BlockKind::Merge,
two_parents(),
root,
Some(mainline),
None
))
.is_err()
);
assert!(
validate_block_v2_shape(&merge_payload(
BlockKind::Merge,
two_parents(),
root,
Some(mainline),
Some(baseline)
))
.is_ok()
);
assert!(
validate_block_v2_shape(&merge_payload(
BlockKind::Normal,
vec![mainline],
root,
Some(mainline),
None
))
.is_err()
);
Ok(())
}
#[test]
fn verification_recomputes_empty_root_and_rejects_mismatch() -> prikk_error::Result<()> {
let store = MemoryObjectStore::new();
let block_id = ObjectId::from_bytes([7; 32]);
let valid = payload(BlockKind::Root, Vec::new(), compute_state_root(&[])?);
verify_block_v2_state(&store, block_id, &valid, &mut LineageStateMemo::new())?;
let invalid = payload(BlockKind::Root, Vec::new(), MerkleRoot([0; 32]));
assert!(
verify_block_v2_state(&store, block_id, &invalid, &mut LineageStateMemo::new()).is_err()
);
Ok(())
}
#[test]
fn format2_lineage_rejects_schema1_parent() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let parent_payload = payload(BlockKind::Root, Vec::new(), MerkleRoot([0; 32]));
let parent =
ObjectEnvelope::unsigned(ObjectType::Block, 1, parent_payload.to_canonical_bytes()?);
let parent_id = store.write_object(&parent)?;
assert!(derive_next_state_root(&store, Some(parent_id), &[]).is_err());
Ok(())
}
#[test]
fn format2_lineage_rejects_parent_with_forged_state_root() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let parent_payload = payload(BlockKind::Root, Vec::new(), MerkleRoot([0; 32]));
let parent =
ObjectEnvelope::unsigned(ObjectType::Block, 2, parent_payload.to_canonical_bytes()?);
let parent_id = store.write_object(&parent)?;
assert!(derive_next_state_root(&store, Some(parent_id), &[]).is_err());
Ok(())
}
#[test]
fn equivalent_state_ignores_patch_identity() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let blob_payload = BlobPayload::new(BlobKind::Text, b"same state\n".to_vec());
let blob = ObjectEnvelope::unsigned(ObjectType::Blob, 1, blob_payload.to_canonical_bytes()?);
let blob_id = store.write_object(&blob)?;
let operation = Operation {
op_seq: 1,
op_id: None,
preconditions: Vec::new(),
kind: OperationKind::CreateFile(CreateFile {
path: "same.txt".to_string(),
node_id: NodeId::from_bytes([0x41; 32]),
blob_id,
mode: 0o100644,
}),
};
let first = patch_with_parents(operation.clone(), Vec::new())?;
let second = patch_with_parents(operation, vec![ObjectId::from_bytes([0x77; 32])])?;
let first_id = store.write_object(&first)?;
let second_id = store.write_object(&second)?;
assert_ne!(first_id, second_id);
assert_eq!(
derive_next_state_root(&store, None, &[first_id])?,
derive_next_state_root(&store, None, &[second_id])?
);
Ok(())
}
fn patch_with_parents(
operation: Operation,
parent_patch_ids: Vec<ObjectId>,
) -> prikk_error::Result<ObjectEnvelope> {
let mut writer = prikk_object::CanonicalWriter::new();
writer.repeated_record_list(1, &[operation])?;
writer.repeated_object_id(2, &parent_patch_ids)?;
let canonical_payload = writer.finish();
Ok(ObjectEnvelope::unsigned(
ObjectType::Patch,
1,
canonical_payload,
))
}
#[test]
fn a_repository_holding_both_patch_schemas_derives_state_correctly() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let blob_payload = BlobPayload::new(BlobKind::Text, b"schema mix\n".to_vec());
let blob = ObjectEnvelope::unsigned(ObjectType::Blob, 1, blob_payload.to_canonical_bytes()?);
let blob_id = store.write_object(&blob)?;
let schema1_operation = Operation {
op_seq: 1,
op_id: None,
preconditions: Vec::new(),
kind: OperationKind::CreateFile(CreateFile {
path: "schema1.txt".to_string(),
node_id: NodeId::from_bytes([0x9a; 32]),
blob_id,
mode: 0o100644,
}),
};
let schema1_patch = patch_with_parents(schema1_operation.clone(), Vec::new())?;
let schema1_id = store.write_object(&schema1_patch)?;
let schema2_operation = Operation {
op_seq: 1,
op_id: None,
preconditions: Vec::new(),
kind: OperationKind::CreateFile(CreateFile {
path: "schema2.txt".to_string(),
node_id: NodeId::from_bytes([0x9b; 32]),
blob_id,
mode: 0o100644,
}),
};
let schema2_payload = prikk_object::PatchPayload {
operations: vec![schema2_operation.clone()],
intent: None,
preconditions: Vec::new(),
purpose: prikk_object::PatchPurpose::Normal,
};
let schema2_patch = ObjectEnvelope::unsigned(
ObjectType::Patch,
prikk_object::PATCH_PARENT_IDS_RETIRED_SCHEMA,
schema2_payload.to_canonical_bytes()?,
);
let schema2_id = store.write_object(&schema2_patch)?;
let mixed_root = derive_next_state_root(&store, None, &[schema1_id, schema2_id])?;
let combined_second_operation = Operation {
op_seq: 2,
..schema2_operation
};
let mut writer = prikk_object::CanonicalWriter::new();
writer.repeated_record_list(1, &[schema1_operation, combined_second_operation])?;
let combined_patch = ObjectEnvelope::unsigned(ObjectType::Patch, 1, writer.finish());
let combined_id = store.write_object(&combined_patch)?;
let expected_root = derive_next_state_root(&store, None, &[combined_id])?;
assert_eq!(
mixed_root, expected_root,
"a repository holding both a schema-1 and a schema-2 patch must derive the same state as \
a single patch carrying both operations"
);
let out_of_range_patch =
ObjectEnvelope::unsigned(ObjectType::Patch, 3, schema2_payload.to_canonical_bytes()?);
let out_of_range_id = store.write_object(&out_of_range_patch)?;
let result = derive_next_state_root(&store, None, &[schema1_id, out_of_range_id]);
assert!(
result.is_err(),
"a Patch at schema 3 (outside Patch's admitted set) must be refused, not silently applied"
);
Ok(())
}
fn create_file_patch(
store: &mut MemoryObjectStore,
path: &str,
node_seed: u8,
) -> prikk_error::Result<ObjectId> {
let blob_payload = BlobPayload::new(BlobKind::Text, format!("{path} content\n").into_bytes());
let blob = ObjectEnvelope::unsigned(ObjectType::Blob, 1, blob_payload.to_canonical_bytes()?);
let blob_id = store.write_object(&blob)?;
let operation = Operation {
op_seq: 1,
op_id: None,
preconditions: Vec::new(),
kind: OperationKind::CreateFile(CreateFile {
path: path.to_string(),
node_id: NodeId::from_bytes([node_seed; 32]),
blob_id,
mode: 0o100_644,
}),
};
let patch = patch_with_parents(operation, Vec::new())?;
store.write_object(&patch)
}
fn write_block(
store: &mut MemoryObjectStore,
parent: Option<ObjectId>,
patch_ids: Vec<ObjectId>,
root: MerkleRoot,
) -> prikk_error::Result<ObjectId> {
let block_payload = BlockPayload {
parent_block_ids: parent.into_iter().collect(),
kind: if parent.is_some() {
BlockKind::Normal
} else {
BlockKind::Root
},
patch_ids,
state_merkle_root: root,
snapshot_blob_ref: None,
mainline_parent_id: None,
merge_baseline_block_id: None,
};
let envelope =
ObjectEnvelope::unsigned(ObjectType::Block, 2, block_payload.to_canonical_bytes()?);
store.write_object(&envelope)
}
fn naive_continue(
store: &MemoryObjectStore,
state: &mut NodeLifecycleState,
text_cache: &mut TextCache,
patch_ids: &[ObjectId],
) -> prikk_error::Result<MerkleRoot> {
apply_candidate_patches(store, state, text_cache, patch_ids)?;
compute_state_root(&entries_from_state(state)?)
}
const WRONG_ROOT: MerkleRoot = MerkleRoot([0xEE; 32]);
#[test]
fn genesis_corruption_is_caught_when_reached_as_a_lineage_member() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let mut state = NodeLifecycleState::new();
let mut text_cache = TextCache::new();
let genesis_id = write_block(&mut store, None, Vec::new(), WRONG_ROOT)?;
let mut parent = genesis_id;
for index in 0_u8..3 {
let patch_id = create_file_patch(&mut store, &format!("g{index}.txt"), index + 1)?;
let root = naive_continue(&store, &mut state, &mut text_cache, &[patch_id])?;
parent = write_block(&mut store, Some(parent), vec![patch_id], root)?;
}
let payload = block_payload_of(&store, parent)?;
assert!(verify_block_v2_state(&store, parent, &payload, &mut LineageStateMemo::new()).is_err());
Ok(())
}
#[test]
fn mid_chain_corruption_is_caught_when_reached_as_a_lineage_member() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let mut state = NodeLifecycleState::new();
let mut text_cache = TextCache::new();
let genesis_id = write_block(&mut store, None, Vec::new(), compute_state_root(&[])?)?;
let mut parent = genesis_id;
for index in 0_u8..2 {
let patch_id = create_file_patch(&mut store, &format!("v{index}.txt"), index + 1)?;
let root = derive_next_state_root(&store, Some(parent), &[patch_id])?;
parent = write_block(&mut store, Some(parent), vec![patch_id], root)?;
naive_continue(&store, &mut state, &mut text_cache, &[patch_id])?;
}
let corrupted_patch = create_file_patch(&mut store, "corrupted.txt", 200)?;
let corrupted_id = write_block(&mut store, Some(parent), vec![corrupted_patch], WRONG_ROOT)?;
let mut parent = corrupted_id;
for index in 0..2 {
let patch_id = create_file_patch(&mut store, &format!("after{index}.txt"), 210 + index)?;
let root = naive_continue(&store, &mut state, &mut text_cache, &[patch_id])?;
parent = write_block(&mut store, Some(parent), vec![patch_id], root)?;
}
let payload = block_payload_of(&store, parent)?;
assert!(verify_block_v2_state(&store, parent, &payload, &mut LineageStateMemo::new()).is_err());
Ok(())
}
#[test]
fn tip_corruption_is_caught() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let genesis_id = write_block(&mut store, None, Vec::new(), compute_state_root(&[])?)?;
let mut parent = genesis_id;
for index in 0_u8..3 {
let patch_id = create_file_patch(&mut store, &format!("t{index}.txt"), index + 1)?;
let root = derive_next_state_root(&store, Some(parent), &[patch_id])?;
parent = write_block(&mut store, Some(parent), vec![patch_id], root)?;
}
let tip_patch = create_file_patch(&mut store, "tip.txt", 250)?;
let tip_id = write_block(&mut store, Some(parent), vec![tip_patch], WRONG_ROOT)?;
let payload = block_payload_of(&store, tip_id)?;
assert!(verify_block_v2_state(&store, tip_id, &payload, &mut LineageStateMemo::new()).is_err());
Ok(())
}
#[test]
fn shape_violation_at_a_lineage_member_position_is_caught() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let genesis_id = write_block(&mut store, None, Vec::new(), compute_state_root(&[])?)?;
let other_patch = create_file_patch(&mut store, "other-root.txt", 219)?;
let other_root = naive_continue(
&store,
&mut NodeLifecycleState::new(),
&mut TextCache::new(),
&[other_patch],
)?;
let other_parent = write_block(&mut store, None, vec![other_patch], other_root)?;
let mut parents = vec![genesis_id, other_parent];
parents.sort();
let invalid_shape_patch = create_file_patch(&mut store, "shape.txt", 220)?;
let (mut first_parent_state, mut first_parent_text_cache) =
if parents.first() == Some(&genesis_id) {
(NodeLifecycleState::new(), TextCache::new())
} else {
let mut state = NodeLifecycleState::new();
let mut text_cache = TextCache::new();
naive_continue(&store, &mut state, &mut text_cache, &[other_patch])?;
(state, text_cache)
};
let true_root_if_shape_ignored = naive_continue(
&store,
&mut first_parent_state,
&mut first_parent_text_cache,
&[invalid_shape_patch],
)?;
let invalid_payload = BlockPayload {
parent_block_ids: parents,
kind: BlockKind::Normal,
patch_ids: vec![invalid_shape_patch],
state_merkle_root: true_root_if_shape_ignored,
snapshot_blob_ref: None,
mainline_parent_id: None,
merge_baseline_block_id: None,
};
let invalid_envelope =
ObjectEnvelope::unsigned(ObjectType::Block, 2, invalid_payload.to_canonical_bytes()?);
let invalid_id = store.write_object(&invalid_envelope)?;
let child_patch = create_file_patch(&mut store, "child.txt", 221)?;
let root = naive_continue(
&store,
&mut first_parent_state,
&mut first_parent_text_cache,
&[child_patch],
)?;
let child_id = write_block(&mut store, Some(invalid_id), vec![child_patch], root)?;
let payload = block_payload_of(&store, child_id)?;
assert!(
verify_block_v2_state(&store, child_id, &payload, &mut LineageStateMemo::new()).is_err()
);
Ok(())
}
fn block_payload_of(
store: &MemoryObjectStore,
block_id: ObjectId,
) -> prikk_error::Result<BlockPayload> {
let envelope = crate::ObjectReader::read_object(store, block_id)?
.ok_or_else(|| prikk_error::PrikkError::Integrity("test block missing".to_string()))?;
BlockPayload::decode_canonical(&envelope.canonical_payload)
}
#[test]
fn multi_branch_history_bounds_peak_memo_size_by_branch_count_not_block_count()
-> prikk_error::Result<()> {
const BRANCHES: usize = 4;
const DEPTH: usize = 10;
let mut store = MemoryObjectStore::new();
let genesis_id = write_block(&mut store, None, Vec::new(), compute_state_root(&[])?)?;
let mut blocks = vec![(genesis_id, block_payload_of(&store, genesis_id)?)];
for branch in 0..BRANCHES {
let mut parent = genesis_id;
for step in 0..DEPTH {
let seed = (branch * DEPTH + step + 1) as u8;
let patch_id = create_file_patch(&mut store, &format!("b{branch}-{step}.txt"), seed)?;
let root = derive_next_state_root(&store, Some(parent), &[patch_id])?;
let block_id = write_block(&mut store, Some(parent), vec![patch_id], root)?;
blocks.push((block_id, block_payload_of(&store, block_id)?));
parent = block_id;
}
}
blocks.sort_by_key(|(id, _)| *id);
let total = blocks.len();
let mut memo = LineageStateMemo::new();
let result = verify_blocks_topological(&store, &blocks, &mut memo)?;
let peak = result.peak_memo_entries;
assert_eq!(
memo.len(),
0,
"every entry must be evicted once nothing in the batch still needs it"
);
assert_eq!(
result.outcomes.len(),
total,
"no block may be silently absent"
);
assert!(
result
.outcomes
.iter()
.all(|outcome| matches!(outcome.status, BlockStateStatus::Verified)),
"every block in this fixture is genuinely clean: {:?}",
result.outcomes
);
assert!(
peak <= BRANCHES + 2,
"peak live memo entries ({peak}) should track open-branch count ({BRANCHES}), not grow \
with history depth"
);
assert!(
peak < total,
"peak ({peak}) should be far below total block count ({total}) -- proving the entries \
were never all live at once, which is the whole point of §4.2"
);
Ok(())
}
#[test]
fn verify_blocks_topological_reports_two_independent_bad_blocks_in_different_subtrees()
-> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let bad_root_a = write_block(&mut store, None, Vec::new(), WRONG_ROOT)?;
let bad_root_b = write_block(&mut store, None, Vec::new(), MerkleRoot([0xEF; 32]))?;
let blocks = vec![
(bad_root_a, block_payload_of(&store, bad_root_a)?),
(bad_root_b, block_payload_of(&store, bad_root_b)?),
];
let mut memo = LineageStateMemo::new();
let result = verify_blocks_topological(&store, &blocks, &mut memo)?;
assert_eq!(result.outcomes.len(), 2, "no block may be silently absent");
for outcome in &result.outcomes {
assert!(
matches!(outcome.status, BlockStateStatus::Failed { .. }),
"expected block {} to be Failed, got: {:?}",
outcome.block_id,
outcome.status
);
}
Ok(())
}
#[test]
fn verify_blocks_topological_names_immediate_parent_not_root_cause() -> prikk_error::Result<()> {
let mut store = MemoryObjectStore::new();
let correct_empty_root = compute_state_root(&entries_from_state(&NodeLifecycleState::new())?)?;
let root_id = write_block(&mut store, None, Vec::new(), WRONG_ROOT)?;
let child_id = write_block(&mut store, Some(root_id), Vec::new(), correct_empty_root)?;
let grandchild_id = write_block(&mut store, Some(child_id), Vec::new(), correct_empty_root)?;
let blocks = vec![
(root_id, block_payload_of(&store, root_id)?),
(child_id, block_payload_of(&store, child_id)?),
(grandchild_id, block_payload_of(&store, grandchild_id)?),
];
let mut memo = LineageStateMemo::new();
let result = verify_blocks_topological(&store, &blocks, &mut memo)?;
assert_eq!(result.outcomes.len(), 3, "no block may be silently absent");
let status_of = |block_id: ObjectId| -> &BlockStateStatus {
&result
.outcomes
.iter()
.find(|outcome| outcome.block_id == block_id)
.unwrap_or_else(|| panic!("expected an outcome for block {block_id}, found none"))
.status
};
assert!(
matches!(status_of(root_id), BlockStateStatus::Failed { .. }),
"expected root to be Failed, got: {:?}",
status_of(root_id)
);
assert_eq!(
status_of(child_id),
&BlockStateStatus::NotEvaluated {
blocked_by: root_id
},
"child must name its own immediate parent (root), not attempt its own check"
);
assert_eq!(
status_of(grandchild_id),
&BlockStateStatus::NotEvaluated {
blocked_by: child_id
},
"grandchild must name its own immediate parent (child), not eagerly resolve to root -- \
the root cause is discoverable by following the chain one hop at a time"
);
Ok(())
}
#[test]
fn two_blocks_naming_each_other_as_state_parent_are_caught_as_a_cycle() -> prikk_error::Result<()> {
let id_a = ObjectId::from_bytes([0xC1; 32]);
let id_b = ObjectId::from_bytes([0xC2; 32]);
let payload_a = payload(BlockKind::Normal, vec![id_b], WRONG_ROOT);
let payload_b = payload(BlockKind::Normal, vec![id_a], WRONG_ROOT);
let blocks = vec![(id_a, payload_a), (id_b, payload_b)];
let store = MemoryObjectStore::new();
let mut memo = LineageStateMemo::new();
match verify_blocks_topological(&store, &blocks, &mut memo) {
Ok(_) => panic!("a two-block cycle must be rejected, not silently dropped from the batch"),
Err(error) => {
let message = error.to_string();
assert!(
message.contains("lineage cycle"),
"expected a lineage-cycle error, got: {message}"
);
}
}
Ok(())
}