git_policy_cli/foundation/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 crate::child_environment::environment_value;
25/// Debug lines for problems that never fail a command.
26use crate::diagnostic_log::debug;
27/// The private creation primitives shared by every durable record.
28use crate::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 crate::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, so a live process never loses a file. Measured: the longest command in the
45/// commit timing log (1,051 commits, 2026-11-02T09:01:37Z to 2026-11-03T15:10:12Z) took 750.6 s and the longest worktree add in the
46/// add timing log (48 adds) 539.7 s; a day is about a hundred times the longest. The patience for an owner of another namespace
47/// (3,600 s) is far under a day. A wait for a live program's lock has no bound of its own, so a command that waits on one for
48/// more than a day is the case this age does not cover (none in the logs came near).
49/// Kept as the coordinator's pick under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Sizes and ages of the service's own housekeeping").
50pub const SCRATCH_STALE_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
51
52/// How many times in the stale age a command that waits renews the modification time of its scratch directory.
53const REFRESHES_PER_STALE_AGE: u64 = 24;
54
55/// How often a command that waits renews its scratch directory: a twenty-fourth of `SCRATCH_STALE_AFTER`, so a sweep that
56/// runs at any moment finds a waiter's directory at most an hour old, and a directory whose waiter died stops being renewed
57/// and ages out as before. The renewal is one `utimensat` call an hour.
58pub const SCRATCH_REFRESH_EVERY: Duration = Duration::from_secs(SCRATCH_STALE_AFTER.as_secs() / REFRESHES_PER_STALE_AGE);
59
60/// What:
61/// Sets the modification time of `path` to now.
62/// Why:
63/// A command that waits for a lock holder or for the commit service with no bound can outlast `SCRATCH_STALE_AFTER`, and the
64/// sweep removes entries by modification time; renewing the time while the command lives ties the entry's age to the
65/// command's liveness, with no lock and no record of the process.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// await utimes(path, new Date(), new Date())
70/// ```
71pub fn renew(path: &Path) {
72 let renewed: std::io::Result<()> = std::fs::File::open(path).and_then(|opened| { return opened.set_modified(SystemTime::now()); });
73 if let Err(error) = renewed {
74 debug(TAG, format!("could not renew {}: {error}", path.display()).as_str());
75 }
76}
77
78/// What:
79/// The per-user state directory of the wrapper:
80/// `cli-git` under an absolute `XDG_STATE_HOME`,
81/// or under `.local/state` in the home directory.
82/// Why:
83/// The injected environment decides the location,
84/// never a fixed path;
85/// a relative `XDG_STATE_HOME` is ignored as the XDG rules say.
86/// It mirrors the cache directory of the version cache.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// const base = isAbsolute(env.XDG_STATE_HOME ?? '') ? env.XDG_STATE_HOME : env.HOME && join(env.HOME, '.local/state');
91/// ```
92pub fn state_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
93 if let Some(state_home) = environment_value(environment, "XDG_STATE_HOME") {
94 let state_path: PathBuf = PathBuf::from(state_home);
95 if state_path.is_absolute() {
96 return Some(state_path.join("cli-git"));
97 }
98 }
99 let home: OsString = environment_value(environment, "HOME")?;
100 if home.is_empty() {
101 return None;
102 }
103 return Some(PathBuf::from(home).join(".local").join("state").join("cli-git"));
104}
105
106/// What:
107/// The per-user scratch directory's path,
108/// or the sentence that says what the environment lacks.
109/// Nothing is created.
110/// Why:
111/// Callers that run without a repository learn the location first and create it only when they need it,
112/// so a command with nothing to write never touches the disk.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// function userScratchPath(env): string; // throws string
117/// ```
118pub fn user_scratch_path(environment: &[(OsString, OsString)]) -> Result<PathBuf, String> {
119 let Some(state) = state_directory(environment) else {
120 return Err(String::from(
121 "cli-git keeps its temporary files in a private directory under XDG_STATE_HOME (or HOME/.local/state) and \
122 never in the system temporary directory, and this command's environment has neither an absolute \
123 XDG_STATE_HOME nor a non-empty HOME; set one of them",
124 ));
125 };
126 return Ok(state.join("scratch"));
127}
128
129/// What:
130/// Create the per-user scratch directory with its missing parents,
131/// check that it is private,
132/// sweep its stale entries,
133/// and return it.
134/// Why:
135/// The linter and the push probe write there;
136/// the check refuses a directory another account could write.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// async function prepareUserScratch(env): Promise<string>; // throws string
141/// ```
142pub fn prepare_user_scratch(environment: &[(OsString, OsString)]) -> Result<PathBuf, String> {
143 let given: PathBuf = user_scratch_path(environment)?;
144 // A relative home would name another place once a child changes directory.
145 let directory: PathBuf = std::path::absolute(&given).unwrap_or_else(|_| { return given.clone(); });
146 if let Err(error) = create_private_directory_all(directory.as_path()) {
147 return Err(format!(
148 "cli-git could not create its private scratch directory {}: {error}",
149 directory.display()
150 ));
151 }
152 if let Err(error) = assert_private_directory(directory.as_path()) {
153 return Err(format!(
154 "cli-git will not use {} for temporary files: {error}",
155 directory.display()
156 ));
157 }
158 sweep_stale(directory.as_path(), SystemTime::now(), SCRATCH_STALE_AFTER);
159 return Ok(directory);
160}
161
162/// What:
163/// An anonymous file (no name in the directory) in the per-user scratch directory of `environment`,
164/// open for reading and writing.
165/// Why:
166/// Child output goes to a file so that no reader thread can outlive a stopped child;
167/// the file has no name from the start (an unnamed temporary file where the system has them,
168/// otherwise a name removed at once),
169/// so a killed process leaves nothing to collect,
170/// and the system temporary directory is never used.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// const handle = await open(await prepareUserScratch(env), 'wx+'); // unlinked at once
175/// ```
176pub fn scratch_file(environment: &[(OsString, OsString)]) -> std::io::Result<std::fs::File> {
177 let directory: PathBuf = prepare_user_scratch(environment).map_err(std::io::Error::other)?;
178 return tempfile::tempfile_in(directory);
179}
180
181/// What:
182/// Create the repository scratch directory beside the common Git directory's other private storage,
183/// check that it is private,
184/// sweep its stale entries,
185/// and return it.
186/// Why:
187/// Git input for a commit transaction is written there:
188/// it is on the repository's own device,
189/// needs no environment variable,
190/// and is collected with the repository.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// async function prepareRepositoryScratch(commonDir: string): Promise<string>;
195/// ```
196pub fn prepare_repository_scratch(common_dir: &Path) -> std::io::Result<PathBuf> {
197 let directory: PathBuf = common_dir.join(REPOSITORY_SCRATCH_NAME);
198 match create_private_directory(directory.as_path()) {
199 Ok(()) => {}
200 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
201 Err(error) => return Err(error),
202 }
203 assert_private_directory(directory.as_path())?;
204 sweep_stale(directory.as_path(), SystemTime::now(), SCRATCH_STALE_AFTER);
205 return Ok(directory);
206}
207
208/// What:
209/// Accept `path` only if it is a real directory (not a link) of this user that no one else can enter,
210/// tightening a looser mode of a directory this user owns.
211/// Why:
212/// Another account that could write into the directory could plant a link or read the files,
213/// and a link in place of the directory could redirect every write.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// const s = await lstat(path); if (!s.isDirectory() || s.uid !== process.geteuid()) throw ...; if (s.mode & 0o077) await chmod(path, 0o700);
218/// ```
219#[cfg(unix)]
220fn assert_private_directory(path: &Path) -> std::io::Result<()> {
221 // `MetadataExt` gives the owner and the mode bits.
222 use std::os::unix::fs::MetadataExt;
223 let metadata: std::fs::Metadata = std::fs::symlink_metadata(path)?;
224 if !metadata.is_dir() {
225 return Err(std::io::Error::other("it is a link or a file, not a directory"));
226 }
227 // SAFETY: `geteuid` has no preconditions.
228 let owner: u32 = unsafe { libc::geteuid() };
229 if metadata.uid() != owner {
230 return Err(std::io::Error::other("it belongs to another account"));
231 }
232 if metadata.mode() & 0o077 != 0 {
233 protect_path(path, PRIVATE_DIRECTORY_MODE)?;
234 }
235 return Ok(());
236}
237
238/// What:
239/// Accept `path` only if it is a real directory.
240/// Why:
241/// Ownership and modes are Unix notions;
242/// other platforms keep their inherited access rules.
243///
244/// In TS you'd write (pseudocode):
245/// ```ts
246/// if (!(await lstat(path)).isDirectory()) throw ...;
247/// ```
248// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
249#[cfg(not(unix))]
250fn assert_private_directory(path: &Path) -> std::io::Result<()> {
251 if !std::fs::symlink_metadata(path)?.is_dir() {
252 return Err(std::io::Error::other("it is a link or a file, not a directory"));
253 }
254 return Ok(());
255}
256
257/// What:
258/// Remove every entry of `directory` last modified more than `max_age` before `now`,
259/// and return how many it removed.
260/// A link is removed itself and never followed;
261/// a directory is removed with its contents.
262/// Why:
263/// A process killed between making a scratch file and removing it leaves the file behind,
264/// and nothing else would ever collect it.
265/// The directory belongs to the wrapper alone,
266/// so every entry in it is the wrapper's;
267/// an entry modified in the future is kept,
268/// because a wrong clock must not delete live files.
269///
270/// In TS you'd write (pseudocode):
271/// ```ts
272/// for (const entry of await readdir(dir)) if (now - (await lstat(entry)).mtimeMs > maxAgeMs) await rm(entry, { recursive: true });
273/// ```
274pub fn sweep_stale(directory: &Path, now: SystemTime, max_age: Duration) -> usize {
275 let entries: std::fs::ReadDir = match std::fs::read_dir(directory) {
276 Ok(found) => found,
277 Err(error) => {
278 debug(TAG, format!("could not list {} for stale entries: {error}", directory.display()).as_str());
279 return 0;
280 }
281 };
282 let mut removed: usize = 0;
283 for listed in entries {
284 let entry: std::fs::DirEntry = match listed {
285 Ok(found) => found,
286 Err(error) => {
287 debug(TAG, format!("could not read an entry of {}: {error}", directory.display()).as_str());
288 continue;
289 }
290 };
291 let path: PathBuf = entry.path();
292 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(&path) {
293 Ok(found) => found,
294 Err(error) => {
295 debug(TAG, format!("could not inspect {}: {error}", path.display()).as_str());
296 continue;
297 }
298 };
299 let age: Option<Duration> = metadata
300 .modified()
301 .ok()
302 .and_then(|modified| { return now.duration_since(modified).ok(); });
303 if !age.is_some_and(|found| { return found > max_age; }) {
304 continue;
305 }
306 let outcome: std::io::Result<()> = if metadata.is_dir() {
307 std::fs::remove_dir_all(&path)
308 } else {
309 std::fs::remove_file(&path)
310 };
311 match outcome {
312 Ok(()) => removed += 1,
313 Err(error) => debug(TAG, format!("could not remove the stale {}: {error}", path.display()).as_str()),
314 }
315 }
316 return removed;
317}
318
319/// Environment resolution, preparation, privacy checks and the sweep are controlled in the test module.
320#[cfg(test)]
321#[path = "scratch_location_tests.rs"]
322mod tests;