Skip to main content

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}