use std::collections::{BTreeMap, BTreeSet};
use kernel::TimeMs;
use kernel::secret::scan;
use crate::error::StorageError;
use super::commit::{Checkpoint, git_err};
use super::opening::{HeadMove, commit_refused};
use super::provenance::Provenance;
const GITLINK: u32 = 0o160_000;
mod index_lock;
pub(crate) mod pathspec;
mod stage_filter;
pub(super) use index_lock::write_index;
use stage_filter::{StageFilter, workdir};
pub(crate) struct CommitPlan<'a> {
pub(crate) t: TimeMs,
pub(crate) of: &'a Provenance,
pub(crate) subject: &'a str,
pub(crate) head: HeadMove,
}
impl Checkpoint {
pub fn scan_staged(&mut self) -> Result<(), StorageError> {
let index = self.repo.index().map_err(git_err("read index"))?;
let mut hits: Vec<String> = Vec::new();
match self.last_tree()? {
None => {
for entry in index.iter() {
if entry.mode != GITLINK {
self.scan_blob(entry.id, &String::from_utf8_lossy(&entry.path), &mut hits)?;
}
}
}
Some(tree) => {
let diff = self
.repo
.diff_tree_to_index(Some(&tree), Some(&index), None)
.map_err(git_err("diff checkpoint against the staged tree"))?;
for delta in diff.deltas() {
let staged = delta.new_file();
if staged.id().is_zero() || staged.mode() == git2::FileMode::Commit {
continue;
}
let path = String::from_utf8_lossy(staged.path_bytes().unwrap_or_default())
.replace('\\', "/");
self.scan_blob(staged.id(), &path, &mut hits)?;
}
}
}
if hits.is_empty() {
return Ok(());
}
Err(StorageError::SecretEgress {
locations: hits.join(" "),
})
}
fn scan_blob(
&self,
id: git2::Oid,
path: &str,
hits: &mut Vec<String>,
) -> Result<(), StorageError> {
let blob = self
.repo
.find_blob(id)
.map_err(|err| StorageError::Checkpoint {
op: "scan a staged file",
detail: format!("{path}: {}", err.message()),
})?;
for span in scan(blob.content()) {
hits.push(format!("{path}:{}+{}", span.start, span.len));
}
Ok(())
}
fn last_tree(&self) -> Result<Option<git2::Tree<'_>>, StorageError> {
let commit = match self.last {
Some(oid) => self
.repo
.find_commit(oid)
.map_err(git_err("read the last checkpoint"))?,
None => match Self::head_commit(&self.repo)? {
Some(commit) => commit,
None => return Ok(None),
},
};
commit
.tree()
.map(Some)
.map_err(git_err("read the last checkpoint's tree"))
}
pub(super) fn head_commit(
repo: &git2::Repository,
) -> Result<Option<git2::Commit<'_>>, StorageError> {
match repo.head() {
Ok(head) => head
.peel_to_commit()
.map(Some)
.map_err(git_err("read HEAD")),
Err(err)
if matches!(
err.code(),
git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound
) =>
{
Ok(None)
}
Err(err) => Err(git_err("read HEAD")(err)),
}
}
pub(crate) fn stage_scopes(&mut self, scopes: &[String]) -> Result<Vec<String>, StorageError> {
let files = self.stage_scopes_held(scopes)?;
write_index(&mut self.repo.index().map_err(git_err("read index"))?)?;
Ok(files)
}
pub(crate) fn stage_scopes_held(
&mut self,
scopes: &[String],
) -> Result<Vec<String>, StorageError> {
let mut index = self.repo.index().map_err(git_err("read index"))?;
let found: BTreeMap<Vec<u8>, git2::Oid> =
index.iter().map(|entry| (entry.path, entry.id)).collect();
let root = workdir(&self.repo)?;
let (files, prefixes): (Vec<&String>, Vec<&String>) = scopes
.iter()
.partition(|scope| names_a_file(root, &index, scope));
let mut filter = StageFilter::new(root);
for file in files {
self.stage_file(&mut index, &mut filter, std::path::Path::new(file.as_str()))?;
}
if scopes.is_empty() || !prefixes.is_empty() {
let patterns = pathspec::of(&prefixes.into_iter().cloned().collect::<Vec<_>>());
let specs: Vec<&str> = patterns.iter().map(String::as_str).collect();
let mut admit = |path: &std::path::Path, _matched: &[u8]| -> i32 { filter.admit(path) };
index
.add_all(
specs.iter(),
git2::IndexAddOption::DEFAULT,
Some(&mut admit),
)
.map_err(git_err("stage scope"))?;
}
filter.refused()?;
let mut changed: BTreeSet<Vec<u8>> = BTreeSet::new();
let mut present = 0usize;
for entry in index.iter() {
let unchanged = match found.get(&entry.path) {
Some(id) => {
present = present.saturating_add(1);
*id == entry.id
}
None => false,
};
if !unchanged {
changed.insert(entry.path);
}
}
if present < found.len() {
let staged: BTreeSet<Vec<u8>> = index.iter().map(|entry| entry.path).collect();
changed.extend(found.into_keys().filter(|path| !staged.contains(path)));
}
Ok(changed
.into_iter()
.map(|path| String::from_utf8_lossy(&path).into_owned())
.collect())
}
fn stage_file(
&self,
index: &mut git2::Index,
filter: &mut StageFilter,
file: &std::path::Path,
) -> Result<(), StorageError> {
let ignored = self
.repo
.is_path_ignored(file)
.map_err(git_err("stage scope"))?;
if filter.admit(file) != 0 || (ignored && index.get_path(file, 0).is_none()) {
return Ok(());
}
if workdir(&self.repo)?.join(file).is_file() {
index.add_path(file)
} else {
index.remove_path(file)
}
.map_err(git_err("stage scope"))
}
pub(crate) fn commit(&mut self, plan: &CommitPlan<'_>) -> Result<git2::Oid, StorageError> {
let mut index = self.repo.index().map_err(git_err("read index"))?;
let tree_oid = index.write_tree().map_err(git_err("write tree"))?;
let tree = self
.repo
.find_tree(tree_oid)
.map_err(git_err("find staged tree"))?;
let seconds = i64::try_from(plan.t.value().saturating_div(1000)).map_err(|_| {
StorageError::Checkpoint {
op: "stamp the commit",
detail: format!("{} ms is past what git can date", plan.t.value()),
}
})?;
let when = git2::Time::new(seconds, 0);
let email = plan.of.email();
let signature = git2::Signature::new(plan.of.actor().as_str(), &email, &when)
.map_err(git_err("build signature"))?;
let (update, parents): (_, Vec<git2::Commit>) = match plan.head {
HeadMove::Leave => (None, Self::head_commit(&self.repo)?.into_iter().collect()),
HeadMove::Advance => (
Some("HEAD"),
Self::head_commit(&self.repo)?.into_iter().collect(),
),
};
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let full_message = format!("{}\n\n{}", plan.subject, plan.of.trailers());
let oid = self
.repo
.commit(
update,
&signature,
&signature,
&full_message,
&tree,
&parent_refs,
)
.map_err(|err| commit_refused(update, err))?;
self.last = Some(oid);
Ok(oid)
}
pub fn land(
&mut self,
scopes: &[String],
t: TimeMs,
of: &Provenance,
subject: &str,
) -> Result<String, StorageError> {
let _head_moves = super::opening::moving_head()?;
self.stage_scopes(scopes)?;
self.scan_staged()?;
let oid = self.commit(&CommitPlan {
t,
of,
subject,
head: HeadMove::Advance,
})?;
Ok(oid.to_string())
}
}
fn names_a_file(root: &std::path::Path, index: &git2::Index, scope: &str) -> bool {
match std::fs::symlink_metadata(root.join(scope)) {
Ok(meta) => meta.is_file(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
index.get_path(std::path::Path::new(scope), 0).is_some()
}
Err(_) => false,
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests;