mod log;
mod pointer;
mod verify;
use prikk_error::{PrikkError, Result};
use prikk_object::{ObjectEnvelope, ObjectId, ObjectType, RefStatePayload, RefUpdatePayload};
use crate::fsutil::sync_directory_best_effort;
use crate::layout::RepositoryLayout;
use crate::lock::RefLock;
use crate::object_store::{FileObjectStore, ObjectReader, ObjectWriter};
pub(crate) use log::decode_log_file_bytes;
pub use log::{RefLogRecord, RefLogReplay};
pub(crate) use verify::verify_refs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefRecoveryCandidate {
pub ref_name: String,
pub ref_state_id: ObjectId,
pub target_object_id: ObjectId,
pub update_seq: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefRecoveryRepair {
pub ref_name: String,
pub ref_state_id: ObjectId,
pub wrote_pointer: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefPublication {
pub ref_name: String,
pub expected_previous_ref_state_id: Option<ObjectId>,
pub ref_state: ObjectEnvelope,
pub ref_update: ObjectEnvelope,
}
#[derive(Debug, Clone)]
pub struct RefStore {
layout: RepositoryLayout,
}
impl RefStore {
#[must_use]
pub fn new(layout: RepositoryLayout) -> Self {
Self { layout }
}
#[must_use]
pub fn layout(&self) -> &RepositoryLayout {
&self.layout
}
pub fn publish(&self, publication: &RefPublication) -> Result<ObjectId> {
validate_publication(publication)?;
let ref_state_id = publication.ref_state.object_id();
let ref_lock = RefLock::acquire(self.layout.ref_lock_path(&publication.ref_name))?;
if publication.expected_previous_ref_state_id.is_none() {
self.ensure_unborn_ref_creation_allowed(&publication.ref_name)?;
}
let mut object_store = FileObjectStore::new(self.layout.clone());
object_store.write_object(&publication.ref_state)?;
self.ensure_current_matches(
&publication.ref_name,
publication.expected_previous_ref_state_id,
)?;
log::append_log_record(&self.layout, &publication.ref_name, &publication.ref_update)?;
self.ensure_current_matches(
&publication.ref_name,
publication.expected_previous_ref_state_id,
)?;
self.write_ref_pointer_candidate(&publication.ref_name, ref_state_id)?;
self.ensure_current_matches(
&publication.ref_name,
publication.expected_previous_ref_state_id,
)?;
self.promote_ref_pointer_candidate(&publication.ref_name)?;
drop(ref_lock);
Ok(ref_state_id)
}
pub fn read_current_ref_state_id(&self, ref_name: &str) -> Result<Option<ObjectId>> {
let path = self.layout.ref_pointer_path(ref_name);
if !path.exists() {
return Ok(None);
}
let pointer = pointer::read_ref_pointer(&path)?;
if pointer.ref_name != ref_name {
return Err(PrikkError::Integrity(format!(
"ref pointer name mismatch: expected {ref_name}, got {}",
pointer.ref_name
)));
}
Ok(Some(pointer.ref_state_id))
}
pub fn replay_log(&self, ref_name: &str) -> Result<RefLogReplay> {
log::replay_log(&self.layout, ref_name)
}
pub fn recoverable_missing_ref(&self, ref_name: &str) -> Result<Option<RefRecoveryCandidate>> {
if self.read_current_ref_state_id(ref_name)?.is_some() {
return Ok(None);
}
let replay = self.replay_log(ref_name)?;
if replay.records.is_empty() {
return Ok(None);
}
if replay.trailing_partial_bytes != 0 {
return Err(PrikkError::Integrity(format!(
"ref log for {ref_name} has trailing partial bytes"
)));
}
let object_store = FileObjectStore::new(self.layout.clone());
let mut previous_ref_state_id = None;
let mut latest = None;
for record in &replay.records {
let update = RefUpdatePayload::decode_canonical(&record.envelope.canonical_payload)?;
if update.ref_name != ref_name {
return Err(PrikkError::Integrity(format!(
"ref-log record name mismatch: expected {ref_name}, got {}",
update.ref_name
)));
}
if update.old_ref_state_id != previous_ref_state_id {
return Err(PrikkError::Integrity(format!(
"ref-log chain mismatch for {ref_name} at update {}",
update.update_seq
)));
}
let ref_state = verified_ref_state_payload(
&object_store,
update.new_ref_state_id,
ref_name,
update.new_target_object_id,
)?;
if ref_state.previous_ref_state_id != update.old_ref_state_id {
return Err(PrikkError::Integrity(format!(
"RefState previous link disagrees with RefUpdate for {ref_name}"
)));
}
if ref_state.update_seq != update.update_seq {
return Err(PrikkError::Integrity(format!(
"RefState update sequence disagrees with RefUpdate for {ref_name}"
)));
}
previous_ref_state_id = Some(update.new_ref_state_id);
latest = Some(update);
}
let Some(update) = latest else {
return Ok(None);
};
Ok(Some(RefRecoveryCandidate {
ref_name: ref_name.to_string(),
ref_state_id: update.new_ref_state_id,
target_object_id: update.new_target_object_id,
update_seq: update.update_seq,
}))
}
pub fn reconstruct_missing_ref_from_log(&self, ref_name: &str) -> Result<RefRecoveryRepair> {
let ref_lock = RefLock::acquire(self.layout.ref_lock_path(ref_name))?;
if let Some(current) = self.read_current_ref_state_id(ref_name)? {
drop(ref_lock);
return Ok(RefRecoveryRepair {
ref_name: ref_name.to_string(),
ref_state_id: current,
wrote_pointer: false,
});
}
let candidate = self.recoverable_missing_ref(ref_name)?.ok_or_else(|| {
PrikkError::Integrity(format!(
"ref {ref_name} has no recoverable committed ref-log record"
))
})?;
self.write_ref_pointer_candidate(ref_name, candidate.ref_state_id)?;
self.ensure_current_matches(ref_name, None)?;
self.promote_ref_pointer_candidate(ref_name)?;
drop(ref_lock);
Ok(RefRecoveryRepair {
ref_name: ref_name.to_string(),
ref_state_id: candidate.ref_state_id,
wrote_pointer: true,
})
}
fn ensure_current_matches(&self, ref_name: &str, expected: Option<ObjectId>) -> Result<()> {
let current = self.read_current_ref_state_id(ref_name)?;
if current != expected {
return Err(PrikkError::LockConflict(format!(
"ref CAS mismatch for {ref_name}: expected {:?}, got {:?}",
expected, current
)));
}
Ok(())
}
fn ensure_unborn_ref_creation_allowed(&self, ref_name: &str) -> Result<()> {
self.ensure_current_matches(ref_name, None)?;
let log = self.replay_log(ref_name)?;
if log.trailing_partial_bytes != 0 {
return Err(PrikkError::Integrity(format!(
"ref {ref_name} pointer is missing and its log has trailing partial bytes; \
run doctor before creating a root publication"
)));
}
if !log.records.is_empty() {
return Err(PrikkError::Integrity(format!(
"ref {ref_name} pointer is missing but ref-log history exists; \
run doctor before creating a root publication"
)));
}
Ok(())
}
fn write_ref_pointer_candidate(&self, ref_name: &str, ref_state_id: ObjectId) -> Result<()> {
pointer::write_ref_pointer_candidate(&self.layout, ref_name, ref_state_id)
}
fn promote_ref_pointer_candidate(&self, ref_name: &str) -> Result<()> {
let candidate = self.layout.ref_tmp_path(ref_name);
let pointer = self.layout.ref_pointer_path(ref_name);
let Some(parent) = pointer.parent() else {
return Err(PrikkError::Io(
"ref pointer path has no parent directory".to_string(),
));
};
std::fs::create_dir_all(parent)?;
std::fs::rename(candidate, &pointer)?;
sync_directory_best_effort(parent)?;
Ok(())
}
}
fn verified_ref_state_payload(
object_store: &FileObjectStore,
ref_state_id: ObjectId,
ref_name: &str,
target_object_id: ObjectId,
) -> Result<RefStatePayload> {
let Some(envelope) = object_store.read_typed(ref_state_id, ObjectType::RefState)? else {
return Err(PrikkError::Integrity(format!(
"missing RefState object for ref recovery: {ref_state_id}"
)));
};
if envelope.signatures.is_empty() {
return Err(PrikkError::Integrity(format!(
"RefState {ref_state_id} is unsigned"
)));
}
let payload = RefStatePayload::decode_canonical(&envelope.canonical_payload)?;
if payload.ref_name != ref_name {
return Err(PrikkError::Integrity(format!(
"RefState {ref_state_id} name mismatch: expected {ref_name}, got {}",
payload.ref_name
)));
}
if payload.target_object_id != target_object_id {
return Err(PrikkError::Integrity(format!(
"RefState {ref_state_id} target disagrees with ref log for {ref_name}"
)));
}
let Some(target) = object_store.read_object(target_object_id)? else {
return Err(PrikkError::Integrity(format!(
"RefState {ref_state_id} targets missing block {target_object_id}"
)));
};
if target.object_type != ObjectType::Block {
return Err(PrikkError::Integrity(format!(
"RefState {ref_state_id} targets {}, expected block",
target.object_type
)));
}
Ok(payload)
}
pub(crate) fn validate_publication(publication: &RefPublication) -> Result<()> {
if publication.ref_name.is_empty() {
return Err(PrikkError::InvalidName(
"ref name must not be empty".to_string(),
));
}
require_signed_type(&publication.ref_state, ObjectType::RefState)?;
require_signed_type(&publication.ref_update, ObjectType::RefUpdate)?;
Ok(())
}
pub(crate) fn require_signed_type(
envelope: &ObjectEnvelope,
object_type: ObjectType,
) -> Result<()> {
if envelope.object_type != object_type {
return Err(PrikkError::ObjectTypeMismatch {
expected: object_type.to_string(),
actual: envelope.object_type.to_string(),
});
}
if envelope.signatures.is_empty() {
return Err(PrikkError::InvalidSignature(format!(
"{object_type} publication envelope must be signed"
)));
}
envelope.validate()
}
pub fn validate_local_branch_ref(ref_name: &str) -> Result<String> {
if ref_name.is_empty() {
return Err(PrikkError::InvalidName(
"ref name must not be empty".to_string(),
));
}
if ref_name.starts_with("tags/")
|| ref_name.starts_with("remotes/")
|| ref_name.starts_with("rollback/")
{
return Err(PrikkError::InvalidName(format!(
"ref namespace is reserved: {ref_name}"
)));
}
if !ref_name.starts_with("heads/") {
return Err(PrikkError::InvalidName(format!(
"ref {ref_name} is not a local branch ref; expected heads/<name>"
)));
}
let branch = &ref_name["heads/".len()..];
if branch.is_empty() {
return Err(PrikkError::InvalidName(
"branch ref must include a name after heads/".to_string(),
));
}
if ref_name.chars().any(|ch| ch == '\0' || ch.is_control()) {
return Err(PrikkError::InvalidName(format!(
"ref {ref_name} contains a forbidden control character"
)));
}
if branch.starts_with('/') || branch.ends_with('/') || branch.contains("//") {
return Err(PrikkError::InvalidName(format!(
"branch ref {ref_name} contains an empty path component"
)));
}
if branch
.split('/')
.any(|component| component == "." || component == "..")
{
return Err(PrikkError::InvalidName(format!(
"branch ref {ref_name} contains a traversal component"
)));
}
Ok(ref_name.to_string())
}
#[cfg(test)]
mod tests;