use std::fs;
use std::io::Read as _;
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),
}
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()
}
}