use prikk_crypto::ED25519_SIGNATURE_LEN;
use prikk_error::{PrikkError, Result};
use prikk_object::{
CanonicalEncode, ObjectEnvelope, ObjectId, ObjectType, PatchPurpose, Signature,
SignatureAlgorithm, SignerRole,
};
use crate::layout::RepositoryLayout;
use crate::patch_inverse::prepare_patch_inverse_plan;
use crate::patch_replay::decode::{decode_patch_operations, ensure_apply_supported};
use crate::rollback_draft::is_rollback_draft_envelope;
use crate::wal::{Wal, WalRecord};
const LEGACY_ROLLBACK_MARKER_KEY_ID: &str = "dev-placeholder-rollback-author";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollbackDraftVerification {
pub ref_name: String,
pub wal_sequence: u64,
pub draft_patch_id: ObjectId,
pub author_key_id: String,
pub target_block_id: ObjectId,
pub block_count: usize,
pub patch_count: usize,
pub inverse_operation_count: usize,
pub decoded_operation_count: usize,
}
pub fn verify_active_rollback_draft(
layout: &RepositoryLayout,
ref_name: &str,
) -> Result<RollbackDraftVerification> {
let wal = Wal::for_layout(layout);
let replay = wal.replay()?;
if replay.trailing_partial_bytes != 0 {
return Err(PrikkError::Integrity(format!(
"active WAL has {} trailing partial bytes; run doctor before rollback-draft-verify",
replay.trailing_partial_bytes
)));
}
if replay.has_item_failure() {
return Err(PrikkError::Integrity(
"active WAL has a damaged record; run doctor before rollback-draft-verify".to_string(),
));
}
let Some(record) = single_wal_record(&replay.records)? else {
return Err(PrikkError::Integrity(
"rollback-draft-verify requires exactly one active WAL record".to_string(),
));
};
verify_active_rollback_record(record)?;
let mut inverse = prepare_patch_inverse_plan(layout, ref_name)?;
inverse.inverse_payload.purpose = PatchPurpose::RollbackDraft;
let expected_payload = inverse.inverse_payload.to_canonical_bytes()?;
if record.envelope.canonical_payload != expected_payload {
return Err(PrikkError::Integrity(
"active rollback draft payload does not match the current inverse plan".to_string(),
));
}
let decoded = decode_patch_operations(&record.envelope.canonical_payload)?;
for operation in &decoded {
ensure_apply_supported(operation)?;
}
if decoded.len() != inverse.inverse_operation_count {
return Err(PrikkError::Integrity(format!(
"rollback draft decoded {} operations but inverse plan has {}",
decoded.len(),
inverse.inverse_operation_count
)));
}
Ok(RollbackDraftVerification {
ref_name: ref_name.to_string(),
wal_sequence: record.seq,
draft_patch_id: record.envelope.object_id(),
author_key_id: rollback_author_key_id(&record.envelope)?,
target_block_id: inverse.target_block_id,
block_count: inverse.block_count,
patch_count: inverse.patch_count,
inverse_operation_count: inverse.inverse_operation_count,
decoded_operation_count: decoded.len(),
})
}
pub(crate) fn verify_rollback_draft_wal_records(records: &[WalRecord]) -> Result<usize> {
let mut rollback_drafts = 0_usize;
for record in records {
let context = format!("rollback draft WAL record {}", record.seq);
if verify_rollback_patch_envelope(&record.envelope, &context)? {
rollback_drafts = rollback_drafts.checked_add(1).ok_or_else(|| {
PrikkError::Integrity("rollback draft WAL count overflow".to_string())
})?;
}
}
Ok(rollback_drafts)
}
pub(crate) fn verify_rollback_patch_envelope(
envelope: &ObjectEnvelope,
context: &str,
) -> Result<bool> {
if !is_rollback_draft_envelope(envelope)? {
return Ok(false);
}
if envelope.object_type != ObjectType::Patch {
return Err(PrikkError::Integrity(format!(
"{context} is {}, expected patch",
envelope.object_type
)));
}
let decoded = decode_patch_operations(&envelope.canonical_payload)?;
for operation in &decoded {
ensure_apply_supported(operation)?;
}
if decoded.is_empty() {
return Err(PrikkError::Integrity(format!(
"{context} has no supported inverse operations"
)));
}
require_rollback_author_signature(envelope, context)?;
Ok(true)
}
fn single_wal_record(records: &[WalRecord]) -> Result<Option<&WalRecord>> {
match records {
[] => Ok(None),
[record] => Ok(Some(record)),
_ => Err(PrikkError::LockConflict(
"rollback-draft-verify requires an active WAL containing only the rollback draft"
.to_string(),
)),
}
}
fn verify_active_rollback_record(record: &WalRecord) -> Result<()> {
if record.envelope.object_type != ObjectType::Patch {
return Err(PrikkError::Integrity(format!(
"rollback draft WAL record {} contains {}, expected patch",
record.seq, record.envelope.object_type
)));
}
if !is_rollback_draft_envelope(&record.envelope)? {
return Err(PrikkError::InvalidSignature(format!(
"active WAL record {} is not a rollback draft PatchPurpose",
record.seq
)));
}
require_rollback_author_signature(
&record.envelope,
&format!("active WAL record {}", record.seq),
)?;
Ok(())
}
fn rollback_author_key_id(envelope: &ObjectEnvelope) -> Result<String> {
Ok(
require_rollback_author_signature(envelope, "rollback draft Patch")?
.key_id
.clone(),
)
}
fn require_rollback_author_signature<'a>(
envelope: &'a ObjectEnvelope,
context: &str,
) -> Result<&'a Signature> {
envelope
.signatures
.iter()
.find(|signature| signature.signer_role == SignerRole::Author)
.ok_or_else(|| {
PrikkError::InvalidSignature(
"rollback draft Patch must carry an AUTHOR signature".to_string(),
)
})
.and_then(|signature| {
if signature.algorithm != SignatureAlgorithm::Ed25519 {
return Err(PrikkError::InvalidSignature(format!(
"{context} rollback draft AUTHOR signature must use Ed25519"
)));
}
if signature.key_id == LEGACY_ROLLBACK_MARKER_KEY_ID {
return Err(PrikkError::InvalidSignature(format!(
"{context} uses legacy rollback marker key id"
)));
}
if signature.signature_bytes.len() != ED25519_SIGNATURE_LEN {
return Err(PrikkError::InvalidSignature(format!(
"{context} rollback draft AUTHOR signature must be {ED25519_SIGNATURE_LEN} bytes"
)));
}
let _preimage = Signature::signed_bytes(
signature.algorithm,
ObjectType::Patch,
envelope.object_id(),
SignerRole::Author,
&signature.key_id,
)?;
Ok(signature)
})
}
#[cfg(test)]
mod tests;