1mod container;
9mod evidence;
10mod pointer_index;
11mod publication;
12mod verify;
13
14#[cfg(all(test, target_os = "linux"))]
20pub(crate) use container::append_ref_container_record;
21#[cfg(test)]
22pub(crate) use container::{
23 append_torn_ref_log_tail_for_test, encode_ref_container_record_for_test,
24};
25#[cfg(feature = "test-support")]
26pub use pointer_index::{
27 force_ref_pointer_to_arbitrary_state_for_test_support,
28 remove_ref_pointer_entry_for_test_support,
29};
30#[cfg(test)]
31pub(crate) use pointer_index::{
32 remove_pointer_entries_for_test,
33 write_ref_pointer_candidate_for_test as write_ref_pointer_candidate,
34 write_ref_pointer_entry_with_explicit_key_for_test,
35};
36pub(crate) use pointer_index::{
41 PointerIndexEntry, encode_pointer_index_record, replay_pointer_index,
42};
43
44use prikk_error::{PrikkError, Result};
45use prikk_object::{
46 ObjectEnvelope, ObjectId, ObjectType, RefKind, RefStatePayload, RefUpdatePayload, TagPayload,
47};
48
49use crate::layout::RepositoryLayout;
50use crate::lock::ActiveLock;
51use crate::object_store::{FileObjectStore, ObjectReader, ObjectWriter};
52
53#[cfg(test)]
57pub(crate) fn append_log_record_for_signature_test(
58 layout: &RepositoryLayout,
59 ref_name: &str,
60 envelope: &ObjectEnvelope,
61) -> Result<()> {
62 container::append_ref_container_record(
63 layout,
64 crate::layout::ref_name_key_bytes(ref_name),
65 envelope,
66 )
67}
68
69#[cfg(test)]
74pub(crate) fn encode_log_record_for_test(envelope: &ObjectEnvelope) -> Result<Vec<u8>> {
75 let update = RefUpdatePayload::decode_canonical(&envelope.canonical_payload)?;
76 container::encode_ref_container_record_for_test(
77 crate::layout::ref_name_key_bytes(&update.ref_name),
78 envelope,
79 )
80}
81
82pub use container::{RefLogRecord, RefLogReplay};
83pub use verify::{
84 RefFileOutcome, RefFileStatus, RefItemOutcome, RefItemStatus, RefPublicationIssue,
85};
86pub(crate) use verify::{ensure_ref_target_valid, verify_refs};
87
88pub(crate) fn resolve_ref_tip_block(
105 object_store: &impl ObjectReader,
106 ref_state_payload: &RefStatePayload,
107) -> Result<(ObjectId, Option<ObjectEnvelope>)> {
108 match ref_state_payload.kind {
109 RefKind::Branch => Ok((ref_state_payload.target_object_id, None)),
110 RefKind::Tag => {
111 let tag_id = ref_state_payload.target_object_id;
112 let tag_envelope = object_store
113 .read_typed(tag_id, ObjectType::Tag)?
114 .ok_or_else(|| PrikkError::Integrity(format!("missing Tag object: {tag_id}")))?;
115 let tag_payload = TagPayload::decode_canonical(&tag_envelope.canonical_payload)?;
116 Ok((tag_payload.target_block_id, Some(tag_envelope)))
117 }
118 }
119}
120
121pub(crate) fn ensure_no_incomplete_publication(layout: &RepositoryLayout) -> Result<()> {
122 let verification = verify_refs(layout)?;
123 if verification.publication_issues.is_empty()
128 && !verification.has_item_failure()
129 && !evidence::has_incomplete_active_cleanup(layout)?
130 {
131 return Ok(());
132 }
133 Err(PrikkError::LockConflict(
134 "repository mutation is blocked by incomplete ref publication; run verify/doctor and use signer-backed seal retry"
135 .to_string(),
136 ))
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RefRecoveryCandidate {
142 pub ref_name: String,
144 pub ref_state_id: ObjectId,
146 pub target_object_id: ObjectId,
148 pub update_seq: u64,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct RefPointerSummary {
155 pub ref_name: String,
157 pub ref_state_id: ObjectId,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct RefPublication {
164 pub ref_name: String,
166 pub expected_previous_ref_state_id: Option<ObjectId>,
168 pub ref_state: ObjectEnvelope,
170 pub ref_update: ObjectEnvelope,
172}
173
174#[derive(Debug, Clone)]
176pub struct RefStore {
177 layout: RepositoryLayout,
178}
179
180impl RefStore {
181 #[must_use]
183 pub fn new(layout: RepositoryLayout) -> Self {
184 Self { layout }
185 }
186
187 #[must_use]
189 pub fn layout(&self) -> &RepositoryLayout {
190 &self.layout
191 }
192
193 pub fn publish(&self, publication: &RefPublication) -> Result<ObjectId> {
197 self.publish_with_object_store(&mut FileObjectStore::new(self.layout.clone()), publication)
198 }
199
200 pub fn publish_with_object_store(
205 &self,
206 object_store: &mut impl ObjectWriter,
207 publication: &RefPublication,
208 ) -> Result<ObjectId> {
209 self.layout.require_current_format()?;
210 crate::format::validate_object_envelope(self.layout.format(), &publication.ref_state)?;
211 crate::format::validate_object_envelope(self.layout.format(), &publication.ref_update)?;
212 publication::publish(self, object_store, publication)
213 }
214
215 pub fn finish_interrupted_publication(
219 &self,
220 active_lock: &ActiveLock,
221 publication: &RefPublication,
222 ) -> Result<ObjectId> {
223 self.finish_interrupted_publication_with_object_store(
224 &mut FileObjectStore::new(self.layout.clone()),
225 active_lock,
226 publication,
227 )
228 }
229
230 pub fn finish_interrupted_publication_with_object_store(
234 &self,
235 object_store: &mut impl ObjectWriter,
236 active_lock: &ActiveLock,
237 publication: &RefPublication,
238 ) -> Result<ObjectId> {
239 self.layout.validate_format()?;
240 active_lock.require_layout(&self.layout)?;
241 crate::format::validate_read_schema(self.layout.format(), &publication.ref_state)?;
242 crate::format::validate_read_schema(self.layout.format(), &publication.ref_update)?;
243 evidence::validate_signer_backed_recovery(&self.layout, publication)?;
244 publication::finish_interrupted(self, object_store, publication)
245 }
246
247 #[cfg(all(test, target_os = "linux"))]
248 pub(crate) fn finish_interrupted_publication_for_test(
249 &self,
250 publication: &RefPublication,
251 ) -> Result<ObjectId> {
252 publication::finish_interrupted(
253 self,
254 &mut FileObjectStore::new(self.layout.clone()),
255 publication,
256 )
257 }
258
259 pub fn read_current_ref_state_id(&self, ref_name: &str) -> Result<Option<ObjectId>> {
261 let key = crate::layout::ref_name_key_bytes(ref_name);
262 let Some(entry) = pointer_index::lookup_ref_pointer(&self.layout, key)? else {
263 return Ok(None);
264 };
265 if entry.ref_name != ref_name {
266 return Err(PrikkError::Integrity(format!(
267 "ref pointer name mismatch: expected {ref_name}, got {}",
268 entry.ref_name
269 )));
270 }
271 Ok(Some(entry.ref_state_id))
272 }
273
274 pub fn replay_log(&self, ref_name: &str) -> Result<RefLogReplay> {
276 let key = crate::layout::ref_name_key_bytes(ref_name);
277 container::replay_ref_subsequence(&self.layout, key)
278 }
279
280 pub fn list_ref_pointers(&self) -> Result<Vec<RefPointerSummary>> {
285 let replay = pointer_index::replay_pointer_index(&self.layout)?;
286 if replay.has_item_failure() {
287 return Err(PrikkError::Integrity(
288 "ref pointer index has a damaged entry; run doctor before listing".to_string(),
289 ));
290 }
291 let mut latest: std::collections::BTreeMap<[u8; 32], pointer_index::PointerIndexEntry> =
292 std::collections::BTreeMap::new();
293 for entry in replay.entries {
294 latest.insert(entry.ref_name_key, entry);
295 }
296 let mut summaries: Vec<RefPointerSummary> = latest
297 .into_values()
298 .map(|entry| RefPointerSummary {
299 ref_name: entry.ref_name,
300 ref_state_id: entry.ref_state_id,
301 })
302 .collect();
303 summaries.sort_by(|left, right| left.ref_name.cmp(&right.ref_name));
304 Ok(summaries)
305 }
306
307 pub fn recoverable_missing_ref(&self, ref_name: &str) -> Result<Option<RefRecoveryCandidate>> {
309 if self.read_current_ref_state_id(ref_name)?.is_some() {
310 return Ok(None);
311 }
312 let replay = self.replay_log(ref_name)?;
313 if replay.has_item_failure() {
317 return Err(PrikkError::Integrity(format!(
318 "ref log for {ref_name} has a damaged record"
319 )));
320 }
321 if replay.records.is_empty() {
322 return Ok(None);
323 }
324 if replay.trailing_partial_bytes != 0 {
325 return Err(PrikkError::Integrity(format!(
326 "ref log for {ref_name} has trailing partial bytes"
327 )));
328 }
329 let object_store = FileObjectStore::new(self.layout.clone());
330 let mut previous_ref_state_id = None;
331 let mut latest = None;
332 for record in &replay.records {
333 let update = RefUpdatePayload::decode_canonical(&record.envelope.canonical_payload)?;
334 if update.ref_name != ref_name {
335 return Err(PrikkError::Integrity(format!(
336 "ref-log record name mismatch: expected {ref_name}, got {}",
337 update.ref_name
338 )));
339 }
340 if update.old_ref_state_id != previous_ref_state_id {
341 return Err(PrikkError::Integrity(format!(
342 "ref-log chain mismatch for {ref_name} at update {}",
343 update.update_seq
344 )));
345 }
346 let ref_state = verified_ref_state_payload(
347 &object_store,
348 update.new_ref_state_id,
349 ref_name,
350 update.new_target_object_id,
351 )?;
352 if ref_state.previous_ref_state_id != update.old_ref_state_id {
353 return Err(PrikkError::Integrity(format!(
354 "RefState previous link disagrees with RefUpdate for {ref_name}"
355 )));
356 }
357 if ref_state.update_seq != update.update_seq {
358 return Err(PrikkError::Integrity(format!(
359 "RefState update sequence disagrees with RefUpdate for {ref_name}"
360 )));
361 }
362 previous_ref_state_id = Some(update.new_ref_state_id);
363 latest = Some(update);
364 }
365 let Some(update) = latest else {
366 return Ok(None);
367 };
368 Ok(Some(RefRecoveryCandidate {
369 ref_name: ref_name.to_string(),
370 ref_state_id: update.new_ref_state_id,
371 target_object_id: update.new_target_object_id,
372 update_seq: update.update_seq,
373 }))
374 }
375
376 fn ensure_current_matches(&self, ref_name: &str, expected: Option<ObjectId>) -> Result<()> {
377 let current = self.read_current_ref_state_id(ref_name)?;
378 if current != expected {
379 return Err(PrikkError::LockConflict(format!(
380 "ref CAS mismatch for {ref_name}: expected {:?}, got {:?}",
381 expected, current
382 )));
383 }
384 Ok(())
385 }
386}
387
388fn verified_ref_state_payload(
389 object_store: &FileObjectStore,
390 ref_state_id: ObjectId,
391 ref_name: &str,
392 target_object_id: ObjectId,
393) -> Result<RefStatePayload> {
394 let Some(envelope) = object_store.read_typed(ref_state_id, ObjectType::RefState)? else {
395 return Err(PrikkError::Integrity(format!(
396 "missing RefState object for ref recovery: {ref_state_id}"
397 )));
398 };
399 if envelope.signatures.is_empty() {
400 return Err(PrikkError::Integrity(format!(
401 "RefState {ref_state_id} is unsigned"
402 )));
403 }
404 let payload =
405 RefStatePayload::decode_canonical(&envelope.canonical_payload, envelope.schema_version)?;
406 if payload.ref_name != ref_name {
407 return Err(PrikkError::Integrity(format!(
408 "RefState {ref_state_id} name mismatch: expected {ref_name}, got {}",
409 payload.ref_name
410 )));
411 }
412 if payload.target_object_id != target_object_id {
413 return Err(PrikkError::Integrity(format!(
414 "RefState {ref_state_id} target disagrees with ref log for {ref_name}"
415 )));
416 }
417 let Some(target) = object_store.read_object(target_object_id)? else {
418 return Err(PrikkError::Integrity(format!(
419 "RefState {ref_state_id} targets missing block {target_object_id}"
420 )));
421 };
422 if target.object_type != ObjectType::Block {
423 return Err(PrikkError::Integrity(format!(
424 "RefState {ref_state_id} targets {}, expected block",
425 target.object_type
426 )));
427 }
428 Ok(payload)
429}
430
431pub(crate) fn validate_publication(publication: &RefPublication) -> Result<()> {
432 require_signed_type(&publication.ref_state, ObjectType::RefState)?;
433 require_signed_type(&publication.ref_update, ObjectType::RefUpdate)?;
434 publication.ref_state.validate_strict()?;
435 publication.ref_update.validate_strict()?;
436 Ok(())
437}
438
439pub(crate) fn require_signed_type(
440 envelope: &ObjectEnvelope,
441 object_type: ObjectType,
442) -> Result<()> {
443 if envelope.object_type != object_type {
444 return Err(PrikkError::ObjectTypeMismatch {
445 expected: object_type.to_string(),
446 actual: envelope.object_type.to_string(),
447 });
448 }
449 if envelope.signatures.is_empty() {
450 return Err(PrikkError::InvalidSignature(format!(
451 "{object_type} publication envelope must be signed"
452 )));
453 }
454 envelope.validate()
455}
456
457pub fn validate_local_branch_ref(ref_name: &str) -> Result<String> {
459 if ref_name.is_empty() {
460 return Err(PrikkError::InvalidName(
461 "ref name must not be empty".to_string(),
462 ));
463 }
464 if ref_name.starts_with("tags/")
465 || ref_name.starts_with("remotes/")
466 || ref_name.starts_with("rollback/")
467 {
468 return Err(PrikkError::InvalidName(format!(
469 "ref namespace is reserved: {ref_name}"
470 )));
471 }
472 if !ref_name.starts_with("heads/") {
473 return Err(PrikkError::InvalidName(format!(
474 "ref {ref_name} is not a local branch ref; expected heads/<name>"
475 )));
476 }
477 let branch = &ref_name["heads/".len()..];
478 if branch.is_empty() {
479 return Err(PrikkError::InvalidName(
480 "branch ref must include a name after heads/".to_string(),
481 ));
482 }
483 if ref_name.chars().any(|ch| ch == '\0' || ch.is_control()) {
484 return Err(PrikkError::InvalidName(format!(
485 "ref {ref_name} contains a forbidden control character"
486 )));
487 }
488 if branch.starts_with('/') || branch.ends_with('/') || branch.contains("//") {
489 return Err(PrikkError::InvalidName(format!(
490 "branch ref {ref_name} contains an empty path component"
491 )));
492 }
493 if branch
494 .split('/')
495 .any(|component| component == "." || component == "..")
496 {
497 return Err(PrikkError::InvalidName(format!(
498 "branch ref {ref_name} contains a traversal component"
499 )));
500 }
501 Ok(ref_name.to_string())
502}
503
504pub fn validate_local_tag_ref(ref_name: &str) -> Result<String> {
513 if ref_name.is_empty() {
514 return Err(PrikkError::InvalidName(
515 "ref name must not be empty".to_string(),
516 ));
517 }
518 if ref_name.starts_with("heads/")
519 || ref_name.starts_with("remotes/")
520 || ref_name.starts_with("rollback/")
521 {
522 return Err(PrikkError::InvalidName(format!(
523 "ref namespace is reserved: {ref_name}"
524 )));
525 }
526 if !ref_name.starts_with("tags/") {
527 return Err(PrikkError::InvalidName(format!(
528 "ref {ref_name} is not a local tag ref; expected tags/<name>"
529 )));
530 }
531 let tag = &ref_name["tags/".len()..];
532 if tag.is_empty() {
533 return Err(PrikkError::InvalidName(
534 "tag ref must include a name after tags/".to_string(),
535 ));
536 }
537 if ref_name.chars().any(|ch| ch == '\0' || ch.is_control()) {
538 return Err(PrikkError::InvalidName(format!(
539 "ref {ref_name} contains a forbidden control character"
540 )));
541 }
542 if tag.starts_with('/') || tag.ends_with('/') || tag.contains("//") {
543 return Err(PrikkError::InvalidName(format!(
544 "tag ref {ref_name} contains an empty path component"
545 )));
546 }
547 if tag
548 .split('/')
549 .any(|component| component == "." || component == "..")
550 {
551 return Err(PrikkError::InvalidName(format!(
552 "tag ref {ref_name} contains a traversal component"
553 )));
554 }
555 Ok(ref_name.to_string())
556}
557
558#[cfg(all(test, target_os = "linux"))]
562mod tests;