use std::path::{Path, PathBuf};
use crate::crypto::format::KEY_ID_LEN;
use crate::crypto::keyfile;
use crate::git::repo::Repo;
use crate::{Error, Result};
#[derive(Debug)]
pub struct Report {
pub key_id: [u8; KEY_ID_LEN],
pub path: PathBuf,
}
#[derive(Debug)]
pub struct Exported {
pub key_id: [u8; KEY_ID_LEN],
pub went_to_a_terminal: bool,
}
pub const SCROLLBACK_WARNING: &str = "that was a terminal, so the key is now in the scrollback, in your \
multiplexer's buffer and in any session log — none of which this command \
can reach. Treat it as exposed unless you clear all three, or rotate it.";
pub fn to_stdout(repo: &Repo) -> Result<Exported> {
use std::io::IsTerminal as _;
to_writer(
repo,
&mut std::io::stdout().lock(),
std::io::stdout().is_terminal(),
)
}
fn to_writer(
repo: &Repo,
out: &mut impl std::io::Write,
destination_is_a_terminal: bool,
) -> Result<Exported> {
let key = repo.load_key()?;
let key_id = key.key_id();
let exported = keyfile::encode_portable(&key);
out.write_all(exported.as_bytes())?;
out.flush()?;
Ok(Exported {
key_id,
went_to_a_terminal: destination_is_a_terminal,
})
}
pub fn run(repo: &Repo, destination: &Path, force: bool) -> Result<Report> {
let resolved = refuse_bad_destination(repo, destination, force)?;
let key = repo.load_key()?;
let key_id = key.key_id();
if let Some(parent) = destination.parent()
&& !parent.as_os_str().is_empty()
{
create_key_directory(parent)?;
}
keyfile::write_portable(destination, &key)?;
Ok(Report {
key_id,
path: resolved,
})
}
fn refuse_bad_destination(repo: &Repo, destination: &Path, force: bool) -> Result<PathBuf> {
let here = std::env::current_dir()?;
let resolved = resolve(&here, destination);
for work_tree in repo.work_trees() {
let work_tree = resolve(&here, &work_tree);
if resolved.starts_with(&work_tree) {
return Err(Error::Config(format!(
"refusing to write the repository key to {}: it is inside the working tree of {}, \
which is one `git add` away from a commit. Choose a path outside the repository, \
such as a directory only you can read.",
resolved.display(),
work_tree.display()
)));
}
}
for private in [repo.git_dir(), repo.common_dir()] {
let private = resolve(&here, private);
if resolved.starts_with(&private) {
return Err(Error::Config(format!(
"refusing to write the repository key to {}: it is inside the git directory {}, \
which is where this repository's own key lives. Choose a path outside the \
repository, such as a directory only you can read.",
resolved.display(),
private.display()
)));
}
}
if !force && destination.symlink_metadata().is_ok() {
return Err(Error::Config(format!(
"{} already exists; pass --force to replace it. \
Overwriting a key file destroys the only copy of whatever key it held.",
destination.display()
)));
}
Ok(resolved)
}
fn create_key_directory(path: &Path) -> Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
builder.create(path).map_err(|err| {
Error::Io(std::io::Error::other(format!(
"{}: could not create the directory to hold the key ({err})",
path.display()
)))
})
}
fn resolve(base: &Path, path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
};
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
let mut probe: &Path = &absolute;
loop {
if let Ok(real) = probe.canonicalize() {
let mut out = real;
for name in tail.iter().rev() {
out.push(name);
}
return crate::git::repo::lexically_normal(&out);
}
let (Some(parent), Some(name)) = (probe.parent(), probe.file_name()) else {
return crate::git::repo::lexically_normal(&absolute);
};
tail.push(name);
probe = parent;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let dir = TempDir::new().expect("temporary directory");
let ok = Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.status()
.expect("git must be on PATH")
.success();
assert!(ok, "git init failed");
dir
}
fn prepared() -> (TempDir, TempDir, Repo) {
let dir = init_repo();
let repo = Repo::discover(dir.path()).expect("discovery");
crate::commands::init::run(&repo).expect("init must succeed");
let elsewhere = TempDir::new().expect("temporary directory");
(dir, elsewhere, repo)
}
#[test]
fn a_destination_inside_the_git_directory_is_refused_too() {
let (_dir, _elsewhere, repo) = prepared();
let path = repo.git_dir().join("exported.key");
assert!(run(&repo, &path, false).is_err());
}
#[test]
fn an_existing_file_is_refused_unless_force_says_otherwise() {
let (_dir, elsewhere, repo) = prepared();
let path = elsewhere.path().join("repo.key");
fs::write(&path, b"someone else's key").expect("writing");
let error = run(&repo, &path, false).expect_err("a mistyped path must not destroy a key");
assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
assert_eq!(fs::read(&path).expect("reading"), b"someone else's key");
run(&repo, &path, true).expect("--force must replace it");
assert!(keyfile::read_portable(&path).is_ok());
}
#[test]
fn the_key_goes_to_a_pipe_and_to_a_terminal_that_is_told_what_it_costs() {
let dir = init_repo();
let repo = Repo::discover(dir.path()).expect("discovery");
crate::commands::init::run(&repo).expect("init must succeed");
let mut piped: Vec<u8> = Vec::new();
let exported = to_writer(&repo, &mut piped, false).expect("a pipe must be written to");
let text = String::from_utf8(piped).expect("an export is text");
assert!(
text.contains(&crate::format_key_id(&exported.key_id)),
"the export must name the key it holds: {text}"
);
let parsed = keyfile::decode_portable(&text).expect("the export must parse");
assert_eq!(parsed.key_id(), exported.key_id);
assert!(
!exported.went_to_a_terminal,
"a pipe must not drag the terminal warning into a CI log"
);
let mut to_a_terminal: Vec<u8> = Vec::new();
let shown = to_writer(&repo, &mut to_a_terminal, true)
.expect("a terminal is the caller's own call since 2026-08-11");
let shown_text = String::from_utf8(to_a_terminal).expect("an export is text");
assert_eq!(
shown_text, text,
"a terminal must get the same export a pipe gets, or the flag lies"
);
assert!(
shown.went_to_a_terminal,
"the cost must be reported, or the scrollback goes unmentioned"
);
assert!(
SCROLLBACK_WARNING.contains("scrollback"),
"the warning must say where the key now lives, or it reads as noise"
);
}
}