use crate::crypto::cipher;
use crate::crypto::format::{FLAG_LF_NORMALIZED, Header, looks_encrypted};
use crate::crypto::key::MasterKey;
use crate::rules::declaration::{self, Config, EolMode};
use crate::rules::eol;
use crate::{Error, Result};
use bstr::ByteSlice as _;
pub struct Outcome {
pub content: Vec<u8>,
pub warning: Option<String>,
}
impl std::fmt::Debug for Outcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Outcome")
.field("content", &format_args!("<{} bytes>", self.content.len()))
.field("warning", &self.warning)
.finish()
}
}
impl Outcome {
fn plain(content: Vec<u8>) -> Self {
Self {
content,
warning: None,
}
}
}
pub fn clean(
key: Option<&MasterKey>,
config: &Config,
path: &[u8],
content: &[u8],
) -> Result<Outcome> {
if declaration::is_never_encrypted(path) {
return Ok(Outcome::plain(content.to_vec()));
}
if config.missing {
return Err(Error::Config(format!(
"{}: the file that says what to encrypt is missing, so nothing can be \
added safely; restore it from the repository or run `git-xcrypt init`",
crate::git::repo::CONFIG_FILE
)));
}
let decision = config.decide(path);
if !decision.encrypt {
return Ok(Outcome::plain(content.to_vec()));
}
if looks_encrypted(content) {
return already_encrypted(key, path, content);
}
let key = key.ok_or(Error::NoKey)?;
let normalise = eol::should_normalise(decision.text, content);
let (flags, plaintext) = if normalise {
(FLAG_LF_NORMALIZED, eol::normalise_to_lf(content))
} else {
(0, content.to_vec())
};
Ok(Outcome::plain(cipher::encrypt(key, flags, &plaintext)?))
}
fn already_encrypted(key: Option<&MasterKey>, path: &[u8], content: &[u8]) -> Result<Outcome> {
let header = Header::parse(content)?;
let Some(key) = key else {
return Ok(Outcome {
content: content.to_vec(),
warning: Some(format!(
"{}: already encrypted and no key is loaded; storing it unchanged",
path.as_bstr()
)),
});
};
if header.key_id != key.key_id() {
return Err(Error::KeyMismatch {
wanted: header.key_id,
have: key.key_id(),
});
}
drop(zeroize::Zeroizing::new(cipher::decrypt(key, content)?.1));
Ok(Outcome::plain(content.to_vec()))
}
pub fn smudge(
key: Option<&MasterKey>,
path: &[u8],
content: &[u8],
selected: bool,
declared_eol: Option<EolMode>,
autocrlf: Option<&str>,
core_eol: Option<&str>,
) -> Result<Outcome> {
if !looks_encrypted(content) {
let warning = selected.then(|| {
format!(
"{}: stored in the clear, so it is checked out unchanged; \
run `git-xcrypt status` to see whether it leaked",
path.as_bstr()
)
});
return Ok(Outcome {
content: content.to_vec(),
warning,
});
}
let key = key.ok_or(Error::NoKey)?;
let (flags, plaintext) = cipher::decrypt(key, content)?;
if flags & FLAG_LF_NORMALIZED == 0 {
return Ok(Outcome::plain(plaintext));
}
let mode = eol::resolve_output(declared_eol, autocrlf, core_eol);
Ok(Outcome::plain(eol::apply(&plaintext, mode)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::key::MASTER_KEY_LEN;
fn key() -> MasterKey {
MasterKey::from_bytes([5u8; MASTER_KEY_LEN])
}
fn config() -> Config {
Config::parse("secrets/\n*.env\n!secrets/README.md\n").expect("test config")
}
#[test]
fn pass_through_is_byte_identical_for_arbitrary_content() {
let config = config();
let samples: Vec<Vec<u8>> = vec![
Vec::new(),
b"x".to_vec(),
b"plain text\r\nwith crlf\r\n".to_vec(),
(0u8..=255).collect(),
(0u8..=255).cycle().take(100_000).collect(),
vec![0u8; 4096],
b"\0GITXCRYPT\0not actually one of ours".to_vec(),
];
for content in samples {
for path in [&b"README.md"[..], b"src/main.rs", b"secrets/README.md"] {
let outcome = clean(Some(&key()), &config, path, &content)
.expect("an unselected path must never fail");
assert_eq!(
outcome.content,
content,
"{} was altered on its way into the object database",
path.as_bstr()
);
}
}
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
#[test]
fn an_unselected_path_is_handed_back_byte_for_byte(
content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
path in proptest::prelude::prop_oneof![
proptest::prelude::Just(&b"README.md"[..]),
proptest::prelude::Just(&b"src/main.rs"[..]),
proptest::prelude::Just(&b"secrets/README.md"[..]),
proptest::prelude::Just(&b".gitattributes"[..]),
proptest::prelude::Just(&b".git-xcrypt"[..]),
],
) {
let outcome = clean(Some(&key()), &config(), path, &content)
.expect("an unselected path must never fail");
proptest::prop_assert_eq!(&outcome.content, &content);
proptest::prop_assert!(outcome.warning.is_none());
}
#[test]
fn content_without_our_magic_reaches_the_working_tree_unchanged(
content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
) {
proptest::prop_assume!(!looks_encrypted(&content));
let outcome = smudge(Some(&key()), b"README.md", &content, false, None, None, None)
.expect("content that is not ours must pass through");
proptest::prop_assert_eq!(&outcome.content, &content);
}
#[test]
fn a_selected_path_survives_check_in_and_check_out(
content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
) {
proptest::prop_assume!(!looks_encrypted(&content));
let stored = clean(Some(&key()), &config(), b"secrets/pw", &content)
.expect("encryption must succeed");
proptest::prop_assert!(looks_encrypted(&stored.content));
let back = smudge(
Some(&key()),
b"secrets/pw",
&stored.content,
true,
Some(EolMode::Lf),
None,
None,
)
.expect("decryption must succeed");
let expected = if eol::should_normalise(config().decide(b"secrets/pw").text, &content) {
eol::normalise_to_lf(&content)
} else {
content.clone()
};
proptest::prop_assert_eq!(&back.content, &expected);
let again = clean(Some(&key()), &config(), b"secrets/pw", &back.content)
.expect("re-encryption must succeed");
proptest::prop_assert_eq!(&again.content, &stored.content);
}
}
}