git_xcrypt/commands/export_key.rs
1//! `git-xcrypt export-key` — hand the repository key to the user, once.
2//!
3//! This is the command that gives a key away, so it is the shortest route to a
4//! leak in the whole product. PRD FR-007 says as much: "one run inside CI, or
5//! one redirect into the repository directory, is all it takes". Two refusals
6//! close the routes that do not need a compromised machine:
7//!
8//! * a destination inside the working tree is refused outright, because that is
9//! one `git add -A` away from a commit;
10//! * an existing file is refused unless `--force`, so a mistyped path cannot
11//! silently destroy someone's backup of a *different* key.
12//!
13//! The key reaches `stdout` only when `--stdout` asks for it, and the flag
14//! exists for the one workflow the file form cannot serve: piping the key
15//! straight into a secret store (`| pbcopy`, `| gh secret set …`) without it
16//! ever touching the disk. **A terminal was refused there between 2026-08-06 and
17//! 2026-08-11, and is now warned about instead — the owner's call, on the
18//! grounds that typing `--stdout` is the consent.** The cost is unchanged and
19//! measured, so the warning names it: a key printed to a terminal stays in the
20//! scrollback, in the multiplexer's buffer and in any session log, none of which
21//! this process can reach afterwards. What the flag cannot police at all is a
22//! shell redirect: `--stdout > secrets/key.txt` writes where the refusals above
23//! would have said no, because a process cannot portably learn the path behind
24//! its own file descriptor. Said out loud in the command's own warning and in
25//! the README, because it is the FR-007 leak with the guard rail removed by
26//! hand.
27//!
28//! The file itself is written owner-only and atomically, by the same code that
29//! writes the repository's own key.
30
31use std::path::{Path, PathBuf};
32
33use crate::crypto::format::KEY_ID_LEN;
34use crate::crypto::keyfile;
35use crate::git::repo::Repo;
36use crate::{Error, Result};
37
38/// What `export-key` wrote, so the binary can say so without naming the key.
39#[derive(Debug)]
40pub struct Report {
41 /// Fingerprint of the exported key. Safe to print; the key is not.
42 pub key_id: [u8; KEY_ID_LEN],
43 /// Where it landed, resolved the way the refusal check saw it.
44 pub path: PathBuf,
45}
46
47/// What `--stdout` handed over, and whether the destination keeps a copy.
48#[derive(Debug)]
49pub struct Exported {
50 /// Fingerprint of the exported key. Safe to print; the key is not.
51 pub key_id: [u8; KEY_ID_LEN],
52 /// The key went to a terminal, so it now lives somewhere this process
53 /// cannot reach. The caller says so on `stderr`; see [`SCROLLBACK_WARNING`].
54 pub went_to_a_terminal: bool,
55}
56
57/// What the caller prints when the key landed in a terminal.
58///
59/// Here rather than in the binary so the rule and its wording sit beside the
60/// code that decides it, the way every other message in this module does.
61pub const SCROLLBACK_WARNING: &str = "that was a terminal, so the key is now in the scrollback, in your \
62 multiplexer's buffer and in any session log — none of which this command \
63 can reach. Treat it as exposed unless you clear all three, or rotate it.";
64
65/// Writes the repository key to `stdout`, for piping into a secret store.
66///
67/// A terminal is written to like anything else and reported back, so the caller
68/// can name the cost. Refusing it was the rule from 2026-08-06 until
69/// 2026-08-11; see the module comment for why it is a warning now, and for the
70/// half of the bargain no process can enforce either way.
71///
72/// # Errors
73///
74/// [`Error::NoKey`] when the repository has no key, [`Error::Io`] when the
75/// write fails.
76pub fn to_stdout(repo: &Repo) -> Result<Exported> {
77 use std::io::IsTerminal as _;
78 to_writer(
79 repo,
80 &mut std::io::stdout().lock(),
81 std::io::stdout().is_terminal(),
82 )
83}
84
85/// [`to_stdout`], with the destination and the terminal answer as arguments.
86///
87/// Split for the same reason as `gitconfig::global_attributes_file_for`: a test
88/// cannot portably arrange a terminal — a pty is a Unix mechanism and this rule
89/// has to hold on all three platforms. Passing the answer in makes both arms
90/// reachable from anywhere, which is worth more than a guard that runs on one
91/// platform out of three.
92///
93/// # Errors
94///
95/// As [`to_stdout`].
96fn to_writer(
97 repo: &Repo,
98 out: &mut impl std::io::Write,
99 destination_is_a_terminal: bool,
100) -> Result<Exported> {
101 let key = repo.load_key()?;
102 let key_id = key.key_id();
103 // The same text the file form writes, so one format round-trips through
104 // both routes and the header keeps verifying the material behind it.
105 let exported = keyfile::encode_portable(&key);
106 out.write_all(exported.as_bytes())?;
107 out.flush()?;
108 Ok(Exported {
109 key_id,
110 went_to_a_terminal: destination_is_a_terminal,
111 })
112}
113
114/// Writes the repository key to `destination`.
115///
116/// # Errors
117///
118/// [`Error::Config`] when the destination is inside the working tree or already
119/// exists without `--force`, [`Error::NoKey`] when the repository has no key,
120/// [`Error::Io`] when the file cannot be written.
121pub fn run(repo: &Repo, destination: &Path, force: bool) -> Result<Report> {
122 // Before the key is even loaded: a refusal must never be reached with key
123 // material already in this process's memory if it does not have to be.
124 let resolved = refuse_bad_destination(repo, destination, force)?;
125
126 let key = repo.load_key()?;
127 let key_id = key.key_id();
128
129 if let Some(parent) = destination.parent()
130 && !parent.as_os_str().is_empty()
131 {
132 create_key_directory(parent)?;
133 }
134 keyfile::write_portable(destination, &key)?;
135
136 Ok(Report {
137 key_id,
138 path: resolved,
139 })
140}
141
142/// Refuses every destination that would defeat the point of the command.
143///
144/// Returns the resolved path, so the message the user sees names the place the
145/// check actually looked at rather than what they typed.
146fn refuse_bad_destination(repo: &Repo, destination: &Path, force: bool) -> Result<PathBuf> {
147 let here = std::env::current_dir()?;
148 let resolved = resolve(&here, destination);
149
150 // **Every** checkout, not just the one this command was run from. A linked
151 // worktree is a different directory that is not a prefix of this one, so a
152 // single comparison let `export-key ../linked/k.key` through — measured on
153 // git 2.55, the key landed in the sibling checkout's `git status` as an
154 // untracked file, which is the exact state this refusal exists to prevent.
155 for work_tree in repo.work_trees() {
156 let work_tree = resolve(&here, &work_tree);
157 if resolved.starts_with(&work_tree) {
158 return Err(Error::Config(format!(
159 "refusing to write the repository key to {}: it is inside the working tree of {}, \
160 which is one `git add` away from a commit. Choose a path outside the repository, \
161 such as a directory only you can read.",
162 resolved.display(),
163 work_tree.display()
164 )));
165 }
166 }
167
168 // The git directory is not always inside a working tree: with `git init
169 // --separate-git-dir` it sits somewhere else entirely, and the loop above
170 // then has nothing to say about it. Nothing legitimate writes an exported
171 // key in there, and the repository's own key already lives one directory
172 // down, where `--force` would overwrite it.
173 for private in [repo.git_dir(), repo.common_dir()] {
174 let private = resolve(&here, private);
175 if resolved.starts_with(&private) {
176 return Err(Error::Config(format!(
177 "refusing to write the repository key to {}: it is inside the git directory {}, \
178 which is where this repository's own key lives. Choose a path outside the \
179 repository, such as a directory only you can read.",
180 resolved.display(),
181 private.display()
182 )));
183 }
184 }
185
186 // `symlink_metadata`, not `exists`: a broken symlink is still an entry the
187 // rename would replace, and `exists` follows the link and says no.
188 if !force && destination.symlink_metadata().is_ok() {
189 return Err(Error::Config(format!(
190 "{} already exists; pass --force to replace it. \
191 Overwriting a key file destroys the only copy of whatever key it held.",
192 destination.display()
193 )));
194 }
195
196 Ok(resolved)
197}
198
199/// Creates the directory the key is about to land in, owner-only.
200///
201/// A directory this command creates exists to hold keys, so `0700` rather than
202/// the usual `0755` — the file itself is `0600` either way, but a directory
203/// anyone can list is one more thing a user did not ask for. Directories that
204/// already exist keep whatever permissions their owner chose.
205///
206/// On Windows neither number applies: there is no mode to set, so the directory
207/// and the key file both inherit the ACL of wherever the user pointed this
208/// command. That is the limitation recorded in `README.md` §Known limitations,
209/// and it is why the message there tells a Windows user to pick the directory
210/// deliberately.
211fn create_key_directory(path: &Path) -> Result<()> {
212 let mut builder = std::fs::DirBuilder::new();
213 builder.recursive(true);
214 #[cfg(unix)]
215 {
216 use std::os::unix::fs::DirBuilderExt as _;
217 builder.mode(0o700);
218 }
219
220 // The bare error is `File exists`, which for a parent that is a regular file
221 // reads as "you told me not to overwrite" rather than "there is a file where
222 // a directory has to go", and names nothing.
223 builder.create(path).map_err(|err| {
224 Error::Io(std::io::Error::other(format!(
225 "{}: could not create the directory to hold the key ({err})",
226 path.display()
227 )))
228 })
229}
230
231/// An absolute, symlink-resolved form of `path`, which need not exist yet.
232///
233/// Canonicalising the whole path is not an option — the destination is normally
234/// a file that is about to be created — so the deepest ancestor that does exist
235/// is canonicalised and the rest is appended and normalised lexically. Without
236/// the canonicalisation the check would miss the case that matters most on
237/// macOS, where a repository under `/var/folders/...` is reached through a
238/// symlink from `/private/var/folders/...` and the two spellings do not compare
239/// equal.
240///
241/// `base` is the directory a relative path is measured from — the process's
242/// current directory in production, and an argument here so the refusal can be
243/// tested without mutating process-wide state.
244fn resolve(base: &Path, path: &Path) -> PathBuf {
245 let absolute = if path.is_absolute() {
246 path.to_path_buf()
247 } else {
248 base.join(path)
249 };
250
251 let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
252 let mut probe: &Path = &absolute;
253
254 loop {
255 if let Ok(real) = probe.canonicalize() {
256 let mut out = real;
257 for name in tail.iter().rev() {
258 out.push(name);
259 }
260 return crate::git::repo::lexically_normal(&out);
261 }
262
263 let (Some(parent), Some(name)) = (probe.parent(), probe.file_name()) else {
264 // Nothing along the path exists, which on a sane system means the
265 // root does not either. Lexical normalisation is all that is left.
266 return crate::git::repo::lexically_normal(&absolute);
267 };
268 tail.push(name);
269 probe = parent;
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use std::fs;
277 use std::process::Command;
278 use tempfile::TempDir;
279
280 fn init_repo() -> TempDir {
281 let dir = TempDir::new().expect("temporary directory");
282 let ok = Command::new("git")
283 .args(["init", "-q"])
284 .current_dir(dir.path())
285 .status()
286 .expect("git must be on PATH")
287 .success();
288 assert!(ok, "git init failed");
289 dir
290 }
291
292 /// A repository with a key, plus a directory outside it to export into.
293 fn prepared() -> (TempDir, TempDir, Repo) {
294 let dir = init_repo();
295 let repo = Repo::discover(dir.path()).expect("discovery");
296 crate::commands::init::run(&repo).expect("init must succeed");
297 let elsewhere = TempDir::new().expect("temporary directory");
298 (dir, elsewhere, repo)
299 }
300
301 #[test]
302 fn a_destination_inside_the_git_directory_is_refused_too() {
303 // `.git/` is not versioned, but it is inside the tree and a user who
304 // typed it meant something else.
305 let (_dir, _elsewhere, repo) = prepared();
306 let path = repo.git_dir().join("exported.key");
307 assert!(run(&repo, &path, false).is_err());
308 }
309
310 #[test]
311 fn an_existing_file_is_refused_unless_force_says_otherwise() {
312 let (_dir, elsewhere, repo) = prepared();
313 let path = elsewhere.path().join("repo.key");
314 fs::write(&path, b"someone else's key").expect("writing");
315
316 let error = run(&repo, &path, false).expect_err("a mistyped path must not destroy a key");
317 assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
318 assert_eq!(fs::read(&path).expect("reading"), b"someone else's key");
319
320 run(&repo, &path, true).expect("--force must replace it");
321 assert!(keyfile::read_portable(&path).is_ok());
322 }
323
324 /// Both arms of the destination a test cannot arrange from outside.
325 ///
326 /// A terminal needs a pty, which is a Unix mechanism, and this rule has to
327 /// hold on all three platforms — so the answer arrives as an argument. Both
328 /// arms carry weight and for opposite reasons: refusing a pipe would break
329 /// the only workflow the flag exists for, and refusing a terminal is what
330 /// the owner removed on 2026-08-11 — `--stdout` is the consent, so the key
331 /// must actually appear, with the cost named rather than the write blocked.
332 #[test]
333 fn the_key_goes_to_a_pipe_and_to_a_terminal_that_is_told_what_it_costs() {
334 let dir = init_repo();
335 let repo = Repo::discover(dir.path()).expect("discovery");
336 crate::commands::init::run(&repo).expect("init must succeed");
337
338 let mut piped: Vec<u8> = Vec::new();
339 let exported = to_writer(&repo, &mut piped, false).expect("a pipe must be written to");
340 let text = String::from_utf8(piped).expect("an export is text");
341 assert!(
342 text.contains(&crate::format_key_id(&exported.key_id)),
343 "the export must name the key it holds: {text}"
344 );
345 // The one format, so a key piped out reads back through the same parser
346 // a file goes through — the header still verifies the material.
347 let parsed = keyfile::decode_portable(&text).expect("the export must parse");
348 assert_eq!(parsed.key_id(), exported.key_id);
349 assert!(
350 !exported.went_to_a_terminal,
351 "a pipe must not drag the terminal warning into a CI log"
352 );
353
354 let mut to_a_terminal: Vec<u8> = Vec::new();
355 let shown = to_writer(&repo, &mut to_a_terminal, true)
356 .expect("a terminal is the caller's own call since 2026-08-11");
357 let shown_text = String::from_utf8(to_a_terminal).expect("an export is text");
358 assert_eq!(
359 shown_text, text,
360 "a terminal must get the same export a pipe gets, or the flag lies"
361 );
362 assert!(
363 shown.went_to_a_terminal,
364 "the cost must be reported, or the scrollback goes unmentioned"
365 );
366 assert!(
367 SCROLLBACK_WARNING.contains("scrollback"),
368 "the warning must say where the key now lives, or it reads as noise"
369 );
370 }
371}