use claw_core::id::ObjectId;
use crate::layout::RepoLayout;
use crate::StoreError;
fn validate_ref_path(name: &str, allow_empty: bool) -> Result<(), StoreError> {
use std::path::{Component, Path};
if name.is_empty() {
if allow_empty {
return Ok(());
}
return Err(StoreError::InvalidRefName(name.to_string()));
}
if name.contains('\0') || name.contains('\\') {
return Err(StoreError::InvalidRefName(name.to_string()));
}
let path = Path::new(name);
if path.is_absolute() {
return Err(StoreError::InvalidRefName(name.to_string()));
}
let mut saw_component = false;
for component in path.components() {
match component {
Component::Normal(seg) => {
saw_component = true;
let seg_str = seg
.to_str()
.ok_or_else(|| StoreError::InvalidRefName(name.to_string()))?;
if seg_str.is_empty() || seg_str == "." || seg_str == ".." {
return Err(StoreError::InvalidRefName(name.to_string()));
}
if seg_str
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return Err(StoreError::InvalidRefName(name.to_string()));
}
}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return Err(StoreError::InvalidRefName(name.to_string()));
}
}
}
if !saw_component && !allow_empty {
return Err(StoreError::InvalidRefName(name.to_string()));
}
if name.contains("//") {
return Err(StoreError::InvalidRefName(name.to_string()));
}
Ok(())
}
pub fn validate_ref_name(name: &str) -> Result<(), StoreError> {
validate_ref_path(name, false)
}
pub fn write_ref(layout: &RepoLayout, name: &str, target: &ObjectId) -> Result<(), StoreError> {
validate_ref_name(name)?;
let path = layout.refs_dir().join(name);
let parent = path
.parent()
.ok_or_else(|| StoreError::InvalidRefName(name.to_string()))?;
std::fs::create_dir_all(parent)?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
{
use std::io::Write;
let file = temp.as_file_mut();
file.write_all(target.to_hex().as_bytes())?;
file.write_all(b"\n")?;
file.sync_all()?;
}
temp.persist(&path).map_err(|e| StoreError::Io(e.error))?;
if let Ok(dir_handle) = std::fs::File::open(parent) {
dir_handle.sync_all()?;
}
Ok(())
}
pub fn read_ref(layout: &RepoLayout, name: &str) -> Result<Option<ObjectId>, StoreError> {
validate_ref_name(name)?;
let path = layout.refs_dir().join(name);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)?;
let id = ObjectId::from_hex(content.trim())?;
Ok(Some(id))
}
pub fn delete_ref(layout: &RepoLayout, name: &str) -> Result<(), StoreError> {
validate_ref_name(name)?;
let path = layout.refs_dir().join(name);
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn update_ref_cas(
layout: &RepoLayout,
name: &str,
expected_old: Option<&ObjectId>,
new_target: &ObjectId,
author: &str,
message: &str,
) -> Result<(), StoreError> {
use crate::lockfile::LockFile;
use crate::reflog;
validate_ref_name(name)?;
let ref_path = layout.refs_dir().join(name);
let _lock = LockFile::acquire(&ref_path)?;
let current = read_ref(layout, name)?;
match (expected_old, ¤t) {
(None, None) => {}
(Some(expected), Some(actual)) if expected == actual => {}
(expected, actual) => {
return Err(StoreError::RefCasConflict {
expected: expected
.map(|id| id.to_hex())
.unwrap_or_else(|| "none".to_string()),
actual: actual
.as_ref()
.map(|id| id.to_hex())
.unwrap_or_else(|| "none".to_string()),
});
}
}
write_ref(layout, name, new_target)?;
reflog::append_reflog(layout, name, current.as_ref(), new_target, author, message)?;
Ok(())
}
pub fn list_refs(layout: &RepoLayout, prefix: &str) -> Result<Vec<(String, ObjectId)>, StoreError> {
validate_ref_path(prefix, true)?;
let base = layout.refs_dir().join(prefix);
if !base.exists() {
return Ok(Vec::new());
}
let mut results = Vec::new();
collect_refs(&base, &layout.refs_dir(), &mut results)?;
Ok(results)
}
fn collect_refs(
dir: &std::path::Path,
refs_root: &std::path::Path,
results: &mut Vec<(String, ObjectId)>,
) -> Result<(), StoreError> {
if !dir.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_refs(&path, refs_root, results)?;
} else if path.is_file() {
let content = std::fs::read_to_string(&path)?;
if let Ok(id) = ObjectId::from_hex(content.trim()) {
let rel = path
.strip_prefix(refs_root)
.map_err(|_| StoreError::RefPathEscapesRoot(path.clone()))?
.to_string_lossy()
.to_string();
results.push((rel, id));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use claw_core::hash::content_hash;
use claw_core::object::TypeTag;
#[test]
fn ref_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let layout = RepoLayout::new(tmp.path());
layout.create_dirs().unwrap();
let id = content_hash(TypeTag::Blob, b"test");
write_ref(&layout, "heads/main", &id).unwrap();
let read_back = read_ref(&layout, "heads/main").unwrap();
assert_eq!(read_back, Some(id));
}
#[test]
fn list_refs_finds_all() {
let tmp = tempfile::tempdir().unwrap();
let layout = RepoLayout::new(tmp.path());
layout.create_dirs().unwrap();
let id1 = content_hash(TypeTag::Blob, b"a");
let id2 = content_hash(TypeTag::Blob, b"b");
write_ref(&layout, "heads/main", &id1).unwrap();
write_ref(&layout, "heads/dev", &id2).unwrap();
let refs = list_refs(&layout, "heads").unwrap();
assert_eq!(refs.len(), 2);
}
#[test]
fn rejects_traversal_ref_names() {
let tmp = tempfile::tempdir().unwrap();
let layout = RepoLayout::new(tmp.path());
layout.create_dirs().unwrap();
let id = content_hash(TypeTag::Blob, b"x");
let err = write_ref(&layout, "../outside", &id).unwrap_err();
assert!(matches!(err, StoreError::InvalidRefName(_)));
let err = write_ref(&layout, "heads/../main", &id).unwrap_err();
assert!(matches!(err, StoreError::InvalidRefName(_)));
let err = list_refs(&layout, "../../").unwrap_err();
assert!(matches!(err, StoreError::InvalidRefName(_)));
}
#[test]
fn allows_common_ref_shapes() {
validate_ref_name("heads/main").unwrap();
validate_ref_name("changes/01J00000000000000000000000").unwrap();
validate_ref_name("capsules/by-revision/abcdef0123456789").unwrap();
validate_ref_path("", true).unwrap();
}
#[test]
fn update_ref_cas_creates_missing_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let layout = RepoLayout::new(tmp.path());
layout.create_dirs().unwrap();
let id = content_hash(TypeTag::Blob, b"policy");
update_ref_cas(
&layout,
"policies/release-gate",
None,
&id,
"policy",
"policy create/update",
)
.unwrap();
let read_back = read_ref(&layout, "policies/release-gate").unwrap();
assert_eq!(read_back, Some(id));
}
#[test]
fn stale_ref_lock_blocks_cas_without_changing_existing_ref() {
let tmp = tempfile::tempdir().unwrap();
let layout = RepoLayout::new(tmp.path());
layout.create_dirs().unwrap();
let old = content_hash(TypeTag::Blob, b"old");
let new = content_hash(TypeTag::Blob, b"new");
write_ref(&layout, "heads/main", &old).unwrap();
let lock_path = layout.refs_dir().join("heads/main.lock");
std::fs::write(&lock_path, b"interrupted update").unwrap();
let err = update_ref_cas(
&layout,
"heads/main",
Some(&old),
&new,
"test",
"simulate stale lock",
)
.expect_err("stale lock should block CAS");
assert!(matches!(err, StoreError::LockContention(_)));
assert_eq!(read_ref(&layout, "heads/main").unwrap(), Some(old));
}
}