This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-git / src / repo.rs
58 kB 1747 lines
1use std::collections::{BTreeSet, HashMap, HashSet}; 2use std::path::{Path, PathBuf}; 3use std::sync::atomic::{AtomicU64, Ordering}; 4use std::sync::{Arc, Mutex, OnceLock}; 5 6use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; 7use gix::refs::{FullName, Target}; 8use knot_cache::{Cache, Moka, Weight}; 9use knot_types::{ 10 BranchName, KnotId, ObjectFormat, Oid, OriginUrl, RefName, RefTransition, RepoDid, UnixSeconds, 11}; 12 13use crate::error::GitError; 14use crate::objects::{Haves, PackBudget, Walked, Wants}; 15 16const RESERVED_PREFIX: &str = "refs/cobs/"; 17const CHECKPOINT_PREFIX: &str = "refs/cob-checkpoints/"; 18const HIDDEN_PREFIX: &str = "refs/hidden/"; 19const REFLOG_COMMITTER_NAME: &str = "knot"; 20const REFLOG_COMMITTER_EMAIL: &str = "noreply@knot"; 21const HEADS_PREFIX: &str = "refs/heads/"; 22const TAGS_PREFIX: &str = "refs/tags/"; 23const MAX_SYMREF_DEPTH: usize = 5; 24const ADVERT_BYTES_PER_REF: u64 = 128; 25 26fn tuned(mut git: gix::Repository) -> gix::Repository { 27 git.object_cache_size_if_unset(knot_resource::object_cache_bytes()); 28 pin_reflog_identity(&mut git); 29 git 30} 31 32fn assembled(git: gix::Repository, path: PathBuf) -> Repo { 33 Repo { 34 git: tuned(git), 35 path, 36 commit_graph: OnceLock::new(), 37 } 38} 39 40fn pin_reflog_identity(git: &mut gix::Repository) { 41 use gix::config::tree::{Committer, Core}; 42 let mut config = git.config_snapshot_mut(); 43 let pinned = config.set_value(&Core::LOG_ALL_REF_UPDATES, "true").is_ok() 44 && config 45 .set_value(&Committer::NAME, REFLOG_COMMITTER_NAME) 46 .is_ok() 47 && config 48 .set_value(&Committer::EMAIL, REFLOG_COMMITTER_EMAIL) 49 .is_ok(); 50 if pinned { 51 let _ = config.commit(); 52 } 53} 54 55knot_types::scalar_newtype! { 56 struct RefEpoch(u64); 57 struct RefGeneration(u64); 58} 59 60struct RefState { 61 lock: Mutex<()>, 62 generation: AtomicU64, 63 epoch: RefEpoch, 64} 65 66type RefRegistry = Mutex<HashMap<PathBuf, Arc<RefState>>>; 67 68fn ref_registry() -> &'static RefRegistry { 69 static REGISTRY: OnceLock<RefRegistry> = OnceLock::new(); 70 REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) 71} 72 73fn next_epoch() -> RefEpoch { 74 static EPOCH: AtomicU64 = AtomicU64::new(0); 75 RefEpoch::new(EPOCH.fetch_add(1, Ordering::Relaxed)) 76} 77 78fn ref_state(git_dir: &Path) -> Arc<RefState> { 79 let mut states = ref_registry() 80 .lock() 81 .unwrap_or_else(|poisoned| poisoned.into_inner()); 82 Arc::clone(states.entry(git_dir.to_path_buf()).or_insert_with(|| { 83 Arc::new(RefState { 84 lock: Mutex::new(()), 85 generation: AtomicU64::new(0), 86 epoch: next_epoch(), 87 }) 88 })) 89} 90 91fn forget_ref_state(git_dir: &Path) { 92 ref_registry() 93 .lock() 94 .unwrap_or_else(|poisoned| poisoned.into_inner()) 95 .remove(git_dir); 96} 97 98type AdvertCache = Moka<(RefEpoch, RefGeneration), Arc<Vec<RefRecord>>>; 99 100fn advert_cache() -> &'static Arc<AdvertCache> { 101 static CACHE: OnceLock<Arc<AdvertCache>> = OnceLock::new(); 102 CACHE.get_or_init(|| { 103 let cache = Arc::new(Moka::by_weight( 104 Weight::new(knot_resource::advert_cache_bytes()), 105 |refs: &Arc<Vec<RefRecord>>| { 106 Weight::new( 107 (refs.len() as u64) 108 .max(1) 109 .saturating_mul(ADVERT_BYTES_PER_REF), 110 ) 111 }, 112 )); 113 knot_cache::register(&cache); 114 cache 115 }) 116} 117 118fn safe_component(part: &str) -> bool { 119 !matches!(part, "." | "..") && !part.contains(['/', '\\', '\0']) 120} 121 122pub fn repo_shard(did: &RepoDid) -> Result<PathBuf, GitError> { 123 shard_components(did.as_str()) 124} 125 126pub fn knot_shard(knot: &KnotId) -> Result<PathBuf, GitError> { 127 shard_components(knot.as_str()) 128} 129 130fn shard_components(did: &str) -> Result<PathBuf, GitError> { 131 let mut parts = did.splitn(3, ':'); 132 parts.next(); 133 let method = parts.next().unwrap_or("did"); 134 let msid = parts.next().unwrap_or_default(); 135 let split = msid 136 .char_indices() 137 .nth(2) 138 .map(|(index, _)| index) 139 .unwrap_or(msid.len()); 140 let (shard, remainder) = msid.split_at(split); 141 if [method, shard, remainder] 142 .iter() 143 .any(|part| !safe_component(part)) 144 { 145 return Err(GitError::UnsafeRepoDid(did.to_string())); 146 } 147 Ok(PathBuf::from(method).join(shard).join(remainder)) 148} 149 150#[derive(Debug, Clone)] 151pub struct Layout { 152 scan_path: PathBuf, 153 head: RefName, 154 reserved: Option<PathBuf>, 155 object_format: ObjectFormat, 156} 157 158fn default_head() -> RefName { 159 RefName::new(format!("{HEADS_PREFIX}main")).expect("refs/heads/main is valid ref name") 160} 161 162impl Layout { 163 pub fn new(scan_path: impl Into<PathBuf>) -> Self { 164 Self { 165 scan_path: scan_path.into(), 166 head: default_head(), 167 reserved: None, 168 object_format: ObjectFormat::default(), 169 } 170 } 171 172 pub fn with_default_branch(mut self, branch: BranchName) -> Self { 173 self.head = branch.head_ref(); 174 self 175 } 176 177 pub fn with_object_format(mut self, object_format: ObjectFormat) -> Self { 178 self.object_format = object_format; 179 self 180 } 181 182 pub fn reserving_meta(mut self, knot: &KnotId) -> Result<Self, GitError> { 183 let reserved = self.meta_path(knot)?; 184 self.reserved = Some(reserved); 185 Ok(self) 186 } 187 188 pub fn repo_path(&self, did: &RepoDid) -> Result<PathBuf, GitError> { 189 Ok(self.scan_path.join(shard_components(did.as_str())?)) 190 } 191 192 pub fn scratch_dir(&self) -> &Path { 193 &self.scan_path 194 } 195 196 pub fn meta_path(&self, knot: &KnotId) -> Result<PathBuf, GitError> { 197 Ok(self.scan_path.join(shard_components(knot.as_str())?)) 198 } 199 200 pub fn guarded_path(&self, did: &RepoDid) -> Result<PathBuf, GitError> { 201 let path = self.repo_path(did)?; 202 match &self.reserved { 203 Some(reserved) if *reserved == path => { 204 Err(GitError::ReservedDid(did.as_str().to_string())) 205 } 206 _ => Ok(path), 207 } 208 } 209 210 pub fn open(&self, did: &RepoDid) -> Result<Repo, GitError> { 211 Repo::open(self.guarded_path(did)?) 212 } 213 214 pub fn create(&self, did: &RepoDid) -> Result<Repo, GitError> { 215 self.create_with_format(did, self.object_format) 216 } 217 218 pub fn create_with_format( 219 &self, 220 did: &RepoDid, 221 format: ObjectFormat, 222 ) -> Result<Repo, GitError> { 223 let repo = Repo::create_with_format(self.guarded_path(did)?, format)?; 224 repo.set_head(&self.head)?; 225 Ok(repo) 226 } 227 228 pub fn remove(&self, did: &RepoDid) -> Result<(), GitError> { 229 let path = self.guarded_path(did)?; 230 if let Ok(repo) = Repo::open(&path) { 231 forget_ref_state(repo.git.git_dir()); 232 } 233 match std::fs::remove_dir_all(&path) { 234 Ok(()) => Ok(()), 235 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), 236 Err(error) => Err(GitError::Remove { 237 path, 238 message: error.to_string(), 239 }), 240 } 241 } 242 243 pub fn bootstrap_meta(&self, knot: &KnotId) -> Result<Repo, GitError> { 244 init_bare_idempotent(self.meta_path(knot)?) 245 } 246} 247 248// `worktree_stream` builds a `gix_filter::Pipeline` 249// out of whatever config it has loaded, 250// so a filter command defined in any of those sources 251// runs against the tree being archived. 252// The repository's own config is then the only source gix reads, 253// and mr knot wrote that file when it created the repo under the scan path. 254// We pin the trust because gix otherwise works it out from who owns the git dir, 255// and it reduces the trust when someone else owns that dir, 256// at which point it applies a 16MiB object allocation limit 257// and treats the repository's own config sections as untrusted. 258// Under isolation no safe.directory entry can restore `Full` trust, 259// since gix honors that key from only system/global config. 260fn isolated_open_options() -> gix::open::Options { 261 gix::open::Options::isolated().with(gix::sec::Trust::Full) 262} 263 264pub(crate) fn init_bare_with_format( 265 path: &Path, 266 format: ObjectFormat, 267) -> Result<gix::Repository, String> { 268 let object_hash = (format != ObjectFormat::SHA1).then(|| format.kind()); 269 gix::ThreadSafeRepository::init_opts( 270 path, 271 gix::create::Kind::Bare, 272 gix::create::Options { 273 object_hash, 274 ..Default::default() 275 }, 276 isolated_open_options(), 277 ) 278 .map(Into::into) 279 .map_err(|error| error.to_string()) 280} 281 282fn staging_path(parent: &Path) -> PathBuf { 283 static COUNTER: AtomicU64 = AtomicU64::new(0); 284 let nonce = COUNTER.fetch_add(1, Ordering::Relaxed); 285 parent.join(format!(".knot-staging.{}.{}", std::process::id(), nonce)) 286} 287 288fn init_bare_idempotent(path: PathBuf) -> Result<Repo, GitError> { 289 if let Ok(git) = gix::open_opts(&path, isolated_open_options()) { 290 return Ok(assembled(git, path)); 291 } 292 let parent = path.parent().ok_or_else(|| GitError::Create { 293 path: path.clone(), 294 message: "meta path has no parent directory".to_string(), 295 })?; 296 std::fs::create_dir_all(parent).map_err(|error| GitError::Create { 297 path: path.clone(), 298 message: error.to_string(), 299 })?; 300 let staging = staging_path(parent); 301 let _ = std::fs::remove_dir_all(&staging); 302 init_bare_with_format(&staging, ObjectFormat::SHA1).map_err(|message| GitError::Create { 303 path: staging.clone(), 304 message, 305 })?; 306 match std::fs::rename(&staging, &path) { 307 Ok(()) => Repo::open(path), 308 Err(_) => { 309 let _ = std::fs::remove_dir_all(&staging); 310 Repo::open(path) 311 } 312 } 313} 314 315pub struct Repo { 316 git: gix::Repository, 317 path: PathBuf, 318 commit_graph: OnceLock<Option<gix::commitgraph::Graph>>, 319} 320 321#[derive(Debug, Clone, PartialEq, Eq)] 322pub struct RefRecord { 323 pub name: RefName, 324 pub target: Oid, 325} 326 327#[derive(Debug, Clone, PartialEq, Eq)] 328pub struct PackHash(String); 329 330impl PackHash { 331 pub fn new(value: impl Into<String>) -> Option<Self> { 332 let value = value.into(); 333 (matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())) 334 .then_some(Self(value)) 335 } 336 337 pub fn as_str(&self) -> &str { 338 &self.0 339 } 340} 341 342#[derive(Debug, Clone, PartialEq, Eq)] 343pub struct PackfileUrl(String); 344 345impl PackfileUrl { 346 pub fn new(value: impl Into<String>) -> Option<Self> { 347 let value = value.into(); 348 let authority = value 349 .strip_prefix("https://") 350 .or_else(|| value.strip_prefix("http://")) 351 .filter(|rest| !rest.is_empty() && !rest.starts_with('/')); 352 (authority.is_some() && !value.chars().any(|c| c.is_whitespace() || c.is_control())) 353 .then_some(Self(value)) 354 } 355 356 pub fn as_str(&self) -> &str { 357 &self.0 358 } 359} 360 361#[derive(Debug, Clone, PartialEq, Eq)] 362pub struct PackfileUri { 363 pub oid: Oid, 364 pub pack_hash: PackHash, 365 pub uri: PackfileUrl, 366} 367 368#[derive(Debug, Clone, PartialEq, Eq)] 369pub struct HeadRef { 370 pub name: RefName, 371 pub target: Oid, 372} 373 374#[derive(Debug, Clone, PartialEq, Eq)] 375pub struct ReflogUpdate { 376 pub name: RefName, 377 pub old: Option<Oid>, 378 pub new: Oid, 379 pub seconds: UnixSeconds, 380} 381 382#[derive(Debug, Clone, PartialEq, Eq)] 383pub enum RefUpdate { 384 Create { name: RefName, new: Oid }, 385 Update { name: RefName, old: Oid, new: Oid }, 386 Delete { name: RefName, old: Oid }, 387} 388 389impl RefUpdate { 390 pub fn name(&self) -> &RefName { 391 match self { 392 RefUpdate::Create { name, .. } 393 | RefUpdate::Update { name, .. } 394 | RefUpdate::Delete { name, .. } => name, 395 } 396 } 397 398 pub fn transition(&self) -> RefTransition { 399 match self { 400 RefUpdate::Create { new, .. } => RefTransition::Create { new: *new }, 401 RefUpdate::Update { old, new, .. } => RefTransition::Advance { 402 old: *old, 403 new: *new, 404 }, 405 RefUpdate::Delete { old, .. } => RefTransition::Delete { old: *old }, 406 } 407 } 408} 409 410pub fn is_reserved(name: &RefName) -> bool { 411 screens_reserved(name.as_str()) 412} 413 414pub fn screens_reserved(raw: &str) -> bool { 415 raw.starts_with(RESERVED_PREFIX) || raw.starts_with(CHECKPOINT_PREFIX) 416} 417 418fn is_hidden(name: &RefName) -> bool { 419 name.as_str().starts_with(HIDDEN_PREFIX) 420} 421 422pub fn is_branch(name: &RefName) -> bool { 423 name.as_str().starts_with(HEADS_PREFIX) 424} 425 426pub fn is_public_ref(name: &RefName) -> bool { 427 !is_reserved(name) && !is_hidden(name) 428} 429 430#[derive(Clone, Copy, Debug, PartialEq, Eq)] 431pub enum AdvertScope { 432 Upload, 433 Receive, 434} 435 436impl AdvertScope { 437 fn config_key(self) -> &'static str { 438 match self { 439 AdvertScope::Upload => "uploadpack.hideRefs", 440 AdvertScope::Receive => "receive.hideRefs", 441 } 442 } 443} 444 445fn ref_hidden_by(name: &RefName, patterns: &[String]) -> bool { 446 patterns.iter().any(|pattern| { 447 name.as_str() == pattern || name.as_str().starts_with(&format!("{pattern}/")) 448 }) 449} 450 451pub(crate) fn fsync_if_present(path: &Path) -> Result<(), GitError> { 452 knot_resource::fsync_path(path).map_err(|error| GitError::Fsync { 453 path: error.path, 454 message: error.source.to_string(), 455 }) 456} 457 458impl Repo { 459 pub fn open(path: impl Into<PathBuf>) -> Result<Repo, GitError> { 460 let path = path.into(); 461 let git = 462 gix::open_opts(&path, isolated_open_options()).map_err(|error| GitError::Open { 463 path: path.clone(), 464 message: error.to_string(), 465 })?; 466 Ok(assembled(git, path)) 467 } 468 469 pub fn create(path: impl Into<PathBuf>) -> Result<Repo, GitError> { 470 Self::create_with_format(path, ObjectFormat::default()) 471 } 472 473 pub fn create_with_format( 474 path: impl Into<PathBuf>, 475 format: ObjectFormat, 476 ) -> Result<Repo, GitError> { 477 let path = path.into(); 478 if path.exists() { 479 return Err(GitError::AlreadyExists(path)); 480 } 481 if let Some(parent) = path.parent() { 482 std::fs::create_dir_all(parent).map_err(|error| GitError::Create { 483 path: path.clone(), 484 message: error.to_string(), 485 })?; 486 } 487 let git = init_bare_with_format(&path, format).map_err(|message| GitError::Create { 488 path: path.clone(), 489 message, 490 })?; 491 Ok(assembled(git, path)) 492 } 493 494 pub fn git(&self) -> &gix::Repository { 495 &self.git 496 } 497 498 pub fn object_format(&self) -> ObjectFormat { 499 ObjectFormat::from_kind(self.git.object_hash()) 500 } 501 502 pub fn path(&self) -> &Path { 503 &self.path 504 } 505 506 pub fn objects_dir(&self) -> PathBuf { 507 self.git.git_dir().join("objects") 508 } 509 510 pub fn references(&self) -> Result<Vec<RefRecord>, GitError> { 511 self.git 512 .references() 513 .map_err(|error| GitError::Backend(error.to_string()))? 514 .all() 515 .map_err(|error| GitError::Backend(error.to_string()))? 516 .filter_map(|reference| { 517 let reference = match reference { 518 Ok(reference) => reference, 519 Err(error) => return Some(Err(GitError::Backend(error.to_string()))), 520 }; 521 let raw = reference.name().as_bstr().to_string(); 522 let target = Oid::from(self.direct_target(&reference, MAX_SYMREF_DEPTH)?); 523 let name = RefName::new(raw).ok()?; 524 Some(Ok(RefRecord { name, target })) 525 }) 526 .collect() 527 } 528 529 pub fn reflog_updates_since(&self, since_seconds: UnixSeconds) -> Vec<ReflogUpdate> { 530 let Ok(references) = self.git.references() else { 531 return Vec::new(); 532 }; 533 let Ok(all) = references.all() else { 534 return Vec::new(); 535 }; 536 all.filter_map(Result::ok) 537 .filter(|reference| { 538 let name = reference.name().as_bstr().to_string(); 539 name.starts_with(HEADS_PREFIX) || name.starts_with(TAGS_PREFIX) 540 }) 541 .flat_map(|reference| self.ref_reflog_since(&reference, since_seconds)) 542 .collect() 543 } 544 545 fn ref_reflog_since( 546 &self, 547 reference: &gix::Reference<'_>, 548 since_seconds: UnixSeconds, 549 ) -> Vec<ReflogUpdate> { 550 let Ok(name) = RefName::new(reference.name().as_bstr().to_string()) else { 551 return Vec::new(); 552 }; 553 let mut platform = reference.log_iter(); 554 let Ok(Some(reverse)) = platform.rev() else { 555 return Vec::new(); 556 }; 557 reverse 558 .filter_map(Result::ok) 559 .take_while(|line| UnixSeconds::new(line.signature.time.seconds) >= since_seconds) 560 .filter_map(|line| { 561 (!line.new_oid.is_null()).then(|| ReflogUpdate { 562 name: name.clone(), 563 old: Some(line.previous_oid) 564 .filter(|previous| !previous.is_null()) 565 .map(Oid::from), 566 new: Oid::from(line.new_oid), 567 seconds: UnixSeconds::new(line.signature.time.seconds), 568 }) 569 }) 570 .collect() 571 } 572 573 pub fn find_ref(&self, name: &RefName) -> Result<Option<Oid>, GitError> { 574 match self 575 .git 576 .try_find_reference(name.as_str()) 577 .map_err(|error| GitError::Backend(error.to_string()))? 578 { 579 Some(reference) => Ok(self 580 .direct_target(&reference, MAX_SYMREF_DEPTH) 581 .map(Oid::from)), 582 None => Ok(None), 583 } 584 } 585 586 pub fn hidden_ref_commit(&self, spec: &str) -> Option<Oid> { 587 let name = match spec.starts_with("refs/") { 588 true => RefName::new(spec.to_string()), 589 false => RefName::new(format!("refs/{spec}")), 590 } 591 .ok() 592 .filter(is_hidden)?; 593 self.find_ref(&name).ok().flatten() 594 } 595 596 fn direct_target(&self, reference: &gix::Reference<'_>, depth: usize) -> Option<gix::ObjectId> { 597 match (depth, reference.follow()) { 598 (_, None) => reference.try_id().map(|id| id.detach()), 599 (0, Some(_)) => None, 600 (_, Some(Ok(next))) => self.direct_target(&next, depth - 1), 601 (_, Some(Err(_))) => None, 602 } 603 } 604 605 pub(crate) fn commit_graph(&self) -> Option<&gix::commitgraph::Graph> { 606 self.commit_graph 607 .get_or_init(|| self.git.commit_graph().ok()) 608 .as_ref() 609 } 610 611 pub fn with_ref_lock<R>(&self, body: impl FnOnce() -> R) -> R { 612 let state = ref_state(self.git.git_dir()); 613 let _guard = state 614 .lock 615 .lock() 616 .unwrap_or_else(|poisoned| poisoned.into_inner()); 617 body() 618 } 619 620 fn locked_value<R>(&self, body: impl FnOnce() -> R) -> R { 621 self.with_ref_lock(|| { 622 let outcome = body(); 623 let state = ref_state(self.git.git_dir()); 624 let previous = RefGeneration::new(state.generation.fetch_add(1, Ordering::SeqCst)); 625 advert_cache().invalidate(&(state.epoch, previous)); 626 outcome 627 }) 628 } 629 630 pub(crate) fn locked<R>( 631 &self, 632 body: impl FnOnce() -> Result<R, GitError>, 633 ) -> Result<R, GitError> { 634 self.locked_value(body) 635 } 636 637 pub fn with_ref_txn<R>(&self, body: impl FnOnce(&RefTxn<'_>) -> R) -> R { 638 self.locked_value(|| body(&RefTxn { repo: self })) 639 } 640 641 pub fn set_head(&self, target: &RefName) -> Result<(), GitError> { 642 self.locked(|| self.set_head_locked(target)) 643 } 644 645 pub fn set_head_sealed<R>( 646 &self, 647 target: &RefName, 648 seal: impl FnOnce() -> R, 649 ) -> Result<R, GitError> { 650 self.locked(|| { 651 self.set_head_locked(target)?; 652 Ok(seal()) 653 }) 654 } 655 656 fn set_head_locked(&self, target: &RefName) -> Result<(), GitError> { 657 let raw = target.as_str(); 658 let target_name = FullName::try_from(raw).map_err(|error| GitError::Reference { 659 name: raw.to_string(), 660 message: error.to_string(), 661 })?; 662 let edit = RefEdit { 663 change: Change::Update { 664 log: LogChange { 665 mode: RefLog::AndReference, 666 force_create_reflog: false, 667 message: "knot set HEAD".into(), 668 }, 669 expected: PreviousValue::Any, 670 new: Target::Symbolic(target_name), 671 }, 672 name: FullName::try_from("HEAD").map_err(|error| GitError::Reference { 673 name: "HEAD".to_string(), 674 message: error.to_string(), 675 })?, 676 deref: false, 677 }; 678 self.git 679 .edit_reference(edit) 680 .map_err(|error| GitError::Reference { 681 name: "HEAD".to_string(), 682 message: error.to_string(), 683 })?; 684 let git_dir = self.git.git_dir(); 685 fsync_if_present(&git_dir.join("HEAD"))?; 686 fsync_if_present(git_dir) 687 } 688 689 fn persist_refs<'a>( 690 &self, 691 mut names: impl Iterator<Item = &'a RefName>, 692 ) -> Result<(), GitError> { 693 let git_dir = self.git.git_dir(); 694 let mut dirs = BTreeSet::from([git_dir.to_path_buf()]); 695 names.try_for_each(|name| -> Result<(), GitError> { 696 let ref_path = git_dir.join(name.as_str()); 697 fsync_if_present(&ref_path)?; 698 std::iter::successors(ref_path.parent(), |path| path.parent()) 699 .take_while(|path| path.starts_with(git_dir)) 700 .for_each(|path| { 701 dirs.insert(path.to_path_buf()); 702 }); 703 Ok(()) 704 })?; 705 fsync_if_present(&git_dir.join("packed-refs"))?; 706 dirs.iter().try_for_each(|dir| fsync_if_present(dir)) 707 } 708 709 pub fn origin_url(&self) -> Option<OriginUrl> { 710 self.git 711 .config_snapshot() 712 .string("remote.origin.url") 713 .map(|value| OriginUrl::new(value.to_string())) 714 } 715 716 pub fn set_origin_url(&self, url: &OriginUrl) -> Result<(), GitError> { 717 let path = self.git.git_dir().join("config"); 718 let report = |message: String| GitError::Config { 719 path: path.clone(), 720 message, 721 }; 722 let mut file = 723 gix::config::File::from_path_no_includes(path.clone(), gix::config::Source::Local) 724 .map_err(|error| report(error.to_string()))?; 725 file.set_raw_value_by( 726 "remote", 727 Some(gix::bstr::BStr::new("origin")), 728 "url", 729 gix::bstr::BStr::new(url.as_str()), 730 ) 731 .map_err(|error| report(error.to_string()))?; 732 knot_resource::atomic_write(&path, knot_resource::FileMode::Inherited, |out| { 733 file.write_to(out) 734 .map_err(|error| report(error.to_string())) 735 })?; 736 fsync_if_present(self.git.git_dir()) 737 } 738 739 pub fn branches(&self) -> Result<Vec<RefRecord>, GitError> { 740 self.references().map(|records| { 741 records 742 .into_iter() 743 .filter(|record| record.name.as_str().starts_with(HEADS_PREFIX)) 744 .collect() 745 }) 746 } 747 748 pub fn tags(&self) -> Result<Vec<RefRecord>, GitError> { 749 self.references().map(|records| { 750 records 751 .into_iter() 752 .filter(|record| record.name.as_str().starts_with(TAGS_PREFIX)) 753 .collect() 754 }) 755 } 756 757 pub fn advertised_refs(&self) -> Result<Arc<Vec<RefRecord>>, GitError> { 758 let state = ref_state(self.git.git_dir()); 759 let key = ( 760 state.epoch, 761 RefGeneration::new(state.generation.load(Ordering::SeqCst)), 762 ); 763 advert_cache() 764 .get_or_try_insert_with(key, || self.public_refs().map(Arc::new)) 765 .map_err(|error: Arc<GitError>| GitError::Backend(error.to_string())) 766 } 767 768 fn public_refs(&self) -> Result<Vec<RefRecord>, GitError> { 769 self.references().map(|records| { 770 records 771 .into_iter() 772 .filter(|record| is_public_ref(&record.name)) 773 .collect() 774 }) 775 } 776 777 pub fn advertised_refs_for(&self, scope: AdvertScope) -> Result<Vec<RefRecord>, GitError> { 778 let patterns = self.hidden_ref_patterns(scope); 779 let base = self.advertised_refs()?; 780 if patterns.is_empty() { 781 return Ok(base.to_vec()); 782 } 783 Ok(base 784 .iter() 785 .filter(|record| !ref_hidden_by(&record.name, &patterns)) 786 .cloned() 787 .collect()) 788 } 789 790 pub fn blob_packfile_uris(&self) -> Vec<PackfileUri> { 791 let snapshot = self.git.config_snapshot(); 792 snapshot 793 .strings("uploadpack.blobPackfileUri") 794 .into_iter() 795 .flatten() 796 .filter_map(|value| { 797 let text = value.to_string(); 798 let mut parts = text.split_whitespace(); 799 let oid = Oid::from_hex(parts.next()?).ok()?; 800 let pack_hash = PackHash::new(parts.next()?)?; 801 let uri = PackfileUrl::new(parts.next()?)?; 802 Some(PackfileUri { 803 oid, 804 pack_hash, 805 uri, 806 }) 807 }) 808 .collect() 809 } 810 811 fn hidden_ref_patterns(&self, scope: AdvertScope) -> Vec<String> { 812 let snapshot = self.git.config_snapshot(); 813 ["transfer.hideRefs", scope.config_key()] 814 .into_iter() 815 .filter_map(|key| snapshot.strings(key)) 816 .flatten() 817 .map(|value| value.to_string()) 818 .collect() 819 } 820 821 pub fn head(&self) -> Option<HeadRef> { 822 let target = self.git.head_id().ok()?.detach(); 823 let raw = self.git.head_name().ok()??.as_bstr().to_string(); 824 let name = RefName::new(raw).ok()?; 825 Some(HeadRef { 826 name, 827 target: Oid::from(target), 828 }) 829 } 830 831 pub fn default_branch(&self) -> Option<RefName> { 832 let raw = self.git.head_name().ok()??.as_bstr().to_string(); 833 RefName::new(raw).ok() 834 } 835 836 pub fn contains(&self, oid: Oid) -> bool { 837 self.git.has_object(oid.object_id()) 838 } 839 840 pub fn is_shallow(&self) -> bool { 841 self.git.is_shallow() 842 } 843 844 pub(crate) fn shallow_grafts(&self) -> Result<HashSet<gix::ObjectId>, GitError> { 845 Ok(self 846 .git 847 .shallow_commits() 848 .map_err(|error| GitError::Decode(format!("shallow file: {error}")))? 849 .map(|commits| commits.iter().copied().collect()) 850 .unwrap_or_default()) 851 } 852 853 pub fn rev_walk(&self, wants: Wants, haves: Haves) -> Result<Vec<Oid>, GitError> { 854 let mut walked = Walked::new(PackBudget::unbounded()); 855 self.rev_walk_each(wants, haves, &mut walked) 856 } 857 858 pub(crate) fn rev_walk_each( 859 &self, 860 wants: Wants<'_>, 861 haves: Haves<'_>, 862 walked: &mut Walked, 863 ) -> Result<Vec<Oid>, GitError> { 864 let present: Vec<gix::ObjectId> = haves 865 .as_slice() 866 .iter() 867 .copied() 868 .filter(|oid| self.contains(*oid)) 869 .map(Oid::object_id) 870 .collect(); 871 let mut probe = *walked; 872 let collected = self 873 .git 874 .rev_walk(wants.as_slice().iter().copied().map(Oid::object_id)) 875 .with_hidden(present.iter().copied()) 876 .all() 877 .ok() 878 .and_then(|walk| { 879 walk.map(|info| { 880 probe.tick()?; 881 info.map(|info| Oid::from(info.id)) 882 .map_err(|error| GitError::RevWalk(error.to_string())) 883 }) 884 .collect::<Result<Vec<Oid>, _>>() 885 .ok() 886 }); 887 match collected { 888 Some(commits) => { 889 *walked = probe; 890 Ok(commits) 891 } 892 None => self.rev_walk_lenient(wants.as_slice(), &present, walked), 893 } 894 } 895 896 fn rev_walk_lenient( 897 &self, 898 wants: &[Oid], 899 hidden_tips: &[gix::ObjectId], 900 walked: &mut Walked, 901 ) -> Result<Vec<Oid>, GitError> { 902 let mut hidden: HashSet<gix::ObjectId> = HashSet::new(); 903 let mut stack = hidden_tips.to_vec(); 904 while let Some(oid) = stack.pop() { 905 if hidden.insert(oid) 906 && let Ok((_, parents)) = self.commit_tree_and_parents(Oid::from(oid)) 907 { 908 stack.extend(parents); 909 } 910 } 911 let mut visited: HashSet<gix::ObjectId> = HashSet::new(); 912 let mut commits = Vec::new(); 913 let mut stack: Vec<gix::ObjectId> = wants.iter().copied().map(Oid::object_id).collect(); 914 while let Some(oid) = stack.pop() { 915 if hidden.contains(&oid) || !visited.insert(oid) { 916 continue; 917 } 918 walked.tick()?; 919 let (_, parents) = self.commit_tree_and_parents(Oid::from(oid))?; 920 commits.push(Oid::from(oid)); 921 stack.extend(parents); 922 } 923 Ok(commits) 924 } 925 926 fn ref_edit(update: &RefUpdate, via_head: bool) -> Result<RefEdit, GitError> { 927 let edited = if via_head { 928 "HEAD" 929 } else { 930 update.name().as_str() 931 }; 932 let name = FullName::try_from(edited).map_err(|error| GitError::Reference { 933 name: edited.to_string(), 934 message: error.to_string(), 935 })?; 936 let log = LogChange { 937 mode: RefLog::AndReference, 938 force_create_reflog: true, 939 message: "knot ref update".into(), 940 }; 941 let change = match update { 942 RefUpdate::Create { new, .. } => Change::Update { 943 log, 944 expected: PreviousValue::MustNotExist, 945 new: Target::Object(new.object_id()), 946 }, 947 RefUpdate::Update { old, new, .. } => Change::Update { 948 log, 949 expected: PreviousValue::MustExistAndMatch(Target::Object(old.object_id())), 950 new: Target::Object(new.object_id()), 951 }, 952 RefUpdate::Delete { old, .. } => Change::Delete { 953 expected: PreviousValue::MustExistAndMatch(Target::Object(old.object_id())), 954 log: RefLog::AndReference, 955 }, 956 }; 957 Ok(RefEdit { 958 change, 959 name, 960 deref: via_head, 961 }) 962 } 963 964 fn updates_head_branch(&self, update: &RefUpdate) -> bool { 965 !matches!(update, RefUpdate::Delete { .. }) 966 && self 967 .default_branch() 968 .is_some_and(|head| head.as_str() == update.name().as_str()) 969 } 970 971 pub fn update_ref(&self, update: &RefUpdate) -> Result<(), GitError> { 972 self.locked(|| self.update_ref_locked(update)) 973 } 974 975 pub fn update_ref_sealed<R>( 976 &self, 977 update: &RefUpdate, 978 seal: impl FnOnce() -> R, 979 ) -> Result<R, GitError> { 980 self.locked(|| { 981 self.update_ref_locked(update)?; 982 Ok(seal()) 983 }) 984 } 985 986 fn update_ref_locked(&self, update: &RefUpdate) -> Result<(), GitError> { 987 let raw = update.name().as_str().to_string(); 988 let via_head = self.updates_head_branch(update); 989 self.git 990 .edit_reference(Self::ref_edit(update, via_head)?) 991 .map_err(|error| GitError::Reference { 992 name: raw, 993 message: error.to_string(), 994 })?; 995 self.persist_refs(std::iter::once(update.name())) 996 } 997 998 pub fn update_refs(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 999 self.locked(|| self.update_refs_locked(updates)) 1000 } 1001 1002 pub fn update_refs_sealed<R>( 1003 &self, 1004 updates: &[RefUpdate], 1005 seal: impl FnOnce() -> R, 1006 ) -> Result<R, GitError> { 1007 self.locked(|| { 1008 self.update_refs_locked(updates)?; 1009 Ok(seal()) 1010 }) 1011 } 1012 1013 fn reject_df_conflicts(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 1014 let creates: Vec<&str> = updates 1015 .iter() 1016 .filter_map(|update| match update { 1017 RefUpdate::Create { name, .. } => Some(name.as_str()), 1018 _ => None, 1019 }) 1020 .collect(); 1021 if creates.is_empty() { 1022 return Ok(()); 1023 } 1024 let deletes: HashSet<&str> = updates 1025 .iter() 1026 .filter_map(|update| match update { 1027 RefUpdate::Delete { name, .. } => Some(name.as_str()), 1028 _ => None, 1029 }) 1030 .collect(); 1031 let names: Vec<String> = self 1032 .references()? 1033 .iter() 1034 .map(|record| record.name.as_str().to_string()) 1035 .filter(|name| !deletes.contains(name.as_str())) 1036 .chain(creates.iter().copied().map(str::to_string)) 1037 .collect(); 1038 let name_set: HashSet<&str> = names.iter().map(String::as_str).collect(); 1039 names 1040 .iter() 1041 .find_map(|name| { 1042 name.match_indices('/') 1043 .map(|(at, _)| &name[..at]) 1044 .find(|ancestor| name_set.contains(ancestor)) 1045 .map(|ancestor| (ancestor.to_string(), name.clone())) 1046 }) 1047 .map_or(Ok(()), |(directory, leaf)| { 1048 Err(GitError::AtomicRefs(format!( 1049 "d/f conflict: {directory} blocks {leaf}" 1050 ))) 1051 }) 1052 } 1053 1054 fn update_refs_locked(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 1055 self.reject_df_conflicts(updates)?; 1056 let edits = updates 1057 .iter() 1058 .map(|update| { 1059 let via_head = self.updates_head_branch(update); 1060 Self::ref_edit(update, via_head) 1061 }) 1062 .collect::<Result<Vec<_>, _>>()?; 1063 self.git 1064 .edit_references(edits) 1065 .map_err(|error| GitError::AtomicRefs(error.to_string()))?; 1066 self.persist_refs(updates.iter().map(RefUpdate::name)) 1067 } 1068} 1069 1070pub struct RefTxn<'a> { 1071 repo: &'a Repo, 1072} 1073 1074impl RefTxn<'_> { 1075 pub fn update_ref(&self, update: &RefUpdate) -> Result<(), GitError> { 1076 self.repo.update_ref_locked(update) 1077 } 1078 1079 pub fn update_refs(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 1080 self.repo.update_refs_locked(updates) 1081 } 1082} 1083 1084#[cfg(test)] 1085mod tests { 1086 use super::*; 1087 1088 const A: &str = "1111111111111111111111111111111111111111"; 1089 const B: &str = "2222222222222222222222222222222222222222"; 1090 1091 fn oid(hex: &str) -> Oid { 1092 Oid::from_hex(hex).unwrap() 1093 } 1094 1095 fn head_ref() -> RefName { 1096 RefName::new("refs/heads/main").unwrap() 1097 } 1098 1099 fn repo() -> (tempfile::TempDir, Layout, RepoDid) { 1100 let dir = tempfile::tempdir().unwrap(); 1101 let layout = Layout::new(dir.path()); 1102 let did = RepoDid::new("did:plc:squid").unwrap(); 1103 (dir, layout, did) 1104 } 1105 1106 #[test] 1107 fn every_repo_opens_against_its_own_config_and_nothing_ambient() { 1108 let permissions = isolated_open_options().permissions; 1109 let config = permissions.config; 1110 assert!( 1111 !config.system 1112 && !config.git 1113 && !config.user 1114 && !config.env 1115 && !config.includes 1116 && !config.git_binary, 1117 "a filter driver in ambient config would execute when worktree_stream archives a \ 1118 pushed tree, so we read only the repository's own config: {config:?}" 1119 ); 1120 assert!( 1121 !permissions.attributes.system 1122 && !permissions.attributes.git 1123 && !permissions.attributes.git_binary, 1124 "only the archived tree's own .gitattributes may set filter= on a path: {:?}", 1125 permissions.attributes 1126 ); 1127 let denied = |permission| matches!(permission, gix::sec::Permission::Deny); 1128 let env = permissions.env; 1129 assert!( 1130 denied(env.xdg_config_home) 1131 && denied(env.home) 1132 && denied(env.git_prefix) 1133 && denied(env.ssh_prefix) 1134 && denied(env.identity) 1135 && denied(env.objects) 1136 && denied(env.http_transport), 1137 "gix resolves GIT_CONFIG_KEY_n, HOME and XDG_CONFIG_HOME into config that can define a \ 1138 filter driver: {env:?}" 1139 ); 1140 assert!( 1141 permissions.is_isolated(), 1142 "every permission must match the set gix itself calls isolated, including any field a \ 1143 gix upgrade adds that the three checks above don't name: {permissions:?}" 1144 ); 1145 } 1146 1147 #[cfg(unix)] 1148 #[test] 1149 fn the_knot_keeps_full_trust_on_a_git_dir_it_no_longer_owns() { 1150 const NOBODY: u32 = 65534; 1151 let (_dir, layout, did) = repo(); 1152 layout.create(&did).unwrap(); 1153 let path = layout.repo_path(&did).unwrap(); 1154 let unreachable_uid = |kind| { 1155 matches!( 1156 kind, 1157 std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::InvalidInput 1158 ) 1159 }; 1160 match std::os::unix::fs::chown(&path, Some(NOBODY), None) { 1161 Err(error) if unreachable_uid(error.kind()) => { 1162 eprintln!( 1163 "skipping the foreign-owner trust check: chown to {NOBODY} needs root and a \ 1164 uid mapping reaching that far" 1165 ); 1166 return; 1167 } 1168 outcome => outcome.unwrap(), 1169 } 1170 assert_eq!( 1171 layout.open(&did).unwrap().git().git_dir_trust(), 1172 gix::sec::Trust::Full, 1173 "reduced trust applies a 16MiB limit to every object allocation" 1174 ); 1175 assert_eq!( 1176 gix::open_opts(&path, gix::open::Options::isolated()) 1177 .unwrap() 1178 .git_dir_trust(), 1179 gix::sec::Trust::Reduced, 1180 "gix raised the trust of a git dir owned by another user with no safe.directory entry \ 1181 in reach" 1182 ); 1183 } 1184 1185 #[test] 1186 fn layout_paths_shard_and_stay_within_scan() { 1187 let layout = Layout::new("/srv/git"); 1188 let cases: &[(&str, &str)] = &[ 1189 ("did:plc:squid", "plc/sq/uid"), 1190 ("did:web:oyster.cafe", "web/oy/ster.cafe"), 1191 ("did:web:nel.pet", "web/ne/l.pet"), 1192 ]; 1193 cases.iter().for_each(|&(raw, suffix)| { 1194 let path = layout.repo_path(&RepoDid::new(raw).unwrap()).unwrap(); 1195 assert!(path.ends_with(suffix), "{path:?} missing shard {suffix}"); 1196 assert!(path.starts_with("/srv/git"), "{path:?} escaped scan path"); 1197 }); 1198 } 1199 1200 #[test] 1201 fn dot_only_did_cannot_escape_scan_path() { 1202 let layout = Layout::new("/srv/git/scan"); 1203 ["did:plc:....", "did:web:....", "did:plc:...", "did:plc:.."] 1204 .into_iter() 1205 .map(|raw| RepoDid::new(raw).unwrap()) 1206 .for_each(|did| { 1207 assert!( 1208 matches!(layout.repo_path(&did), Err(GitError::UnsafeRepoDid(_))), 1209 "dot-only method-specific-id must be refused, never resolved to path" 1210 ); 1211 assert!(matches!(layout.open(&did), Err(GitError::UnsafeRepoDid(_)))); 1212 assert!(matches!( 1213 layout.create(&did), 1214 Err(GitError::UnsafeRepoDid(_)) 1215 )); 1216 }); 1217 1218 let real = RepoDid::new("did:web:oyster.cafe").unwrap(); 1219 assert!( 1220 layout.repo_path(&real).is_ok(), 1221 "legitimate did:web with dots in its domain must still resolve" 1222 ); 1223 } 1224 1225 #[test] 1226 fn meta_repo_path_is_sharded_and_never_collides() { 1227 let layout = Layout::new("/srv/git"); 1228 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1229 let meta = layout.meta_path(&knot).unwrap(); 1230 assert!(meta.ends_with("web/oy/ster.cafe")); 1231 ["did:plc:squid", "did:web:nel.pet"] 1232 .into_iter() 1233 .map(|raw| RepoDid::new(raw).unwrap()) 1234 .for_each(|did| { 1235 assert_ne!(layout.repo_path(&did).unwrap(), meta); 1236 }); 1237 } 1238 1239 #[test] 1240 fn bootstrap_meta_creates_then_opens_idempotently() { 1241 let dir = tempfile::tempdir().unwrap(); 1242 let layout = Layout::new(dir.path()); 1243 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1244 1245 let created = layout.bootstrap_meta(&knot).unwrap(); 1246 assert!(created.references().unwrap().is_empty()); 1247 assert_eq!(created.path(), layout.meta_path(&knot).unwrap()); 1248 1249 let reopened = layout.bootstrap_meta(&knot).unwrap(); 1250 assert_eq!(reopened.path(), layout.meta_path(&knot).unwrap()); 1251 } 1252 1253 #[test] 1254 fn concurrent_bootstrap_meta_converges_for_every_caller() { 1255 let dir = tempfile::tempdir().unwrap(); 1256 let layout = Layout::new(dir.path()); 1257 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1258 let meta = layout.meta_path(&knot).unwrap(); 1259 1260 let paths = std::thread::scope(|scope| { 1261 let handles: Vec<_> = (0..8) 1262 .map(|_| { 1263 let layout = layout.clone(); 1264 let knot = knot.clone(); 1265 scope.spawn(move || { 1266 layout 1267 .bootstrap_meta(&knot) 1268 .map(|repo| repo.path().to_path_buf()) 1269 }) 1270 }) 1271 .collect(); 1272 handles 1273 .into_iter() 1274 .map(|handle| handle.join().unwrap()) 1275 .collect::<Vec<_>>() 1276 }); 1277 1278 assert!( 1279 paths 1280 .iter() 1281 .all(|outcome| matches!(outcome, Ok(path) if path == &meta)), 1282 "every racing bootstrap must converge on one meta repo, not fail: {paths:?}" 1283 ); 1284 let reopened = layout.bootstrap_meta(&knot).unwrap(); 1285 assert!(reopened.references().unwrap().is_empty()); 1286 } 1287 1288 #[test] 1289 fn reserving_meta_refuses_the_knot_did_for_open_and_create() { 1290 let dir = tempfile::tempdir().unwrap(); 1291 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1292 let layout = Layout::new(dir.path()).reserving_meta(&knot).unwrap(); 1293 layout.bootstrap_meta(&knot).unwrap(); 1294 1295 let knot_as_repo = RepoDid::new("did:web:oyster.cafe").unwrap(); 1296 assert!(matches!( 1297 layout.open(&knot_as_repo), 1298 Err(GitError::ReservedDid(_)) 1299 )); 1300 assert!(matches!( 1301 layout.create(&knot_as_repo), 1302 Err(GitError::ReservedDid(_)) 1303 )); 1304 1305 let ordinary = RepoDid::new("did:plc:squid").unwrap(); 1306 assert!(layout.create(&ordinary).is_ok()); 1307 assert!(layout.open(&ordinary).is_ok()); 1308 } 1309 1310 #[test] 1311 fn creating_a_bare_repo_is_sha1_and_rejects_a_second_create() { 1312 let (_dir, layout, did) = repo(); 1313 1314 let repo = layout.create(&did).unwrap(); 1315 assert!(repo.references().unwrap().is_empty()); 1316 assert!(repo.head().is_none()); 1317 assert_eq!(repo.object_format(), ObjectFormat::SHA1); 1318 1319 assert!(layout.open(&did).unwrap().references().unwrap().is_empty()); 1320 assert!(matches!( 1321 layout.create(&did), 1322 Err(GitError::AlreadyExists(_)) 1323 )); 1324 } 1325 1326 #[test] 1327 fn with_ref_txn_holds_the_ref_lock_across_its_whole_body() { 1328 use std::sync::Mutex; 1329 use std::sync::mpsc::channel; 1330 1331 let (_dir, layout, did) = repo(); 1332 let repo = layout.create(&did).unwrap(); 1333 let contender_repo = layout.open(&did).unwrap(); 1334 1335 let order: Mutex<Vec<&str>> = Mutex::new(Vec::new()); 1336 let order_ref = &order; 1337 let (contending, observed) = channel(); 1338 1339 std::thread::scope(|scope| { 1340 repo.with_ref_txn(|_txn| { 1341 order_ref.lock().unwrap().push("txn-enter"); 1342 scope.spawn(move || { 1343 contending.send(()).unwrap(); 1344 contender_repo.with_ref_lock(|| order_ref.lock().unwrap().push("contender")); 1345 }); 1346 observed.recv().unwrap(); 1347 std::thread::yield_now(); 1348 order_ref.lock().unwrap().push("txn-exit"); 1349 }); 1350 }); 1351 1352 assert_eq!( 1353 *order.lock().unwrap(), 1354 ["txn-enter", "txn-exit", "contender"], 1355 "object migration runs inside the transaction body, so no other ref-lock holder can observe the half-applied push" 1356 ); 1357 } 1358 1359 #[test] 1360 fn create_with_sha256_object_format_round_trips() { 1361 let dir = tempfile::tempdir().unwrap(); 1362 let layout = Layout::new(dir.path()).with_object_format(ObjectFormat::SHA256); 1363 let did = RepoDid::new("did:plc:squid").unwrap(); 1364 1365 let repo = layout.create(&did).unwrap(); 1366 assert_eq!(repo.object_format(), ObjectFormat::SHA256); 1367 let oid = Oid::from(repo.git().write_blob(b"hello sha256\n").unwrap().detach()); 1368 assert_eq!( 1369 oid.to_hex().len(), 1370 64, 1371 "sha256 repo names objects with 32-byte digests" 1372 ); 1373 1374 let reopened = layout.open(&did).unwrap(); 1375 assert_eq!( 1376 reopened.object_format(), 1377 ObjectFormat::SHA256, 1378 "object format survives reopen, read from repo config" 1379 ); 1380 } 1381 1382 #[test] 1383 fn compare_and_swap_governs_every_ref_write() { 1384 let (_dir, layout, did) = repo(); 1385 let repo = layout.create(&did).unwrap(); 1386 let main = head_ref(); 1387 1388 repo.update_ref(&RefUpdate::Create { 1389 name: main.clone(), 1390 new: oid(A), 1391 }) 1392 .unwrap(); 1393 assert!( 1394 repo.update_ref(&RefUpdate::Create { 1395 name: main.clone(), 1396 new: oid(B), 1397 }) 1398 .is_err() 1399 ); 1400 assert!( 1401 repo.update_ref(&RefUpdate::Update { 1402 name: main.clone(), 1403 old: oid(B), 1404 new: oid(A), 1405 }) 1406 .is_err() 1407 ); 1408 repo.update_ref(&RefUpdate::Update { 1409 name: main.clone(), 1410 old: oid(A), 1411 new: oid(B), 1412 }) 1413 .unwrap(); 1414 let refs = repo.references().unwrap(); 1415 assert_eq!(refs.len(), 1); 1416 assert_eq!(refs[0].target, oid(B)); 1417 1418 drop(repo); 1419 let repo = layout.open(&did).unwrap(); 1420 assert_eq!( 1421 repo.find_ref(&main).unwrap(), 1422 Some(oid(B)), 1423 "update is visible after reopen" 1424 ); 1425 1426 repo.update_ref(&RefUpdate::Delete { 1427 name: main.clone(), 1428 old: oid(B), 1429 }) 1430 .unwrap(); 1431 assert!(repo.references().unwrap().is_empty()); 1432 1433 let x = RefName::new("refs/heads/x").unwrap(); 1434 let y = RefName::new("refs/heads/y").unwrap(); 1435 repo.update_refs(&[ 1436 RefUpdate::Create { 1437 name: x.clone(), 1438 new: oid(A), 1439 }, 1440 RefUpdate::Create { 1441 name: y.clone(), 1442 new: oid(A), 1443 }, 1444 ]) 1445 .unwrap(); 1446 let result = repo.update_refs(&[ 1447 RefUpdate::Update { 1448 name: x.clone(), 1449 old: oid(A), 1450 new: oid(B), 1451 }, 1452 RefUpdate::Update { 1453 name: y.clone(), 1454 old: oid(B), 1455 new: oid(A), 1456 }, 1457 ]); 1458 assert!( 1459 result.is_err(), 1460 "batch with one stale compare-and-swap must fail as a whole" 1461 ); 1462 assert_eq!( 1463 repo.find_ref(&x).unwrap(), 1464 Some(oid(A)), 1465 "valid edit in failed batch must roll back" 1466 ); 1467 assert_eq!(repo.find_ref(&y).unwrap(), Some(oid(A))); 1468 } 1469 1470 #[test] 1471 fn ref_writes_leave_a_recoverable_reflog_under_the_knot_identity() { 1472 let (_dir, layout, did) = repo(); 1473 let repo = layout.create(&did).unwrap(); 1474 1475 let committer = repo 1476 .git() 1477 .committer() 1478 .expect("committer is always pinned so reflog writes never depend on ambient config") 1479 .expect("pinned committer signature parses"); 1480 assert_eq!(committer.name.to_string(), REFLOG_COMMITTER_NAME); 1481 assert_eq!(committer.email.to_string(), REFLOG_COMMITTER_EMAIL); 1482 1483 repo.update_ref(&RefUpdate::Create { 1484 name: head_ref(), 1485 new: oid(A), 1486 }) 1487 .unwrap(); 1488 repo.update_ref(&RefUpdate::Update { 1489 name: head_ref(), 1490 old: oid(A), 1491 new: oid(B), 1492 }) 1493 .unwrap(); 1494 repo.update_ref(&RefUpdate::Create { 1495 name: RefName::new("refs/tags/v1").unwrap(), 1496 new: oid(A), 1497 }) 1498 .unwrap(); 1499 1500 let logs = repo.git().git_dir().join("logs"); 1501 let branch = std::fs::read_to_string(logs.join("refs/heads/main")) 1502 .expect("branch update must leave reflog so clobbering push is recoverable"); 1503 assert!( 1504 branch.contains(A) && branch.contains(B) && branch.contains(REFLOG_COMMITTER_NAME), 1505 "branch reflog records both tips under knot identity:\n{branch}" 1506 ); 1507 assert!( 1508 logs.join("refs/tags/v1").exists(), 1509 "force_create_reflog must log tags too, not just conventional refs/heads set" 1510 ); 1511 1512 let updates = repo.reflog_updates_since(UnixSeconds::new(0)); 1513 let head_new: Vec<Oid> = updates 1514 .iter() 1515 .filter(|update| update.name == head_ref()) 1516 .map(|update| update.new) 1517 .collect(); 1518 assert!( 1519 head_new.contains(&oid(A)) && head_new.contains(&oid(B)), 1520 "both branch tips are recovered from reflog: {head_new:?}" 1521 ); 1522 let create = updates 1523 .iter() 1524 .find(|update| update.name == head_ref() && update.new == oid(A)) 1525 .unwrap(); 1526 assert_eq!(create.old, None, "branch creation has no previous oid"); 1527 let update = updates 1528 .iter() 1529 .find(|update| update.name == head_ref() && update.new == oid(B)) 1530 .unwrap(); 1531 assert_eq!(update.old, Some(oid(A)), "branch update records prior oid"); 1532 assert!( 1533 updates 1534 .iter() 1535 .any(|update| update.name.as_str() == "refs/tags/v1" && update.new == oid(A)), 1536 "tag update is recovered too" 1537 ); 1538 assert!( 1539 repo.reflog_updates_since(UnixSeconds::new(i64::MAX)) 1540 .is_empty(), 1541 "horizon past every entry filters whole reflog out" 1542 ); 1543 } 1544 1545 #[test] 1546 fn advertisement_hides_reserved_refs_and_tracks_each_change() { 1547 let (_dir, layout, did) = repo(); 1548 let repo = layout.create(&did).unwrap(); 1549 let main = head_ref(); 1550 let feature = RefName::new("refs/heads/feature").unwrap(); 1551 1552 repo.update_ref(&RefUpdate::Create { 1553 name: main.clone(), 1554 new: oid(A), 1555 }) 1556 .unwrap(); 1557 let first = repo.advertised_refs().unwrap(); 1558 assert_eq!(first.len(), 1); 1559 assert_eq!(first[0].name.as_str(), "refs/heads/main"); 1560 assert_eq!( 1561 repo.advertised_refs().unwrap(), 1562 first, 1563 "repeated advertisement with no ref change serves same answer" 1564 ); 1565 1566 repo.update_ref(&RefUpdate::Create { 1567 name: RefName::new("refs/cobs/sh.tangled.repo.collaborator/limpet").unwrap(), 1568 new: oid(B), 1569 }) 1570 .unwrap(); 1571 let advertised = repo.advertised_refs().unwrap(); 1572 assert_eq!(advertised.len(), 1, "cob ref is hidden from advertisement"); 1573 assert_eq!(advertised[0].name.as_str(), "refs/heads/main"); 1574 assert_eq!(repo.references().unwrap().len(), 2); 1575 assert!(is_reserved(&RefName::new("refs/cobs/x/y").unwrap())); 1576 1577 repo.update_ref(&RefUpdate::Create { 1578 name: feature.clone(), 1579 new: oid(B), 1580 }) 1581 .unwrap(); 1582 assert_eq!( 1583 repo.advertised_refs().unwrap().len(), 1584 2, 1585 "ref created after advertisement invalidates cached answer" 1586 ); 1587 repo.update_ref(&RefUpdate::Delete { 1588 name: feature, 1589 old: oid(B), 1590 }) 1591 .unwrap(); 1592 assert_eq!( 1593 repo.advertised_refs().unwrap().len(), 1594 1, 1595 "delete after advertisement invalidates cached answer" 1596 ); 1597 1598 drop(repo); 1599 layout.remove(&did).unwrap(); 1600 let repo = layout.create(&did).unwrap(); 1601 repo.update_ref(&RefUpdate::Create { 1602 name: RefName::new("refs/heads/new").unwrap(), 1603 new: oid(B), 1604 }) 1605 .unwrap(); 1606 let advertised = repo.advertised_refs().unwrap(); 1607 assert_eq!( 1608 advertised.len(), 1609 1, 1610 "recreated repo advertises only its own ref" 1611 ); 1612 assert_eq!( 1613 advertised[0].name.as_str(), 1614 "refs/heads/new", 1615 "deleted repo's cached advertisement mustn't survive recreation at same path" 1616 ); 1617 } 1618 1619 #[test] 1620 fn advertisement_reflects_each_update_under_concurrent_readers() { 1621 let (_dir, layout, did) = repo(); 1622 let repo = layout.create(&did).unwrap(); 1623 let main = head_ref(); 1624 repo.update_ref(&RefUpdate::Create { 1625 name: main.clone(), 1626 new: oid(A), 1627 }) 1628 .unwrap(); 1629 drop(repo); 1630 1631 let stop = std::sync::atomic::AtomicBool::new(false); 1632 std::thread::scope(|scope| { 1633 (0..4).for_each(|_| { 1634 scope.spawn(|| { 1635 let reader = layout.open(&did).unwrap(); 1636 while !stop.load(Ordering::Relaxed) { 1637 let refs = reader.advertised_refs().unwrap(); 1638 assert_eq!(refs.len(), 1, "live branch is advertised exactly once"); 1639 assert!( 1640 refs[0].target == oid(A) || refs[0].target == oid(B), 1641 "reader must never observe value branch never held" 1642 ); 1643 } 1644 }); 1645 }); 1646 1647 let writer = layout.open(&did).unwrap(); 1648 (0..64).for_each(|round| { 1649 let (old, new) = if round % 2 == 0 { 1650 (oid(A), oid(B)) 1651 } else { 1652 (oid(B), oid(A)) 1653 }; 1654 writer 1655 .update_ref(&RefUpdate::Update { 1656 name: main.clone(), 1657 old, 1658 new, 1659 }) 1660 .unwrap(); 1661 assert_eq!( 1662 writer.advertised_refs().unwrap()[0].target, 1663 new, 1664 "advertisement taken after update reflects that update" 1665 ); 1666 }); 1667 stop.store(true, Ordering::Relaxed); 1668 }); 1669 } 1670 1671 #[test] 1672 fn create_honors_configured_default_branch() { 1673 let dir = tempfile::tempdir().unwrap(); 1674 let layout = Layout::new(dir.path()).with_default_branch(BranchName::new("trunk").unwrap()); 1675 let repo = layout 1676 .create(&RepoDid::new("did:plc:squid").unwrap()) 1677 .unwrap(); 1678 assert_eq!(repo.default_branch().unwrap().as_str(), "refs/heads/trunk"); 1679 } 1680 1681 #[test] 1682 fn concurrent_create_has_exactly_one_winner() { 1683 let (_dir, layout, did) = repo(); 1684 layout.create(&did).unwrap(); 1685 let main = head_ref(); 1686 1687 const C: &str = "3333333333333333333333333333333333333333"; 1688 const D: &str = "4444444444444444444444444444444444444444"; 1689 let winners = std::thread::scope(|scope| { 1690 let handles = [A, B, C, D].map(|hex| { 1691 let layout = layout.clone(); 1692 let did = did.clone(); 1693 let main = main.clone(); 1694 scope.spawn(move || { 1695 layout 1696 .open(&did) 1697 .unwrap() 1698 .update_ref(&RefUpdate::Create { 1699 name: main, 1700 new: oid(hex), 1701 }) 1702 .is_ok() 1703 }) 1704 }); 1705 handles 1706 .into_iter() 1707 .map(|handle| handle.join().unwrap()) 1708 .filter(|created| *created) 1709 .count() 1710 }); 1711 1712 assert_eq!(winners, 1, "concurrent creates of one ref mustn't both win"); 1713 assert_eq!(layout.open(&did).unwrap().references().unwrap().len(), 1); 1714 } 1715 1716 #[test] 1717 fn symref_cycle_does_not_overflow_references() { 1718 let (_dir, layout, did) = repo(); 1719 let repo = layout.create(&did).unwrap(); 1720 let symref = |name: &str, target: &str| RefEdit { 1721 change: Change::Update { 1722 log: LogChange { 1723 mode: RefLog::AndReference, 1724 force_create_reflog: false, 1725 message: "cycle".into(), 1726 }, 1727 expected: PreviousValue::Any, 1728 new: Target::Symbolic(FullName::try_from(target).unwrap()), 1729 }, 1730 name: FullName::try_from(name).unwrap(), 1731 deref: false, 1732 }; 1733 repo.git() 1734 .edit_reference(symref("refs/cycle/a", "refs/cycle/b")) 1735 .unwrap(); 1736 repo.git() 1737 .edit_reference(symref("refs/cycle/b", "refs/cycle/a")) 1738 .unwrap(); 1739 1740 let refs = repo.references().unwrap(); 1741 assert!( 1742 refs.iter() 1743 .all(|record| !record.name.as_str().starts_with("refs/cycle/")), 1744 "cyclic symref must be skipped, not resolved" 1745 ); 1746 } 1747}