Skip to main content

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}