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}