use std::fmt;
use std::path::PathBuf;
use crate::git::repo::{DRIVER, Repo, git_spelling};
use crate::rules::declaration::Config;
use gix_object::Write as _;
use crate::Result;
use crate::git::attributes;
use crate::git::config as gitconfig;
use crate::git::history;
use crate::git::index;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetupGap {
MissingKey(String),
NotTrue {
key: String,
value: String,
},
CatchAllMissing,
FilterUnresolved {
paths: Vec<String>,
total: usize,
resolved: String,
},
CiphertextConverted {
paths: Vec<String>,
total: usize,
culprit: String,
},
DeclarationMissing,
SectionStale,
Untracked(String),
}
impl fmt::Display for SetupGap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingKey(key) => write!(
f,
"{key} is not set, so git has no filter to run for this repository"
),
Self::NotTrue { key, value } => write!(
f,
"{key} is `{value}`, not true — without it a failing filter is \
ignored and git stores the unfiltered content with exit code 0"
),
Self::CatchAllMissing => write!(
f,
"{} carries no `{}` line, so git never calls the filter",
crate::git::repo::ATTRIBUTES_FILE,
attributes::CATCH_ALL
),
Self::FilterUnresolved {
paths,
total,
resolved,
} => {
write!(
f,
"git resolves `filter` to `{resolved}` for {total} declared path(s), \
not `{DRIVER}` — so committing them stores the plain text, whatever \
the `{catch_all}` line says. Some attribute line outranks it; \
`git check-attr filter -- <path>` shows which, and the notes below \
name every file carrying a `filter` line. Deleting or narrowing that \
line is the fix — `git-xcrypt init` will not remove it. Reached: {}",
paths.join(", "),
catch_all = attributes::CATCH_ALL
)?;
if *total > paths.len() {
write!(f, ", … and {} more", total - paths.len())?;
}
Ok(())
}
Self::CiphertextConverted {
paths,
total,
culprit,
} => {
write!(
f,
"git converts the line endings of {total} declared path(s) itself, \
because this line outranks the managed `-text`:\n {culprit}\n \
That conversion runs over the **ciphertext**: `git add` and \
`git commit` both exit 0, the damaged blob is committed, and the \
next checkout fails the authentication tag and leaves no file at \
all — measured on git 2.55, 34 `CR` bytes eaten out of a 2 MB blob. \
What is committed cannot be decrypted again by anyone, with any \
key. Delete or narrow that line so the managed `-text` wins, then \
run `git-xcrypt sync`; anything already committed under it has to \
be re-added from a copy of the plain text. Reached: {}",
paths.join(", ")
)?;
if *total > paths.len() {
write!(f, ", … and {} more", total - paths.len())?;
}
Ok(())
}
Self::DeclarationMissing => write!(
f,
"{config} is missing, so nothing here declares what to encrypt. \
Nothing is stored in the clear over this — every `git add` in \
this repository refuses until it is back — and nothing is \
enforced either. `git-xcrypt init` creates one; a clone gets it \
from the commit that carries it",
config = crate::git::repo::CONFIG_FILE
),
Self::SectionStale => write!(
f,
"{} no longer matches {} — the per-pattern lines are out of \
date, so the `-text` that keeps git's own CRLF conversion off \
the ciphertext does not reach every declared path. Nothing is \
stored in the clear over this, and it costs nothing until some \
other attribute source declares one of those paths `text` — at \
which point git corrupts the blob silently and the file is gone \
at checkout, unrecoverably. A clone's `unlock` will also \
rewrite the section and leave `git status` dirty. \
`git-xcrypt sync` settles both",
crate::git::repo::ATTRIBUTES_FILE,
crate::git::repo::CONFIG_FILE
),
Self::Untracked(path) => write!(
f,
"{path} is not committed, so no clone of this repository gets it \
— and without it a clone filters nothing. `git add {path}` and \
commit it"
),
}
}
}
#[derive(Debug, Default)]
pub struct Report {
pub setup: Vec<SetupGap>,
pub has_key: bool,
pub encrypted: Vec<Vec<u8>>,
pub in_the_clear: Vec<Vec<u8>>,
pub leaked: Vec<crate::git::history::Exposure>,
pub by_choice: Vec<Vec<u8>>,
pub fixed: Vec<Vec<u8>>,
pub undetermined: Vec<String>,
pub scanned: Scanned,
pub scan_ran: bool,
pub fix_requested: bool,
pub notes: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Scanned {
pub commits: usize,
pub blobs: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Clean,
Undetermined,
Exposed,
Misconfigured,
}
impl Report {
#[must_use]
pub fn verdict(&self) -> Verdict {
if !self.setup.is_empty() {
Verdict::Misconfigured
} else if !self.in_the_clear.is_empty() || !self.leaked.is_empty() {
Verdict::Exposed
} else if self.undetermined.is_empty() {
Verdict::Clean
} else {
Verdict::Undetermined
}
}
#[must_use]
pub fn exposed(&self) -> bool {
!self.in_the_clear.is_empty() || !self.leaked.is_empty()
}
fn stores_in_the_clear(&self) -> bool {
self.setup.iter().any(|gap| {
!matches!(
gap,
SetupGap::CiphertextConverted { .. }
| SetupGap::DeclarationMissing
| SetupGap::Untracked(_)
)
})
}
fn only_the_declaration_is_missing(&self) -> bool {
self.setup
.iter()
.all(|gap| matches!(gap, SetupGap::DeclarationMissing))
}
fn only_the_bootstrap_is_untracked(&self) -> bool {
self.setup
.iter()
.all(|gap| matches!(gap, SetupGap::Untracked(_)))
}
}
const MAX_LISTED: usize = 10;
const MAX_SIGHTINGS: usize = 3;
fn show(path: &[u8]) -> String {
bstr::BStr::new(path).to_string()
}
fn shell_quoted(path: &[u8]) -> String {
format!("'{}'", show(path).replace('\'', r"'\''"))
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.write_verdict(f)?;
if self.setup.is_empty() {
writeln!(
f,
"setup: git is configured to run the filter in this repository."
)?;
} else {
if self.stores_in_the_clear() {
writeln!(
f,
"setup: git is NOT filtering this repository. Until this is fixed, \
committing a declared file stores it in the clear, with exit code 0 \
and no warning."
)?;
} else if self.only_the_declaration_is_missing() {
writeln!(
f,
"setup: git calls the filter here, but the filter has nothing to \
read. Nothing is stored in the clear over this — every `git add` \
in this repository refuses until the declaration is back — and \
nothing below was checked, because there is no way to tell which \
paths should have been."
)?;
} else if self.only_the_bootstrap_is_untracked() {
writeln!(
f,
"setup: git enforces the declarations on this machine — the files \
below are read from the working tree — but they are not \
committed, so no clone gets them and nothing published enforces \
anything. Commits made *here* store ciphertext; commits made \
from a clone would not."
)?;
} else {
writeln!(
f,
"setup: git runs the filter here, but does not leave its output \
alone. Nothing is stored in the clear over this; what it costs is \
the ciphertext, and with it the file."
)?;
}
for gap in &self.setup {
writeln!(f, " - {gap}")?;
}
if self.stores_in_the_clear() {
writeln!(f, "\n Fix it with one of:")?;
if self.has_key {
writeln!(f, " git-xcrypt init # the key here is kept")?;
} else {
writeln!(f, " git-xcrypt unlock <key-file>")?;
}
} else if self.only_the_bootstrap_is_untracked() {
writeln!(f, "\n Fix it by committing the files:")?;
writeln!(
f,
" git add {} {} && git commit",
crate::git::repo::ATTRIBUTES_FILE,
crate::git::repo::CONFIG_FILE
)?;
}
}
self.write_undetermined(f)?;
self.write_fixed(f)?;
self.write_encrypted(f)?;
self.write_in_the_clear(f)?;
self.write_leaked(f)?;
self.write_by_choice(f)?;
for note in &self.notes {
writeln!(f, "\nnote: {note}")?;
}
if self.scan_ran {
writeln!(
f,
"\nscanned {} commit(s) and {} distinct blob(s) under a declared \
path. `status` answers whether your declarations are enforced, not \
whether this repository holds secrets: a path no pattern ever \
matched is invisible to it.",
self.scanned.commits, self.scanned.blobs
)
} else {
writeln!(
f,
"\nhistory was NOT scanned — see `undetermined` above. `status` \
answers whether your declarations are enforced, not whether this \
repository holds secrets: a path no pattern ever matched is \
invisible to it."
)
}
}
}
impl Report {
fn write_verdict(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.verdict() {
Verdict::Clean => return writeln!(f, "VERDICT: no findings.\n"),
Verdict::Undetermined => {
return writeln!(
f,
"VERDICT: undetermined — {} thing(s) could not be checked. \
NOTHING WAS FOUND, and nothing is ruled out either.\n",
self.undetermined.len()
);
}
Verdict::Exposed | Verdict::Misconfigured => {}
}
let mut parts: Vec<String> = Vec::new();
if !self.leaked.is_empty() {
parts.push(format!("{} path(s) leaked in history", self.leaked.len()));
}
if !self.in_the_clear.is_empty() {
parts.push(format!(
"{} path(s) stored in the clear now",
self.in_the_clear.len()
));
}
if !self.undetermined.is_empty() {
parts.push(format!("{} thing(s) undetermined", self.undetermined.len()));
}
if self.verdict() == Verdict::Misconfigured {
write!(
f,
"VERDICT: {} setup gap(s) — git is not enforcing the declarations \
in this repository. Fix the setup first and ask again; until then \
nothing here can be called clean.",
self.setup.len()
)?;
if parts.is_empty() {
return writeln!(f, "\n");
}
return writeln!(
f,
" Also found, and NOT cancelled by the above — see the sections \
below: {}.\n",
parts.join(", ")
);
}
writeln!(f, "VERDICT: {}.\n", parts.join(", "))
}
fn write_undetermined(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.undetermined.is_empty() {
return Ok(());
}
writeln!(
f,
"\nundetermined: this run could not answer the following, so nothing \
below is a clean bill of health."
)?;
for reason in &self.undetermined {
writeln!(f, " - {reason}")?;
}
if self.verdict() == Verdict::Undetermined {
writeln!(
f,
"\n This is exit code {undetermined}, not {exposed}: settle the reasons above \
and ask again. Nothing here was found — it was not looked at.",
undetermined = crate::util::exit::UNDETERMINED,
exposed = crate::util::exit::EXPOSED
)?;
}
Ok(())
}
fn write_fixed(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.fixed.is_empty() {
return Ok(());
}
writeln!(
f,
"\nfixed: {} path(s) were re-staged through the filter, so the NEXT \
commit stores them encrypted.",
self.fixed.len()
)?;
for path in &self.fixed {
writeln!(f, " {}", show(path))?;
}
writeln!(
f,
"\n What is staged for each of them is its **working-tree** content, \
the same as `git add` would stage — so any edit you had not staged \
yet is staged now. Check `git diff --cached` before committing.\n\
\n \
No file was rewritten and NO HISTORY WAS REWRITTEN. Nothing was \
un-leaked: every plain-text version already committed is still in \
this repository and in every clone of it. If any of these files held \
a secret that has been pushed, rotate the secret — that is the only \
step that revokes it."
)
}
fn write_encrypted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.encrypted.is_empty() {
return Ok(());
}
writeln!(
f,
"\nencrypted: {} declared path(s) are stored as ciphertext.",
self.encrypted.len()
)?;
for path in self.encrypted.iter().take(MAX_LISTED) {
writeln!(f, " {}", show(path))?;
}
if self.encrypted.len() > MAX_LISTED {
writeln!(f, " … and {} more", self.encrypted.len() - MAX_LISTED)?;
}
Ok(())
}
fn write_in_the_clear(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.in_the_clear.is_empty() {
return Ok(());
}
writeln!(
f,
"\nin the clear: {} declared path(s) are stored unencrypted right now, \
so a commit made from here would push the plain text.",
self.in_the_clear.len()
)?;
for path in &self.in_the_clear {
writeln!(f, " {}", show(path))?;
}
if self.fix_requested {
return writeln!(
f,
"\n `--fix` was asked for and did not re-stage these — the reason for \
each is on stderr. `git add` on them by hand does the same job."
);
}
writeln!(
f,
"\n `git add` on each of them re-stages the content through the filter, \
and `git-xcrypt status --fix` does exactly that for all of them at once. \
It changes what the NEXT commit stores. It does not touch history, and \
any plain text already committed stays where it is."
)
}
fn write_leaked(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.leaked.is_empty() {
return Ok(());
}
writeln!(
f,
"\nleaked in history: {} declared path(s) sat in this repository in the \
clear at some point, and the blobs are still here.",
self.leaked.len()
)?;
for exposure in &self.leaked {
writeln!(
f,
" {} — {} plaintext blob(s)",
show(&exposure.path),
exposure.sightings.len()
)?;
for sighting in exposure.sightings.iter().take(MAX_SIGHTINGS) {
writeln!(
f,
" blob {} in commit {}",
sighting.blob, sighting.commit
)?;
}
if exposure.sightings.len() > MAX_SIGHTINGS {
writeln!(
f,
" … and {} more",
exposure.sightings.len() - MAX_SIGHTINGS
)?;
}
}
writeln!(
f,
"\n Rewriting history does NOT undo this. If the repository was ever \
pushed, the plain text is in every clone, fork, cache and CI log that \
saw it. In order:"
)?;
writeln!(
f,
"\n 1. ROTATE THE SECRET. This is the only step that actually revokes \
the exposure, and it is worth doing even if you do nothing else."
)?;
if self.fix_requested && self.in_the_clear.is_empty() {
writeln!(
f,
" 2. Already done: the current content is re-staged, so future \
commits are encrypted."
)?;
} else {
writeln!(
f,
" 2. Re-stage the current content so future commits are encrypted:\n\
\x20 git-xcrypt status --fix"
)?;
}
writeln!(
f,
" 3. Only then, and only if you also want the old blobs gone, rewrite \
history with the external git-filter-repo. git-xcrypt does not rewrite \
history and will not pretend to:"
)?;
if self.leaked.len() > MAX_LISTED {
writeln!(
f,
"\x20 # {} paths — put them in a file, one per line, then:\n\
\x20 git filter-repo --invert-paths --paths-from-file leaked.txt",
self.leaked.len()
)?;
} else {
write!(f, "\x20 git filter-repo --invert-paths")?;
for exposure in &self.leaked {
write!(f, " --path {}", shell_quoted(&exposure.path))?;
}
writeln!(f)?;
}
writeln!(
f,
" That deletes the file from every commit. To keep the file and drop \
only its history, remove it, rewrite, then add it back through the \
filter. Either way everyone with a clone has to re-clone."
)?;
if self
.leaked
.iter()
.any(|exposure| std::str::from_utf8(&exposure.path).is_err())
{
writeln!(
f,
"\n One or more of these paths is not valid UTF-8, so the names \
above are shown with replacement characters and the command WILL \
NOT match them — it would rewrite history and remove nothing, \
reporting success. Take the exact bytes from `git log --all \
--name-only -z` (or `git ls-tree -z -r <commit>`) and pass them \
through `--paths-from-file`."
)?;
}
Ok(())
}
fn write_by_choice(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.by_choice.is_empty() {
return Ok(());
}
writeln!(
f,
"\nin the clear by choice: {} path(s) a `!` line in {} takes back out, \
so they are stored unencrypted on purpose.",
self.by_choice.len(),
crate::git::repo::CONFIG_FILE
)?;
for path in &self.by_choice {
writeln!(f, " {}", show(path))?;
}
Ok(())
}
}
pub fn run(repo: &Repo, fix: bool) -> Result<Report> {
let mut report = Report {
has_key: repo.has_key(),
fix_requested: fix,
..Report::default()
};
let config = gitconfig::open_full(repo.git_dir(), repo.common_dir())?;
for key in attributes::driver_keys() {
match gitconfig::get(&config, &key) {
None => report.setup.push(SetupGap::MissingKey(key)),
Some(value) if key.ends_with(".required") && !gitconfig::is_true(&value) => {
report.setup.push(SetupGap::NotTrue { key, value });
}
Some(value) if value.trim().is_empty() && key.ends_with(".process") => {
report.setup.push(SetupGap::MissingKey(key));
}
Some(_) => {}
}
}
let catch_all = attributes::catch_all_present(&repo.attributes_path()).map_err(|err| {
crate::Error::Config(format!(
"{} could not be read ({err}), so whether git filters this repository \
at all cannot be determined",
repo.attributes_path().display()
))
})?;
if !catch_all {
report.setup.push(SetupGap::CatchAllMissing);
}
report.notes.extend(diff_driver_note(repo, &config));
if !report.has_key {
report.notes.push(
"there is no key in this repository, so nothing here can be decrypted. \
That is the expected state after `lock` and in a fresh clone; \
`git-xcrypt unlock <key-file>` opens it."
.into(),
);
}
let declarations = Config::load(&repo.xcrypt_config_path()).map_err(|err| match err {
crate::Error::Io(err) => crate::Error::Config(format!(
"{err}; status cannot tell which paths were meant to be encrypted, so \
nothing was checked. The check-in path refuses over the same state, \
so nothing is being stored in the clear; make {} readable and ask \
again",
crate::git::repo::CONFIG_FILE
)),
other => other,
})?;
if declarations.missing {
report.setup.push(SetupGap::DeclarationMissing);
report.undetermined.push(format!(
"nothing below was checked: without {} there is no way to tell which \
paths should be encrypted, so neither the index nor the history was \
scanned. This says nothing about what is in this repository.",
crate::git::repo::CONFIG_FILE
));
return Ok(report);
}
report.warnings.extend(declarations.pointless_eol.clone());
match section_verdict(repo, &declarations) {
SectionVerdict::Current => {}
SectionVerdict::Stale => report.setup.push(SetupGap::SectionStale),
SectionVerdict::Unanswerable(why) => report.undetermined.push(format!(
"{why} — so whether the managed section still covers every declared \
path could not be determined. Nothing above rules it out: every \
command that writes this file refuses over the same state."
)),
}
let hash = index::object_hash(gitconfig::get(&config, "extensions.objectformat").as_deref());
let objects = history::objects(repo.common_dir(), hash)?;
let ignore_case =
gitconfig::get(&config, "core.ignorecase").is_some_and(|value| gitconfig::is_true(&value));
let global_attributes = gitconfig::global_attributes_file(&config);
let mut filters = attributes::AttributeResolver::new(
repo.work_tree(),
repo.common_dir(),
global_attributes.as_deref(),
ignore_case,
attributes::staged_fallbacks(
repo.work_tree(),
&repo.git_dir().join("index"),
repo.common_dir(),
hash,
ignore_case,
),
);
inspect_index(
repo,
&declarations,
&objects,
hash,
&mut filters,
&mut report,
)?;
let mut note_sources = attributes::attribute_files_under(repo.work_tree());
note_sources.push(repo.common_dir().join("info").join("attributes"));
note_sources.extend(global_attributes.clone());
report.notes.extend(foreign_source_note(
repo,
¬e_sources,
report
.setup
.iter()
.any(|gap| matches!(gap, SetupGap::FilterUnresolved { .. })),
));
if fix {
restage(repo, &declarations, hash, &mut report)?;
}
let scan = history::scan(
&objects,
repo.git_dir(),
repo.common_dir(),
hash,
&declarations,
is_partial_clone(&config),
)?;
report.scan_ran = true;
report.scanned = Scanned {
commits: scan.commits,
blobs: scan.blobs,
};
report.warnings.extend(scan.warnings);
if scan.partial {
report.undetermined.push(
"this is a partial clone, so some objects were never downloaded and \
could not be judged. `git fetch --refetch --filter=blob:none` or a \
full clone brings them down; `git fsck` will not report them missing."
.into(),
);
}
if scan.shallow {
report.undetermined.push(
"this is a shallow clone, so the history before its graft point was \
never fetched and could not be scanned. `git fetch --unshallow` \
brings the rest down; until then nothing here covers it."
.into(),
);
}
if scan.unreadable > 0 {
report.undetermined.push(format!(
"{} object(s) in this repository could not be read, so they were not \
judged. A history scan that skipped something has proved nothing \
about it; `git fsck` says what is missing.",
scan.unreadable
));
}
if scan.refs_unavailable {
report.undetermined.push(
"this repository's references could not be listed, so no history was \
scanned at all. Nothing above says anything about what is in it."
.into(),
);
} else if scan.unresolved_refs > 0 {
let mut named = scan.unresolved_names.join(", ");
if scan.unresolved_refs > scan.unresolved_names.len() {
named.push_str(", …");
}
report.undetermined.push(format!(
"{} reference(s) could not be resolved, so whatever is reachable only \
through them was not scanned: {named}",
scan.unresolved_refs
));
}
report.notes.extend(scan.notes);
report.leaked = scan.exposed;
Ok(report)
}
fn is_partial_clone(config: &gix_config::File) -> bool {
if gitconfig::get(config, "extensions.partialclone").is_some() {
return true;
}
config
.sections_by_name("remote")
.into_iter()
.flatten()
.any(|section| {
section
.value("promisor")
.is_some_and(|value| gitconfig::is_true(&value.to_string()))
})
}
fn inspect_index(
repo: &Repo,
declarations: &Config,
objects: &gix_odb::Handle,
hash: gix_hash::Kind,
filters: &mut attributes::AttributeResolver,
report: &mut Report,
) -> Result<()> {
let index_path = repo.git_dir().join("index");
let listed = index::list(&index_path, hash)
.unwrap_or_else(|err| index::Listed::Unavailable(format!("it could not be read ({err})")));
let entries = match listed {
index::Listed::Read(entries) => entries,
index::Listed::Unavailable(why) => {
report.undetermined.push(format!(
"{} could not be used because {why}, so nothing is known about what \
the next commit would store. For a split index, \
`git update-index --no-split-index` converts it back.",
index_path.display()
));
return Ok(());
}
};
if !entries.is_empty() {
let mut tracked_bootstrap = [false, false];
for entry in &entries {
if entry.path == crate::git::repo::ATTRIBUTES_FILE.as_bytes() {
tracked_bootstrap[0] = true;
} else if entry.path == crate::git::repo::CONFIG_FILE.as_bytes() {
tracked_bootstrap[1] = true;
}
}
for (present, name) in tracked_bootstrap.iter().zip([
crate::git::repo::ATTRIBUTES_FILE,
crate::git::repo::CONFIG_FILE,
]) {
if !present {
report.setup.push(SetupGap::Untracked(name.to_string()));
}
}
}
let mut unfiltered: Vec<(String, String)> = Vec::new();
let mut converted: Vec<(String, String)> = Vec::new();
for entry in entries {
if !entry.holds_content() {
continue;
}
let index::Tracked { path: name, id, .. } = entry;
if declarations.negated(&name) {
report.by_choice.push(name);
continue;
}
if !declarations.decide(&name).encrypt {
continue;
}
let resolved = filters.resolve(&name);
if !resolved.filter.is_ours() {
unfiltered.push((show(&name), resolved.filter.to_string()));
}
if let attributes::EolConversion::On(culprit) = resolved.conversion {
converted.push((show(&name), display_culprit(repo, &culprit)));
}
let Ok(id) = gix_hash::oid::try_from_bytes(&id) else {
report.undetermined.push(format!(
"{}: the index records an object id this build cannot read",
show(&name)
));
continue;
};
match history::stored_in_the_clear(objects, id) {
Some(true) => report.in_the_clear.push(name),
Some(false) => report.encrypted.push(name),
None => report.undetermined.push(format!(
"{}: the index names object {id}, which is not in this repository's \
object database, so what it holds is unknown",
show(&name)
)),
}
}
report.encrypted.sort();
report.in_the_clear.sort();
report.by_choice.sort();
if !unfiltered.is_empty() {
unfiltered.sort();
let resolved = unfiltered
.first()
.map(|(_, resolved)| resolved.clone())
.unwrap_or_default();
report.setup.push(SetupGap::FilterUnresolved {
paths: unfiltered
.iter()
.take(MAX_LISTED)
.map(|(path, _)| path.clone())
.collect(),
total: unfiltered.len(),
resolved,
});
}
if !converted.is_empty() {
converted.sort();
let culprit = converted
.first()
.map(|(_, culprit)| culprit.clone())
.unwrap_or_default();
report.setup.push(SetupGap::CiphertextConverted {
paths: converted
.iter()
.take(MAX_LISTED)
.map(|(path, _)| path.clone())
.collect(),
total: converted.len(),
culprit,
});
}
Ok(())
}
fn display_culprit(repo: &Repo, culprit: &attributes::Culprit) -> String {
let Some(source) = &culprit.source else {
return culprit.to_string();
};
let shown = repo.relative(source).unwrap_or(source);
attributes::Culprit {
source: Some(shown.to_path_buf()),
..culprit.clone()
}
.to_string()
}
fn foreign_source_note(
repo: &Repo,
sources: &[PathBuf],
reached_a_declared_path: bool,
) -> Vec<String> {
let mut notes = Vec::new();
for source in sources {
let Ok(lines) = attributes::foreign_lines_touching(source, &["filter"]) else {
continue;
};
if lines.is_empty() {
continue;
}
let shown = git_spelling(repo.relative(source).unwrap_or(source));
let verdict = if reached_a_declared_path {
"and git takes the LAST match. Some of them reach a declared path — \
see the setup gap above, which is the finding."
} else {
"and git takes the LAST match. Checked against every declared path the \
index holds: git still resolves `filter=git-xcrypt` for all of them, \
so nothing tracked is unprotected by these. A path they reach which \
the index does not yet hold would be."
};
notes.push(format!(
"{shown} carries {} line(s) of its own that set or unset `filter`, \
{verdict} Check with `git check-attr filter -- <path>`:\n {}",
lines.len(),
lines.join("\n ")
));
}
notes
}
fn restage(
repo: &Repo,
declarations: &Config,
hash: gix_hash::Kind,
report: &mut Report,
) -> Result<()> {
if report.in_the_clear.is_empty() {
return Ok(());
}
let key = match repo.load_key() {
Ok(key) => key,
Err(err) => {
let what = match err {
crate::Error::NoKey => "there is none here".to_string(),
other => format!("it could not be read ({other})"),
};
report.undetermined.push(format!(
"--fix needs the repository key in order to re-encrypt, and {what}, \
so nothing was re-staged. `git-xcrypt unlock <key-file>` puts one \
in place. The {} path(s) reported below are still in the clear.",
report.in_the_clear.len()
));
return Ok(());
}
};
let loose = gix_odb::loose::Store::at(
repo.common_dir().join("objects"),
gix_odb::loose::Options {
object_hash: hash,
..gix_odb::loose::Options::default()
},
);
let mut updates: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
let mut kept: Vec<Vec<u8>> = Vec::new();
for name in std::mem::take(&mut report.in_the_clear) {
let path = repo
.work_tree()
.join(crate::git::repo::working_tree_path(&name));
if let Ok(metadata) = std::fs::symlink_metadata(&path)
&& !metadata.is_file()
{
report.warnings.push(format!(
"{}: not re-staged, it is no longer a regular file on disk, so \
reading it would take content from somewhere else. The index \
still holds it in the clear; `git add` records what is really \
there.",
show(&name)
));
kept.push(name);
continue;
}
let content = match std::fs::read(&path) {
Ok(content) => zeroize::Zeroizing::new(content),
Err(err) => {
report.warnings.push(format!(
"{}: not re-staged, its working-tree file could not be read \
({err}). The index still holds it in the clear.",
show(&name)
));
kept.push(name);
continue;
}
};
let outcome = match crate::rules::decide::clean(Some(&key), declarations, &name, &content) {
Ok(outcome) => outcome,
Err(err) => {
report.warnings.push(format!(
"{}: not re-staged ({}). The index still holds it in the clear.",
show(&name),
named(&name, err)
));
kept.push(name);
continue;
}
};
if let Some(warning) = outcome.warning {
report.warnings.push(warning);
}
match loose.write_buf(gix_object::Kind::Blob, &outcome.content) {
Ok(id) => updates.push((name, id.as_slice().to_vec())),
Err(err) => {
report.warnings.push(format!(
"{}: not re-staged, its encrypted form could not be written to \
the object database ({err})",
show(&name)
));
kept.push(name);
}
}
}
let restaged = index::restage(&repo.git_dir().join("index"), hash, &updates)
.unwrap_or_else(|err| index::Restaged::Skipped(err.to_string()));
match restaged {
index::Restaged::Done(patched) => {
let missed: Vec<Vec<u8>> = updates
.into_iter()
.map(|(name, _)| name)
.filter(|name| !patched.contains(name))
.collect();
if !missed.is_empty() {
report.warnings.push(format!(
"{} path(s) were not found in the index under the name they \
have on disk, so they were left as they were: {}. `git add` \
on them by hand settles it.",
missed.len(),
missed
.iter()
.map(|name| show(name))
.collect::<Vec<_>>()
.join(", ")
));
kept.extend(missed);
}
report.fixed = patched;
}
index::Restaged::Skipped(why) => {
report.warnings.push(why);
kept.extend(updates.into_iter().map(|(name, _)| name));
}
}
report.in_the_clear = kept;
report.in_the_clear.sort();
report.fixed.sort();
Ok(())
}
fn named(name: &[u8], err: crate::Error) -> crate::Error {
use crate::Error;
let at = show(name);
match err {
Error::Format(message) => Error::Format(format!("{at}: {message}")),
Error::Crypto(message) => Error::Crypto(format!("{at}: {message}")),
Error::Config(message) => Error::Config(format!("{at}: {message}")),
Error::Io(err) => Error::Io(std::io::Error::other(format!("{at}: {err}"))),
mismatch @ Error::KeyMismatch { .. } => Error::Format(format!("{at}: {mismatch}")),
other => other,
}
}
enum SectionVerdict {
Current,
Stale,
Unanswerable(String),
}
fn section_verdict(repo: &Repo, declarations: &Config) -> SectionVerdict {
let path = repo.attributes_path();
let mut refusal = None;
for rendering in attributes::ACCEPTED {
let lines = attributes::render_lines(declarations, rendering);
match attributes::desired(&path, &lines) {
Ok((existing, desired)) if existing == desired => return SectionVerdict::Current,
Ok(_) => {}
Err(err) => {
if refusal.is_none() {
refusal = Some(err.to_string());
}
}
}
}
refusal.map_or(SectionVerdict::Stale, SectionVerdict::Unanswerable)
}
fn diff_driver_note(repo: &Repo, config: &gix_config::File) -> Option<String> {
if !repo.has_key() {
return None;
}
if gitconfig::get(config, &format!("diff.{DRIVER}.textconv")).is_some() {
return None;
}
Some(format!(
"diff.{DRIVER}.textconv is not registered, so `git diff` on an encrypted \
file reports `Binary files differ` instead of comparing the plain text. \
`git-xcrypt init` registers it. Nothing is stored in the clear over this."
))
}
#[cfg(test)]
mod tests {
use super::*;
fn a_leak() -> crate::git::history::Exposure {
crate::git::history::Exposure {
path: b"secrets/db.env".to_vec(),
sightings: Vec::new(),
}
}
#[test]
fn a_question_left_unanswered_is_its_own_verdict_and_never_masks_a_finding() {
let clean = Report::default();
assert_eq!(clean.verdict(), Verdict::Clean);
let undetermined = Report {
undetermined: vec!["a shallow clone".into()],
..Report::default()
};
assert_eq!(undetermined.verdict(), Verdict::Undetermined);
assert!(
undetermined.to_string().contains("NOTHING WAS FOUND"),
"the verdict line must not read as a finding: {undetermined}"
);
let both = Report {
undetermined: vec!["a shallow clone".into()],
in_the_clear: vec![b"secrets/db.env".to_vec()],
..Report::default()
};
assert_eq!(both.verdict(), Verdict::Exposed);
assert!(
!both.to_string().contains("NOTHING WAS FOUND"),
"an exposure must not be softened by what could not be checked: {both}"
);
}
#[test]
fn configuration_outranks_both_other_answers_and_conceals_neither() {
let gap = || {
vec![SetupGap::MissingKey(
"filter.git-xcrypt.process".to_string(),
)]
};
let misconfigured = Report {
setup: gap(),
..Report::default()
};
assert_eq!(misconfigured.verdict(), Verdict::Misconfigured);
let over_a_question = Report {
setup: gap(),
undetermined: vec!["a shallow clone".into()],
..Report::default()
};
assert_eq!(over_a_question.verdict(), Verdict::Misconfigured);
let over_a_finding = Report {
setup: gap(),
leaked: vec![a_leak()],
in_the_clear: vec![b"secrets/late.env".to_vec()],
..Report::default()
};
assert_eq!(over_a_finding.verdict(), Verdict::Misconfigured);
let text = over_a_finding.to_string();
for expected in [
"leaked in history",
"secrets/db.env",
"ROTATE THE SECRET",
"in the clear:",
"secrets/late.env",
"Also found",
] {
assert!(
text.contains(expected),
"the configuration verdict swallowed `{expected}`:\n{text}"
);
}
assert!(
over_a_finding.exposed(),
"a leak under a configuration verdict is still a leak:\n{text}"
);
let settled = Report {
leaked: vec![a_leak()],
in_the_clear: vec![b"secrets/late.env".to_vec()],
..Report::default()
};
assert_eq!(settled.verdict(), Verdict::Exposed);
}
#[test]
fn a_missing_declaration_is_a_configuration_gap_that_still_admits_it_checked_nothing() {
let report = Report {
setup: vec![SetupGap::DeclarationMissing],
undetermined: vec!["nothing below was checked".into()],
..Report::default()
};
assert_eq!(report.verdict(), Verdict::Misconfigured);
let text = report.to_string();
assert!(
text.contains("history was NOT scanned"),
"a run that stopped before the scan must say so: {text}"
);
assert!(
!text.contains("stores it in the clear"),
"nothing is stored in the clear over a refused `git add`, and saying \
otherwise sends a user to rotate a secret that was never exposed: {text}"
);
}
}