use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::error::{Error, ObjectKindHint, Result};
use crate::oid::{HashAlgo, ObjectId};
use crate::vfs::Vfs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reference {
Direct(ObjectId),
Symbolic(String),
}
impl Reference {
pub fn parse(algo: HashAlgo, contents: &[u8]) -> Result<Self> {
let text = core::str::from_utf8(contents)
.map_err(|_| Error::Reference("non-utf8 ref contents".to_string()))?
.trim();
if let Some(target) = text.strip_prefix("ref:") {
let target = target.trim();
if !is_valid_ref_name(target) {
return Err(Error::Reference(format!(
"symbolic ref target is not a valid name: {target:?}"
)));
}
Ok(Reference::Symbolic(target.to_string()))
} else {
Ok(Reference::Direct(ObjectId::from_hex(algo, text)?))
}
}
pub fn to_file_contents(&self) -> Vec<u8> {
let mut s = match self {
Reference::Direct(id) => id.to_hex(),
Reference::Symbolic(target) => format!("ref: {target}"),
};
s.push('\n');
s.into_bytes()
}
}
pub fn is_valid_ref_name(name: &str) -> bool {
if name.is_empty() || name == "@" {
return false;
}
if name.starts_with('/') || name.ends_with('/') || name.contains("//") {
return false;
}
if name.contains("..") || name.contains("@{") {
return false;
}
if name.ends_with('.') || name.ends_with(".lock") {
return false;
}
for component in name.split('/') {
if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
return false;
}
}
for &b in name.as_bytes() {
match b {
0..=0x20 | 0x7f => return false, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\' => return false,
_ => {}
}
}
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackedRef {
pub name: String,
pub id: ObjectId,
pub peeled: Option<ObjectId>,
}
fn serialize_packed_refs<'a>(entries: impl Iterator<Item = &'a PackedRef>) -> Vec<u8> {
let mut out = String::from("# pack-refs with: peeled fully-peeled sorted \n");
for e in entries {
out.push_str(&format!("{} {}\n", e.id.to_hex(), e.name));
if let Some(peeled) = e.peeled {
out.push_str(&format!("^{}\n", peeled.to_hex()));
}
}
out.into_bytes()
}
pub fn parse_packed_refs(algo: HashAlgo, contents: &[u8]) -> Result<Vec<PackedRef>> {
let text = core::str::from_utf8(contents)
.map_err(|_| Error::Reference("non-utf8 packed-refs".to_string()))?;
let mut out: Vec<PackedRef> = Vec::new();
let mut last_skipped = false;
for line in text.lines() {
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(peel) = line.strip_prefix('^') {
let id = ObjectId::from_hex(algo, peel.trim())?;
if last_skipped {
continue;
}
let last = out
.last_mut()
.ok_or_else(|| Error::Reference("packed-refs: peel line without ref".into()))?;
last.peeled = Some(id);
continue;
}
let (oid, name) = line
.split_once(' ')
.ok_or_else(|| Error::Reference("packed-refs: malformed line".into()))?;
let name = name.trim();
if !is_valid_ref_name(name) {
last_skipped = true;
continue;
}
last_skipped = false;
out.push(PackedRef {
name: name.to_string(),
id: ObjectId::from_hex(algo, oid.trim())?,
peeled: None,
});
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct RefStore<V: Vfs> {
vfs: V,
algo: HashAlgo,
}
const MAX_SYMREF_DEPTH: usize = 5;
impl<V: Vfs> RefStore<V> {
pub fn new(vfs: V, algo: HashAlgo) -> Self {
RefStore { vfs, algo }
}
pub fn lookup(&self, name: &str) -> Result<Reference> {
if self.vfs.exists(name) {
let contents = self.vfs.read(name)?;
return Reference::parse(self.algo, &contents);
}
for entry in self.packed()? {
if entry.name == name {
return Ok(Reference::Direct(entry.id));
}
}
Err(Error::NotFound(ObjectKindHint::Reference(name.to_string())))
}
pub fn resolve(&self, name: &str) -> Result<ObjectId> {
let mut current = name.to_string();
for _ in 0..MAX_SYMREF_DEPTH {
match self.lookup(¤t)? {
Reference::Direct(id) => return Ok(id),
Reference::Symbolic(target) => current = target,
}
}
Err(Error::Reference(format!(
"symref chain too deep starting at {name}"
)))
}
pub fn update(&self, name: &str, value: &Reference) -> Result<()> {
if !is_valid_ref_name(name) && name != "HEAD" {
return Err(Error::Reference(format!("invalid ref name {name:?}")));
}
let contents = value.to_file_contents();
let lock = format!("{name}.lock");
self.vfs.create_new(&lock, &contents).map_err(|_| {
Error::Reference(format!(
"cannot lock ref {name:?}: it is already locked (another update in progress?)"
))
})?;
match self.vfs.rename(&lock, name) {
Ok(()) => Ok(()),
Err(e) => {
let _ = self.vfs.remove_file(&lock);
Err(e)
}
}
}
pub fn delete(&self, name: &str) -> Result<()> {
if self.vfs.exists(name) {
self.vfs.remove_file(name)?;
}
if self.vfs.exists("packed-refs") {
let contents = self.vfs.read("packed-refs")?;
let entries = parse_packed_refs(self.algo, &contents)?;
if entries.iter().any(|e| e.name == name) {
let rewritten = serialize_packed_refs(entries.iter().filter(|e| e.name != name));
self.vfs.write("packed-refs", &rewritten)?;
}
}
Ok(())
}
pub fn packed(&self) -> Result<Vec<PackedRef>> {
if !self.vfs.exists("packed-refs") {
return Ok(Vec::new());
}
let contents = self.vfs.read("packed-refs")?;
parse_packed_refs(self.algo, &contents)
}
pub fn list(&self) -> Result<BTreeMap<String, ObjectId>> {
let mut map = BTreeMap::new();
for entry in self.packed()? {
map.insert(entry.name, entry.id);
}
self.collect_loose("refs", &mut map)?;
Ok(map)
}
fn collect_loose(&self, dir: &str, map: &mut BTreeMap<String, ObjectId>) -> Result<()> {
if !self.vfs.exists(dir) {
return Ok(());
}
for entry in self.vfs.read_dir(dir)? {
let child = format!("{dir}/{}", entry.name);
match entry.file_type {
crate::vfs::FileType::Dir => self.collect_loose(&child, map)?,
_ => {
if let Ok(id) = self.resolve(&child) {
map.insert(child, id);
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_names() {
assert!(is_valid_ref_name("refs/heads/main"));
assert!(is_valid_ref_name("refs/tags/v1.0.0"));
assert!(!is_valid_ref_name("refs/heads/"));
assert!(!is_valid_ref_name("refs//heads"));
assert!(!is_valid_ref_name("refs/heads/..hidden"));
assert!(!is_valid_ref_name("refs/heads/foo.lock"));
assert!(!is_valid_ref_name("refs/heads/a b"));
assert!(!is_valid_ref_name("@"));
}
#[test]
fn parse_symbolic_head() {
let r = Reference::parse(HashAlgo::Sha1, b"ref: refs/heads/main\n").unwrap();
assert_eq!(r, Reference::Symbolic("refs/heads/main".to_string()));
assert_eq!(r.to_file_contents(), b"ref: refs/heads/main\n");
}
#[test]
fn parse_direct() {
let hex = "ce013625030ba8dba906f756967f9e9ca394464a";
let r = Reference::parse(HashAlgo::Sha1, format!("{hex}\n").as_bytes()).unwrap();
match r {
Reference::Direct(id) => assert_eq!(id.to_hex(), hex),
_ => panic!("expected direct"),
}
}
#[test]
fn serialize_packed_refs_roundtrips_and_filters() {
let oid =
|b: u8| ObjectId::from_hex(HashAlgo::Sha1, &format!("{:02x}", b).repeat(20)).unwrap();
let entries = alloc::vec![
PackedRef {
name: "refs/heads/main".into(),
id: oid(0x11),
peeled: None,
},
PackedRef {
name: "refs/tags/v1".into(),
id: oid(0x22),
peeled: Some(oid(0x33)),
},
];
let back =
parse_packed_refs(HashAlgo::Sha1, &serialize_packed_refs(entries.iter())).unwrap();
assert_eq!(back, entries);
let filtered =
serialize_packed_refs(entries.iter().filter(|e| e.name != "refs/heads/main"));
let kept = parse_packed_refs(HashAlgo::Sha1, &filtered).unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "refs/tags/v1");
assert_eq!(kept[0].peeled, Some(oid(0x33)));
}
#[test]
fn packed_refs_skips_invalid_names() {
let good = "1111111111111111111111111111111111111111";
let bad = "2222222222222222222222222222222222222222";
let evil_peel = "3333333333333333333333333333333333333333";
let contents = format!(
"# pack-refs with: peeled\n\
{good} refs/heads/main\n\
{bad} refs/heads/../../etc/evil\n\
^{evil_peel}\n"
);
let parsed = parse_packed_refs(HashAlgo::Sha1, contents.as_bytes()).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "refs/heads/main");
assert!(parsed[0].peeled.is_none());
}
#[test]
fn packed_refs_with_peel() {
let hex_tag = "1111111111111111111111111111111111111111";
let hex_commit = "2222222222222222222222222222222222222222";
let contents = format!(
"# pack-refs with: peeled fully-peeled sorted\n{hex_tag} refs/tags/v1\n^{hex_commit}\n"
);
let parsed = parse_packed_refs(HashAlgo::Sha1, contents.as_bytes()).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "refs/tags/v1");
assert_eq!(parsed[0].peeled.as_ref().unwrap().to_hex(), hex_commit);
}
}