Skip to main content

gunnar_server/storage/
encrypted_quarantine.rs

1//! **An encrypted repository's push, checked in quarantine** (2026-10-06,
2//! crypt-level2 item 4 pulled forward).
3//!
4//! On a repository flagged ENCRYPTED the server must never keep plaintext — not
5//! on main's disk, not in the transaction log the twin replays. Until this
6//! module the encrypted gate ran AFTER the pushed pack was framed and stored,
7//! so a refused plaintext push moved no reference and still left its pack on
8//! main until `gunnar gc` and in the twin's history (MEASURED on the estate by
9//! the kit's CR4, 3 of 3 plaintext needles in both).
10//!
11//! Now the pack is held where the engine already holds it before it stores it
12//! (the push's spool, in memory or beside the store and gone when the push
13//! ends), resolved by znippy's one resolver against the repository's own
14//! objects for a thin pack's bases, and put to the SAME walk the gate runs
15//! ([`gunnar_policy::check_pushed`]). Only a pack that passes is framed and
16//! stored; a refusal is [`Refused`], which the transport tells the client in
17//! the gate's own words.
18
19use 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/// **The push is refused, and nothing of it was kept**: the gate's sentence.
31#[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
42/// The refusal in `err`'s chain, if the quarantine refused.
43pub 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
49/// What the quarantine keeps of one resolved object: a tree's or a commit's
50/// bytes (the walk parses them), a blob's shape — and, for a small blob that
51/// is not an envelope, its bytes, because it may be the key ring or the
52/// members file the walk checks against each other (crypt-pq). Envelopes and
53/// anything large are let go.
54enum Held {
55    Bytes(ObjType, Vec<u8>),
56    Blob(Shape, Option<Vec<u8>>),
57    Other(ObjType),
58}
59
60/// The largest blob kept for the ring check; a key ring or a members file is
61/// far smaller (a quantum-safe stanza is ~1.6 KB).
62const 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
88/// A thin pack's bases: the repository's own objects.
89struct 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
113/// The quarantine as the walk reads it; `repo` answers for a key ring or a
114/// members file the push did not change.
115struct 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            // Too large to keep, or an envelope: not a file the check reads.
150            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
171/// ★ **Check `pack` before a byte of it is kept**: `Err(`[`Refused`]`)` with
172/// the gate's sentence when it would put plaintext on an encrypted repository;
173/// `Ok(n)` with how many objects were checked otherwise. `repo` answers a thin
174/// pack's bases.
175pub 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
206/// The sentence of a refusing verdict.
207fn 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}