Skip to main content

git_xcrypt/util/
atomic.rs

1//! Replacing a file without a window in which it is half-written.
2//!
3//! Two files in this tool decide whether encryption happens at all: the managed
4//! section of `.gitattributes`, which carries `* filter=git-xcrypt`, and
5//! `.git/config`, which carries the driver registration. `fs::write` truncates
6//! first and writes second, so a failure between the two — a full disk, a
7//! crash, a power loss — leaves whichever file it hit short or empty. Git then
8//! sees no filter at all and treats every path as plain: `git add` on a secret
9//! succeeds with exit code 0 and stores the plaintext, with no signal to the
10//! user. Truncating `.gitattributes` also loses whatever the user wrote outside
11//! our markers, which the section-editing code promises to preserve.
12//!
13//! Writing a sibling file and renaming it over the target closes that window:
14//! `rename` replaces the entry in one step on every platform this tool targets
15//! (`MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows), so a reader sees
16//! either the old file or the new one.
17//!
18//! The key file goes through [`write_owner_only`] rather than [`write`]: it has
19//! the same half-written failure — a truncated key file is a repository nobody
20//! can decrypt again — but the opposite permission rule, since inheriting a
21//! loose mode from whatever was there before is exactly what a key must not do.
22//!
23//! The temporary file is created with `O_EXCL` and an unguessable name, which is
24//! not tidiness. Without `O_EXCL` the name is merely a name: anyone able to
25//! write the destination directory could pre-create it as a symlink, and
26//! `File::create` would follow the link, write the master key wherever it points
27//! and then rename the link over the destination. `export-key ~/keys/repo.key`
28//! is a private directory, but `export-key /tmp/repo.key` is not, and the
29//! command whose whole job is to hand over a key is the wrong place to rely on
30//! the user picking a safe directory.
31
32use std::ffi::OsString;
33use std::fs;
34use std::io::Write as _;
35use std::path::{Path, PathBuf};
36
37use crate::{Error, Result};
38
39/// How the replacement file's permissions are decided.
40#[derive(Debug, Clone, Copy)]
41enum Mode {
42    /// Keep whatever the target had. A file that did not exist gets the
43    /// process default, which is what git itself would have produced.
44    InheritTarget,
45    /// Owner-only, whatever the target had. For key material, where inheriting
46    /// a loose mode from a file that happened to be there is exactly wrong.
47    OwnerOnly,
48}
49
50/// Writes `contents` to `path`, replacing it in one step.
51///
52/// The temporary file lives beside the target, because `rename` across
53/// filesystems is not a rename at all and would fall back to a copy.
54///
55/// Three limits worth knowing rather than discovering.
56///
57/// A target that is a symlink is replaced by a regular file, where `fs::write`
58/// would have followed the link — `.gitattributes` under a dotfile manager is
59/// the case where that shows. A target that is one of several hard links to the
60/// same inode loses the link for the same reason. And the temporary file is only
61/// cleaned up on a returned error, so a process killed outright can leave one
62/// behind.
63///
64/// That last one is not always harmless. `unlock` replaces working-tree files
65/// through here, so on that path the leftover holds a **decrypted secret** under
66/// a name no `.git-xcrypt` pattern was written for — `secrets/` still covers it,
67/// `*.env` does not. It inherits the target's permissions, which for a file git
68/// checked out means it is no more readable than the file it replaces, but a
69/// later `git add -A` could store it in the clear. There is no portable way to
70/// clean up after `SIGKILL`; the residue is recorded here rather than hidden.
71/// Every such file is recognised by [`is_temporary_name`], which is what the
72/// user documentation has to tell people to look for after a killed `unlock` —
73/// and what `lock` sweeps before it encrypts, since a plaintext leftover would
74/// otherwise survive the one command whose job is to leave none.
75///
76/// # Errors
77///
78/// [`Error::Io`] when the temporary file cannot be created, written, flushed or
79/// renamed. On failure the target is left exactly as it was.
80pub fn write(path: &Path, contents: &[u8]) -> Result<()> {
81    replace(path, contents, Mode::InheritTarget)
82}
83
84/// Writes `contents` to `path` with owner-only permissions, in one step.
85///
86/// The same replacement as [`write`], with two differences that matter only for
87/// key material: the file is created `0600` before a single byte reaches it, and
88/// the target's permissions are **not** inherited — a key file that was somehow
89/// left world readable must not stay that way.
90///
91/// **On Windows there is no mode to set, so nothing here narrows anything**: the
92/// file inherits the ACL of the directory it is created in. For the repository's
93/// own key that is the protection git gives `.git/config`, which is the same
94/// protection as the rest of the checkout. For `export-key` the user picks the
95/// directory, so the directory *is* the protection. Weaker than `0600` either
96/// way, and recorded as a limitation in `README.md` §Known limitations and in
97/// `context/foundation/zalozenia.md` — the founding document claimed owner-only
98/// ACLs here until 2026-08-05, which was never true of any build.
99///
100/// # Errors
101///
102/// As [`write`].
103pub fn write_owner_only(path: &Path, contents: &[u8]) -> Result<()> {
104    replace(path, contents, Mode::OwnerOnly)
105}
106
107fn replace(path: &Path, contents: &[u8], mode: Mode) -> Result<()> {
108    let (temporary, mut file) = create_temporary(path, mode)?;
109
110    let result = (|| -> std::io::Result<()> {
111        // A fresh file gets 0666 minus the umask, so without this a deliberately
112        // narrowed `.git/config` — the one that holds credential helpers and
113        // remote URLs — would come back world readable. It happens before the
114        // first write, so there is no window in which the content is on disk
115        // under looser permissions than the file it replaces.
116        if matches!(mode, Mode::InheritTarget)
117            && let Ok(existing) = fs::metadata(path)
118        {
119            file.set_permissions(existing.permissions())?;
120        }
121        file.write_all(contents)?;
122        // Without this the rename can land before the content does, which on a
123        // crash leaves an empty file where a complete one is expected.
124        file.sync_all()?;
125        drop(file);
126        fs::rename(&temporary, path)?;
127        sync_directory(path);
128        Ok(())
129    })();
130
131    if result.is_err() {
132        // Best effort: a leftover temporary file is untidy, not dangerous.
133        let _ = fs::remove_file(&temporary);
134    }
135    result.map_err(Error::Io)
136}
137
138/// Flushes the directory entry the rename just created.
139///
140/// Without it the promise above holds only where the target already existed: a
141/// crash right after `init` could otherwise leave a repository with a key, a
142/// filter registration and no `.gitattributes` at all — which is the state where
143/// git stores plaintext and reports success. Best effort, and a no-op on
144/// platforms that do not allow opening a directory.
145fn sync_directory(path: &Path) {
146    if let Some(parent) = path.parent()
147        && let Ok(directory) = fs::File::open(parent)
148    {
149        let _ = directory.sync_all();
150    }
151}
152
153/// Creates a fresh file next to `path`, and only ever a fresh one.
154///
155/// `create_new` is `O_EXCL`: it fails rather than opening anything that is
156/// already there, symlink included, which is what keeps a pre-created link from
157/// redirecting the write. The name is random rather than derived from the
158/// process id, so it cannot be predicted and pre-created in the first place;
159/// `O_EXCL` alone would then turn the attack into a denial of service, hence the
160/// retries.
161///
162/// A key file is created at `0600` from the outset, so its content is never on
163/// disk under a wider mode even for an instant.
164fn create_temporary(path: &Path, mode: Mode) -> Result<(PathBuf, fs::File)> {
165    let name = path.file_name().ok_or_else(|| {
166        Error::Io(std::io::Error::other(format!(
167            "{} does not name a file",
168            path.display()
169        )))
170    })?;
171
172    #[cfg(not(unix))]
173    let _ = mode;
174
175    const ATTEMPTS: usize = 8;
176    for _ in 0..ATTEMPTS {
177        let candidate = path.with_file_name(temporary_name(name)?);
178
179        let mut options = fs::OpenOptions::new();
180        options.write(true).create_new(true);
181        #[cfg(unix)]
182        if matches!(mode, Mode::OwnerOnly) {
183            use std::os::unix::fs::OpenOptionsExt as _;
184            options.mode(0o600);
185        }
186
187        match options.open(&candidate) {
188            Ok(file) => return Ok((candidate, file)),
189            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
190            Err(err) => return Err(Error::Io(err)),
191        }
192    }
193
194    // Eight random names taken in a row is not luck. Reporting the last
195    // `AlreadyExists` would name a file the user never chose and read as "the
196    // destination is in the way", which is the opposite of what happened.
197    Err(Error::Io(std::io::Error::other(format!(
198        "could not write {}: {ATTEMPTS} temporary names beside it were all taken",
199        path.display()
200    ))))
201}
202
203/// What every temporary name carries between the target's name and `.tmp`.
204const MARKER: &[u8] = b".git-xcrypt-";
205
206/// How many random bytes go into a temporary name.
207const RANDOM_LEN: usize = 8;
208
209/// The longest single name a filesystem will normally take, in bytes.
210///
211/// `NAME_MAX` is 255 on ext4, APFS, HFS+, XFS and NTFS alike. Nothing here reads
212/// the real limit — there is no portable way to — so this is the floor every
213/// target platform meets.
214const MAX_NAME: usize = 255;
215
216/// A sibling name no one can guess and therefore no one can pre-create.
217///
218/// The target's own name is **shortened when the suffix would not fit**. Git
219/// puts no such ceiling on a path: a file whose name is 224 bytes or longer
220/// commits and checks out perfectly, and before this the sibling name came to
221/// 256 bytes and `create_temporary` failed with `ENAMETOOLONG`. Measured, on a
222/// repository holding one such file: `lock` exited 1 saying "running lock again
223/// finishes the job", which was false — it failed identically for ever, so the
224/// repository could never be closed and the secret stayed in the clear.
225///
226/// The cost of shortening is that [`strip_temporary_suffix`] then reconstructs a
227/// *truncated* target, so residue left by a killed run on such a file may not be
228/// recognised as belonging to a declared path and may go unswept. That is the
229/// same outcome residue under an undeclared path already has, and it replaces a
230/// command that could not run at all.
231fn temporary_name(name: &std::ffi::OsStr) -> Result<OsString> {
232    let mut random = [0u8; RANDOM_LEN];
233    getrandom::fill(&mut random).map_err(|err| Error::Entropy(err.to_string()))?;
234
235    let suffix = format!(
236        "{}{}.tmp",
237        String::from_utf8_lossy(MARKER),
238        crate::hex(&random)
239    );
240
241    let mut temporary = shorten(name, MAX_NAME.saturating_sub(suffix.len()));
242    temporary.push(suffix);
243    Ok(temporary)
244}
245
246/// `name`, cut down to at most `limit` bytes.
247///
248/// A file name is an arbitrary byte string on Unix, so the cut is by bytes and
249/// may land inside a multi-byte character — which is fine for a name nothing
250/// ever decodes. On other platforms the name goes through its lossy text form,
251/// which is what every other path in this crate does with a Windows name.
252fn shorten(name: &std::ffi::OsStr, limit: usize) -> OsString {
253    #[cfg(unix)]
254    {
255        use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
256        let bytes = name.as_bytes();
257        if bytes.len() <= limit {
258            return name.to_os_string();
259        }
260        OsString::from_vec(bytes[..limit].to_vec())
261    }
262    #[cfg(not(unix))]
263    {
264        let text = name.to_string_lossy();
265        if text.len() <= limit {
266            return name.to_os_string();
267        }
268        let mut cut = limit;
269        while cut > 0 && !text.is_char_boundary(cut) {
270            cut -= 1;
271        }
272        OsString::from(&text[..cut])
273    }
274}
275
276/// The target a temporary file was named after, if `name` is one of ours.
277///
278/// A process killed outright cannot clean up after itself, and on the `unlock`
279/// and `lock` paths the residue holds a **decrypted secret**. `lock` promises
280/// that no plaintext of a selected path survives it, so it has to recognise
281/// residue — and, because it deletes what it recognises, it has to recognise it
282/// *narrowly*. Returning the target rather than a yes/no is what lets the caller
283/// add the second condition that makes deletion safe: only sweep residue whose
284/// target the declaration actually selects.
285///
286/// Deliberately exact. The marker must be followed by exactly [`RANDOM_LEN`]
287/// bytes of **lowercase** hex — the only kind [`temporary_name`] emits — then
288/// `.tmp`, and something must precede the marker, because these names are always
289/// built from a target's own name. A file a user happens to have called
290/// `notes.git-xcrypt-draft.tmp` is not matched, and neither is
291/// `notes.git-xcrypt-DEADBEEFDEADBEEF.tmp`.
292#[must_use]
293pub fn strip_temporary_suffix(name: &[u8]) -> Option<&[u8]> {
294    let rest = name.strip_suffix(b".tmp")?;
295    let head = rest.get(..rest.len().checked_sub(RANDOM_LEN * 2)?)?;
296    let hex = &rest[head.len()..];
297
298    if !hex
299        .iter()
300        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
301    {
302        return None;
303    }
304    let target = head.strip_suffix(MARKER)?;
305    (!target.is_empty()).then_some(target)
306}
307
308/// Whether the target [`strip_temporary_suffix`] reconstructed may be **cut**.
309///
310/// [`temporary_name`] shortens the target when the suffix would not otherwise
311/// fit, so above a certain length the name in a temporary file no longer
312/// identifies its target — and a caller deciding what to do with residue is then
313/// deciding about a file it cannot name. `lock` is that caller, and the answer
314/// has to be "refuse", not "guess": measured on this build, a repository
315/// declaring `*.env` with a 230-byte file name, and residue placed exactly as a
316/// killed run leaves it, printed `nothing declares its target, so it was left
317/// alone`, deleted the key and **exited 0** over `AWS_SECRET=hunter2` sitting in
318/// the working tree in the clear — untracked, and not matching `*.env`, so the
319/// next `git add -A` would have committed it that way.
320///
321/// Answered from the temporary name's own length rather than the target's,
322/// because that is the side a caller holds, and with **three bytes of slack**:
323/// the Unix arm of [`shorten`] cuts on a byte boundary and lands exactly on the
324/// limit, but the other arm backs off to a character boundary and can stop a
325/// little short. Being wrong in this direction costs a refusal on a residue file
326/// whose name is within three bytes of the ceiling and whose target is genuinely
327/// undeclared — which is a file a user made themselves, since every temporary
328/// file this crate writes sits beside a declared path or a bootstrap file. Being
329/// wrong in the other direction costs a secret.
330#[must_use]
331pub fn target_may_have_been_shortened(temporary_name: &[u8]) -> bool {
332    temporary_name.len() + 3 >= MAX_NAME
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use tempfile::TempDir;
339
340    #[cfg(unix)]
341    #[test]
342    fn a_narrowed_target_keeps_its_permissions() {
343        // `.git/config` carries credential helpers and remote URLs, so a user
344        // who chmods it to 0600 must not have it widened by a `sync`.
345        use std::os::unix::fs::PermissionsExt as _;
346
347        let dir = TempDir::new().expect("temporary directory");
348        let path = dir.path().join("config");
349        fs::write(&path, b"[core]\n").expect("writing must succeed");
350        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod");
351
352        write(&path, b"[core]\n\tbare = false\n").expect("writing must succeed");
353
354        let mode = fs::metadata(&path).expect("metadata").permissions().mode();
355        assert_eq!(mode & 0o777, 0o600, "the file was widened by the rewrite");
356    }
357
358    #[cfg(unix)]
359    #[test]
360    fn an_owner_only_write_narrows_a_loose_target_instead_of_inheriting_it() {
361        // The opposite rule from the one above, and deliberately so: a key file
362        // that was left world readable must come back owner-only, never keep the
363        // mode it happened to have.
364        use std::os::unix::fs::PermissionsExt as _;
365
366        let dir = TempDir::new().expect("temporary directory");
367        let path = dir.path().join("default");
368        fs::write(&path, b"world readable placeholder").expect("writing must succeed");
369        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");
370
371        write_owner_only(&path, b"key material").expect("writing must succeed");
372
373        let mode = fs::metadata(&path).expect("metadata").permissions().mode();
374        assert_eq!(mode & 0o777, 0o600, "a key file kept loose permissions");
375    }
376
377    #[test]
378    fn a_temporary_file_never_reuses_a_name_and_never_opens_an_existing_one() {
379        // The name used to be `<target>.git-xcrypt-<pid>.tmp`: predictable, and
380        // opened without `O_EXCL`, so anyone able to write the directory could
381        // pre-create it as a symlink and have the master key written through it.
382        let dir = TempDir::new().expect("temporary directory");
383        let target = dir.path().join("repo.key");
384
385        let (first, _held) = create_temporary(&target, Mode::OwnerOnly).expect("first");
386        let (second, _also) = create_temporary(&target, Mode::OwnerOnly).expect("second");
387
388        assert_ne!(first, second, "two runs picked the same temporary name");
389        assert!(
390            create_temporary(&first, Mode::OwnerOnly).is_ok(),
391            "a name in use must simply be skipped"
392        );
393    }
394
395    #[cfg(unix)]
396    #[test]
397    fn a_key_temporary_is_owner_only_before_any_content_reaches_it() {
398        use std::os::unix::fs::PermissionsExt as _;
399
400        let dir = TempDir::new().expect("temporary directory");
401        let (path, _held) =
402            create_temporary(&dir.path().join("repo.key"), Mode::OwnerOnly).expect("creating");
403
404        let mode = fs::metadata(&path).expect("metadata").permissions().mode();
405        assert_eq!(
406            mode & 0o777,
407            0o600,
408            "the key would have been world readable while it was being written"
409        );
410    }
411}