git_policy_cli/scratch_location.rs
1//! What:
2//! Where the wrapper keeps short-lived private files:
3//! the per-user scratch directory (`<state>/cli-git/scratch`) for work that has no repository
4//! in hand,
5//! and the repository scratch directory (`<common-dir>/cli-git-scratch`) for work inside one.
6//! Both are checked as private (a real directory of this user, mode `0700`) and swept of entries
7//! older than a day each time they are prepared.
8//! Why:
9//! The system temporary directory is never used (issue 1809, repo rule XTD):
10//! which device backs it is unknowable (a small tmpfs, a per-user quota, a slow disk),
11//! and a full one broke every session.
12//! A file left by a killed process must not accumulate without bound,
13//! so every preparation of a directory also collects what is stale in it.
14//! `doc/planning/cli-git-scratch-locations.md` records the choice per site.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! const scratch = join(env.XDG_STATE_HOME ?? join(env.HOME, '.local/state'), 'cli-git', 'scratch');
19//! await mkdir(scratch, { recursive: true, mode: 0o700 });
20//! await sweepOlderThan(scratch, 24 * 60 * 60 * 1000);
21//! ```
22
23/// The environment lookup shared by the wrapper.
24use super::child_environment::environment_value;
25/// Debug lines for problems that never fail a command.
26use super::diagnostic_log::debug;
27/// The private creation primitives shared by every durable record.
28use super::private_storage::{create_private_directory, create_private_directory_all};
29/// The mode a private directory ends with, and the call that sets it, which only Unix has.
30#[cfg(unix)]
31use super::private_storage::{PRIVATE_DIRECTORY_MODE, protect_path};
32/// OS strings, paths and time.
33use std::ffi::OsString;
34use std::path::{Path, PathBuf};
35use std::time::{Duration, SystemTime};
36
37/// The tag of this module's diagnostics.
38const TAG: &str = "cli-git";
39
40/// The entry in a Git common directory that holds repository scratch.
41pub const REPOSITORY_SCRATCH_NAME: &str = "cli-git-scratch";
42
43/// How old an entry must be before a preparation removes it:
44/// far longer than any command of the wrapper runs (its longest bound is minutes),
45/// so a live process never loses a file.
46pub const SCRATCH_STALE_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
47
48/// What:
49/// The per-user state directory of the wrapper:
50/// `cli-git` under an absolute `XDG_STATE_HOME`,
51/// or under `.local/state` in the home directory.
52/// Why:
53/// The injected environment decides the location,
54/// never a fixed path;
55/// a relative `XDG_STATE_HOME` is ignored as the XDG rules say.
56/// It mirrors the cache directory of the version cache.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// const base = isAbsolute(env.XDG_STATE_HOME ?? '') ? env.XDG_STATE_HOME : env.HOME && join(env.HOME, '.local/state');
61/// ```
62pub fn state_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
63 if let Some(state_home) = environment_value(environment, "XDG_STATE_HOME") {
64 let state_path: PathBuf = PathBuf::from(state_home);
65 if state_path.is_absolute() {
66 return Some(state_path.join("cli-git"));
67 }
68 }
69 let home: OsString = environment_value(environment, "HOME")?;
70 if home.is_empty() {
71 return None;
72 }
73 return Some(PathBuf::from(home).join(".local").join("state").join("cli-git"));
74}
75
76/// What:
77/// The per-user scratch directory's path,
78/// or the sentence that says what the environment lacks.
79/// Nothing is created.
80/// Why:
81/// Callers that run without a repository learn the location first and create it only when they need it,
82/// so a command with nothing to write never touches the disk.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// function userScratchPath(env): string; // throws string
87/// ```
88pub fn user_scratch_path(environment: &[(OsString, OsString)]) -> Result<PathBuf, String> {
89 let Some(state) = state_directory(environment) else {
90 return Err(String::from(
91 "cli-git keeps its temporary files in a private directory under XDG_STATE_HOME (or HOME/.local/state) and \
92 never in the system temporary directory, and this command's environment has neither an absolute \
93 XDG_STATE_HOME nor a non-empty HOME; set one of them",
94 ));
95 };
96 return Ok(state.join("scratch"));
97}
98
99/// What:
100/// Create the per-user scratch directory with its missing parents,
101/// check that it is private,
102/// sweep its stale entries,
103/// and return it.
104/// Why:
105/// The linter and the push probe write there;
106/// the check refuses a directory another account could write.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// async function prepareUserScratch(env): Promise<string>; // throws string
111/// ```
112pub fn prepare_user_scratch(environment: &[(OsString, OsString)]) -> Result<PathBuf, String> {
113 let given: PathBuf = user_scratch_path(environment)?;
114 // A relative home would name another place once a child changes directory.
115 let directory: PathBuf = std::path::absolute(&given).unwrap_or_else(|_| { return given.clone(); });
116 if let Err(error) = create_private_directory_all(directory.as_path()) {
117 return Err(format!(
118 "cli-git could not create its private scratch directory {}: {error}",
119 directory.display()
120 ));
121 }
122 if let Err(error) = assert_private_directory(directory.as_path()) {
123 return Err(format!(
124 "cli-git will not use {} for temporary files: {error}",
125 directory.display()
126 ));
127 }
128 sweep_stale(directory.as_path(), SystemTime::now(), SCRATCH_STALE_AFTER);
129 return Ok(directory);
130}
131
132/// What:
133/// An anonymous file (no name in the directory) in the per-user scratch directory of `environment`,
134/// open for reading and writing.
135/// Why:
136/// Child output goes to a file so that no reader thread can outlive a stopped child;
137/// the file has no name from the start (an unnamed temporary file where the system has them,
138/// otherwise a name removed at once),
139/// so a killed process leaves nothing to collect,
140/// and the system temporary directory is never used.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// const handle = await open(await prepareUserScratch(env), 'wx+'); // unlinked at once
145/// ```
146pub fn scratch_file(environment: &[(OsString, OsString)]) -> std::io::Result<std::fs::File> {
147 let directory: PathBuf = prepare_user_scratch(environment).map_err(std::io::Error::other)?;
148 return tempfile::tempfile_in(directory);
149}
150
151/// What:
152/// Create the repository scratch directory beside the common Git directory's other private storage,
153/// check that it is private,
154/// sweep its stale entries,
155/// and return it.
156/// Why:
157/// Git input for a commit transaction is written there:
158/// it is on the repository's own device,
159/// needs no environment variable,
160/// and is collected with the repository.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// async function prepareRepositoryScratch(commonDir: string): Promise<string>;
165/// ```
166pub fn prepare_repository_scratch(common_dir: &Path) -> std::io::Result<PathBuf> {
167 let directory: PathBuf = common_dir.join(REPOSITORY_SCRATCH_NAME);
168 match create_private_directory(directory.as_path()) {
169 Ok(()) => {}
170 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
171 Err(error) => return Err(error),
172 }
173 assert_private_directory(directory.as_path())?;
174 sweep_stale(directory.as_path(), SystemTime::now(), SCRATCH_STALE_AFTER);
175 return Ok(directory);
176}
177
178/// What:
179/// Accept `path` only if it is a real directory (not a link) of this user that no one else can enter,
180/// tightening a looser mode of a directory this user owns.
181/// Why:
182/// Another account that could write into the directory could plant a link or read the files,
183/// and a link in place of the directory could redirect every write.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// const s = await lstat(path); if (!s.isDirectory() || s.uid !== process.geteuid()) throw ...; if (s.mode & 0o077) await chmod(path, 0o700);
188/// ```
189#[cfg(unix)]
190fn assert_private_directory(path: &Path) -> std::io::Result<()> {
191 // `MetadataExt` gives the owner and the mode bits.
192 use std::os::unix::fs::MetadataExt;
193 let metadata: std::fs::Metadata = std::fs::symlink_metadata(path)?;
194 if !metadata.is_dir() {
195 return Err(std::io::Error::other("it is a link or a file, not a directory"));
196 }
197 // SAFETY: `geteuid` has no preconditions.
198 let owner: u32 = unsafe { libc::geteuid() };
199 if metadata.uid() != owner {
200 return Err(std::io::Error::other("it belongs to another account"));
201 }
202 if metadata.mode() & 0o077 != 0 {
203 protect_path(path, PRIVATE_DIRECTORY_MODE)?;
204 }
205 return Ok(());
206}
207
208/// What:
209/// Accept `path` only if it is a real directory.
210/// Why:
211/// Ownership and modes are Unix notions;
212/// other platforms keep their inherited access rules.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// if (!(await lstat(path)).isDirectory()) throw ...;
217/// ```
218// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
219#[cfg(not(unix))]
220fn assert_private_directory(path: &Path) -> std::io::Result<()> {
221 if !std::fs::symlink_metadata(path)?.is_dir() {
222 return Err(std::io::Error::other("it is a link or a file, not a directory"));
223 }
224 return Ok(());
225}
226
227/// What:
228/// Remove every entry of `directory` last modified more than `max_age` before `now`,
229/// and return how many it removed.
230/// A link is removed itself and never followed;
231/// a directory is removed with its contents.
232/// Why:
233/// A process killed between making a scratch file and removing it leaves the file behind,
234/// and nothing else would ever collect it.
235/// The directory belongs to the wrapper alone,
236/// so every entry in it is the wrapper's;
237/// an entry modified in the future is kept,
238/// because a wrong clock must not delete live files.
239///
240/// In TS you'd write (pseudocode):
241/// ```ts
242/// for (const entry of await readdir(dir)) if (now - (await lstat(entry)).mtimeMs > maxAgeMs) await rm(entry, { recursive: true });
243/// ```
244pub fn sweep_stale(directory: &Path, now: SystemTime, max_age: Duration) -> usize {
245 let entries: std::fs::ReadDir = match std::fs::read_dir(directory) {
246 Ok(found) => found,
247 Err(error) => {
248 debug(TAG, format!("could not list {} for stale entries: {error}", directory.display()).as_str());
249 return 0;
250 }
251 };
252 let mut removed: usize = 0;
253 for listed in entries {
254 let entry: std::fs::DirEntry = match listed {
255 Ok(found) => found,
256 Err(error) => {
257 debug(TAG, format!("could not read an entry of {}: {error}", directory.display()).as_str());
258 continue;
259 }
260 };
261 let path: PathBuf = entry.path();
262 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(&path) {
263 Ok(found) => found,
264 Err(error) => {
265 debug(TAG, format!("could not inspect {}: {error}", path.display()).as_str());
266 continue;
267 }
268 };
269 let age: Option<Duration> = metadata
270 .modified()
271 .ok()
272 .and_then(|modified| { return now.duration_since(modified).ok(); });
273 if !age.is_some_and(|found| { return found > max_age; }) {
274 continue;
275 }
276 let outcome: std::io::Result<()> = if metadata.is_dir() {
277 std::fs::remove_dir_all(&path)
278 } else {
279 std::fs::remove_file(&path)
280 };
281 match outcome {
282 Ok(()) => removed += 1,
283 Err(error) => debug(TAG, format!("could not remove the stale {}: {error}", path.display()).as_str()),
284 }
285 }
286 return removed;
287}
288
289/// Environment resolution, preparation, privacy checks and the sweep are controlled in the test module.
290#[cfg(test)]
291#[path = "scratch_location_tests.rs"]
292mod tests;