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;