#![allow(clippy::indexing_slicing)]
use prikk_error::Result;
use prikk_object::{
CanonicalEncode, DeleteNode, DeleteNodePreimage, NodeId, NodeKind, ObjectEnvelope, ObjectId,
ObjectType, Operation, OperationKind, PatchPayload, PatchPurpose, Signature,
SignatureAlgorithm, SignerRole,
};
use super::{assert_stage_failed, assert_wal_item_failed};
use crate::test_support::{
rollback_author_signature, rollback_patch_blob_envelope, sample_object_id,
signed_patch_blob_envelope, unique_temp_dir,
};
use crate::wal::{WalRecord, WalRecordStatus, encode_record_for_test};
use crate::{
DoctorRepairOptions, FileObjectStore, ObjectWriter, RepositoryLayout, VerificationStage, Wal,
repair_repository, verify_repository,
};
fn write_wal_records(wal: &Wal, records: &[WalRecord]) -> Result<()> {
let mut bytes = Vec::new();
for record in records {
bytes.extend(encode_record_for_test(record)?);
}
std::fs::write(wal.path(), &bytes)?;
Ok(())
}
fn rollback_payload_with_operations(operations: Vec<Operation>) -> PatchPayload {
PatchPayload {
operations,
parent_patch_ids: Vec::new(),
intent: None,
preconditions: Vec::new(),
purpose: PatchPurpose::RollbackDraft,
}
}
fn create_file_operation(op_seq: u32, blob_id: ObjectId) -> Result<Operation> {
Ok(Operation {
op_seq,
op_id: None,
preconditions: Vec::new(),
kind: OperationKind::CreateFile(prikk_object::CreateFile {
path: "a.txt".to_string(),
node_id: NodeId::from_bytes([0x61; 32]),
blob_id,
mode: 0o100_644,
}),
})
}
#[test]
fn verify_repository_detects_wal_checksum_mismatch() -> Result<()> {
let root = unique_temp_dir("verify-wal-checksum");
let layout = RepositoryLayout::init(root.clone())?;
let mut objects = FileObjectStore::new(layout.clone());
let blob = signed_patch_blob_envelope();
objects.write_object(&blob)?;
let payload =
rollback_payload_with_operations(vec![create_file_operation(1, blob.object_id())?]);
let mut envelope =
ObjectEnvelope::unsigned(ObjectType::Patch, 1, payload.to_canonical_bytes()?);
envelope.add_signature(rollback_author_signature())?;
let wal = Wal::for_layout(&layout);
wal.append_patch(&envelope)?;
let mut bytes = std::fs::read(wal.path())?;
let last_byte = bytes
.last_mut()
.ok_or_else(|| prikk_error::PrikkError::Io("WAL file unexpectedly empty".to_string()))?;
*last_byte ^= 0x01;
std::fs::write(wal.path(), &bytes)?;
let report = verify_repository(&layout)?;
assert_wal_item_failed(&report, "WAL checksum mismatch");
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn verify_repository_detects_non_patch_active_wal_record() -> Result<()> {
let root = unique_temp_dir("verify-wal-type-mismatch");
let layout = RepositoryLayout::init(root.clone())?;
let wal = Wal::for_layout(&layout);
let record = WalRecord {
seq: 1,
envelope: signed_patch_blob_envelope(),
};
write_wal_records(&wal, &[record])?;
let report = verify_repository(&layout)?;
assert_stage_failed(&report, VerificationStage::WalPersistence, "expected patch");
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn verify_repository_detects_rollback_draft_operation_sequence_mismatch() -> Result<()> {
let root = unique_temp_dir("verify-rollback-decode-failure");
let layout = RepositoryLayout::init(root.clone())?;
let mut objects = FileObjectStore::new(layout.clone());
let blob = rollback_patch_blob_envelope();
objects.write_object(&blob)?;
let payload =
rollback_payload_with_operations(vec![create_file_operation(1, blob.object_id())?]);
let mut payload_bytes = payload.to_canonical_bytes()?;
let op_seq_field_header: [u8; 11] = [
0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04,
];
let matches: Vec<usize> = payload_bytes
.windows(op_seq_field_header.len())
.enumerate()
.filter(|(_, window)| *window == op_seq_field_header)
.map(|(index, _)| index)
.collect();
assert_eq!(
matches.len(),
1,
"expected exactly one op_seq field header in the encoded payload, found {}",
matches.len()
);
let match_offset = *matches.first().ok_or_else(|| {
prikk_error::PrikkError::Io("unreachable: length just checked".to_string())
})?;
let value_start = match_offset + op_seq_field_header.len();
let value_end = value_start
.checked_add(4)
.ok_or_else(|| prikk_error::PrikkError::Io("op_seq value range overflow".to_string()))?;
let value_bytes = payload_bytes
.get_mut(value_start..value_end)
.ok_or_else(|| prikk_error::PrikkError::Io("op_seq value out of range".to_string()))?;
assert_eq!(value_bytes, &1_u32.to_be_bytes());
value_bytes.copy_from_slice(&2_u32.to_be_bytes());
let mut envelope = ObjectEnvelope::unsigned(ObjectType::Patch, 1, payload_bytes);
envelope.add_signature(rollback_author_signature())?;
Wal::for_layout(&layout).append_patch(&envelope)?;
let report = verify_repository(&layout)?;
assert_stage_failed(
&report,
VerificationStage::RollbackDrafts,
"does not match physical position",
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn verify_repository_detects_rollback_draft_unsupported_operation() -> Result<()> {
let root = unique_temp_dir("verify-rollback-apply-unsupported");
let layout = RepositoryLayout::init(root.clone())?;
let payload = rollback_payload_with_operations(vec![Operation {
op_seq: 1,
op_id: None,
preconditions: Vec::new(),
kind: OperationKind::DeleteNode(DeleteNode {
path: "link.txt".to_string(),
node_id: NodeId::from_bytes([0x62; 32]),
old_node_kind: NodeKind::Symlink,
preimage: DeleteNodePreimage::Symlink {
old_target: "target.txt".to_string(),
},
}),
}]);
let mut envelope =
ObjectEnvelope::unsigned(ObjectType::Patch, 1, payload.to_canonical_bytes()?);
envelope.add_signature(rollback_author_signature())?;
Wal::for_layout(&layout).append_patch(&envelope)?;
let report = verify_repository(&layout)?;
assert_stage_failed(
&report,
VerificationStage::RollbackDrafts,
"DeleteNode(symlink)",
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn verify_repository_detects_rollback_draft_missing_author_signature() -> Result<()> {
let root = unique_temp_dir("verify-rollback-missing-author");
let layout = RepositoryLayout::init(root.clone())?;
let mut objects = FileObjectStore::new(layout.clone());
let blob = rollback_patch_blob_envelope();
objects.write_object(&blob)?;
let payload =
rollback_payload_with_operations(vec![create_file_operation(1, blob.object_id())?]);
let mut envelope =
ObjectEnvelope::unsigned(ObjectType::Patch, 1, payload.to_canonical_bytes()?);
envelope.add_signature(Signature {
algorithm: SignatureAlgorithm::Ed25519,
key_id: "not-the-author".to_string(),
signature_bytes: vec![3; 64],
created_at: 1,
signer_role: SignerRole::Maintainer,
})?;
Wal::for_layout(&layout).append_patch(&envelope)?;
let report = verify_repository(&layout)?;
assert_stage_failed(
&report,
VerificationStage::RollbackDrafts,
"must carry an AUTHOR signature",
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn verify_repository_detects_rollback_draft_legacy_marker_key_id() -> Result<()> {
let root = unique_temp_dir("verify-rollback-legacy-marker");
let layout = RepositoryLayout::init(root.clone())?;
let mut objects = FileObjectStore::new(layout.clone());
let blob = rollback_patch_blob_envelope();
objects.write_object(&blob)?;
let payload =
rollback_payload_with_operations(vec![create_file_operation(1, blob.object_id())?]);
let mut envelope =
ObjectEnvelope::unsigned(ObjectType::Patch, 1, payload.to_canonical_bytes()?);
envelope.add_signature(crate::test_support::legacy_rollback_marker_signature())?;
Wal::for_layout(&layout).append_patch(&envelope)?;
let report = verify_repository(&layout)?;
assert_stage_failed(
&report,
VerificationStage::RollbackDrafts,
"legacy rollback marker key id",
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}
fn normal_patch_envelope(label: &str) -> Result<ObjectEnvelope> {
let payload = PatchPayload {
operations: vec![create_file_operation(1, sample_object_id(label))?],
parent_patch_ids: Vec::new(),
intent: None,
preconditions: Vec::new(),
purpose: PatchPurpose::Normal,
};
let mut envelope =
ObjectEnvelope::unsigned(ObjectType::Patch, 1, payload.to_canonical_bytes()?);
envelope.add_signature(rollback_author_signature())?;
Ok(envelope)
}
fn append_distinct_records(wal: &Wal, labels: &[&str]) -> Result<Vec<usize>> {
let mut offsets = Vec::with_capacity(labels.len());
for label in labels {
let before = std::fs::read(wal.path())
.map(|bytes| bytes.len())
.unwrap_or(0);
offsets.push(before);
wal.append_patch(&normal_patch_envelope(label)?)?;
}
Ok(offsets)
}
fn corrupt_records_last_byte(bytes: &mut [u8], bounds: &[(usize, usize)], damaged: &[usize]) {
for &index in damaged {
let (_, stop) = bounds[index];
bytes[stop - 1] ^= 0x01;
}
}
fn record_bounds(offsets: &[usize], total_len: usize) -> Vec<(usize, usize)> {
offsets
.iter()
.enumerate()
.map(|(index, &start)| {
let stop = offsets.get(index + 1).copied().unwrap_or(total_len);
(start, stop)
})
.collect()
}
#[test]
fn wal_replay_isolates_two_damaged_records_and_reads_every_sound_record() -> Result<()> {
let root = unique_temp_dir("wal-replay-two-damaged");
let layout = RepositoryLayout::init(root.clone())?;
let wal = Wal::for_layout(&layout);
let offsets = append_distinct_records(&wal, &["one", "two", "three", "four", "five"])?;
let mut bytes = std::fs::read(wal.path())?;
let bounds = record_bounds(&offsets, bytes.len());
corrupt_records_last_byte(&mut bytes, &bounds, &[1, 3]);
std::fs::write(wal.path(), &bytes)?;
let replay = wal.replay()?;
assert!(replay.has_item_failure());
assert_eq!(
replay.trailing_partial_bytes, 0,
"the last record is sound and complete; nothing should be left over"
);
let failed_offsets: Vec<usize> = replay
.record_outcomes
.iter()
.filter_map(|outcome| match &outcome.status {
WalRecordStatus::Failed { .. } => Some(outcome.offset),
WalRecordStatus::Evaluated => None,
})
.collect();
assert_eq!(
failed_offsets,
vec![bounds[1].0, bounds[3].0],
"expected exactly the two damaged records' own starting offsets, got {failed_offsets:?} against bounds {bounds:?}"
);
let sound_seqs: Vec<u64> = replay.records.iter().map(|record| record.seq).collect();
assert_eq!(
sound_seqs,
vec![1, 3, 5],
"every sound record -- including the ones immediately after each damaged record -- must \
still be read, in file order"
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn verify_repository_reports_two_independently_damaged_wal_records_with_offsets() -> Result<()> {
let root = unique_temp_dir("verify-wal-two-damaged");
let layout = RepositoryLayout::init(root.clone())?;
let wal = Wal::for_layout(&layout);
let offsets = append_distinct_records(&wal, &["alpha", "beta", "gamma"])?;
let mut bytes = std::fs::read(wal.path())?;
let bounds = record_bounds(&offsets, bytes.len());
corrupt_records_last_byte(&mut bytes, &bounds, &[0, 2]);
std::fs::write(wal.path(), &bytes)?;
let report = verify_repository(&layout)?;
assert!(
report.has_item_failure(),
"criterion 3: a repository with a damaged WAL record must still fail verification"
);
assert!(
!report.has_stage_failure(),
"item containment means WalReplay and its dependent stages stay Evaluated around the two \
damaged records: {report:?}"
);
let mut failed_offsets: Vec<usize> = report
.wal_record_outcomes
.iter()
.filter_map(|outcome| match &outcome.status {
WalRecordStatus::Failed { .. } => Some(outcome.offset),
WalRecordStatus::Evaluated => None,
})
.collect();
failed_offsets.sort_unstable();
let mut expected_offsets = vec![bounds[0].0, bounds[2].0];
expected_offsets.sort_unstable();
assert_eq!(
failed_offsets, expected_offsets,
"criterion 1: both damaged records must be reported, each with its own offset"
);
assert!(
repair_repository(&layout, DoctorRepairOptions::truncate_wal_tail()).is_err(),
"criterion 5: repair_repository must still refuse on a WAL carrying a damaged record"
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}
#[test]
fn wal_replay_still_tolerates_a_trailing_partial_frame_unchanged() -> Result<()> {
let root = unique_temp_dir("wal-replay-trailing-partial");
let layout = RepositoryLayout::init(root.clone())?;
let wal = Wal::for_layout(&layout);
wal.append_patch(&normal_patch_envelope("one")?)?;
wal.append_patch(&normal_patch_envelope("two")?)?;
let mut bytes = std::fs::read(wal.path())?;
let torn_tail = vec![0_u8; 30];
bytes.extend_from_slice(&torn_tail);
std::fs::write(wal.path(), &bytes)?;
let replay = wal.replay()?;
assert!(
!replay.has_item_failure(),
"a torn tail is not corruption and must not surface as an item failure: {replay:?}"
);
assert_eq!(replay.records.len(), 2);
assert_eq!(replay.trailing_partial_bytes, torn_tail.len());
assert!(
replay
.record_outcomes
.iter()
.all(|outcome| matches!(outcome.status, WalRecordStatus::Evaluated)),
"a torn tail must not produce a Failed record_outcomes entry: {:?}",
replay.record_outcomes
);
let _ = std::fs::remove_dir_all(root);
Ok(())
}