use std::fs;
use crate::crypto::key::MasterKey;
use crate::crypto::keyfile;
use crate::git::attributes;
use crate::git::config as gitconfig;
use crate::git::repo::{DRIVER, Repo};
use crate::rules::declaration::Config;
use crate::{Error, Result};
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Report {
pub key_created: bool,
pub config_written: bool,
pub attributes_written: bool,
pub config_file_created: bool,
pub warnings: Vec<String>,
}
impl Report {
#[must_use]
pub fn changed_anything(&self) -> bool {
self.key_created
|| self.config_written
|| self.attributes_written
|| self.config_file_created
}
}
const CONFIG_TEMPLATE: &str = "\
# git-xcrypt — which paths leave this machine encrypted, and how line endings
# are handled. Patterns use .gitignore syntax; attributes use .gitattributes
# vocabulary. Without an attribute a path is treated as `text=auto`.
#
# Whitespace ends the pattern, so a name that contains a space is closed with
# quotes, exactly as .gitattributes closes one. A backslash is only what a glob
# says it is, and a negation keeps its `!` outside the quotes.
#
# secrets/
# *.env
# secrets/deploy.ps1 text eol=crlf
# secrets/key.p12 binary
# \"my secrets/\"
# \"my secrets/*.sh\" text eol=lf
# !secrets/README.md
# !\"my secrets/README.md\"
";
pub fn run(repo: &Repo) -> Result<Report> {
let mut report = Report::default();
if !repo.has_key() {
refuse_if_previously_configured(repo)?;
keyfile::write(&repo.key_path(), &MasterKey::generate()?)?;
report.key_created = true;
}
report.config_written = register_driver(repo)?;
report.config_file_created = create_config_file(repo)?;
let config = Config::load(&repo.xcrypt_config_path())?;
let lines = attributes::render_lines_as_written(&repo.attributes_path(), &config);
report.warnings = config.pointless_eol;
report.warnings.extend(textconv_cache_warning(repo));
report.attributes_written = attributes::write_section(&repo.attributes_path(), &lines)?;
Ok(report)
}
fn refuse_if_previously_configured(repo: &Repo) -> Result<()> {
let attributes = match fs::read_to_string(repo.attributes_path()) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => {
return Err(Error::Config(format!(
"cannot tell whether this repository was already set up: {} could not be \
read ({err}). Refusing rather than risk generating a second key.",
repo.attributes_path().display()
)));
}
};
let has_section = attributes::has_section(&attributes);
let has_config = repo.xcrypt_config_path().is_file();
if !has_section && !has_config {
return Ok(());
}
Err(Error::Config(format!(
"this repository was already set up for git-xcrypt but its key is missing.\n\
Generating a new one would make every file encrypted so far impossible to \
read, for good.\n\
If this is a clone, run `git-xcrypt unlock <key-file>`.\n\
To put the key in place without decrypting anything, add `--key-only`.\n\
If this repository never used git-xcrypt and you wrote {} by hand, delete it \
and run `init` again.\n\
(found: {})",
crate::git::repo::CONFIG_FILE,
match (has_section, has_config) {
(true, true) => "a managed .gitattributes section and .git-xcrypt",
(true, false) => "a managed .gitattributes section",
_ => ".git-xcrypt",
}
)))
}
pub(crate) fn register_driver(repo: &Repo) -> Result<bool> {
let path = repo.config_path();
let mut config = gitconfig::open_local(&path)?;
let binary = current_executable()?;
let wanted = [
(
format!("filter.{DRIVER}.process"),
format!("{binary} process"),
),
(format!("filter.{DRIVER}.required"), "true".to_string()),
(format!("diff.{DRIVER}.textconv"), format!("{binary} diff")),
(format!("diff.{DRIVER}.cachetextconv"), "false".to_string()),
];
let mut changed = false;
for (key, value) in wanted {
if gitconfig::get(&config, &key).as_deref() != Some(value.as_str()) {
gitconfig::set(&mut config, &key, &value)?;
changed = true;
}
}
if changed {
gitconfig::save_local(&path, &config)?;
}
Ok(changed)
}
pub(crate) fn register_driver_for_lock(repo: &Repo) -> Result<LockRegistration> {
let path = repo.config_path();
let mut config = gitconfig::open_local(&path)?;
let mut diff_driver_removed = false;
let textconv = format!("diff.{DRIVER}.textconv");
if gitconfig::get(&config, &textconv).is_some() {
gitconfig::unset(&mut config, &textconv)?;
diff_driver_removed = true;
}
let mut changed = false;
let process = format!("filter.{DRIVER}.process");
if gitconfig::get(&config, &process).is_none_or(|value| value.trim().is_empty()) {
gitconfig::set(
&mut config,
&process,
&format!("{} process", current_executable()?),
)?;
changed = true;
}
let required = format!("filter.{DRIVER}.required");
if gitconfig::get(&config, &required).as_deref() != Some("true") {
gitconfig::set(&mut config, &required, "true")?;
changed = true;
}
if changed || diff_driver_removed {
gitconfig::save_local(&path, &config)?;
}
Ok(LockRegistration {
repaired: changed,
diff_driver_removed,
})
}
pub(crate) fn textconv_cache_warning(repo: &Repo) -> Option<String> {
let reference = format!("refs/notes/textconv/{DRIVER}");
let present = repo.common_dir().join(&reference).is_file()
|| fs::read_to_string(repo.common_dir().join("packed-refs"))
.unwrap_or_default()
.lines()
.any(|line| line.split_whitespace().nth(1) == Some(reference.as_str()));
present.then(|| {
format!(
"{reference} exists: git's textconv cache holds decrypted copies of files \
from this repository in its object database, and they outlive `lock`. \
Remove them with `git update-ref -d {reference}` followed by \
`git gc --prune=now`."
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LockRegistration {
pub(crate) repaired: bool,
pub(crate) diff_driver_removed: bool,
}
fn create_config_file(repo: &Repo) -> Result<bool> {
let path = repo.xcrypt_config_path();
if path.exists() {
return Ok(false);
}
fs::write(&path, CONFIG_TEMPLATE)?;
Ok(true)
}
fn current_executable() -> Result<String> {
shell_quoted(
&std::env::current_exe()?,
crate::git::repo::NATIVE_SEPARATOR,
)
}
fn shell_quoted(path: &std::path::Path, separator: char) -> Result<String> {
let text = path.to_str().ok_or_else(|| {
Error::Config(format!(
"{}: this binary's own path is not valid UTF-8, so it cannot be \
written into .git/config as a command git could run. Approximating \
it would register a path that does not exist, and with \
`filter.{DRIVER}.required` set every later git operation in this \
repository would abort. Move or reinstall git-xcrypt somewhere \
whose name is text, then run this again.",
path.display()
))
})?;
let text = crate::git::repo::with_separator(text, separator);
Ok(format!("'{}'", text.replace('\'', r"'\''")))
}
#[cfg(test)]
mod tests {
use super::*;
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
}
#[test]
fn the_registered_command_rewrites_a_separator_and_never_a_file_name() {
use std::path::Path;
let quoted = |path: &Path, separator| shell_quoted(path, separator).expect("a text path");
assert_eq!(
quoted(Path::new(r"C:\Program Files\xc\git-xcrypt.exe"), '\\'),
"'C:/Program Files/xc/git-xcrypt.exe'"
);
assert_eq!(
quoted(Path::new(r"/opt/a\b/git-xcrypt"), '/'),
r"'/opt/a\b/git-xcrypt'",
"the registered command named a binary that does not exist"
);
assert_eq!(
quoted(Path::new("/opt/it's/git-xcrypt"), '/'),
r"'/opt/it'\''s/git-xcrypt'"
);
let registered = current_executable().expect("the running binary has a path");
assert!(registered.starts_with('\'') && registered.ends_with('\''));
}
#[cfg(any(unix, windows))]
#[test]
fn a_path_that_is_not_text_is_refused_rather_than_approximated() {
#[cfg(unix)]
let not_text = {
use std::os::unix::ffi::OsStrExt as _;
std::ffi::OsStr::from_bytes(b"/opt/wersja-\xb3/git-xcrypt").to_os_string()
};
#[cfg(windows)]
let not_text = {
use std::os::windows::ffi::OsStringExt as _;
std::ffi::OsString::from_wide(&[0x43, 0x3a, 0x5c, 0xd800, 0x5c, 0x78, 0x63])
};
let path = std::path::PathBuf::from(¬_text);
assert!(
path.to_str().is_none(),
"the fixture decodes cleanly, so this test asks nothing"
);
let error = shell_quoted(&path, crate::git::repo::NATIVE_SEPARATOR)
.expect_err("a path that is not text must not be approximated");
assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
assert!(
error.to_string().contains("not valid UTF-8"),
"the refusal must name what is wrong with the path: {error}"
);
}
#[test]
fn the_textconv_cache_is_switched_off_rather_than_merely_left_out() {
let dir = init_repo();
let repo = Repo::discover(dir.path()).expect("discovery");
run(&repo).expect("first init");
let path = repo.config_path();
let key = format!("diff.{DRIVER}.cachetextconv");
assert_eq!(
gitconfig::get(&gitconfig::open_local(&path).expect("config"), &key).as_deref(),
Some("false"),
"an inherited `true` would go unopposed"
);
let mut config = gitconfig::open_local(&path).expect("config");
gitconfig::set(&mut config, &key, "true").expect("setting");
gitconfig::save_local(&path, &config).expect("saving");
let report = run(&repo).expect("init must repair");
assert!(report.config_written, "the repair went unreported");
assert_eq!(
gitconfig::get(&gitconfig::open_local(&path).expect("config"), &key).as_deref(),
Some("false"),
"the textconv cache survived init"
);
}
}