Skip to main content

prikk_store/
history.rs

1//! Read-only sealed-history inspection helpers.
2//!
3//! PR-014 exposes a small history view built from the current RefState chain. It is intentionally
4//! read-only and does not perform graph traversal beyond the published ref-state lineage.
5
6use std::collections::HashSet;
7
8use prikk_error::{PrikkError, Result};
9use prikk_object::{BlockKind, BlockPayload, ObjectId, ObjectType, RefStatePayload};
10
11use crate::layout::RepositoryLayout;
12use crate::object_store::{ObjectReadSnapshot, ObjectReader};
13use crate::refs::RefStore;
14use crate::rollback_verify::verify_rollback_patch_envelope;
15
16/// Default number of history entries shown by the CLI.
17pub const DEFAULT_HISTORY_LIMIT: usize = 20;
18
19/// Read-only history view for a single ref.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RefHistory {
22    /// Human-readable ref name.
23    pub ref_name: String,
24    /// Entries ordered from newest to oldest.
25    pub entries: Vec<HistoryEntry>,
26}
27
28impl RefHistory {
29    /// Return true when the ref has no published history.
30    #[must_use]
31    pub fn is_empty(&self) -> bool {
32        self.entries.is_empty()
33    }
34}
35
36/// One published RefState and its target Block summary.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct HistoryEntry {
39    /// RefState object ID for this publication.
40    pub ref_state_id: ObjectId,
41    /// Target Block object ID.
42    pub block_id: ObjectId,
43    /// Monotonic ref update sequence.
44    pub update_seq: u64,
45    /// Previous RefState object ID, if any.
46    pub previous_ref_state_id: Option<ObjectId>,
47    /// Block kind.
48    pub block_kind: BlockKind,
49    /// Number of parent blocks referenced by the target Block.
50    pub parent_count: usize,
51    /// Number of patches referenced by the target Block.
52    pub patch_count: usize,
53    /// Number of required attestations attached to this RefState.
54    pub required_attestation_count: usize,
55    /// Number of rollback-marked Patch objects in the target Block.
56    pub rollback_patch_count: usize,
57    /// Whether this entry's target Block contains at least one rollback-marked Patch.
58    pub is_rollback_block: bool,
59}
60
61/// Load history for a ref, newest first.
62///
63/// The function follows `RefState.previous_ref_state_id` links and validates that every RefState
64/// targets a persisted Block that decodes successfully. `limit == 0` means no entries are returned.
65pub fn load_ref_history(
66    layout: &RepositoryLayout,
67    ref_name: &str,
68    limit: usize,
69) -> Result<RefHistory> {
70    let ref_store = RefStore::new(layout.clone());
71    let object_store = ObjectReadSnapshot::open(layout)?;
72    let mut current = ref_store.read_current_ref_state_id(ref_name)?;
73    let mut entries = Vec::new();
74    let mut seen = HashSet::new();
75
76    while let Some(ref_state_id) = current {
77        if entries.len() >= limit {
78            break;
79        }
80        if !seen.insert(ref_state_id) {
81            return Err(PrikkError::Integrity(format!(
82                "RefState chain for {ref_name} contains a cycle at {ref_state_id}"
83            )));
84        }
85        let ref_state = read_ref_state(&object_store, ref_state_id, ref_name)?;
86        let block = read_block(&object_store, ref_state.target_object_id)?;
87        let rollback_patch_count =
88            count_rollback_patches(&object_store, ref_state.target_object_id, &block.patch_ids)?;
89        entries.push(HistoryEntry {
90            ref_state_id,
91            block_id: ref_state.target_object_id,
92            update_seq: ref_state.update_seq,
93            previous_ref_state_id: ref_state.previous_ref_state_id,
94            block_kind: block.kind,
95            parent_count: block.parent_block_ids.len(),
96            patch_count: block.patch_ids.len(),
97            required_attestation_count: ref_state.required_attestation_ids.len(),
98            rollback_patch_count,
99            is_rollback_block: rollback_patch_count != 0,
100        });
101        current = ref_state.previous_ref_state_id;
102    }
103
104    Ok(RefHistory {
105        ref_name: ref_name.to_string(),
106        entries,
107    })
108}
109
110/// Load history for a received ref (DC-78 ruling 4), newest first. Received refs have no ref-log
111/// chain of their own (`received.rs`'s single-overwrite pointer) — this walks the same
112/// `RefState.previous_ref_state_id` links as [`load_ref_history`], starting from the received tip.
113/// Each RefState's *embedded* name is checked against the origin's own name (the `remotes/` prefix is
114/// a local rename applied only to the received pointer, never written into the objects themselves —
115/// exactly why received refs cannot use `refs/by-id/`'s pointer format, whose consistency check
116/// requires the opposite: pointer name and embedded name to agree).
117pub fn load_received_ref_history(
118    layout: &RepositoryLayout,
119    received_ref_name: &str,
120    limit: usize,
121) -> Result<RefHistory> {
122    let Some(pointer) = crate::received::read_received_pointer(layout, received_ref_name)? else {
123        return Ok(RefHistory {
124            ref_name: received_ref_name.to_string(),
125            entries: Vec::new(),
126        });
127    };
128    let Some(origin_ref_name) = received_ref_name.strip_prefix("remotes/") else {
129        return Err(PrikkError::InvalidName(format!(
130            "{received_ref_name} is not a received ref"
131        )));
132    };
133    let object_store = ObjectReadSnapshot::open(layout)?;
134    let mut current = Some(pointer.ref_state_id);
135    let mut entries = Vec::new();
136    let mut seen = HashSet::new();
137
138    while let Some(ref_state_id) = current {
139        if entries.len() >= limit {
140            break;
141        }
142        if !seen.insert(ref_state_id) {
143            return Err(PrikkError::Integrity(format!(
144                "RefState chain for {received_ref_name} contains a cycle at {ref_state_id}"
145            )));
146        }
147        let ref_state = read_ref_state(&object_store, ref_state_id, origin_ref_name)?;
148        let block = read_block(&object_store, ref_state.target_object_id)?;
149        let rollback_patch_count =
150            count_rollback_patches(&object_store, ref_state.target_object_id, &block.patch_ids)?;
151        entries.push(HistoryEntry {
152            ref_state_id,
153            block_id: ref_state.target_object_id,
154            update_seq: ref_state.update_seq,
155            previous_ref_state_id: ref_state.previous_ref_state_id,
156            block_kind: block.kind,
157            parent_count: block.parent_block_ids.len(),
158            patch_count: block.patch_ids.len(),
159            required_attestation_count: ref_state.required_attestation_ids.len(),
160            rollback_patch_count,
161            is_rollback_block: rollback_patch_count != 0,
162        });
163        current = ref_state.previous_ref_state_id;
164    }
165
166    Ok(RefHistory {
167        ref_name: received_ref_name.to_string(),
168        entries,
169    })
170}
171
172fn read_ref_state(
173    object_store: &impl ObjectReader,
174    ref_state_id: ObjectId,
175    ref_name: &str,
176) -> Result<RefStatePayload> {
177    let Some(envelope) = object_store.read_object(ref_state_id)? else {
178        return Err(PrikkError::Integrity(format!(
179            "history RefState {ref_state_id} is missing"
180        )));
181    };
182    if envelope.object_type != ObjectType::RefState {
183        return Err(PrikkError::Integrity(format!(
184            "history object {ref_state_id} is {}, expected RefState",
185            envelope.object_type
186        )));
187    }
188    let payload =
189        RefStatePayload::decode_canonical(&envelope.canonical_payload, envelope.schema_version)?;
190    if payload.ref_name != ref_name {
191        return Err(PrikkError::Integrity(format!(
192            "history RefState {ref_state_id} name mismatch: expected {ref_name}, got {}",
193            payload.ref_name
194        )));
195    }
196    Ok(payload)
197}
198
199fn count_rollback_patches(
200    object_store: &impl ObjectReader,
201    block_id: ObjectId,
202    patch_ids: &[ObjectId],
203) -> Result<usize> {
204    let mut count = 0_usize;
205    for patch_id in patch_ids {
206        let Some(envelope) = object_store.read_typed(*patch_id, ObjectType::Patch)? else {
207            return Err(PrikkError::Integrity(format!(
208                "history Block {block_id} references missing Patch {patch_id}"
209            )));
210        };
211        let context = format!("history Block {block_id} Patch {patch_id}");
212        if verify_rollback_patch_envelope(&envelope, &context)? {
213            count = count.checked_add(1).ok_or_else(|| {
214                PrikkError::Integrity("history rollback patch count overflow".to_string())
215            })?;
216        }
217    }
218    Ok(count)
219}
220
221fn read_block(object_store: &impl ObjectReader, block_id: ObjectId) -> Result<BlockPayload> {
222    let Some(envelope) = object_store.read_object(block_id)? else {
223        return Err(PrikkError::Integrity(format!(
224            "history Block {block_id} is missing"
225        )));
226    };
227    if envelope.object_type != ObjectType::Block {
228        return Err(PrikkError::Integrity(format!(
229            "history object {block_id} is {}, expected Block",
230            envelope.object_type
231        )));
232    }
233    BlockPayload::decode_canonical(&envelope.canonical_payload)
234}
235
236#[cfg(test)]
237mod tests;