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