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