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