use std::fs;
use std::io::{BufRead, Read as _, Write};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::crypto::format::{self, Header, KEY_ID_LEN, OVERHEAD};
use crate::crypto::keyfile;
use crate::git::config as gitconfig;
use crate::git::repo::{Repo, git_spelling};
use crate::rules::decide;
use crate::rules::declaration::Config;
use crate::{Error, Result};
#[derive(Debug)]
pub struct Report {
pub key_id: [u8; KEY_ID_LEN],
pub key_imported: bool,
pub config_written: bool,
pub attributes_written: bool,
pub decrypted: Vec<PathBuf>,
pub unreadable: Vec<PathBuf>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum KeySource<'a> {
File(&'a Path),
Material(&'a str),
}
const KEY_ENTRY_ROOM: usize = 128;
pub fn read_key_material<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
input_is_a_terminal: bool,
) -> Result<Zeroizing<String>> {
if input_is_a_terminal {
writeln!(
output,
"Paste the contents of a file written by `export-key`, \
then press Enter on an empty line:"
)?;
output.flush()?;
}
let mut text = Zeroizing::new(String::with_capacity(KEY_ENTRY_ROOM));
let mut seen_content = false;
loop {
let start = text.len();
if input.read_line(&mut text)? == 0 {
break;
}
if text[start..].trim().is_empty() {
text.truncate(start);
if seen_content {
break;
}
} else {
seen_content = true;
}
}
if input_is_a_terminal {
writeln!(
output,
"git-xcrypt: {}",
super::export_key::SCROLLBACK_WARNING
)?;
output.flush()?;
}
Ok(text)
}
pub fn run(repo: &Repo, key_source: Option<KeySource<'_>>, key_only: bool) -> Result<Report> {
let key = match key_source {
Some(source) => {
let key = match source {
KeySource::File(path) => keyfile::read_portable(path)?,
KeySource::Material(text) => keyfile::decode_portable(text)?,
};
refuse_on_conflict(repo, &key)?;
key
}
None => repo.load_key()?,
};
let key_id = key.key_id();
let config = Config::load(&repo.xcrypt_config_path())?;
let git_config = gitconfig::open_full(repo.git_dir(), repo.common_dir())?;
let autocrlf = gitconfig::get(&git_config, "core.autocrlf");
let core_eol = gitconfig::get(&git_config, "core.eol");
let mut walk = Walk::default();
let encrypted = collect_encrypted(repo, &mut walk)?;
refuse_foreign_keys(repo, &encrypted, &key_id)?;
let key_imported = install(repo, &key)?;
let config_written = super::init::register_driver(repo)?;
let attributes_written = crate::git::attributes::write_section(
&repo.attributes_path(),
&crate::git::attributes::render_lines_as_written(&repo.attributes_path(), &config),
)?;
let mut report = Report {
key_id,
key_imported,
config_written,
attributes_written,
decrypted: Vec::new(),
unreadable: walk.unreadable,
warnings: config.pointless_eol.clone(),
};
report.warnings.append(&mut walk.warnings);
if config.missing {
report.warnings.push(format!(
"{} is missing, so every `git add` in this repository will refuse \
until it is restored; run `git-xcrypt init` to create one",
crate::git::repo::CONFIG_FILE
));
}
if key_imported && encrypted.is_empty() {
report.warnings.push(format!(
"no encrypted file was found here, so nothing confirmed that key {} \
is this repository's. Run `git-xcrypt status` once the secrets are \
checked out.",
crate::format_key_id(&key_id)
));
}
if key_only {
return Ok(report);
}
let mut rewritten: Vec<Vec<u8>> = Vec::new();
let mut stopped = None;
for file in &encrypted {
let relative = relative_to(repo, &file.path);
let name = repo_relative_bytes(&relative);
let content = match fs::read(&file.path) {
Ok(content) => content,
Err(err) => {
stopped = Some(named_io(&relative, "read", &err));
break;
}
};
let decision = config.decide(&name);
let outcome = match decide::smudge(
Some(&key),
&name,
&content,
decision.encrypt,
decision.eol,
autocrlf.as_deref(),
core_eol.as_deref(),
) {
Ok(outcome) => outcome,
Err(err) => {
stopped = Some(Error::Format(format!("{}: {err}", git_spelling(&relative))));
break;
}
};
if let Some(warning) = outcome.warning {
report.warnings.push(warning);
}
let plaintext = Zeroizing::new(outcome.content);
if *plaintext == content {
continue;
}
match crate::util::atomic::write(&file.path, &plaintext) {
Ok(()) => {}
Err(Error::Io(err)) => {
stopped = Some(named_io(&relative, "replace", &err));
break;
}
Err(err) => {
stopped = Some(err);
break;
}
}
rewritten.push(name);
report.decrypted.push(relative);
}
let refreshed = crate::git::index::forget_stat(
&repo.git_dir().join("index"),
crate::git::index::object_hash(
gitconfig::get(&git_config, "extensions.objectformat").as_deref(),
),
&rewritten,
);
match refreshed {
Ok(crate::git::index::Outcome::Cleared(_)) => {}
Ok(crate::git::index::Outcome::Skipped(why)) => report.warnings.push(why),
Err(err) if stopped.is_none() => report.warnings.push(format!(
"the index's stat cache could not be refreshed ({err}). The files \
are decrypted correctly; if `git status` shows them as modified, \
`git add --renormalize .` settles it."
)),
Err(err) => report.warnings.push(err.to_string()),
}
if let Some(err) = stopped {
return Err(interrupted(&report, &encrypted, err));
}
Ok(report)
}
fn refuse_on_conflict(repo: &Repo, key: &crate::crypto::key::MasterKey) -> Result<()> {
match repo.load_key() {
Ok(existing) if existing.key_id() == key.key_id() => Ok(()),
Ok(existing) => Err(Error::Config(format!(
"this repository already holds key {}, and that file offers key {}.\n\
Replacing it would make every file encrypted so far impossible to read, for good.\n\
If you really mean to change keys, remove {} deliberately first.",
crate::format_key_id(&existing.key_id()),
crate::format_key_id(&key.key_id()),
repo.key_path().display()
))),
Err(Error::NoKey) => Ok(()),
Err(Error::Format(message)) => Err(Error::Format(format!(
"{}: {message}",
repo.key_path().display()
))),
Err(other) => Err(other),
}
}
fn install(repo: &Repo, key: &crate::crypto::key::MasterKey) -> Result<bool> {
if repo.has_key() {
return Ok(false);
}
keyfile::write(&repo.key_path(), key)?;
Ok(true)
}
fn named_io(relative: &Path, action: &str, err: &std::io::Error) -> Error {
Error::Io(std::io::Error::other(format!(
"{}: could not {action} it ({err})",
git_spelling(relative)
)))
}
fn interrupted(report: &Report, encrypted: &[Encrypted], err: Error) -> Error {
let done = report.decrypted.len();
let left = encrypted.len().saturating_sub(done);
let context = format!(
"\nunlock stopped part way: {done} file(s) are now in the clear and {left} \
are still encrypted. The key is in place, so running unlock again picks up \
the rest once the cause above is fixed."
);
match err {
Error::Format(message) => Error::Format(message + &context),
Error::Crypto(message) => Error::Crypto(message + &context),
Error::Config(message) => Error::Config(message + &context),
Error::Io(err) => Error::Io(std::io::Error::other(format!("{err}{context}"))),
other => other,
}
}
#[derive(Debug)]
pub(super) struct Encrypted {
path: PathBuf,
header: Header,
}
pub(super) fn collect_encrypted(repo: &Repo, walk: &mut Walk) -> Result<Vec<Encrypted>> {
let mut found = Vec::new();
let mut pending = vec![repo.work_tree().to_path_buf()];
while let Some(directory) = pending.pop() {
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(err) => {
walk.warnings
.push(format!("{}: not searched ({err})", directory.display()));
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
walk.warnings
.push(format!("{}: not searched ({err})", directory.display()));
continue;
}
};
if entry.file_name() == ".git" {
continue;
}
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
walk.warnings
.push(format!("{}: skipped, it could not be read", path.display()));
walk.unreadable.push(relative_to(repo, &path));
continue;
};
if metadata.is_symlink() {
continue;
}
if metadata.is_dir() {
if path.join(".git").exists() {
walk.warnings.push(format!(
"{}: a repository of its own, left to its own `git-xcrypt unlock`",
git_spelling(&relative_to(repo, &path))
));
} else {
pending.push(path);
}
continue;
}
if !metadata.is_file() {
continue;
}
match peek_header(&path) {
Ok(Some(header)) => found.push(Encrypted { path, header }),
Ok(None) => {}
Err(Error::Io(err)) => {
walk.warnings
.push(format!("{}: skipped ({err})", path.display()));
walk.unreadable.push(relative_to(repo, &path));
}
Err(err) => return Err(err),
}
}
}
found.sort_by(|left, right| left.path.cmp(&right.path));
Ok(found)
}
#[derive(Debug, Default)]
pub(super) struct Walk {
unreadable: Vec<PathBuf>,
pub(super) warnings: Vec<String>,
}
fn relative_to(repo: &Repo, path: &Path) -> PathBuf {
repo.relative(path)
.map_or_else(|| path.to_path_buf(), Path::to_path_buf)
}
fn peek_header(path: &Path) -> Result<Option<Header>> {
let mut file = fs::File::open(path)?;
let mut prefix = [0u8; OVERHEAD];
let read = fill(&mut file, &mut prefix)?;
let prefix = &prefix[..read];
if !format::looks_encrypted(prefix) {
return Ok(None);
}
Header::parse(prefix)
.map(Some)
.map_err(|err| Error::Format(format!("{}: {err}", path.display())))
}
fn fill(file: &mut fs::File, buffer: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buffer.len() {
match file.read(&mut buffer[filled..]) {
Ok(0) => break,
Ok(read) => filled += read,
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
Err(err) => return Err(err),
}
}
Ok(filled)
}
pub(super) fn refuse_foreign_keys(
repo: &Repo,
encrypted: &[Encrypted],
key_id: &[u8; KEY_ID_LEN],
) -> Result<()> {
for file in encrypted {
if file.header.key_id == *key_id {
continue;
}
let relative = relative_to(repo, &file.path);
return Err(Error::Format(format!(
"{} was encrypted with key {}, but the key offered here is {}.\n\
Nothing has been changed. Unlock this repository with the key whose id is {}.",
git_spelling(&relative),
crate::format_key_id(&file.header.key_id),
crate::format_key_id(key_id),
crate::format_key_id(&file.header.key_id)
)));
}
Ok(())
}
fn repo_relative_bytes(relative: &Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
relative.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
{
relative.to_string_lossy().replace('\\', "/").into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
const EXPORT: &str = "git-xcrypt-key-v1 fd2f0a5c2d19a55b\n\
KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=\n";
fn read(input: &str, terminal: bool) -> (String, String) {
let mut said = Vec::new();
let text = read_key_material(&mut input.as_bytes(), &mut said, terminal)
.expect("reading from a slice cannot fail");
(
text.to_string(),
String::from_utf8(said).expect("the prompt is text"),
)
}
#[test]
fn entry_ends_at_a_blank_line_or_at_the_end_of_the_input() {
let (piped, _) = read(EXPORT, false);
assert_eq!(piped, EXPORT, "end of input did not end the entry");
let (typed, _) = read(&format!("{EXPORT}\n"), true);
assert_eq!(typed, EXPORT, "the blank line was kept, or ate the key");
let (stopped, _) = read(&format!("{EXPORT}\nnot the key\n"), true);
assert_eq!(stopped, EXPORT, "reading ran past the blank line");
let crlf = EXPORT.replace('\n', "\r\n");
let (typed_crlf, _) = read(&format!("{crlf}\r\n"), true);
assert_eq!(
typed_crlf, crlf,
"a CRLF blank line was kept, or ate the key"
);
let (stopped_crlf, _) = read(&format!("{crlf}\r\nnot the key\r\n"), true);
assert_eq!(stopped_crlf, crlf, "reading ran past a CRLF blank line");
for (shape, text) in [
("piped", piped.as_str()),
("typed", typed.as_str()),
("stopped", stopped.as_str()),
("typed with CRLF", typed_crlf.as_str()),
("stopped at a CRLF blank line", stopped_crlf.as_str()),
] {
keyfile::decode_portable(text)
.unwrap_or_else(|err| panic!("the {shape} entry does not parse: {err}"));
}
}
#[test]
fn a_leading_blank_line_is_skipped_rather_than_read_as_the_end() {
let (text, _) = read(&format!("\n\n{EXPORT}\n"), true);
assert!(
keyfile::decode_portable(&text).is_ok(),
"a leading blank line ended the entry before the key: {text:?}"
);
}
#[test]
fn only_an_echoing_terminal_is_told_the_key_is_in_the_scrollback() {
let (_, over_a_pipe) = read(EXPORT, false);
assert_eq!(
over_a_pipe, "",
"a pipe was prompted at, or warned about a scrollback it has not got"
);
let (_, at_a_terminal) = read(&format!("{EXPORT}\n"), true);
assert!(
at_a_terminal.contains("export-key"),
"nothing said what to paste: {at_a_terminal:?}"
);
assert!(
at_a_terminal.contains("scrollback"),
"the key was echoed and nothing said so: {at_a_terminal:?}"
);
assert!(
!at_a_terminal.contains("KioqKioq"),
"the prompt printed the key back: {at_a_terminal:?}"
);
}
}