prikk_store/
rollback_verify.rs1use prikk_crypto::ED25519_SIGNATURE_LEN;
10use prikk_error::{PrikkError, Result};
11use prikk_object::{
12 CanonicalEncode, ObjectEnvelope, ObjectId, ObjectType, PatchPurpose, Signature,
13 SignatureAlgorithm, SignerRole,
14};
15
16use crate::layout::RepositoryLayout;
17use crate::patch_inverse::prepare_patch_inverse_plan;
18use crate::patch_replay::decode::{decode_patch_operations, ensure_apply_supported};
19use crate::rollback_draft::is_rollback_draft_envelope;
20use crate::wal::{Wal, WalRecord};
21
22const LEGACY_ROLLBACK_MARKER_KEY_ID: &str = "dev-placeholder-rollback-author";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RollbackDraftVerification {
27 pub ref_name: String,
29 pub wal_sequence: u64,
31 pub draft_patch_id: ObjectId,
33 pub author_key_id: String,
35 pub target_block_id: ObjectId,
37 pub block_count: usize,
39 pub patch_count: usize,
41 pub inverse_operation_count: usize,
43 pub decoded_operation_count: usize,
45}
46
47pub fn verify_active_rollback_draft(
53 layout: &RepositoryLayout,
54 ref_name: &str,
55) -> Result<RollbackDraftVerification> {
56 let wal = Wal::for_layout(layout);
57 let replay = wal.replay()?;
58 if replay.trailing_partial_bytes != 0 {
59 return Err(PrikkError::Integrity(format!(
60 "active WAL has {} trailing partial bytes; run doctor before rollback-draft-verify",
61 replay.trailing_partial_bytes
62 )));
63 }
64 if replay.has_item_failure() {
68 return Err(PrikkError::Integrity(
69 "active WAL has a damaged record; run doctor before rollback-draft-verify".to_string(),
70 ));
71 }
72 let Some(record) = single_wal_record(&replay.records)? else {
73 return Err(PrikkError::Integrity(
74 "rollback-draft-verify requires exactly one active WAL record".to_string(),
75 ));
76 };
77 verify_active_rollback_record(record)?;
78
79 let mut inverse = prepare_patch_inverse_plan(layout, ref_name)?;
80 inverse.inverse_payload.purpose = PatchPurpose::RollbackDraft;
81 let expected_payload = inverse.inverse_payload.to_canonical_bytes()?;
82 if record.envelope.canonical_payload != expected_payload {
83 return Err(PrikkError::Integrity(
84 "active rollback draft payload does not match the current inverse plan".to_string(),
85 ));
86 }
87 let decoded = decode_patch_operations(&record.envelope.canonical_payload)?;
88 for operation in &decoded {
92 ensure_apply_supported(operation)?;
93 }
94 if decoded.len() != inverse.inverse_operation_count {
95 return Err(PrikkError::Integrity(format!(
96 "rollback draft decoded {} operations but inverse plan has {}",
97 decoded.len(),
98 inverse.inverse_operation_count
99 )));
100 }
101
102 Ok(RollbackDraftVerification {
103 ref_name: ref_name.to_string(),
104 wal_sequence: record.seq,
105 draft_patch_id: record.envelope.object_id(),
106 author_key_id: rollback_author_key_id(&record.envelope)?,
107 target_block_id: inverse.target_block_id,
108 block_count: inverse.block_count,
109 patch_count: inverse.patch_count,
110 inverse_operation_count: inverse.inverse_operation_count,
111 decoded_operation_count: decoded.len(),
112 })
113}
114
115pub(crate) fn verify_rollback_draft_wal_records(records: &[WalRecord]) -> Result<usize> {
116 let mut rollback_drafts = 0_usize;
117 for record in records {
118 let context = format!("rollback draft WAL record {}", record.seq);
119 if verify_rollback_patch_envelope(&record.envelope, &context)? {
120 rollback_drafts = rollback_drafts.checked_add(1).ok_or_else(|| {
121 PrikkError::Integrity("rollback draft WAL count overflow".to_string())
122 })?;
123 }
124 }
125 Ok(rollback_drafts)
126}
127
128pub(crate) fn verify_rollback_patch_envelope(
134 envelope: &ObjectEnvelope,
135 context: &str,
136) -> Result<bool> {
137 if !is_rollback_draft_envelope(envelope)? {
138 return Ok(false);
139 }
140 if envelope.object_type != ObjectType::Patch {
141 return Err(PrikkError::Integrity(format!(
142 "{context} is {}, expected patch",
143 envelope.object_type
144 )));
145 }
146 let decoded = decode_patch_operations(&envelope.canonical_payload)?;
147 for operation in &decoded {
149 ensure_apply_supported(operation)?;
150 }
151 if decoded.is_empty() {
152 return Err(PrikkError::Integrity(format!(
153 "{context} has no supported inverse operations"
154 )));
155 }
156 require_rollback_author_signature(envelope, context)?;
157 Ok(true)
158}
159
160fn single_wal_record(records: &[WalRecord]) -> Result<Option<&WalRecord>> {
161 match records {
162 [] => Ok(None),
163 [record] => Ok(Some(record)),
164 _ => Err(PrikkError::LockConflict(
165 "rollback-draft-verify requires an active WAL containing only the rollback draft"
166 .to_string(),
167 )),
168 }
169}
170
171fn verify_active_rollback_record(record: &WalRecord) -> Result<()> {
172 if record.envelope.object_type != ObjectType::Patch {
173 return Err(PrikkError::Integrity(format!(
174 "rollback draft WAL record {} contains {}, expected patch",
175 record.seq, record.envelope.object_type
176 )));
177 }
178 if !is_rollback_draft_envelope(&record.envelope)? {
179 return Err(PrikkError::InvalidSignature(format!(
180 "active WAL record {} is not a rollback draft PatchPurpose",
181 record.seq
182 )));
183 }
184 require_rollback_author_signature(
185 &record.envelope,
186 &format!("active WAL record {}", record.seq),
187 )?;
188 Ok(())
189}
190
191fn rollback_author_key_id(envelope: &ObjectEnvelope) -> Result<String> {
192 Ok(
193 require_rollback_author_signature(envelope, "rollback draft Patch")?
194 .key_id
195 .clone(),
196 )
197}
198
199fn require_rollback_author_signature<'a>(
200 envelope: &'a ObjectEnvelope,
201 context: &str,
202) -> Result<&'a Signature> {
203 envelope
204 .signatures
205 .iter()
206 .find(|signature| signature.signer_role == SignerRole::Author)
207 .ok_or_else(|| {
208 PrikkError::InvalidSignature(
209 "rollback draft Patch must carry an AUTHOR signature".to_string(),
210 )
211 })
212 .and_then(|signature| {
213 if signature.algorithm != SignatureAlgorithm::Ed25519 {
214 return Err(PrikkError::InvalidSignature(format!(
215 "{context} rollback draft AUTHOR signature must use Ed25519"
216 )));
217 }
218 if signature.key_id == LEGACY_ROLLBACK_MARKER_KEY_ID {
219 return Err(PrikkError::InvalidSignature(format!(
220 "{context} uses legacy rollback marker key id"
221 )));
222 }
223 if signature.signature_bytes.len() != ED25519_SIGNATURE_LEN {
234 return Err(PrikkError::InvalidSignature(format!(
235 "{context} rollback draft AUTHOR signature must be {ED25519_SIGNATURE_LEN} bytes"
236 )));
237 }
238 let _preimage = Signature::signed_bytes(
239 signature.algorithm,
240 ObjectType::Patch,
241 envelope.object_id(),
242 SignerRole::Author,
243 &signature.key_id,
244 )?;
245 Ok(signature)
246 })
247}
248
249#[cfg(test)]
250mod tests;