crates/substrate/gix-ref-store/src/loose.rs
loose.rshistorycomment on this file
| 1 | //! [`LooseRefStore`]: `RefStore` over gitoxide loose refs and packed-refs — |
| 2 | //! the local default backend (`roots.local`). |
| 3 | //! |
| 4 | //! Atomic multi-ref compare-and-swap is layered on gitoxide's own |
| 5 | //! in-process ref transaction (`Repository::edit_references_as`), which |
| 6 | //! does the actual loose-file write, reflog append, and packed-refs |
| 7 | //! interaction. Nothing in this module shells out to `git`. |
| 8 | //! |
| 9 | //! gitoxide's file-transaction precondition check reads a ref's current |
| 10 | //! value *before* acquiring that ref's lock file, then locks and writes |
| 11 | //! without re-verifying — safe for callers who already serialize through |
| 12 | //! one in-process handle, but not for two independent `gix::Repository` |
| 13 | //! handles (two processes, or two handles opened separately in one |
| 14 | //! process) racing the same ref: both can read the same stale |
| 15 | //! precondition before either has locked anything, and both then "win". |
| 16 | //! `arch.loose-cas-discipline` requires this store to write through *its |
| 17 | //! own* compare-and-swap discipline, so [`LooseRefStore::transaction`] |
| 18 | //! closes that window itself with [`STORE_LOCK_NAME`]: a lock file, |
| 19 | //! separate from any ref's own `.lock`, that every `transaction()` call — |
| 20 | //! from any handle, any process, sharing the same on-disk repository — |
| 21 | //! must hold for the full read-check-write sequence before gitoxide's own |
| 22 | //! per-ref locking ever begins. |
| 23 | |
| 24 | use std::path::{Path, PathBuf}; |
| 25 | use std::sync::{Mutex, PoisonError}; |
| 26 | use std::time::Duration; |
| 27 | |
| 28 | use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit as GixRefEdit, RefLog}; |
| 29 | use gix::refs::{FullName, FullNameRef, Target}; |
| 30 | use gix_hash::ObjectId; |
| 31 | |
| 32 | use crate::{Error, Expected, RefEdit, RefIter, RefStore, RefStoreRead, Result, TxOutcome}; |
| 33 | |
| 34 | /// The lock file name, held for the duration of every |
| 35 | /// [`LooseRefStore::transaction`] call, that closes the precondition |
| 36 | /// TOCTOU window described in this module's doc comment. Deliberately |
| 37 | /// distinct from any ref's own name so it can never collide with a |
| 38 | /// `refs/**` path gitoxide locks internally. |
| 39 | const STORE_LOCK_NAME: &str = "gix-ref-store.lock"; |
| 40 | |
| 41 | /// How long [`LooseRefStore::transaction`] waits to acquire |
| 42 | /// [`STORE_LOCK_NAME`] before giving up. Generous relative to how long a |
| 43 | /// transaction actually holds it (a handful of small file writes), so a |
| 44 | /// legitimate queue of waiters drains rather than spuriously failing. |
| 45 | const STORE_LOCK_TIMEOUT: Duration = Duration::from_secs(5); |
| 46 | |
| 47 | /// The identity every transaction's reflog entry is written under. |
| 48 | /// |
| 49 | /// A `RefStore` write is a plumbing-level operation, not an authored |
| 50 | /// change — `gate.adoc`'s tip invariant is what carries authorship for |
| 51 | /// meta-ref content, via the commit's own signature. The reflog identity |
| 52 | /// here exists only so gitoxide has somewhere to write a committer line; |
| 53 | /// it is deliberately fixed and independent of the local `git config`, so |
| 54 | /// a `LooseRefStore` never depends on `user.name`/`user.email` being set. |
| 55 | const REFLOG_NAME: &str = "gix-ref-store"; |
| 56 | const REFLOG_EMAIL: &str = "ref-store@git-ents.invalid"; |
| 57 | const REFLOG_MESSAGE: &str = "gix-ref-store: transaction"; |
| 58 | |
| 59 | /// [`RefStore`] over a gitoxide repository's loose refs and packed-refs. |
| 60 | /// |
| 61 | /// # Examples |
| 62 | /// |
| 63 | /// ``` |
| 64 | /// use gix_ref_store::LooseRefStore; |
| 65 | /// |
| 66 | /// # fn open(dir: &std::path::Path) -> gix_ref_store::Result<()> { |
| 67 | /// let store = LooseRefStore::open(dir)?; |
| 68 | /// # let _ = store; |
| 69 | /// # Ok(()) |
| 70 | /// # } |
| 71 | /// ``` |
| 72 | pub struct LooseRefStore { |
| 73 | repo: Mutex<gix::Repository>, |
| 74 | /// The repository's git directory, captured at open time so |
| 75 | /// [`Self::store_lock_path`] can be computed without locking |
| 76 | /// [`Self::repo`] — the store-level lock must be acquired *before* any |
| 77 | /// gitoxide call touches `repo`, not while already holding it. |
| 78 | git_dir: PathBuf, |
| 79 | } |
| 80 | |
| 81 | impl LooseRefStore { |
| 82 | /// Open the ref store for the repository at `path`. |
| 83 | /// |
| 84 | /// `path` may be a work tree or the `.git` directory itself; gitoxide |
| 85 | /// resolves either the same way `git` does. |
| 86 | // @relation(arch.loose-cas-discipline, scope=function) |
| 87 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { |
| 88 | let path = path.as_ref(); |
| 89 | let repo = gix::open(path).map_err(|source| Error::Open { |
| 90 | path: path.to_path_buf(), |
| 91 | source: Box::new(source), |
| 92 | })?; |
| 93 | let git_dir = repo.git_dir().to_path_buf(); |
| 94 | Ok(Self { |
| 95 | repo: Mutex::new(repo), |
| 96 | git_dir, |
| 97 | }) |
| 98 | } |
| 99 | |
| 100 | /// Lock the underlying repository handle, recovering from a poisoned |
| 101 | /// lock rather than panicking: a panic in one caller while holding the |
| 102 | /// lock must not permanently wedge every other caller sharing this |
| 103 | /// store. |
| 104 | fn repo(&self) -> std::sync::MutexGuard<'_, gix::Repository> { |
| 105 | self.repo.lock().unwrap_or_else(PoisonError::into_inner) |
| 106 | } |
| 107 | |
| 108 | /// The fixed reflog identity every transaction is written under. See |
| 109 | /// [`REFLOG_NAME`] for why this is not the ambient `git config` |
| 110 | /// identity. |
| 111 | fn committer(&self) -> gix::actor::Signature { |
| 112 | gix::actor::Signature { |
| 113 | name: REFLOG_NAME.into(), |
| 114 | email: REFLOG_EMAIL.into(), |
| 115 | time: gix::date::Time::now_local_or_utc(), |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// The path of this store's own serialization lock — see this |
| 120 | /// module's doc comment for why `transaction` needs one beyond |
| 121 | /// whatever gitoxide locks internally. |
| 122 | fn store_lock_path(&self) -> PathBuf { |
| 123 | self.git_dir.join(STORE_LOCK_NAME) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | impl RefStoreRead for LooseRefStore { |
| 128 | fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> { |
| 129 | let repo = self.repo(); |
| 130 | let Some(mut reference) = repo |
| 131 | .try_find_reference(name.as_bstr()) |
| 132 | .map_err(|error| Error::Read(Box::new(error)))? |
| 133 | else { |
| 134 | return Ok(None); |
| 135 | }; |
| 136 | let id = reference |
| 137 | .follow_to_object() |
| 138 | .map_err(|error| Error::Read(Box::new(error)))?; |
| 139 | Ok(Some(id.detach())) |
| 140 | } |
| 141 | |
| 142 | fn iter_prefix(&self, prefix: &str) -> Result<RefIter> { |
| 143 | let repo = self.repo(); |
| 144 | let platform = repo |
| 145 | .references() |
| 146 | .map_err(|error| Error::Read(Box::new(error)))?; |
| 147 | let iter = platform |
| 148 | .prefixed(prefix) |
| 149 | .map_err(|error| Error::Read(Box::new(error)))?; |
| 150 | |
| 151 | let mut out = Vec::new(); |
| 152 | for reference in iter { |
| 153 | let mut reference = reference.map_err(Error::Read)?; |
| 154 | let name = reference.name().to_owned(); |
| 155 | let oid = reference |
| 156 | .follow_to_object() |
| 157 | .map_err(|error| Error::Read(Box::new(error)))? |
| 158 | .detach(); |
| 159 | out.push(Ok((name, oid))); |
| 160 | } |
| 161 | Ok(RefIter::new(out.into_iter())) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | impl RefStore for LooseRefStore { |
| 166 | // @relation(arch.loose-cas-discipline, scope=function) |
| 167 | fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> { |
| 168 | // Close the precondition-read-before-lock race described in this |
| 169 | // module's doc comment: no other `transaction()` call, on this |
| 170 | // handle or any other handle sharing this on-disk repository, may |
| 171 | // be inside its own read-check-write sequence while we are. |
| 172 | let _store_lock = gix_lock::Marker::acquire_to_hold_resource( |
| 173 | self.store_lock_path(), |
| 174 | gix_lock::acquire::Fail::AfterDurationWithBackoff(STORE_LOCK_TIMEOUT), |
| 175 | Some(self.git_dir.clone()), |
| 176 | ) |
| 177 | .map_err(Error::StoreLock)?; |
| 178 | |
| 179 | let gix_edits: Vec<GixRefEdit> = edits.iter().map(to_gix_edit).collect(); |
| 180 | let committer = self.committer(); |
| 181 | let mut buf = gix::date::parse::TimeBuf::default(); |
| 182 | match self |
| 183 | .repo() |
| 184 | .edit_references_as(gix_edits, Some(committer.to_ref(&mut buf))) |
| 185 | { |
| 186 | Ok(_applied) => Ok(TxOutcome::Applied), |
| 187 | Err(error) => match rejected_name(&error) { |
| 188 | Some(name) => Ok(TxOutcome::Rejected { name }), |
| 189 | None => Err(Error::Transaction(error)), |
| 190 | }, |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | /// Convert one backend-agnostic [`RefEdit`] into gitoxide's own |
| 196 | /// transaction edit type. |
| 197 | fn to_gix_edit(edit: &RefEdit) -> GixRefEdit { |
| 198 | let change = match edit.new { |
| 199 | Some(oid) => Change::Update { |
| 200 | log: LogChange { |
| 201 | mode: RefLog::AndReference, |
| 202 | // gitoxide only auto-creates a missing reflog for |
| 203 | // refs/heads/, refs/remotes/, refs/notes/, and HEAD unless |
| 204 | // told otherwise; this project's refs mostly live under |
| 205 | // refs/meta/*, which needs a log regardless of namespace. |
| 206 | force_create_reflog: true, |
| 207 | message: REFLOG_MESSAGE.into(), |
| 208 | }, |
| 209 | expected: to_previous_value(&edit.expected), |
| 210 | new: Target::Object(oid), |
| 211 | }, |
| 212 | None => Change::Delete { |
| 213 | expected: to_previous_value(&edit.expected), |
| 214 | log: RefLog::AndReference, |
| 215 | }, |
| 216 | }; |
| 217 | GixRefEdit { |
| 218 | change, |
| 219 | name: edit.name.clone(), |
| 220 | deref: false, |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Map a backend-agnostic [`Expected`] precondition onto gitoxide's own |
| 225 | /// [`PreviousValue`]. |
| 226 | fn to_previous_value(expected: &Expected) -> PreviousValue { |
| 227 | match expected { |
| 228 | Expected::Any => PreviousValue::Any, |
| 229 | Expected::MustNotExist => PreviousValue::MustNotExist, |
| 230 | Expected::MustExistAndMatch(oid) => PreviousValue::MustExistAndMatch(Target::Object(*oid)), |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /// The ref name a rejected transaction's compare-and-swap precondition |
| 235 | /// failed on, or `None` when `error` is not a CAS mismatch (some other |
| 236 | /// failure — a lock timeout, an I/O error — that should propagate as |
| 237 | /// `Err`, not `Ok(TxOutcome::Rejected)`). |
| 238 | fn rejected_name(error: &gix::reference::edit::Error) -> Option<FullName> { |
| 239 | let gix::reference::edit::Error::FileTransactionPrepare(prepare_error) = error else { |
| 240 | return None; |
| 241 | }; |
| 242 | use gix::refs::file::transaction::prepare::Error as PrepareError; |
| 243 | let full_name = match prepare_error { |
| 244 | PrepareError::MustNotExist { full_name, .. } |
| 245 | | PrepareError::MustExist { full_name, .. } |
| 246 | | PrepareError::ReferenceOutOfDate { full_name, .. } |
| 247 | | PrepareError::DeleteReferenceMustExist { full_name, .. } => full_name, |
| 248 | _ => return None, |
| 249 | }; |
| 250 | full_name_from_bytes(full_name.clone()) |
| 251 | } |
| 252 | |
| 253 | /// Reconstruct a validated [`FullName`] from the raw bytes a |
| 254 | /// `prepare::Error` variant carries. |
| 255 | /// |
| 256 | /// These bytes always originated from a [`FullName`] we constructed |
| 257 | /// ourselves in [`to_gix_edit`] and handed to gitoxide, so re-validating |
| 258 | /// them can only fail if gitoxide's own transaction machinery corrupted a |
| 259 | /// name it was given — a backend bug, not a caller error. `None` is |
| 260 | /// returned rather than panicking so a hypothetical future gitoxide |
| 261 | /// version that reports a differently-shaped name degrades to "not |
| 262 | /// recognized as a CAS rejection" instead of crashing the caller. |
| 263 | fn full_name_from_bytes(bytes: gix::bstr::BString) -> Option<FullName> { |
| 264 | FullName::try_from(bytes).ok() |
| 265 | } |
| 266 | |
| 267 | #[cfg(test)] |
| 268 | mod tests { |
| 269 | #![allow(clippy::unwrap_used, reason = "unit test")] |
| 270 | |
| 271 | use gix_hash::ObjectId; |
| 272 | |
| 273 | use super::LooseRefStore; |
| 274 | use crate::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome}; |
| 275 | |
| 276 | fn init_repo() -> tempfile::TempDir { |
| 277 | let dir = tempfile::tempdir().unwrap(); |
| 278 | gix::init(dir.path()).unwrap(); |
| 279 | dir |
| 280 | } |
| 281 | |
| 282 | fn name(s: &str) -> gix::refs::FullName { |
| 283 | s.try_into().unwrap() |
| 284 | } |
| 285 | |
| 286 | fn fixture_oid(byte: u8) -> ObjectId { |
| 287 | ObjectId::from_bytes_or_panic(&[byte; 20]) |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn get_returns_none_for_an_absent_ref() { |
| 292 | let dir = init_repo(); |
| 293 | let store = LooseRefStore::open(dir.path()).unwrap(); |
| 294 | assert_eq!(store.get(name("refs/heads/nope").as_ref()).unwrap(), None); |
| 295 | } |
| 296 | |
| 297 | #[test] |
| 298 | fn transaction_creates_a_ref_then_rejects_a_stale_cas() { |
| 299 | let dir = init_repo(); |
| 300 | let store = LooseRefStore::open(dir.path()).unwrap(); |
| 301 | let first = fixture_oid(1); |
| 302 | let second = fixture_oid(2); |
| 303 | |
| 304 | let create = RefEdit { |
| 305 | name: name("refs/heads/topic"), |
| 306 | expected: Expected::MustNotExist, |
| 307 | new: Some(first), |
| 308 | }; |
| 309 | assert_eq!(store.transaction(&[create]).unwrap(), TxOutcome::Applied); |
| 310 | assert_eq!( |
| 311 | store.get(name("refs/heads/topic").as_ref()).unwrap(), |
| 312 | Some(first) |
| 313 | ); |
| 314 | |
| 315 | // Re-asserting must-not-exist while the ref already exists is a |
| 316 | // CAS mismatch, reported as `Rejected`, not an `Err`. |
| 317 | let recreate = RefEdit { |
| 318 | name: name("refs/heads/topic"), |
| 319 | expected: Expected::MustNotExist, |
| 320 | new: Some(second), |
| 321 | }; |
| 322 | let outcome = store.transaction(&[recreate]).unwrap(); |
| 323 | assert_eq!( |
| 324 | outcome, |
| 325 | TxOutcome::Rejected { |
| 326 | name: name("refs/heads/topic") |
| 327 | } |
| 328 | ); |
| 329 | // The rejected edit must not have applied. |
| 330 | assert_eq!( |
| 331 | store.get(name("refs/heads/topic").as_ref()).unwrap(), |
| 332 | Some(first) |
| 333 | ); |
| 334 | } |
| 335 | |
| 336 | #[test] |
| 337 | fn transaction_is_all_or_nothing_across_multiple_edits() { |
| 338 | let dir = init_repo(); |
| 339 | let store = LooseRefStore::open(dir.path()).unwrap(); |
| 340 | let oid = fixture_oid(3); |
| 341 | |
| 342 | // The second edit's precondition already fails (the ref doesn't |
| 343 | // exist yet), so neither edit should apply. |
| 344 | let edits = [ |
| 345 | RefEdit { |
| 346 | name: name("refs/heads/a"), |
| 347 | expected: Expected::MustNotExist, |
| 348 | new: Some(oid), |
| 349 | }, |
| 350 | RefEdit { |
| 351 | name: name("refs/heads/b"), |
| 352 | expected: Expected::MustExistAndMatch(oid), |
| 353 | new: Some(oid), |
| 354 | }, |
| 355 | ]; |
| 356 | let outcome = store.transaction(&edits).unwrap(); |
| 357 | assert!(matches!(outcome, TxOutcome::Rejected { .. })); |
| 358 | assert_eq!(store.get(name("refs/heads/a").as_ref()).unwrap(), None); |
| 359 | } |
| 360 | |
| 361 | #[test] |
| 362 | fn iter_prefix_lists_matching_refs() { |
| 363 | let dir = init_repo(); |
| 364 | let store = LooseRefStore::open(dir.path()).unwrap(); |
| 365 | let oid = fixture_oid(4); |
| 366 | store |
| 367 | .transaction(&[RefEdit { |
| 368 | name: name("refs/meta/thing"), |
| 369 | expected: Expected::MustNotExist, |
| 370 | new: Some(oid), |
| 371 | }]) |
| 372 | .unwrap(); |
| 373 | store |
| 374 | .transaction(&[RefEdit { |
| 375 | name: name("refs/heads/unrelated"), |
| 376 | expected: Expected::MustNotExist, |
| 377 | new: Some(oid), |
| 378 | }]) |
| 379 | .unwrap(); |
| 380 | |
| 381 | let names: Vec<String> = store |
| 382 | .iter_prefix("refs/meta/") |
| 383 | .unwrap() |
| 384 | .map(|item| item.unwrap().0.as_bstr().to_string()) |
| 385 | .collect(); |
| 386 | assert_eq!(names, vec!["refs/meta/thing".to_owned()]); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn delete_removes_a_ref() { |
| 391 | let dir = init_repo(); |
| 392 | let store = LooseRefStore::open(dir.path()).unwrap(); |
| 393 | let oid = fixture_oid(5); |
| 394 | store |
| 395 | .transaction(&[RefEdit { |
| 396 | name: name("refs/meta/gone"), |
| 397 | expected: Expected::MustNotExist, |
| 398 | new: Some(oid), |
| 399 | }]) |
| 400 | .unwrap(); |
| 401 | assert_eq!( |
| 402 | store.get(name("refs/meta/gone").as_ref()).unwrap(), |
| 403 | Some(oid) |
| 404 | ); |
| 405 | |
| 406 | let outcome = store |
| 407 | .transaction(&[RefEdit { |
| 408 | name: name("refs/meta/gone"), |
| 409 | expected: Expected::MustExistAndMatch(oid), |
| 410 | new: None, |
| 411 | }]) |
| 412 | .unwrap(); |
| 413 | assert_eq!(outcome, TxOutcome::Applied); |
| 414 | assert_eq!(store.get(name("refs/meta/gone").as_ref()).unwrap(), None); |
| 415 | } |
| 416 | } |