use std::collections::{HashMap, HashSet};
use std::path::Path;
use gix_hash::ObjectId;
use gix_object::{Find as _, FindExt as _, FindHeader as _};
use gix_ref::file::ReferenceExt as _;
use crate::crypto::format;
use crate::rules::declaration::Config;
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Exposure {
pub path: Vec<u8>,
pub sightings: Vec<Sighting>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sighting {
pub blob: ObjectId,
pub commit: ObjectId,
}
#[derive(Debug, Default)]
pub struct Scan {
pub exposed: Vec<Exposure>,
pub commits: usize,
pub blobs: usize,
pub unreadable: usize,
pub unresolved_refs: usize,
pub unresolved_names: Vec<String>,
pub refs_unavailable: bool,
pub partial: bool,
pub shallow: bool,
pub notes: Vec<String>,
pub warnings: Vec<String>,
}
const MAX_UNREADABLE_WARNINGS: usize = 5;
pub fn scan(
objects: &gix_odb::Handle,
git_dir: &Path,
common_dir: &Path,
hash: gix_hash::Kind,
config: &Config,
partial: bool,
) -> Result<Scan> {
let mut scan = Scan::default();
let tips = tips(git_dir, common_dir, hash, objects, &mut scan);
let grafts = grafts(git_dir, common_dir);
scan.shallow = !grafts.is_empty();
scan.partial = partial;
let mut queue: Vec<ObjectId> = tips;
let mut seen_commits: HashSet<ObjectId> = HashSet::new();
let mut seen_trees: HashSet<(ObjectId, Vec<u8>)> = HashSet::new();
let mut verdicts: HashMap<ObjectId, bool> = HashMap::new();
let mut found: HashMap<Vec<u8>, Vec<Sighting>> = HashMap::new();
let mut buffer = Vec::new();
while let Some(commit) = queue.pop() {
if !seen_commits.insert(commit) {
continue;
}
let mut iter = match objects.find_commit_iter(&commit, &mut buffer) {
Ok(iter) => iter,
Err(err) => {
note_unreadable(&mut scan, &commit, &err.to_string());
continue;
}
};
let Ok(tree) = iter.tree_id() else {
note_unreadable(&mut scan, &commit, "its tree could not be read");
continue;
};
let parents: Vec<ObjectId> = iter.parent_ids().collect();
scan.commits += 1;
walk_tree(
objects,
config,
tree,
commit,
&mut seen_trees,
&mut verdicts,
&mut found,
&mut scan,
);
if !grafts.contains(&commit) {
queue.extend(parents);
}
}
scan.blobs = verdicts.len();
scan.exposed = collect(found);
Ok(scan)
}
fn grafts(git_dir: &Path, common_dir: &Path) -> HashSet<ObjectId> {
let mut found = HashSet::new();
for directory in [common_dir, git_dir] {
let Ok(text) = std::fs::read_to_string(directory.join("shallow")) else {
continue;
};
for line in text.lines() {
if let Ok(id) = ObjectId::from_hex(line.trim().as_bytes()) {
found.insert(id);
}
}
}
found
}
pub fn objects(common_dir: &Path, hash: gix_hash::Kind) -> Result<gix_odb::Handle> {
let path = common_dir.join("objects");
gix_odb::at_opts(
&path,
Vec::new(),
gix_odb::store::init::Options {
object_hash: hash,
..gix_odb::store::init::Options::default()
},
)
.map_err(|err| {
Error::Config(format!(
"the object database at {} could not be opened ({err}), so this \
repository cannot be inspected",
path.display()
))
})
}
#[must_use]
pub fn stored_in_the_clear(objects: &gix_odb::Handle, id: &gix_hash::oid) -> Option<bool> {
let mut buffer = Vec::new();
match objects.try_find(id, &mut buffer) {
Ok(Some(data)) => Some(!format::looks_encrypted(data.data)),
Ok(None) | Err(_) => None,
}
}
pub struct HeadLookup {
objects: gix_odb::Handle,
root: Option<ObjectId>,
directories: HashMap<Vec<u8>, Option<ObjectId>>,
}
impl HeadLookup {
#[must_use]
pub fn open(git_dir: &Path, common_dir: &Path, hash: gix_hash::Kind) -> Option<Self> {
let objects = objects(common_dir, hash).ok()?;
let options = gix_ref::store::init::Options {
object_hash: hash,
..gix_ref::store::init::Options::default()
};
let store = if git_dir == common_dir {
gix_ref::file::Store::at(git_dir.to_path_buf(), options)
} else {
gix_ref::file::Store::for_linked_worktree(
git_dir.to_path_buf(),
common_dir.to_path_buf(),
options,
)
};
let mut head = store.try_find("HEAD").ok().flatten()?;
let commit = head.peel_to_id(&store, &objects).ok()?;
let mut buffer = Vec::new();
let root = objects
.find_commit_iter(&commit, &mut buffer)
.ok()
.and_then(|mut iter| iter.tree_id().ok());
Some(Self {
objects,
root,
directories: HashMap::new(),
})
}
pub fn holds_in_the_clear(&mut self, path: &[u8]) -> bool {
let Some(root) = self.root else {
return false;
};
let (directory, filename) = match path.iter().rposition(|byte| *byte == b'/') {
Some(at) => (&path[..at], &path[at + 1..]),
None => (&path[..0], path),
};
let Some(tree) = self.directory(root, directory) else {
return false;
};
let mut buffer = Vec::new();
let Ok(entries) = self.objects.find_tree_iter(&tree, &mut buffer) else {
return false;
};
for entry in entries {
let Ok(entry) = entry else { return false };
if entry.filename != filename {
continue;
}
if !entry.mode.is_blob() {
return false;
}
return stored_in_the_clear(&self.objects, entry.oid).unwrap_or(false);
}
false
}
fn directory(&mut self, root: ObjectId, directory: &[u8]) -> Option<ObjectId> {
if directory.is_empty() {
return Some(root);
}
if let Some(cached) = self.directories.get(directory) {
return *cached;
}
let mut current = root;
for component in directory.split(|byte| *byte == b'/') {
let mut buffer = Vec::new();
let Ok(entries) = self.objects.find_tree_iter(¤t, &mut buffer) else {
self.directories.insert(directory.to_vec(), None);
return None;
};
let mut next = None;
for entry in entries.flatten() {
if entry.filename == component && entry.mode.is_tree() {
next = Some(entry.oid.to_owned());
break;
}
}
match next {
Some(id) => current = id,
None => {
self.directories.insert(directory.to_vec(), None);
return None;
}
}
}
self.directories.insert(directory.to_vec(), Some(current));
Some(current)
}
}
fn collect(found: HashMap<Vec<u8>, Vec<Sighting>>) -> Vec<Exposure> {
let mut exposed: Vec<Exposure> = found
.into_iter()
.map(|(path, mut sightings)| {
sightings.sort_by_key(|sighting| sighting.blob);
Exposure { path, sightings }
})
.collect();
exposed.sort_by(|left, right| left.path.cmp(&right.path));
exposed
}
fn tips(
git_dir: &Path,
common_dir: &Path,
hash: gix_hash::Kind,
objects: &gix_odb::Handle,
scan: &mut Scan,
) -> Vec<ObjectId> {
let options = || gix_ref::store::init::Options {
object_hash: hash,
..gix_ref::store::init::Options::default()
};
let store = if git_dir == common_dir {
gix_ref::file::Store::at(git_dir.to_path_buf(), options())
} else {
gix_ref::file::Store::for_linked_worktree(
git_dir.to_path_buf(),
common_dir.to_path_buf(),
options(),
)
};
let mut tips = Vec::new();
collect_tips(&store, objects, scan, &mut tips);
if git_dir != common_dir {
let main = gix_ref::file::Store::at(common_dir.to_path_buf(), options());
collect_tips(&main, objects, scan, &mut tips);
}
match std::fs::read_dir(common_dir.join("worktrees")) {
Ok(entries) => {
for entry in entries {
let registration = match entry {
Ok(entry) => entry.path(),
Err(err) => {
scan.refs_unavailable = true;
scan.warnings.push(format!(
"a worktree registration could not be read ({err}), so that \
checkout's references were not scanned"
));
continue;
}
};
if registration == git_dir || !registration.join("HEAD").is_file() {
continue;
}
let other = gix_ref::file::Store::for_linked_worktree(
registration,
common_dir.to_path_buf(),
options(),
);
collect_tips(&other, objects, scan, &mut tips);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
scan.refs_unavailable = true;
scan.warnings.push(format!(
"the worktree registrations could not be listed ({err}), so no other \
checkout's references were scanned"
));
}
}
tips.sort();
tips.dedup();
tips
}
fn collect_tips(
store: &gix_ref::file::Store,
objects: &gix_odb::Handle,
scan: &mut Scan,
tips: &mut Vec<ObjectId>,
) {
let mut push = |mut reference: gix_ref::Reference, scan: &mut Scan| {
let name = reference.name.as_bstr().to_string();
if let Some(target) = reference.target.try_name()
&& matches!(store.try_find(target), Ok(None))
{
return;
}
match reference.peel_to_id(store, objects) {
Ok(id) => match objects.try_header(&id) {
Ok(Some(header)) if header.kind == gix_object::Kind::Commit => tips.push(id),
Ok(Some(header)) => scan.notes.push(format!(
"{name} points at a {} and was not walked",
header.kind
)),
Ok(None) | Err(_) => {
note_unresolved(scan, &name, &format!("{id} could not be read"));
}
},
Err(err) => {
note_unresolved(scan, &name, &format!("it could not be resolved ({err})"));
}
}
};
match store.iter() {
Ok(platform) => match platform.all() {
Ok(references) => {
for reference in references {
match reference {
Ok(reference) => push(reference, scan),
Err(gix_ref::file::iter::loose_then_packed::Error::ReferenceCreation {
source,
relative_path,
}) => scan.notes.push(format!(
"a file under refs/ is not a reference \
({relative_path:?}: {source})"
)),
Err(err) => note_unresolved(
scan,
"a reference under refs/",
&format!("it could not be read ({err})"),
),
}
}
}
Err(err) => {
scan.refs_unavailable = true;
scan.warnings
.push(format!("the references could not be listed ({err})"));
}
},
Err(err) => {
scan.refs_unavailable = true;
scan.warnings
.push(format!("packed-refs could not be read ({err})"));
}
}
match store.try_find("HEAD") {
Ok(Some(head)) => push(head, scan),
Ok(None) => {}
Err(err) => note_unresolved(scan, "HEAD", &format!("it could not be read ({err})")),
}
}
#[expect(
clippy::too_many_arguments,
reason = "one walk with one set of caches; splitting the state would mean \
threading a struct that exists only to satisfy the count"
)]
fn walk_tree(
objects: &gix_odb::Handle,
config: &Config,
root: ObjectId,
commit: ObjectId,
seen_trees: &mut HashSet<(ObjectId, Vec<u8>)>,
verdicts: &mut HashMap<ObjectId, bool>,
found: &mut HashMap<Vec<u8>, Vec<Sighting>>,
scan: &mut Scan,
) {
let mut pending = vec![(root, Vec::new())];
while let Some((tree, prefix)) = pending.pop() {
if !seen_trees.insert((tree, prefix.clone())) {
continue;
}
let mut buffer = Vec::new();
let entries = match objects.find_tree_iter(&tree, &mut buffer) {
Ok(entries) => entries,
Err(err) => {
note_unreadable(scan, &tree, &err.to_string());
continue;
}
};
for entry in entries {
let Ok(entry) = entry else {
note_unreadable(scan, &tree, "one of its entries did not parse");
break;
};
let mut path = prefix.clone();
if !path.is_empty() {
path.push(b'/');
}
path.extend_from_slice(entry.filename);
if entry.mode.is_tree() {
pending.push((entry.oid.to_owned(), path));
continue;
}
if !entry.mode.is_blob() {
continue;
}
if !config.decide(&path).encrypt {
continue;
}
let id = entry.oid.to_owned();
let clear = match verdicts.get(&id) {
Some(clear) => *clear,
None => {
let Some(clear) = is_clear(objects, &id, scan) else {
continue;
};
verdicts.insert(id, clear);
clear
}
};
if clear {
let sightings = found.entry(path).or_default();
if !sightings.iter().any(|sighting| sighting.blob == id) {
sightings.push(Sighting { blob: id, commit });
}
}
}
}
}
fn is_clear(objects: &gix_odb::Handle, id: &ObjectId, scan: &mut Scan) -> Option<bool> {
let mut buffer = Vec::new();
match objects.try_find(id, &mut buffer) {
Ok(Some(data)) => Some(!format::looks_encrypted(data.data)),
Ok(None) => {
note_unreadable(scan, id, "it is not in this repository's object database");
None
}
Err(err) => {
note_unreadable(scan, id, &err.to_string());
None
}
}
}
fn note_unresolved(scan: &mut Scan, name: &str, why: &str) {
scan.unresolved_refs += 1;
if scan.unresolved_refs <= MAX_UNREADABLE_WARNINGS {
scan.unresolved_names.push(name.to_string());
scan.warnings.push(format!("{name}: not scanned, {why}"));
}
}
fn note_unreadable(scan: &mut Scan, id: &ObjectId, why: &str) {
scan.unreadable += 1;
if scan.unreadable <= MAX_UNREADABLE_WARNINGS {
scan.warnings.push(format!("{id}: not scanned, {why}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
struct Fixture {
dir: TempDir,
}
impl Fixture {
fn new() -> Self {
let dir = TempDir::new().expect("temporary directory");
let fixture = Self { dir };
fixture.git(&["init", "-q", "-b", "main"]);
fixture.git(&["config", "user.name", "t"]);
fixture.git(&["config", "user.email", "t@t.invalid"]);
fixture
}
fn git(&self, args: &[&str]) -> std::process::Output {
let output = Command::new("git")
.args(args)
.current_dir(self.dir.path())
.output()
.expect("git must be on PATH");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
fn write(&self, relative: &str, content: &[u8]) {
let path = self.dir.path().join(relative);
fs::create_dir_all(path.parent().expect("a parent")).expect("directories");
fs::write(path, content).expect("writing");
}
fn commit(&self, message: &str) {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", message]);
}
fn scan(&self, declarations: &str) -> Scan {
let config = Config::parse(declarations).expect("the declarations must parse");
let git_dir = self.dir.path().join(".git");
let objects = super::objects(&git_dir, gix_hash::Kind::Sha1)
.expect("the object database must open");
super::scan(
&objects,
&git_dir,
&git_dir,
gix_hash::Kind::Sha1,
&config,
false,
)
.expect("the scan must succeed")
}
}
fn paths(scan: &Scan) -> Vec<String> {
scan.exposed
.iter()
.map(|exposure| String::from_utf8_lossy(&exposure.path).into_owned())
.collect()
}
#[test]
fn a_secret_named_only_by_another_worktrees_head_is_found() {
let fixture = Fixture::new();
fixture.write("README.md", b"start\n");
fixture.commit("start");
fixture.git(&["checkout", "-q", "-b", "side"]);
fixture.write("secrets/parked.env", b"hunter2\n");
fixture.commit("on the side");
let head = fixture.git(&["rev-parse", "HEAD"]);
let head = String::from_utf8(head.stdout).expect("a hash");
let head = head.trim().to_string();
fixture.git(&["checkout", "-q", "main"]);
let elsewhere = tempfile::TempDir::new().expect("temporary directory");
let checkout = elsewhere.path().join("wt");
fixture.git(&[
"worktree",
"add",
"-q",
"--detach",
checkout.to_str().expect("a path"),
&head,
]);
fixture.git(&["branch", "-D", "side"]);
let scan = fixture.scan("secrets/\n");
assert_eq!(paths(&scan), ["secrets/parked.env"]);
}
#[test]
fn a_secret_reachable_only_through_a_tag_is_found() {
let fixture = Fixture::new();
fixture.write("README.md", b"start\n");
fixture.commit("start");
fixture.write("secrets/tagged.env", b"hunter2\n");
fixture.commit("tagged");
fixture.git(&["tag", "-a", "v1", "-m", "release"]);
fixture.git(&["reset", "-q", "--hard", "HEAD~1"]);
let scan = fixture.scan("secrets/\n");
assert_eq!(paths(&scan), ["secrets/tagged.env"]);
}
}