use std::collections::HashMap;
use std::sync::Mutex;
use anyhow::{Context as _, Result};
use gix_hash::ObjectId;
use git_storage_trait::{GitServe, ObjType};
use gunnar_policy::{PushedObjects, Shape, Verdict};
use znippy_plugin_git::exploded::PayloadSink;
use znippy_plugin_git::object::{GitHashKind, GitObjectKind};
use znippy_plugin_git::resolve::{resolve_walked, BaseSource};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused(pub String);
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Refused {}
pub fn refusal_in(err: &anyhow::Error) -> Option<String> {
err.chain().find_map(|e| e.downcast_ref::<Refused>()).map(|r| r.0.clone())
}
enum Held {
Bytes(ObjType, Vec<u8>),
Blob(Shape, Option<Vec<u8>>),
Other(ObjType),
}
const SCHEME_FILE_MAX: usize = gunnar_policy::MAX_SCHEME_FILE;
struct Quarantine {
held: Mutex<HashMap<Vec<u8>, Held>>,
}
impl PayloadSink for Quarantine {
fn explode(&self, oid: &[u8], kind: GitObjectKind, payload: &[u8]) -> Result<()> {
let held = match kind {
GitObjectKind::Blob => {
let shape = Shape::of(payload);
let small = !shape.envelope && payload.len() <= SCHEME_FILE_MAX;
Held::Blob(shape, small.then(|| payload.to_vec()))
}
GitObjectKind::Tree => Held::Bytes(ObjType::Tree, payload.to_vec()),
GitObjectKind::Commit => Held::Bytes(ObjType::Commit, payload.to_vec()),
GitObjectKind::Tag => Held::Other(ObjType::Tag),
};
self.held.lock().map_err(|_| anyhow::anyhow!("the quarantine's lock is poisoned"))?.insert(oid.to_vec(), held);
Ok(())
}
}
struct Bases<'s>(&'s dyn GitServe);
impl BaseSource for Bases<'_> {
fn content(&self, oid: &[u8]) -> Result<Option<(GitObjectKind, Vec<u8>)>> {
let Some((kind, bytes)) = self.0.read(oid).context("reading a delta base from the repository")? else {
return Ok(None);
};
let kind = match kind {
ObjType::Commit => GitObjectKind::Commit,
ObjType::Tree => GitObjectKind::Tree,
ObjType::Blob => GitObjectKind::Blob,
ObjType::Tag => GitObjectKind::Tag,
ObjType::OfsDelta | ObjType::RefDelta => anyhow::bail!("the repository answered a delta for a resolved read"),
};
Ok(Some((kind, bytes)))
}
}
struct Pushed<'q> {
held: &'q HashMap<Vec<u8>, Held>,
repo: &'q dyn GitServe,
}
impl PushedObjects for Pushed<'_> {
fn kind(&self, id: &ObjectId) -> gunnar_policy::Result<Option<ObjType>> {
Ok(self.held.get(id.as_bytes()).map(|h| match h {
Held::Bytes(k, _) | Held::Other(k) => *k,
Held::Blob(..) => ObjType::Blob,
}))
}
fn bytes(&self, id: &ObjectId, what: &'static str) -> gunnar_policy::Result<Vec<u8>> {
match self.held.get(id.as_bytes()) {
Some(Held::Bytes(_, b)) => Ok(b.clone()),
_ => Err(gunnar_policy::Error::MissingObject { oid: *id, what }),
}
}
fn shape(&self, id: &ObjectId) -> gunnar_policy::Result<Shape> {
match self.held.get(id.as_bytes()) {
Some(Held::Blob(s, _)) => Ok(*s),
_ => Err(gunnar_policy::Error::MissingObject { oid: *id, what: "a pushed blob" }),
}
}
fn blob(&self, id: &ObjectId) -> gunnar_policy::Result<Option<Vec<u8>>> {
let what = "the key ring or the members file";
let store = |e: anyhow::Error| gunnar_policy::Error::Store(gunnar_store::Error::backend(format!("{e:#}")));
match self.held.get(id.as_bytes()) {
Some(Held::Blob(_, Some(bytes))) => Ok(Some(bytes.clone())),
Some(Held::Blob(_, None)) => Ok(None),
Some(_) => Err(gunnar_policy::Error::MissingObject { oid: *id, what }),
None => {
let size = self.repo.header(id.as_bytes()).map_err(store)?.map(|(_, n)| n);
if size.is_some_and(|n| n > SCHEME_FILE_MAX as u64) {
return Ok(None);
}
self.repo
.read(id.as_bytes())
.map_err(store)?
.map(|(_, bytes)| Some(bytes))
.ok_or(gunnar_policy::Error::MissingObject { oid: *id, what })
}
}
}
}
pub fn check(pack: &[u8], hash_kind: gix_hash::Kind, repo: &dyn GitServe) -> Result<usize> {
let hash = match hash_kind {
gix_hash::Kind::Sha256 => GitHashKind::Sha256,
_ => GitHashKind::Sha1,
};
let walked = znippy_plugin_git::pack_walk::walk(pack, hash.oid_len()).context("walking the pushed pack in quarantine")?;
let quarantine = Quarantine { held: Mutex::new(HashMap::new()) };
resolve_walked(pack, &walked, hash, 0, &Bases(repo), &quarantine).context("resolving the pushed pack in quarantine")?;
let held = quarantine.held.into_inner().map_err(|_| anyhow::anyhow!("the quarantine's lock is poisoned"))?;
let new: Vec<ObjectId> = held
.keys()
.map(|raw| ObjectId::try_from(raw.as_slice()).map_err(|_| anyhow::anyhow!("a {}-byte object id", raw.len())))
.collect::<Result<_>>()?;
match gunnar_policy::check_pushed(&new, &Pushed { held: &held, repo }).map_err(|e| anyhow::anyhow!("{e}"))? {
Verdict::Allow => Ok(new.len()),
refused => Err(Refused(verdict_words(&refused)).into()),
}
}
fn verdict_words(v: &Verdict) -> String {
match v {
Verdict::Allow => String::new(),
other => other.reason().map(str::to_string).unwrap_or_else(|| format!("{other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refusal_is_found_through_the_context_the_engine_adds() {
let err = anyhow::Error::from(Refused("encrypted repository: x is plaintext".into()))
.context("framing the pushed pack in the transaction log");
assert_eq!(refusal_in(&err).as_deref(), Some("encrypted repository: x is plaintext"));
assert_eq!(refusal_in(&anyhow::anyhow!("disk full")), None);
}
}