Skip to main content

git_policy_cli/invocation/
git_version_cache.rs

1//! What:
2//!  A private, per-user cache of the text `git version` prints,
3//!  keyed by the identity of the Git executable that printed it.
4//! Why:
5//!  Every commit starts `git version` to learn whether Git is new enough.
6//!  The text is fixed by the executable,
7//!  so while the executable is byte-for-byte the same file the answer is the same.
8//!  The identity is the canonical path,
9//!  size,
10//!  modification time,
11//!  change time,
12//!  device and inode:
13//!  an upgrade replaces the file (new inode) or rewrites it (new size, times),
14//!  so a replaced Git is noticed on the next start and a stale entry is never trusted.
15//!  The cache is used only for a real compiled executable named `git`:
16//!  a script,
17//!  or a shim such as a version manager's `git` link to a dispatcher,
18//!  picks another Git by directory or environment,
19//!  so its own file proves nothing about the version it will run.
20//!  The cache lives in the user's cache directory because the version check runs before any repository is known.
21//!
22//! In TS you'd write (pseudocode):
23//! ```ts
24//! const identity = await binaryIdentity(gitPath); // undefined: do not cache
25//! const text = identity && (await readCache(dir, identity)) ?? (await runGitVersion());
26//! ```
27
28/// Debug lines for cache problems,
29///  which never fail a command.
30use crate::diagnostic_log::debug;
31/// The environment lookup shared by the wrapper.
32use crate::child_environment::environment_value;
33/// OS strings and paths.
34use std::ffi::OsString;
35use std::path::{Path, PathBuf};
36
37/// The kind of the file that holds the text `git version` prints.
38pub const VERSION_KIND: &str = "git-version";
39
40/// The first line of every cache file of `kind`;
41///  a file in another layout, or of another kind, is ignored.
42fn file_tag(kind: &str) -> String {
43    return format!("cli-git {kind} cache 1\n");
44}
45
46/// The most `git version` bytes the cache keeps;
47///  a longer answer is not cached.
48const TEXT_LIMIT: usize = 1024;
49
50/// The tag of this module's diagnostics.
51const TAG: &str = "git-version-cache";
52
53/// What:
54///  The identity of one Git executable,
55///  as the text that goes into a cache file and the canonical path that names the file.
56/// Why:
57///   The text is compared whole against what the cache file recorded.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type BinaryIdentity = { path: string; text: string };
62/// ```
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct BinaryIdentity {
65    /// The canonical path of the executable.
66    pub path: String,
67    /// The path, size, times, device and inode joined into one line.
68    pub text: String,
69}
70
71/// What:
72///  The identity of the executable at `real_git`,
73///  or `None` when its answer cannot be cached safely.
74/// Why:
75///   Only a compiled ELF file named `git` is identified:
76///  its version is a property of its bytes.
77///  A path or a field that would need escaping in the one-line text is refused too.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// const s = await stat(realpath(gitPath)); return `${path}\t${s.size}\t${s.mtimeNs}\t${s.ctimeNs}\t${s.dev}\t${s.ino}`;
82/// ```
83#[cfg(unix)]
84pub fn binary_identity(real_git: &Path) -> Option<BinaryIdentity> {
85    // `Read` gives `read_exact`; `MetadataExt` gives the times, device and inode.
86    use std::io::Read;
87    use std::os::unix::fs::MetadataExt;
88    let canonical: PathBuf = std::fs::canonicalize(real_git).ok()?;
89    if canonical.file_name()?.to_str()? != "git" {
90        return None;
91    }
92    let path: &str = canonical.to_str()?;
93    if path.contains('\n') || path.contains('\t') {
94        return None;
95    }
96    // One handle supplies both the fields and the first bytes, so they describe the same file.
97    let mut file: std::fs::File = std::fs::File::open(&canonical).ok()?;
98    let metadata: std::fs::Metadata = file.metadata().ok()?;
99    if !metadata.is_file() {
100        return None;
101    }
102    let mut magic: [u8; 4] = [0; 4];
103    file.read_exact(&mut magic).ok()?;
104    if magic != *b"\x7fELF" {
105        return None;
106    }
107    let text: String = format!(
108        "{path}\t{}\t{}.{}\t{}.{}\t{}\t{}",
109        metadata.len(),
110        metadata.mtime(),
111        metadata.mtime_nsec(),
112        metadata.ctime(),
113        metadata.ctime_nsec(),
114        metadata.dev(),
115        metadata.ino()
116    );
117    return Some(BinaryIdentity { path: String::from(path), text });
118}
119
120/// What:
121///  No identity on a platform without Unix file metadata.
122/// Why:
123///   The cache is not offered there,
124///  so every start asks Git.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// return undefined;
129/// ```
130#[cfg(not(unix))]
131pub fn binary_identity(real_git: &Path) -> Option<BinaryIdentity> {
132    let _ = real_git;
133    return None;
134}
135
136/// What:
137///  The directory that holds the cache files:
138///  `cli-git` under `XDG_CACHE_HOME`,
139///  or under `.cache` in the home directory.
140/// Why:
141///   The injected environment decides the location,
142///  never a fixed path; a relative `XDG_CACHE_HOME` is ignored as the XDG rules say.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// const base = isAbsolute(env.XDG_CACHE_HOME ?? '') ? env.XDG_CACHE_HOME : env.HOME && join(env.HOME, '.cache');
147/// ```
148pub fn cache_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
149    if let Some(cache_home) = environment_value(environment, "XDG_CACHE_HOME") {
150        let cache_path: PathBuf = PathBuf::from(cache_home);
151        if cache_path.is_absolute() {
152            return Some(cache_path.join("cli-git"));
153        }
154    }
155    let home: OsString = environment_value(environment, "HOME")?;
156    if home.is_empty() {
157        return None;
158    }
159    return Some(PathBuf::from(home).join(".cache").join("cli-git"));
160}
161
162/// What:
163///  The cache file of one executable inside `directory`.
164/// Why:
165///   One file per canonical path keeps concurrent commits of different Gits apart;
166///  the name is a hash, and the file records the full identity to catch a collision.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// join(directory, `${kind}-${hash(identity.path)}`)
171/// ```
172fn cache_file(directory: &Path, identity: &BinaryIdentity, kind: &str) -> PathBuf {
173    // The hashing traits give `.hash` and `.finish`.
174    use std::hash::{Hash, Hasher};
175    let mut hasher: std::collections::hash_map::DefaultHasher = std::collections::hash_map::DefaultHasher::new();
176    identity.path.hash(&mut hasher);
177    return directory.join(format!("{kind}-{:016x}", hasher.finish()));
178}
179
180/// What:
181///  Whether the cache file at `path` is a regular file of this user that no one else can write.
182/// Why:
183///   A file another account could have planted or edited is not trusted.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// const s = await lstat(path); return s.isFile() && s.uid === process.geteuid() && (s.mode & 0o077) === 0;
188/// ```
189#[cfg(unix)]
190fn is_private_file_of_this_user(path: &Path) -> bool {
191    // `MetadataExt` gives the owner and the mode bits.
192    use std::os::unix::fs::MetadataExt;
193    let Ok(metadata) = std::fs::symlink_metadata(path) else {
194        return false;
195    };
196    // SAFETY: `geteuid` has no preconditions.
197    let owner: u32 = unsafe { libc::geteuid() };
198    return metadata.is_file() && metadata.uid() == owner && metadata.mode() & 0o077 == 0;
199}
200
201/// What:
202///  No file is trusted on a platform without Unix ownership.
203/// Why:
204///   The cache is not offered there.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// return false;
209/// ```
210#[cfg(not(unix))]
211fn is_private_file_of_this_user(path: &Path) -> bool {
212    let _ = path;
213    return false;
214}
215
216/// What:
217///  The `git version` text recorded for exactly this identity,
218///  or `None` for a missing,
219///  foreign,
220///  unreadable or stale entry.
221/// Why:
222///   Anything but an exact match of the whole identity line is stale,
223///  and the caller then asks Git.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// const file = await readFile(cacheFile(dir, identity)); return file.startsWith(TAG + identity.text + '\n') ? file.slice(...) : undefined;
228/// ```
229pub fn read_version_text(directory: &Path, identity: &BinaryIdentity) -> Option<Vec<u8>> {
230    return read_cached_text(directory, identity, VERSION_KIND);
231}
232
233/// What:
234///  The text of `kind` recorded for exactly this identity,
235///  or `None` for a missing,
236///  foreign,
237///  unreadable or stale entry.
238/// Why:
239///   Every fact that is a property of the executable's bytes (its version, its compiled-in configuration path)
240///  shares the identity check and the private-file check; only the file name and tag differ.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// const file = await readFile(cacheFile(dir, identity, kind)); return file.startsWith(tag(kind) + identity.text + '\n') ? file.slice(...) : undefined;
245/// ```
246pub fn read_cached_text(directory: &Path, identity: &BinaryIdentity, kind: &str) -> Option<Vec<u8>> {
247    let path: PathBuf = cache_file(directory, identity, kind);
248    if !is_private_file_of_this_user(path.as_path()) {
249        return None;
250    }
251    let bytes: Vec<u8> = crate::private_storage::read_regular_file(path.as_path(), 4096).ok()?;
252    let rest: &[u8] = bytes.strip_prefix(file_tag(kind).as_bytes())?;
253    let rest_after_identity: &[u8] = rest.strip_prefix(identity.text.as_bytes())?;
254    let text: &[u8] = rest_after_identity.strip_prefix(b"\n")?;
255    return Some(text.to_vec());
256}
257
258/// What:
259///  Record `text` for this identity,
260///  replacing any earlier entry,
261///  and report nothing when recording is impossible.
262/// Why:
263///   The write goes to a private temporary file and is renamed over the entry,
264///  so a reader sees the old entry or the new one whole;
265///  it is not synced because a lost entry only costs one `git version` start.
266///  A failure to record is logged and never fails the command.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(tmp, bytes, { mode: 0o600 }); await rename(tmp, file);
271/// ```
272pub fn store_version_text(directory: &Path, identity: &BinaryIdentity, text: &[u8]) {
273    store_cached_text(directory, identity, VERSION_KIND, text);
274}
275
276/// Record `text` of `kind` for this identity, replacing any earlier entry; see `store_version_text`.
277pub fn store_cached_text(directory: &Path, identity: &BinaryIdentity, kind: &str, text: &[u8]) {
278    if text.len() > TEXT_LIMIT {
279        return;
280    }
281    if let Some(parent) = directory.parent()
282        && let Err(error) = std::fs::create_dir_all(parent)
283    {
284        debug(TAG, format!("could not create {}: {error}", parent.display()).as_str());
285        return;
286    }
287    if let Err(error) = crate::private_storage::create_private_directory(directory)
288        && error.kind() != std::io::ErrorKind::AlreadyExists
289    {
290        debug(TAG, format!("could not create {}: {error}", directory.display()).as_str());
291        return;
292    }
293    let unique: String = match crate::random_id::random_uuid() {
294        Ok(id) => id,
295        Err(error) => {
296            debug(TAG, format!("no random name for the cache file: {}", error.0).as_str());
297            return;
298        }
299    };
300    let temporary: PathBuf = directory.join(format!(".tmp-{unique}"));
301    let tag: String = file_tag(kind);
302    let mut bytes: Vec<u8> = Vec::with_capacity(tag.len() + identity.text.len() + 1 + text.len());
303    bytes.extend_from_slice(tag.as_bytes());
304    bytes.extend_from_slice(identity.text.as_bytes());
305    bytes.push(b'\n');
306    bytes.extend_from_slice(text);
307    if let Err(error) = crate::private_storage::write_exclusive_scratch(temporary.as_path(), bytes.as_slice()) {
308        debug(TAG, format!("could not write {}: {error}", temporary.display()).as_str());
309        return;
310    }
311    if let Err(error) = std::fs::rename(&temporary, cache_file(directory, identity, kind)) {
312        debug(TAG, format!("could not publish the cache file: {error}").as_str());
313        if let Err(removal) = std::fs::remove_file(&temporary) {
314            debug(TAG, format!("could not remove {}: {removal}", temporary.display()).as_str());
315        }
316    }
317}
318
319/// Cache controls stay out of the release executable.
320#[cfg(test)]
321#[path = "git_version_cache_tests.rs"]
322mod tests;