use std::collections::BTreeMap;
use time::OffsetDateTime;
use tracing::warn;
use super::ManageError;
use crate::object_store::{ObjectMeta, ObjectStore};
#[derive(Debug, Clone)]
pub(crate) struct BundleEntry {
pub(crate) sha: String,
pub(crate) key: String,
pub(crate) last_modified: OffsetDateTime,
}
#[derive(Debug, Clone)]
pub(crate) struct MalformedBundleKey {
pub(crate) ref_path: String,
pub(crate) key: String,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RefSnapshot {
pub(crate) is_protected: bool,
pub(crate) bundles: Vec<BundleEntry>,
pub(crate) has_chain: bool,
}
impl RefSnapshot {
#[must_use]
pub(crate) fn has_branch_data(&self) -> bool {
!self.bundles.is_empty() || self.has_chain
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RepoSnapshot {
pub(crate) head: Option<String>,
pub(crate) refs: BTreeMap<String, RefSnapshot>,
pub(crate) malformed_bundle_keys: Vec<MalformedBundleKey>,
}
impl RepoSnapshot {
#[must_use]
pub(crate) fn is_head_valid(&self) -> bool {
self.head
.as_ref()
.and_then(|h| self.refs.get(h))
.is_some_and(RefSnapshot::has_branch_data)
}
}
#[cfg(test)]
pub(crate) async fn analyze(
store: &dyn ObjectStore,
prefix: &str,
) -> Result<RepoSnapshot, ManageError> {
let list_prefix = crate::keys::join(Some(prefix), "");
let objects = store.list(&list_prefix).await?;
analyze_objects(&objects, &list_prefix, store).await
}
pub(crate) async fn analyze_objects(
objects: &[ObjectMeta],
list_prefix: &str,
store: &dyn ObjectStore,
) -> Result<RepoSnapshot, ManageError> {
let prefix_opt = list_prefix.strip_suffix('/').filter(|s| !s.is_empty());
let hidden = crate::packchain::gc::tombstoned_bundle_keys(store, prefix_opt)
.await
.map_err(ManageError::Store)?;
let mut snapshot = RepoSnapshot::default();
for object in objects {
if hidden.contains(&object.key) {
continue;
}
classify_into(list_prefix, object, &mut snapshot, store).await?;
}
Ok(snapshot)
}
async fn classify_into(
list_prefix: &str,
object: &ObjectMeta,
snapshot: &mut RepoSnapshot,
store: &dyn ObjectStore,
) -> Result<(), ManageError> {
let Some(relative) = object.key.strip_prefix(list_prefix) else {
warn!(
key = %object.key,
list_prefix = %list_prefix,
"list returned key outside requested prefix; skipping"
);
return Ok(());
};
if relative == "HEAD" {
let body = store.get_bytes(&object.key).await?;
snapshot.head = std::str::from_utf8(&body)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty());
return Ok(());
}
if is_bookkeeping_dir(relative) {
return Ok(());
}
let Some((ref_path, last)) = relative.rsplit_once('/') else {
return Ok(());
};
if super::is_lock_key(last) || last == "repo.zip" {
return Ok(());
}
if crate::keys::is_protected_marker_segment(last) {
snapshot
.refs
.entry(ref_path.to_owned())
.or_default()
.is_protected = true;
} else if let Some(stem) = last.strip_suffix(".bundle") {
if crate::keys::is_valid_bundle_stem(stem) {
snapshot
.refs
.entry(ref_path.to_owned())
.or_default()
.bundles
.push(BundleEntry {
sha: stem.to_owned(),
key: object.key.clone(),
last_modified: object.last_modified,
});
} else {
snapshot.malformed_bundle_keys.push(MalformedBundleKey {
ref_path: ref_path.to_owned(),
key: object.key.clone(),
});
}
} else if last == "chain.json" {
snapshot
.refs
.entry(ref_path.to_owned())
.or_default()
.has_chain = true;
}
Ok(())
}
fn is_bookkeeping_dir(relative: &str) -> bool {
const BOOKKEEPING_PREFIXES: &[&str] = &["gc/", "lfs/", "packs/"];
BOOKKEEPING_PREFIXES.iter().any(|p| relative.starts_with(p))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::object_store::ObjectStore;
use crate::object_store::mock::MockStore;
use bytes::Bytes;
fn store() -> Arc<dyn ObjectStore> {
Arc::new(MockStore::new())
}
const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
#[tokio::test]
async fn empty_listing_yields_empty_snapshot() {
let s = store();
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.head.is_none());
assert!(snap.refs.is_empty());
assert!(!snap.is_head_valid());
}
#[tokio::test]
async fn single_ref_one_bundle() {
let mock = MockStore::new();
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("body"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let main = snap.refs.get("refs/heads/main").expect("main present");
assert_eq!(main.bundles.len(), 1);
assert_eq!(main.bundles[0].sha, SHA_A);
assert_eq!(
main.bundles[0].key,
format!("myrepo/refs/heads/main/{SHA_A}.bundle")
);
assert!(!main.is_protected);
}
#[tokio::test]
async fn protected_marker_exact_match() {
let mock = MockStore::new();
mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.refs["refs/heads/main"].is_protected);
assert!(snap.refs["refs/heads/main"].bundles.is_empty());
}
#[tokio::test]
async fn protected_marker_requires_exact_match() {
let mock = MockStore::new();
mock.insert("myrepo/refs/heads/main/PROTECTED#audit", Bytes::new());
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
if let Some(entry) = snap.refs.get("refs/heads/main") {
assert!(
!entry.is_protected,
"PROTECTED#-prefixed segment must not be classified as the marker",
);
}
}
#[tokio::test]
async fn head_object_is_decoded_and_trimmed() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main\n"));
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("body"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert_eq!(snap.head.as_deref(), Some("refs/heads/main"));
assert!(snap.is_head_valid());
}
#[tokio::test]
async fn head_object_invalid_utf8_yields_none() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from(vec![0xff, 0xfe]));
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.head.is_none());
assert!(!snap.is_head_valid());
}
#[tokio::test]
async fn head_pointing_at_unknown_ref_is_invalid() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from("refs/heads/missing"));
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("body"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert_eq!(snap.head.as_deref(), Some("refs/heads/missing"));
assert!(!snap.is_head_valid());
}
#[tokio::test]
async fn head_pointing_at_protected_only_ref_is_invalid() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let main = snap.refs.get("refs/heads/main").expect("ref present");
assert!(main.is_protected);
assert!(main.bundles.is_empty());
assert!(!main.has_chain);
assert!(
!snap.is_head_valid(),
"HEAD pointing at a PROTECTED#-only ref must be invalid (#154)",
);
}
#[tokio::test]
async fn head_pointing_at_protected_ref_with_bundle_is_valid() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("body"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.refs["refs/heads/main"].is_protected);
assert!(snap.is_head_valid());
}
#[tokio::test]
async fn head_pointing_at_chain_only_ref_is_valid() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
mock.insert(
"myrepo/refs/heads/main/chain.json",
Bytes::from(r#"{"v":1}"#),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.is_head_valid());
}
#[tokio::test]
async fn multiple_bundles_under_one_ref() {
let mock = MockStore::new();
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("a"),
);
mock.insert(
format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
Bytes::from("b"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let shas: std::collections::BTreeSet<&str> = snap.refs["refs/heads/main"]
.bundles
.iter()
.map(|b| b.sha.as_str())
.collect();
assert_eq!(shas, [SHA_A, SHA_B].into_iter().collect());
}
#[tokio::test]
async fn lock_files_are_skipped_in_ref_grouping() {
let mock = MockStore::new();
mock.insert("myrepo/refs/heads/main/LOCK#.lock", Bytes::new());
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("b"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert_eq!(snap.refs["refs/heads/main"].bundles.len(), 1);
assert!(!snap.refs["refs/heads/main"].is_protected);
}
#[tokio::test]
async fn repo_zip_is_skipped_in_ref_grouping() {
let mock = MockStore::new();
mock.insert("myrepo/refs/heads/main/repo.zip", Bytes::from("zip"));
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("b"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert_eq!(snap.refs["refs/heads/main"].bundles.len(), 1);
}
#[tokio::test]
async fn nested_ref_path_is_preserved() {
let mock = MockStore::new();
mock.insert(
format!("myrepo/refs/heads/feature/x/{SHA_A}.bundle"),
Bytes::from("a"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let entry = snap
.refs
.get("refs/heads/feature/x")
.expect("nested ref recorded");
assert_eq!(entry.bundles.len(), 1);
assert_eq!(entry.bundles[0].sha, SHA_A);
assert_eq!(
entry.bundles[0].key,
format!("myrepo/refs/heads/feature/x/{SHA_A}.bundle")
);
}
#[tokio::test]
async fn root_prefix_lists_bucket_without_leading_slash() {
let mock = MockStore::new();
mock.insert("HEAD", Bytes::from("refs/heads/main"));
mock.insert(
format!("refs/heads/main/{SHA_A}.bundle"),
Bytes::from("body"),
);
mock.insert("refs/heads/main/PROTECTED#", Bytes::new());
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "").await.expect("analyze at root");
assert_eq!(snap.head.as_deref(), Some("refs/heads/main"));
let main = snap.refs.get("refs/heads/main").expect("main present");
assert_eq!(main.bundles.len(), 1);
assert_eq!(main.bundles[0].sha, SHA_A);
assert_eq!(
main.bundles[0].key,
format!("refs/heads/main/{SHA_A}.bundle")
);
assert!(main.is_protected);
}
#[tokio::test]
async fn empty_head_body_treated_as_missing() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from(""));
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.head.is_none());
}
#[tokio::test]
async fn packchain_bookkeeping_dirs_excluded_from_refs() {
let mock = MockStore::new();
mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
mock.insert(
"myrepo/packs/1111111111111111111111111111111111111111.pack",
Bytes::from("pack-body"),
);
mock.insert(
"myrepo/gc/tombstones-abc-1-2025-01-01T00:00:00Z.json",
Bytes::from("{}"),
);
mock.insert("myrepo/lfs/abcdef0123456789", Bytes::from("lfs-body"));
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("b"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert_eq!(
snap.refs.keys().collect::<Vec<_>>(),
vec!["refs/heads/main"],
);
assert!(!snap.refs.contains_key("packs"));
assert!(!snap.refs.contains_key("gc"));
assert!(!snap.refs.contains_key("lfs"));
}
#[tokio::test]
async fn chain_json_sets_has_chain_flag() {
let mock = MockStore::new();
mock.insert(
"myrepo/refs/heads/main/chain.json",
Bytes::from(r#"{"v":1}"#),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let main = snap.refs.get("refs/heads/main").expect("main present");
assert!(main.has_chain, "chain.json must set has_chain");
assert!(main.bundles.is_empty());
}
#[tokio::test]
async fn chain_json_coexists_with_bundle() {
let mock = MockStore::new();
mock.insert(
"myrepo/refs/heads/main/chain.json",
Bytes::from(r#"{"v":1}"#),
);
mock.insert(
format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
Bytes::from("body"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let main = snap.refs.get("refs/heads/main").expect("main present");
assert!(main.has_chain);
assert_eq!(main.bundles.len(), 1);
assert_eq!(main.bundles[0].sha, SHA_A);
}
#[tokio::test]
async fn malformed_bundle_stem_recorded_separately() {
let mock = MockStore::new();
mock.insert(
"myrepo/refs/heads/main/0123456789abcdef0123456789abcdef01234567.bundle",
Bytes::from("good"),
);
mock.insert(
"myrepo/refs/heads/main/not-a-valid-sha.bundle",
Bytes::from("junk"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
let main = snap.refs.get("refs/heads/main").expect("ref recorded");
assert_eq!(main.bundles.len(), 1);
assert_eq!(
main.bundles[0].sha,
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(snap.malformed_bundle_keys.len(), 1);
assert_eq!(snap.malformed_bundle_keys[0].ref_path, "refs/heads/main");
assert_eq!(
snap.malformed_bundle_keys[0].key,
"myrepo/refs/heads/main/not-a-valid-sha.bundle"
);
}
#[tokio::test]
async fn malformed_only_bundle_does_not_create_phantom_ref_entry() {
let mock = MockStore::new();
mock.insert(
"myrepo/refs/heads/junk/not-a-valid-sha.bundle",
Bytes::from("junk"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(
!snap.refs.contains_key("refs/heads/junk"),
"malformed-only refs must not appear in snapshot.refs",
);
assert_eq!(snap.malformed_bundle_keys.len(), 1);
}
#[tokio::test]
async fn well_formed_stems_are_never_flagged() {
let mock = MockStore::new();
mock.insert(
"myrepo/refs/heads/main/0123456789abcdef0123456789abcdef01234567.bundle",
Bytes::from("body"),
);
let s: Arc<dyn ObjectStore> = Arc::new(mock);
let snap = analyze(&s, "myrepo").await.expect("analyze");
assert!(snap.malformed_bundle_keys.is_empty());
}
#[test]
fn is_bookkeeping_dir_matches_known_prefixes() {
assert!(super::is_bookkeeping_dir("packs/something.pack"));
assert!(super::is_bookkeeping_dir("gc/tombstones.json"));
assert!(super::is_bookkeeping_dir("lfs/abcdef"));
assert!(!super::is_bookkeeping_dir("refs/heads/main/abc.bundle"));
assert!(!super::is_bookkeeping_dir("HEAD"));
assert!(!super::is_bookkeeping_dir("packs"));
}
}