Skip to main content

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;