git_policy_cli/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 super::diagnostic_log::debug;
31/// The environment lookup shared by the wrapper.
32use super::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> = super::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) = super::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 super::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) = super::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;