gunnar_server/storage/
encrypted_quarantine.rs1use std::collections::HashMap;
20use std::sync::Mutex;
21
22use anyhow::{Context as _, Result};
23use git_storage_trait::{GitServe, ObjType};
24use gix_hash::ObjectId;
25use gunnar_policy::{PushedObjects, Shape, Verdict};
26use znippy_plugin_git::exploded::PayloadSink;
27use znippy_plugin_git::object::{GitHashKind, GitObjectKind};
28use znippy_plugin_git::resolve::{resolve_walked, BaseSource};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Refused(pub String);
33
34impl std::fmt::Display for Refused {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 f.write_str(&self.0)
37 }
38}
39
40impl std::error::Error for Refused {}
41
42pub fn refusal_in(err: &anyhow::Error) -> Option<String> {
44 err.chain()
45 .find_map(|e| e.downcast_ref::<Refused>())
46 .map(|r| r.0.clone())
47}
48
49enum Held {
55 Bytes(ObjType, Vec<u8>),
56 Blob(Shape, Option<Vec<u8>>),
57 Other(ObjType),
58}
59
60const SCHEME_FILE_MAX: usize = gunnar_policy::MAX_SCHEME_FILE;
63
64struct Quarantine {
65 held: Mutex<HashMap<Vec<u8>, Held>>,
66}
67
68impl PayloadSink for Quarantine {
69 fn explode(&self, oid: &[u8], kind: GitObjectKind, payload: &[u8]) -> Result<()> {
70 let held = match kind {
71 GitObjectKind::Blob => {
72 let shape = Shape::of(payload);
73 let small = !shape.envelope && payload.len() <= SCHEME_FILE_MAX;
74 Held::Blob(shape, small.then(|| payload.to_vec()))
75 }
76 GitObjectKind::Tree => Held::Bytes(ObjType::Tree, payload.to_vec()),
77 GitObjectKind::Commit => Held::Bytes(ObjType::Commit, payload.to_vec()),
78 GitObjectKind::Tag => Held::Other(ObjType::Tag),
79 };
80 self.held
81 .lock()
82 .map_err(|_| anyhow::anyhow!("the quarantine's lock is poisoned"))?
83 .insert(oid.to_vec(), held);
84 Ok(())
85 }
86}
87
88struct Bases<'s>(&'s dyn GitServe);
90
91impl BaseSource for Bases<'_> {
92 fn content(&self, oid: &[u8]) -> Result<Option<(GitObjectKind, Vec<u8>)>> {
93 let Some((kind, bytes)) = self
94 .0
95 .read(oid)
96 .context("reading a delta base from the repository")?
97 else {
98 return Ok(None);
99 };
100 let kind = match kind {
101 ObjType::Commit => GitObjectKind::Commit,
102 ObjType::Tree => GitObjectKind::Tree,
103 ObjType::Blob => GitObjectKind::Blob,
104 ObjType::Tag => GitObjectKind::Tag,
105 ObjType::OfsDelta | ObjType::RefDelta => {
106 anyhow::bail!("the repository answered a delta for a resolved read")
107 }
108 };
109 Ok(Some((kind, bytes)))
110 }
111}
112
113struct Pushed<'q> {
116 held: &'q HashMap<Vec<u8>, Held>,
117 repo: &'q dyn GitServe,
118}
119
120impl PushedObjects for Pushed<'_> {
121 fn kind(&self, id: &ObjectId) -> gunnar_policy::Result<Option<ObjType>> {
122 Ok(self.held.get(id.as_bytes()).map(|h| match h {
123 Held::Bytes(k, _) | Held::Other(k) => *k,
124 Held::Blob(..) => ObjType::Blob,
125 }))
126 }
127 fn bytes(&self, id: &ObjectId, what: &'static str) -> gunnar_policy::Result<Vec<u8>> {
128 match self.held.get(id.as_bytes()) {
129 Some(Held::Bytes(_, b)) => Ok(b.clone()),
130 _ => Err(gunnar_policy::Error::MissingObject { oid: *id, what }),
131 }
132 }
133 fn shape(&self, id: &ObjectId) -> gunnar_policy::Result<Shape> {
134 match self.held.get(id.as_bytes()) {
135 Some(Held::Blob(s, _)) => Ok(*s),
136 _ => Err(gunnar_policy::Error::MissingObject {
137 oid: *id,
138 what: "a pushed blob",
139 }),
140 }
141 }
142 fn blob(&self, id: &ObjectId) -> gunnar_policy::Result<Option<Vec<u8>>> {
143 let what = "the key ring or the members file";
144 let store = |e: anyhow::Error| {
145 gunnar_policy::Error::Store(gunnar_store::Error::backend(format!("{e:#}")))
146 };
147 match self.held.get(id.as_bytes()) {
148 Some(Held::Blob(_, Some(bytes))) => Ok(Some(bytes.clone())),
149 Some(Held::Blob(_, None)) => Ok(None),
151 Some(_) => Err(gunnar_policy::Error::MissingObject { oid: *id, what }),
152 None => {
153 let size = self
154 .repo
155 .header(id.as_bytes())
156 .map_err(store)?
157 .map(|(_, n)| n);
158 if size.is_some_and(|n| n > SCHEME_FILE_MAX as u64) {
159 return Ok(None);
160 }
161 self.repo
162 .read(id.as_bytes())
163 .map_err(store)?
164 .map(|(_, bytes)| Some(bytes))
165 .ok_or(gunnar_policy::Error::MissingObject { oid: *id, what })
166 }
167 }
168 }
169}
170
171pub fn check(pack: &[u8], hash_kind: gix_hash::Kind, repo: &dyn GitServe) -> Result<usize> {
176 let hash = match hash_kind {
177 gix_hash::Kind::Sha256 => GitHashKind::Sha256,
178 _ => GitHashKind::Sha1,
179 };
180 let walked = znippy_plugin_git::pack_walk::walk(pack, hash.oid_len())
181 .context("walking the pushed pack in quarantine")?;
182 let quarantine = Quarantine {
183 held: Mutex::new(HashMap::new()),
184 };
185 resolve_walked(pack, &walked, hash, 0, &Bases(repo), &quarantine)
186 .context("resolving the pushed pack in quarantine")?;
187 let held = quarantine
188 .held
189 .into_inner()
190 .map_err(|_| anyhow::anyhow!("the quarantine's lock is poisoned"))?;
191 let new: Vec<ObjectId> = held
192 .keys()
193 .map(|raw| {
194 ObjectId::try_from(raw.as_slice())
195 .map_err(|_| anyhow::anyhow!("a {}-byte object id", raw.len()))
196 })
197 .collect::<Result<_>>()?;
198 match gunnar_policy::check_pushed(&new, &Pushed { held: &held, repo })
199 .map_err(|e| anyhow::anyhow!("{e}"))?
200 {
201 Verdict::Allow => Ok(new.len()),
202 refused => Err(Refused(verdict_words(&refused)).into()),
203 }
204}
205
206fn verdict_words(v: &Verdict) -> String {
208 match v {
209 Verdict::Allow => String::new(),
210 other => other
211 .reason()
212 .map(str::to_string)
213 .unwrap_or_else(|| format!("{other:?}")),
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn a_refusal_is_found_through_the_context_the_engine_adds() {
223 let err = anyhow::Error::from(Refused("encrypted repository: x is plaintext".into()))
224 .context("framing the pushed pack in the transaction log");
225 assert_eq!(
226 refusal_in(&err).as_deref(),
227 Some("encrypted repository: x is plaintext")
228 );
229 assert_eq!(refusal_in(&anyhow::anyhow!("disk full")), None);
230 }
231}