Skip to main content

prikk_store/
received.rs

1//! Received (imported) ref bookkeeping — DC-78 §D4's distinct `remotes/` namespace.
2//!
3//! A received ref is deliberately **not** a `refs/by-id/` pointer. `refs/by-id/`'s own consistency
4//! check (a RefState object's embedded `ref_name` must equal its pointer's declared name — enforced
5//! by `refs/verify/scan.rs`) cannot be satisfied by a renamed local identifier: the RefState object
6//! keeps the *origin's own* embedded ref name (its content-addressed identity and signature would be
7//! invalidated by editing it), so a pointer declaring `remotes/heads/main` could never agree with a
8//! payload that says `heads/main`. Storing received refs under their own key, in their own small
9//! index format, sidesteps that conflict entirely rather than patching the check to allow it — the
10//! latter would be new verification-path surface, which §D6 rules out.
11//!
12//! This index is never read by `verify_repository`. Every object a received pointer leads to
13//! (RefState, Block, Patch, Blob, Attestation) is an ordinary object-store entry, checked exactly
14//! like any other by the existing type-based object scan — §D6's "no new verification path" holds
15//! because there genuinely is none: this module only makes received tips *discoverable* by name.
16//!
17//! RFC 102 Stage 5, design-v1.md §14/Step 0 item 2: backed by `received_index.rs`'s shared, append-
18//! only, last-entry-wins container (the refs container+pointer-index pattern, applied here because a
19//! received ref's own name doesn't exist at `init` either — the same architecturally-forced shape
20//! Stage 4 hit with refs). Replaced the old one-file-per-ref directory entirely, not layered on top of
21//! it.
22
23use prikk_error::{PrikkError, Result};
24use prikk_object::ObjectId;
25
26use crate::layout::{RepositoryLayout, ref_name_key_bytes};
27use crate::received_index::{
28    ReceivedIndexEntry, append_received_index_entry, list_resolved_received_entries,
29    lookup_received_index_entry,
30};
31
32/// One received ref pointer.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ReceivedPointer {
35    /// Local logical name, always `remotes/<origin ref name>`.
36    pub ref_name: String,
37    /// The received tip's RefState object id.
38    pub ref_state_id: ObjectId,
39}
40
41/// Validate a received ref name: the reserved `remotes/` namespace, required rather than rejected
42/// (the mirror image of `validate_local_branch_ref`/`validate_local_tag_ref`, which reject it).
43pub fn validate_received_ref(ref_name: &str) -> Result<()> {
44    if ref_name.is_empty() {
45        return Err(PrikkError::InvalidName(
46            "ref name must not be empty".to_string(),
47        ));
48    }
49    if !ref_name.starts_with("remotes/") {
50        return Err(PrikkError::InvalidName(format!(
51            "ref {ref_name} is not a received ref; expected remotes/<name>"
52        )));
53    }
54    let rest = &ref_name["remotes/".len()..];
55    if rest.is_empty() {
56        return Err(PrikkError::InvalidName(
57            "received ref must include a name after remotes/".to_string(),
58        ));
59    }
60    if ref_name.chars().any(|ch| ch == '\0' || ch.is_control()) {
61        return Err(PrikkError::InvalidName(format!(
62            "ref {ref_name} contains a forbidden control character"
63        )));
64    }
65    if rest.starts_with('/') || rest.ends_with('/') || rest.contains("//") {
66        return Err(PrikkError::InvalidName(format!(
67            "received ref {ref_name} contains an empty path component"
68        )));
69    }
70    if rest
71        .split('/')
72        .any(|component| component == "." || component == "..")
73    {
74        return Err(PrikkError::InvalidName(format!(
75            "received ref {ref_name} contains a traversal component"
76        )));
77    }
78    Ok(())
79}
80
81/// Write (or overwrite) a received ref's pointer. Each import replaces the prior received state for
82/// that name outright — there is no CAS and no merge between two received histories under one name;
83/// D4 explicitly leaves remote-tracking-ref semantics out of scope, so a re-import is simply "this is
84/// what I have now," and turning that into real local history remains the operator's own deliberate
85/// merge, using machinery that already exists. Never checks for an existing entry first — matching
86/// `append_received_index_entry`'s own reasoning, "last entry wins" already makes a duplicate
87/// harmless and there is no CAS to enforce here.
88pub(crate) fn write_received_pointer(
89    layout: &RepositoryLayout,
90    ref_name: &str,
91    ref_state_id: ObjectId,
92) -> Result<()> {
93    validate_received_ref(ref_name)?;
94    append_received_index_entry(
95        layout,
96        &ReceivedIndexEntry {
97            ref_name_key: ref_name_key_bytes(ref_name),
98            ref_name: ref_name.to_string(),
99            ref_state_id,
100        },
101    )
102}
103
104/// Read a received ref's current pointer, if one has been imported.
105pub fn read_received_pointer(
106    layout: &RepositoryLayout,
107    ref_name: &str,
108) -> Result<Option<ReceivedPointer>> {
109    validate_received_ref(ref_name)?;
110    let key = ref_name_key_bytes(ref_name);
111    Ok(lookup_received_index_entry(layout, key)?.map(ReceivedPointer::from))
112}
113
114/// Enumerate every received ref pointer, sorted by name — the received-namespace counterpart of
115/// `RefStore::list_ref_pointers`.
116pub fn list_received_pointers(layout: &RepositoryLayout) -> Result<Vec<ReceivedPointer>> {
117    let mut pointers: Vec<ReceivedPointer> = list_resolved_received_entries(layout)?
118        .into_iter()
119        .map(ReceivedPointer::from)
120        .collect();
121    pointers.sort_by(|left, right| left.ref_name.cmp(&right.ref_name));
122    Ok(pointers)
123}
124
125impl From<ReceivedIndexEntry> for ReceivedPointer {
126    fn from(entry: ReceivedIndexEntry) -> Self {
127        Self {
128            ref_name: entry.ref_name,
129            ref_state_id: entry.ref_state_id,
130        }
131    }
132}
133
134#[cfg(all(test, target_os = "linux"))]
135mod tests;