Skip to main content

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;