Skip to main content

prikk_store/
rollback_verify.rs

1//! Verification helpers for rollback draft patches.
2//!
3//! This module keeps rollback publication non-mutating, but makes rollback drafts easier to audit before
4//! seal. The active WAL verifier classifies rollback draft records by `PatchPurpose::RollbackDraft`
5//! and validates that their Patch payload remains in the supported replay subset. The stronger
6//! `verify_active_rollback_draft` API additionally compares the WAL
7//! payload with the inverse Patch that would be derived from the currently published ref.
8
9use 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/// Verification result for one active rollback draft.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RollbackDraftVerification {
27    /// Ref used to derive the expected inverse Patch.
28    pub ref_name: String,
29    /// WAL sequence containing the rollback draft Patch envelope.
30    pub wal_sequence: u64,
31    /// Signed rollback draft Patch ID currently present in the active WAL.
32    pub draft_patch_id: ObjectId,
33    /// Real AUTHOR key id recorded in the rollback draft signature.
34    pub author_key_id: String,
35    /// Published block that was used as the rollback target.
36    pub target_block_id: ObjectId,
37    /// Number of blocks inspected while deriving the expected inverse.
38    pub block_count: usize,
39    /// Number of patch objects inspected while deriving the expected inverse.
40    pub patch_count: usize,
41    /// Number of supported inverse operations expected for this rollback draft.
42    pub inverse_operation_count: usize,
43    /// Number of supported operations decoded from the active WAL payload.
44    pub decoded_operation_count: usize,
45}
46
47/// Verify that the active WAL contains exactly one rollback draft matching the current ref.
48///
49/// This is intentionally a pre-seal validation helper. It does not write objects, publish refs,
50/// or mutate the worktree. It refuses trailing partial WAL bytes, non-rollback WAL records, and
51/// rollback payloads that no longer match the inverse Patch derived from the selected ref.
52pub 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    // RFC 102 Stage 2: `single_wal_record` below only sees the surviving records -- a genuinely
65    // two-record WAL with one damaged record would otherwise pass as "exactly one," and this
66    // function's result is trusted to authorize seal.
67    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    // Erratum P1: decoding all ยง9.3 kinds does not prove the draft is replayable. A
89    // rollback draft must consist only of apply-supported operations; gate explicitly
90    // rather than relying on decode success.
91    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
128/// Verify a rollback-marked Patch envelope and return whether it is a rollback patch.
129///
130/// Non-rollback Patch envelopes return `Ok(false)`. Rollback-marked envelopes must decode under
131/// the currently supported replay subset and must contain at least one inverse operation. This
132/// helper is shared by active-WAL verification and sealed Block/history classification.
133pub(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    // Erratum P1: require apply-support, not merely decodability.
148    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            // RFC 103, following DC-95 Stage 1 round 11's own finding: this arm was already reachable
224            // end to end only under format-1 -- `Wal::replay()` calls `validate_read_schema` on every
225            // record before this function ever runs, and under `RepositoryFormat::CurrentV6` that
226            // call already hard-errors on a malformed-length signature via `envelope.validate_strict()`.
227            // With formats 1 through 4 all retired (RFC 102 Stages 3-5), `CurrentV6` is the only
228            // format left, so this arm is now provably unreachable through `verify_repository`'s
229            // pipeline, not merely untested. Kept, per round
230            // 6's ruling on unreachable checks: unreachable today is not unreachable by design, and the
231            // unit-level coverage (`rollback_purpose_with_short_ed25519_author_signature_is_rejected`)
232            // still proves the function's own logic is correct in isolation.
233            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;