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". 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}