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,
}
pub fn to_stdout(repo: &Repo) -> Result<[u8; KEY_ID_LEN]> {
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<[u8; KEY_ID_LEN]> {
if destination_is_a_terminal {
return Err(Error::Config(
"refusing to print the key to a terminal: it would stay in the \
scrollback, in your multiplexer's buffer and in any session log.\n\
Pipe it somewhere (`git-xcrypt export-key --stdout | pbcopy`), or \
write a file with `git-xcrypt export-key <path>`."
.into(),
));
}
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(key_id)
}
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_never_to_a_terminal() {
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 key_id = 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(&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(), key_id);
let mut to_a_terminal: Vec<u8> = Vec::new();
let refused = to_writer(&repo, &mut to_a_terminal, true)
.expect_err("a terminal destination must be refused");
assert_eq!(refused.exit_code(), crate::util::exit::CONFIG);
assert!(to_a_terminal.is_empty(), "the refusal still wrote the key");
assert!(
refused.to_string().contains("scrollback"),
"the refusal must say why, or it reads as a bug: {refused}"
);
}
}