dynamic_config_git/working.rs
1//! Where the objects live between fetches, and who may read them.
2//!
3//! A git store needs somewhere to put an object database. That directory holds
4//! the contents of a private repository, so it is created **private at
5//! creation** — `0700`, by the call that creates it, never by a `chmod`
6//! afterwards. A `chmod` after the fact leaves a window in which the directory
7//! is world-readable, and a window is all an attacker on a shared host needs.
8//! It is the same rule the core crate's on-disk cache follows.
9//!
10//! # Two places it can be
11//!
12//! **A temporary directory, by default.** Made when the first fetch runs,
13//! removed when the source is dropped. Nothing survives the process, which is
14//! the right default for a container: no growth to manage, no stale objects, no
15//! secret left on a volume after the pod dies. It costs one full fetch per
16//! process start.
17//!
18//! **A directory the caller names**, with [`GitSource::cache_dir`]. It survives
19//! restarts, so a restart transfers almost nothing — worth it for a large
20//! repository or a fleet that restarts often.
21//!
22//! # What a store may delete, and where
23//!
24//! Either directory grows: a shallow fetch of a moving branch writes one pack
25//! per transfer, the old ones stop being reachable the moment the local ref
26//! moves, and nothing in git removes them until a `gc`. A long-lived watcher
27//! accumulates one pack per push for as long as it runs, and a caller-named
28//! directory carries that on across restarts.
29//!
30//! So this crate compacts, and the rule it compacts under is narrow enough to
31//! state in one line: **it deletes only what it wrote, only in a directory it
32//! created for itself, and only on a trigger the caller can see and turn off.**
33//!
34//! - *what it wrote* — the working directory is a bare object database holding
35//! packs from this crate's fetches and one ref this crate writes. There is
36//! nothing else in it to lose, and what is removed is re-obtained by the very
37//! fetch that removed it.
38//! - *a directory it created* — a marker file is written when this crate
39//! initialises the object database, and a directory without it is **never**
40//! touched. A caller who points [`cache_dir`](crate::Builder::cache_dir) at a
41//! repository that already exists gets the old behaviour, unpruned, rather
42//! than losing a repository to a store that assumed the directory was its
43//! own.
44//! - *a visible trigger* — [`Builder::compact_after`](crate::Builder::compact_after)
45//! is the number of transfers a directory may accumulate, thirty-two by
46//! default, and `0` turns it off entirely for a caller who would rather run
47//! `git gc --prune=now` on their own cadence.
48//!
49//! The compaction is not a `gc`: it empties the object database and lets the
50//! next fetch refill it, which is one full transfer every `compact_after`
51//! pushes and leaves the directory holding exactly the current commit again.
52//! Deleting a pack *and repacking* would be less to transfer and much more to
53//! get wrong — a store that rewrites an object database is a store that can
54//! corrupt one, and this one cannot: the only thing it can lose is a copy of
55//! something the remote still has.
56//!
57//! It happens **before** a fetch that is going to read, never after one and
58//! never on a watch's idle tick, so nothing the current call depends on is
59//! removed and nothing is emptied that is not about to be refilled.
60//!
61//! # One directory, one source
62//!
63//! Two sources fetching into one directory would interleave their ref updates
64//! and their packs. A caller-named directory is therefore **claimed** by the
65//! source that names it, and a second source in the same program naming the
66//! same directory is refused at construction — before anything is written —
67//! rather than corrupting it. The default, a temporary directory per source,
68//! cannot collide at all.
69//!
70//! # Two processes are still not detected, and that is a decision
71//!
72//! The claim above is in this process only. Two *programs* pointed at one
73//! directory are not detected, are not supported, and this crate will not grow
74//! a lock file to change that. The reasoning is worth writing down, because
75//! "add a lock file" is the obvious answer and it is the wrong one here:
76//!
77//! - **A stale-lock heuristic is wrong exactly where sharing happens.** The
78//! deployment that shares a working directory is not two programs on one
79//! host; it is one volume mounted into two containers. Pid liveness cannot
80//! see across a pid namespace — both containers have a live pid 1, so a lock
81//! left by a dead process reads as held and a lock held by a live one reads
82//! as held for a different reason. An age bound replaces that with a guess
83//! about how long a fetch may take, and two nodes writing one network volume
84//! do not agree on the clock the guess is measured against.
85//! - **An advisory lock the kernel releases has no staleness — and no reach.**
86//! `flock` would be the correct mechanism, and it is unreliable on precisely
87//! the network filesystems that make the sharing possible in the first place.
88//! It also costs a dependency in a crate whose small graph is one of the
89//! things it claims, to convert an unsupported configuration into a start-up
90//! failure on filesystems that do not implement locking.
91//!
92//! What is done instead is to bound the damage. Concurrent *fetches* into one
93//! object database are what git itself is built for: objects are written to a
94//! temporary file and renamed, and a ref update takes a `.lock`. Compaction is
95//! the part this crate added, and it is why the marker above is required: a
96//! program that empties a directory another program is reading costs that
97//! program **one failed fetch**, which is a failure this crate already promises
98//! to survive — the previously fetched document stays installed and a watch
99//! waits out the interval. That is asserted rather than claimed:
100//! `a_working_directory_emptied_by_another_program_costs_a_fetch_rather_than_the_source`
101//! empties one underneath a live source and fetches again. Give each program
102//! its own directory anyway.
103//!
104//! [`GitSource::cache_dir`]: crate::Builder::cache_dir
105
106use std::collections::HashSet;
107use std::path::{Path, PathBuf};
108use std::sync::atomic::{AtomicU64, Ordering};
109use std::sync::Mutex;
110
111use dynamic_config::Error;
112
113/// How many transfers a working directory may accumulate before it is emptied.
114///
115/// One transfer is one pack, so this is also the number of pack files the
116/// object database is allowed to hold. Thirty-two is chosen from both ends: a
117/// configuration repository that moves twice a month reaches it in a year and a
118/// half and effectively never compacts, while a branch that moves every few
119/// minutes reaches it in an afternoon — and pays one full transfer of the
120/// current tree for every thirty-two pushes, which is a bounded fraction of
121/// what it was going to transfer anyway.
122pub(crate) const AFTER: u32 = 32;
123
124/// The file that says this directory is this crate's to empty.
125///
126/// Written when this crate initialises the object database, and checked before
127/// anything is ever deleted. A directory that does not have it was not created
128/// here — a caller-named path that already held a repository is the case that
129/// matters — and is never compacted.
130pub(crate) const MARKER: &str = "dynamic-config-store";
131
132/// What [`MARKER`] says to whoever finds it.
133const MARKER_TEXT: &str = "\
134This directory is the object cache of a `dynamic-config-git` source.
135
136Everything in it was fetched by that crate, and that crate empties it once it
137holds more packs than `GitSource::builder(..).compact_after(..)` allows —
138re-fetching what it needs. Do not keep anything here.
139";
140
141/// Names the temporary directories apart within one process.
142static SEQUENCE: AtomicU64 = AtomicU64::new(0);
143
144/// The caller-named directories some live source is using.
145static CLAIMED: Mutex<Option<HashSet<PathBuf>>> = Mutex::new(None);
146
147fn claimed() -> std::sync::MutexGuard<'static, Option<HashSet<PathBuf>>> {
148 CLAIMED
149 .lock()
150 .unwrap_or_else(std::sync::PoisonError::into_inner)
151}
152
153/// Where a source keeps its object database.
154#[derive(Debug)]
155pub(crate) enum Working {
156 /// Ours, and removed with the source.
157 Temporary(Temporary),
158 /// The caller's, and left alone — but claimed while the source lives.
159 Named(Claimed),
160}
161
162impl Working {
163 /// The directory, creating it if this is the first fetch.
164 ///
165 /// # Errors
166 ///
167 /// If the directory cannot be created.
168 pub(crate) fn path(&self) -> Result<&Path, Error> {
169 match self {
170 Self::Temporary(temporary) => Ok(temporary.path()),
171 Self::Named(claimed) => {
172 let path = claimed.path();
173
174 if !path.exists() {
175 create_private(path).map_err(|error| {
176 Error::remote(format!(
177 "git: cannot create the working directory {}: {error}",
178 path.display()
179 ))
180 })?;
181 }
182
183 Ok(path)
184 }
185 }
186 }
187}
188
189/// Records that this crate created `directory`, so it may empty it later.
190///
191/// # Errors
192///
193/// If the marker cannot be written. That is reported rather than shrugged off:
194/// a working directory this crate can initialise but not write a file into is
195/// one the next fetch is going to fail in anyway, and failing here says so
196/// where the cause is visible.
197pub(crate) fn mark(directory: &Path) -> Result<(), Error> {
198 std::fs::write(directory.join(MARKER), MARKER_TEXT).map_err(|error| {
199 Error::remote(format!(
200 "git: cannot write {} in the working directory {}: {error}",
201 MARKER,
202 directory.display()
203 ))
204 })
205}
206
207/// Whether this crate created `directory` — the one thing that licenses a
208/// delete.
209fn ours(directory: &Path) -> bool {
210 directory.join(MARKER).is_file()
211}
212
213/// How many packs the object database holds, which is how many fetches
214/// transferred anything into it.
215fn packs(directory: &Path) -> usize {
216 let Ok(entries) = std::fs::read_dir(directory.join("objects").join("pack")) else {
217 return 0;
218 };
219
220 entries
221 .flatten()
222 .filter(|entry| entry.path().extension().is_some_and(|kind| kind == "pack"))
223 .count()
224}
225
226/// Empties `directory` when it has accumulated more than `after` transfers.
227///
228/// Returns whether anything was removed. See the [module
229/// documentation](self#what-a-store-may-delete-and-where) for the rule this
230/// enforces; the short version is that a directory without [`MARKER`] in it is
231/// never touched, and `after` of `0` turns the whole thing off.
232///
233/// # Errors
234///
235/// If the directory is this crate's, is over the bound, and cannot be emptied.
236/// A half-emptied object database is not a working one, so this is a failed
237/// fetch rather than something to carry on past.
238pub(crate) fn compact(directory: &Path, after: u32) -> Result<bool, Error> {
239 if after == 0 || !ours(directory) || packs(directory) <= after as usize {
240 return Ok(false);
241 }
242
243 empty(directory).map_err(|error| {
244 Error::remote(format!(
245 "git: cannot empty the working directory {}: {error}",
246 directory.display()
247 ))
248 })?;
249
250 Ok(true)
251}
252
253/// Removes everything in `directory`, leaving the directory itself.
254///
255/// The directory itself stays because the caller may have named it and may have
256/// given it permissions or an ownership this crate should not be re-deciding —
257/// and because removing it would race with anything holding it open. What it
258/// held is rebuilt by the `init_bare` on the next line of the fetch.
259fn empty(directory: &Path) -> std::io::Result<()> {
260 for entry in std::fs::read_dir(directory)? {
261 let entry = entry?;
262
263 // The entry's own type, which does not follow a link: a symlink planted
264 // here is removed as a link rather than followed into whatever it
265 // points at.
266 if entry.file_type()?.is_dir() {
267 std::fs::remove_dir_all(entry.path())?;
268 } else {
269 std::fs::remove_file(entry.path())?;
270 }
271 }
272
273 Ok(())
274}
275
276/// A caller-named directory, held against a second source in this process.
277pub(crate) struct Claimed {
278 /// As the caller wrote it: what is opened, and what error messages say.
279 path: PathBuf,
280 /// What is actually held, by [`identity`]. Two spellings of one
281 /// directory claim the same thing.
282 identity: PathBuf,
283}
284
285impl Claimed {
286 /// Claims `path`.
287 ///
288 /// # Errors
289 ///
290 /// If another live source in this process already named it — under any
291 /// spelling. `cache` and `./cache` are one directory, and so are two
292 /// symlinks to it; a lexical comparison would have let both through and
293 /// given each its own fetch mutex, which is two fetches into one object
294 /// database and a `compact` that can empty it while the other is
295 /// reading.
296 pub(crate) fn new(path: PathBuf) -> Result<Self, Error> {
297 let identity = identity(&path);
298
299 let mut claimed = claimed();
300 let taken = claimed.get_or_insert_with(HashSet::new);
301
302 if !taken.insert(identity.clone()) {
303 return Err(Error::remote(format!(
304 "git: {} is already the working directory of another source in \
305 this program; two sources fetching into one directory would \
306 corrupt it, so give each its own",
307 path.display()
308 )));
309 }
310
311 Ok(Self { path, identity })
312 }
313
314 fn path(&self) -> &Path {
315 self.path.as_path()
316 }
317}
318
319/// What two spellings of one directory have in common.
320///
321/// `canonicalize` where it can: it resolves `.`, `..`, a relative path
322/// against the working directory, and every symlink on the way. It needs
323/// the path to *exist*, though, and a working directory is routinely
324/// claimed before it is created — so what does not exist yet is
325/// canonicalized as far as it goes and the missing tail is appended.
326///
327/// Neither step is a security boundary and neither is asked to be. This is
328/// a same-process bookkeeping question — which is why the answer is allowed
329/// to fall back to the path as written when the filesystem will not answer
330/// at all.
331fn identity(path: &Path) -> PathBuf {
332 if let Ok(resolved) = path.canonicalize() {
333 return resolved;
334 }
335
336 let mut missing = Vec::new();
337 let mut existing = path;
338
339 while let (Some(parent), Some(name)) = (existing.parent(), existing.file_name()) {
340 missing.push(name.to_owned());
341 existing = parent;
342
343 if let Ok(resolved) = existing.canonicalize() {
344 let mut identity = resolved;
345
346 for name in missing.iter().rev() {
347 identity.push(name);
348 }
349
350 return identity;
351 }
352 }
353
354 // Nothing on the path exists, so there is nothing to resolve against.
355 // `absolute` still folds `./` and settles a relative path against the
356 // working directory, which is the pair of spellings this is mostly
357 // about; a failure leaves the path as the caller wrote it.
358 std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
359}
360
361impl std::fmt::Debug for Claimed {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 f.debug_tuple("Claimed").field(&self.path).finish()
364 }
365}
366
367impl Drop for Claimed {
368 fn drop(&mut self) {
369 if let Some(taken) = claimed().as_mut() {
370 taken.remove(&self.identity);
371 }
372 }
373}
374
375/// A directory this process made and will remove.
376pub(crate) struct Temporary(PathBuf);
377
378impl Temporary {
379 /// Makes one, private, under the system temporary directory.
380 ///
381 /// # Errors
382 ///
383 /// If it cannot be created. The name carries the process id and a counter,
384 /// so `create` — which is not recursive and therefore fails on an existing
385 /// entry — is a claim rather than a race: a directory or symlink somebody
386 /// planted at that path makes this fail rather than be adopted.
387 pub(crate) fn new() -> Result<Self, Error> {
388 let path = std::env::temp_dir().join(format!(
389 "dynamic-config-git-{}-{}",
390 std::process::id(),
391 SEQUENCE.fetch_add(1, Ordering::Relaxed)
392 ));
393
394 create_private(&path).map_err(|error| {
395 Error::remote(format!(
396 "git: cannot create a working directory at {}: {error}",
397 path.display()
398 ))
399 })?;
400
401 Ok(Self(path))
402 }
403
404 pub(crate) fn path(&self) -> &Path {
405 self.0.as_path()
406 }
407}
408
409impl std::fmt::Debug for Temporary {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 f.debug_tuple("Temporary").field(&self.0).finish()
412 }
413}
414
415impl Drop for Temporary {
416 fn drop(&mut self) {
417 // Best effort: a directory that cannot be removed is a leaked
418 // temporary directory, not a reason to panic in a `Drop`.
419 let _ = std::fs::remove_dir_all(&self.0);
420 }
421}
422
423/// Creates `path`, readable only by this user, with no window in between.
424///
425/// Not recursive, on purpose: a recursive create silently adopts whatever is
426/// already there, and what is already there might be a symlink into somebody
427/// else's directory.
428/// Two functions rather than one with a `#[cfg]` block inside it: the block
429/// form leaves the `let mut builder` it needs unused on Windows, which is an
430/// error under this workspace's `-D warnings` and one that reading the Unix
431/// branch will never show you.
432#[cfg(unix)]
433fn create_private(path: &Path) -> std::io::Result<()> {
434 use std::os::unix::fs::DirBuilderExt as _;
435
436 std::fs::DirBuilder::new().mode(0o700).create(path)
437}
438
439/// **Windows has no `0700`**, and this crate does not pretend otherwise: the
440/// directory inherits the parent's ACL. A deployment keeping fetched objects
441/// on a shared Windows host should name a [`cache_dir`] whose permissions it
442/// has already decided, rather than trusting a default this crate did not
443/// set.
444///
445/// [`cache_dir`]: crate::Builder::cache_dir
446#[cfg(not(unix))]
447fn create_private(path: &Path) -> std::io::Result<()> {
448 std::fs::DirBuilder::new().create(path)
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn a_temporary_directory_is_private_from_the_moment_it_exists() {
457 let temporary = Temporary::new().unwrap();
458
459 assert!(temporary.path().is_dir());
460
461 #[cfg(unix)]
462 {
463 use std::os::unix::fs::PermissionsExt;
464
465 let mode = std::fs::metadata(temporary.path())
466 .unwrap()
467 .permissions()
468 .mode();
469
470 assert_eq!(
471 mode & 0o777,
472 0o700,
473 "objects from a private repository must not be world-readable"
474 );
475 }
476 }
477
478 #[test]
479 fn a_temporary_directory_is_removed_with_its_source() {
480 let path = {
481 let temporary = Temporary::new().unwrap();
482 temporary.path().to_owned()
483 };
484
485 assert!(!path.exists(), "nothing survives the source");
486 }
487
488 #[test]
489 fn two_sources_never_share_a_temporary_directory() {
490 let one = Temporary::new().unwrap();
491 let other = Temporary::new().unwrap();
492
493 assert_ne!(one.path(), other.path());
494 }
495
496 #[test]
497 fn a_named_directory_is_refused_to_a_second_source() {
498 let directory = Temporary::new().unwrap();
499 let path = directory.path().join("shared");
500
501 let first = Claimed::new(path.clone()).expect("nobody has it yet");
502
503 let error = Claimed::new(path.clone())
504 .expect_err("two sources fetching into one directory would corrupt it");
505 assert!(
506 error.to_string().contains("already the working directory"),
507 "{error}"
508 );
509
510 // ...and dropping the first source hands it back, so a program that
511 // replaces a source can reuse the directory it paid to fill.
512 drop(first);
513 Claimed::new(path).expect("the claim ends with the source");
514 }
515
516 /// The claim is on a *directory*, not on a string. Two spellings of one
517 /// path — and two symlinks to it — are one claim, because what they
518 /// would share is one object database and one `compact` that can empty
519 /// it under the other reader.
520 #[test]
521 fn one_directory_under_two_spellings_is_one_claim() {
522 let directory = Temporary::new().unwrap();
523 let path = directory.path().join("shared");
524 std::fs::create_dir(&path).unwrap();
525
526 let _first = Claimed::new(path.clone()).expect("nobody has it yet");
527
528 // `dir/./shared` and `dir/shared/../shared` name what the first
529 // source is already fetching into.
530 for spelling in [
531 directory.path().join(".").join("shared"),
532 path.join("..").join("shared"),
533 ] {
534 Claimed::new(spelling.clone())
535 .expect_err("the same directory, spelled differently, is the same directory");
536 }
537
538 // And through a symlink, which is the shape a deployment reaches by
539 // accident rather than by writing `..`.
540 #[cfg(unix)]
541 {
542 let link = directory.path().join("by-another-name");
543 std::os::unix::fs::symlink(&path, &link).unwrap();
544
545 Claimed::new(link).expect_err("a symlink to a claimed directory is that directory");
546 }
547 }
548
549 /// A working directory is routinely claimed before it exists — the
550 /// fetch creates it — so the identity has to survive a path the
551 /// filesystem cannot resolve yet, and still see through the part of it
552 /// that does exist.
553 #[test]
554 fn a_directory_that_does_not_exist_yet_can_still_be_claimed_once() {
555 let directory = Temporary::new().unwrap();
556 let path = directory.path().join("not-yet");
557
558 let _first = Claimed::new(path.clone()).expect("nobody has it yet");
559
560 Claimed::new(directory.path().join(".").join("not-yet"))
561 .expect_err("the same directory that does not exist yet is still the same one");
562 }
563
564 /// Builds a directory that looks like an object database holding `count`
565 /// packs, marked or not.
566 fn a_working_directory(marked: bool, count: usize) -> Temporary {
567 let directory = Temporary::new().unwrap();
568 let packs = directory.path().join("objects").join("pack");
569
570 std::fs::create_dir_all(&packs).unwrap();
571
572 for index in 0..count {
573 std::fs::write(packs.join(format!("pack-{index}.pack")), b"not really").unwrap();
574 std::fs::write(packs.join(format!("pack-{index}.idx")), b"nor this").unwrap();
575 }
576
577 if marked {
578 mark(directory.path()).unwrap();
579 }
580
581 directory
582 }
583
584 #[test]
585 fn a_directory_under_the_bound_is_left_alone_and_one_over_it_is_emptied() {
586 let directory = a_working_directory(true, 4);
587
588 assert!(
589 !compact(directory.path(), 4).unwrap(),
590 "four packs is not more than four"
591 );
592 assert!(directory.path().join("objects").is_dir());
593
594 let directory = a_working_directory(true, 5);
595
596 assert!(compact(directory.path(), 4).unwrap(), "five is");
597 assert!(
598 !directory.path().join("objects").exists(),
599 "the object database is what compaction removes"
600 );
601 assert_eq!(
602 std::fs::read_dir(directory.path()).unwrap().count(),
603 0,
604 "and it leaves nothing behind but the directory itself"
605 );
606 assert!(directory.path().is_dir());
607 }
608
609 /// **The check that makes deleting defensible.** A caller who names a
610 /// directory that is already a repository has not given this crate
611 /// permission to empty it, and nothing here asks whether it looks empty
612 /// enough.
613 #[test]
614 fn a_directory_this_crate_did_not_create_is_never_emptied() {
615 let directory = a_working_directory(false, 50);
616
617 assert!(
618 !compact(directory.path(), 4).unwrap(),
619 "no marker, no delete — at any size"
620 );
621 assert_eq!(
622 std::fs::read_dir(directory.path().join("objects").join("pack"))
623 .unwrap()
624 .count(),
625 100,
626 "somebody else's packs are still there"
627 );
628 }
629
630 #[test]
631 fn compaction_can_be_turned_off_entirely() {
632 let directory = a_working_directory(true, 50);
633
634 assert!(
635 !compact(directory.path(), 0).unwrap(),
636 "`compact_after(0)` is a caller who runs their own `git gc`"
637 );
638 assert!(directory.path().join("objects").is_dir());
639 }
640}