Skip to main content

git_xcrypt/commands/
diff.rs

1//! `git-xcrypt diff` — the textconv driver behind `git diff` on a secret.
2//!
3//! Registered by `init` as `diff.git-xcrypt.textconv`. Git runs it with the path
4//! of a file and reads its `stdout` as the text to compare, which makes this the
5//! one place in the product where writing to `stdout` is the contract rather
6//! than a corruption. The rule it looks like an exception to is narrower than it
7//! sounds: on the *filter* path git treats `stdout` as the file itself, so a
8//! stray byte damages a user's file. Here `stdout` is a diff, never a file.
9//!
10//! Four properties shape it.
11//!
12//! **It decides from the header, never from `.git-xcrypt`.** The same rule the
13//! smudge path follows, and it is not a stylistic choice here either: git hands
14//! this command two different kinds of content under the same argument. For a
15//! blob it writes a temporary file holding the stored bytes — ciphertext. For a
16//! working-tree side, and for a blob whose working-tree copy is already
17//! identical, git *borrows the working-tree file itself* rather than converting
18//! it, so the argument is plaintext. Only the header can tell the two apart.
19//!
20//! **Content without our magic passes through untouched.** That is the same
21//! case, plus the one the plan names: a file committed before it was ever
22//! declared. Failing on it would break `git log -p` across the whole history,
23//! and the content is not a secret this command could protect anyway — it is
24//! already in the object database in the clear.
25//!
26//! **The output is git-form, never working-tree form.** The decrypting branch is
27//! only reached when the smudge filter did *not* run first — a repository where
28//! the diff driver is registered and the filter is not. Both sides of such a
29//! diff arrive as ciphertext, so emitting the bytes that were fed to the cipher
30//! keeps them comparable, and it makes the output a function of the blob alone
31//! rather than of the machine's `core.autocrlf`.
32//!
33//! **The key is fetched only when the content needs it.** A clone with no key
34//! can still run `git log -p` over history from before the repository was
35//! configured, and `git diff` on an ordinary file never touches the key at all.
36
37use std::fs;
38use std::path::{Path, PathBuf};
39
40use bstr::ByteSlice as _;
41
42use crate::crypto::format::looks_encrypted;
43use crate::crypto::key::MasterKey;
44use crate::crypto::keyfile;
45use crate::git::repo::Repo;
46use crate::rules::decide::{self, Outcome};
47use crate::rules::declaration::EolMode;
48use crate::{Error, Result};
49
50/// Reads `path` and returns the text git should diff.
51///
52/// # Errors
53///
54/// [`Error::Config`] for a path inside the git directory, [`Error::Io`] when the
55/// file cannot be read, [`Error::NoKey`] when it is ours and no key is loaded,
56/// and the errors [`crate::crypto::cipher::decrypt`] reports for content that is ours
57/// but belongs to another key, is truncated or fails authentication.
58pub fn run(path: &Path) -> Result<Outcome> {
59    // Discovered once, up front, because both branches want it: one to refuse a
60    // path this command must never print, the other to find the key.
61    let repo = Repo::discover_from_cwd();
62    if let Ok(repo) = &repo {
63        refuse_private_path(repo, path)?;
64    }
65
66    let content = fs::read(path).map_err(|err| named_io(path, &err))?;
67
68    let key = if looks_encrypted(&content) {
69        Some(repo?.load_key()?)
70    } else {
71        None
72    };
73
74    convert(key.as_ref(), &name_of(path), &content)
75}
76
77/// Refuses to print anything from inside the git directory.
78///
79/// The key file lives there and carries its own magic, one byte different from
80/// the data magic, so [`looks_encrypted`] says no and the pass-through branch
81/// would hand the repository's master key to `stdout` — where
82/// `git-xcrypt diff .git/git-xcrypt/keys/default > k` puts it in the working
83/// tree, one `git add -A` from a commit. That is the leak `export-key` guards
84/// against by hand, and the rule behind it has no exception: a key reaches
85/// `stdout` from `export-key` and from nowhere else.
86///
87/// Git never asks for a path in there, so nothing legitimate is lost.
88fn refuse_private_path(repo: &Repo, path: &Path) -> Result<()> {
89    let Some(target) = resolved(path) else {
90        return Ok(());
91    };
92
93    for private in [repo.git_dir(), repo.common_dir()] {
94        if resolved(private).is_some_and(|private| target.starts_with(private)) {
95            return Err(Error::Config(format!(
96                "{}: this is inside the git directory, which this command never prints. \
97                 To carry the repository key to another machine, use `git-xcrypt export-key`.",
98                path.display()
99            )));
100        }
101    }
102    Ok(())
103}
104
105/// An absolute path with symlinks resolved, so the check above cannot be walked
106/// around by pointing a link in the working tree at the key.
107///
108/// Falls back to a lexical answer for a path that does not exist — the caller
109/// then fails on the read instead, which is the same outcome.
110fn resolved(path: &Path) -> Option<PathBuf> {
111    let absolute = if path.is_absolute() {
112        path.to_path_buf()
113    } else {
114        std::env::current_dir().ok()?.join(path)
115    };
116    Some(
117        fs::canonicalize(&absolute)
118            .unwrap_or_else(|_| crate::git::repo::lexically_normal(&absolute)),
119    )
120}
121
122/// Puts the path in front of a bare I/O failure.
123///
124/// `No such file or directory (os error 2)` names nothing, and here the path is
125/// usually one git invented rather than one the user typed.
126fn named_io(path: &Path, err: &std::io::Error) -> Error {
127    Error::Io(std::io::Error::other(format!(
128        "{}: could not read it ({err})",
129        path.display()
130    )))
131}
132
133/// Turns one file's bytes into the text git should diff.
134///
135/// Split from [`run`] so the decision is testable without a repository on disk,
136/// and so the decryption itself goes through [`decide::smudge`] — the very
137/// function the checkout path calls. A second implementation here is exactly
138/// what the roadmap names as this slice's risk: it would drift from the format
139/// the first time the format changes.
140///
141/// # Errors
142///
143/// As [`run`], minus the I/O.
144pub fn convert(key: Option<&MasterKey>, name: &[u8], content: &[u8]) -> Result<Outcome> {
145    // Decided on the content, so it holds wherever the file is and whatever the
146    // current directory is — the location check in `run` cannot say that, and it
147    // has nothing to say at all about a copy `export-key` wrote into the working
148    // tree. The rule has no exception: a key leaves through `export-key`.
149    if keyfile::holds_a_key(content) {
150        return Err(refuse_key(name));
151    }
152
153    if !looks_encrypted(content) {
154        return Ok(Outcome {
155            content: content.to_vec(),
156            warning: None,
157        });
158    }
159
160    // `EolMode::Lf` rather than git's configuration: see the module comment. It
161    // makes the output a function of the blob alone, which is what keeps the two
162    // sides of a diff comparable on the one path that reaches here. Content
163    // recorded as binary is unaffected either way — `smudge` writes it out
164    // verbatim, because its header says it never went through a conversion.
165    // `selected` is false because it only governs a warning on the branch above,
166    // which the header has already ruled out.
167    let outcome = decide::smudge(key, name, content, false, Some(EolMode::Lf), None, None)?;
168
169    // Asked again of what is about to be printed, not only of what was read.
170    // The check above sees ciphertext for a key file that was committed under a
171    // declared pattern, and ciphertext is not a key file — the decrypted bytes
172    // are. Measured on git 2.55: with a `secrets/** -filter` line below the
173    // managed section (so smudge never ran and the working tree held
174    // ciphertext) and the diff driver still registered, `git log -p` printed
175    // the decrypted master key to stdout. The rule has no exception, so the
176    // question is asked on both sides of the cipher.
177    if keyfile::holds_a_key(&outcome.content) {
178        // The decrypted key does not outlive the refusal on the heap.
179        drop(zeroize::Zeroizing::new(outcome.content));
180        return Err(refuse_key(name));
181    }
182    Ok(outcome)
183}
184
185/// The one refusal both sides of the cipher share.
186fn refuse_key(name: &[u8]) -> Error {
187    Error::Config(format!(
188        "{}: this is a git-xcrypt key file, and a key is never printed. \
189         To carry it to another machine, use `git-xcrypt export-key`.",
190        name.as_bstr()
191    ))
192}
193
194/// The path as the decision function wants it: bytes, not text.
195///
196/// Only ever used in a message. On Unix a path is an arbitrary byte string, and
197/// this is the spelling the rest of the crate passes around.
198fn name_of(path: &Path) -> Vec<u8> {
199    #[cfg(unix)]
200    {
201        use std::os::unix::ffi::OsStrExt as _;
202        path.as_os_str().as_bytes().to_vec()
203    }
204    #[cfg(not(unix))]
205    {
206        path.to_string_lossy().replace('\\', "/").into_bytes()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::crypto::key::MASTER_KEY_LEN;
214
215    fn key() -> MasterKey {
216        MasterKey::from_bytes([5u8; MASTER_KEY_LEN])
217    }
218
219    #[test]
220    fn a_key_file_is_refused_although_it_carries_no_data_magic() {
221        // Both shapes. Neither starts with the data magic, so without this the
222        // pass-through branch would print the repository's master key.
223        let exported = crate::crypto::keyfile::encode_portable(&key());
224        for content in [exported.as_bytes(), b"\0GITXCRYPTKEY\0\x01somekeymaterial"] {
225            let error = convert(Some(&key()), b"notes.txt", content).expect_err("must refuse");
226            assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
227            assert!(
228                error.to_string().contains("export-key"),
229                "the refusal must say where a key is allowed to go: {error}"
230            );
231        }
232    }
233}