Skip to main content

gunnar_server/
registry.rs

1//! **The registry's one rule** (roadmap phase 4).
2//!
3//! > No artifact without valid, logged provenance.
4//!
5//! That sentence is the product, and this module is it as a function. Whatever
6//! the registry eventually speaks — the OCI Distribution API, a cargo sparse
7//! index, both — the thing that decides whether bytes may land is this, and it
8//! is one place so the two surfaces cannot come to disagree (one implementation).
9//!
10//! # Why the check is here and not in the transport
11//!
12//! Because a rule that lives in a handler is a rule each handler has to
13//! remember, and the second handler is where it gets forgotten. `admit` takes
14//! the bytes' digest and the server's stores; a transport that skips calling it
15//! is a transport that does not compile against this module's only entry point.
16//!
17//! # It refuses in four different ways, and they are four different sentences
18//!
19//! An operator's next action is different for each, so collapsing them into
20//! "rejected" would make the error useless:
21//!
22//! * nothing has attested to this artifact — build it through the runner;
23//! * a record exists and does not verify — somebody forged it, or the builder's
24//!   key rotated;
25//! * the record is not in the log — the builder appended nothing;
26//! * the record is in the log and no signed head covers it — run
27//!   `gunnar attest head --builds`.
28//!
29//! The fourth is the one worth being pedantic about: an inclusion proof with
30//! nothing signed over it is arithmetic the server did about itself, and
31//! admitting on it would make the log decorative.
32
33use anyhow::{Context, Result};
34
35use crate::stores::ServerStores;
36
37/// Why an artifact may not be admitted.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Refusal {
40    /// No build record claims to have produced it.
41    NotAttested,
42    /// A record exists and its signature does not verify.
43    ProvenanceForged(String),
44    /// The record is not in the build log.
45    NotLogged,
46    /// The record is in the log and nothing has signed over the tree it is in.
47    NotUnderASignedHead { index: usize, size: usize },
48    /// The log's records no longer hash to what a signed head swore to.
49    LogRewritten { signed: String, actual: String },
50}
51
52impl std::fmt::Display for Refusal {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Refusal::NotAttested => write!(
56                f,
57                "no build record claims to have produced this artifact. The registry does not \
58                 take bytes on trust: build it through a builder that attests."
59            ),
60            Refusal::ProvenanceForged(why) => write!(
61                f,
62                "a build record names this artifact and does not verify: {why}"
63            ),
64            Refusal::NotLogged => write!(
65                f,
66                "the build record is not in the transparency log, so nobody outside this \
67                 server could ever check it"
68            ),
69            Refusal::NotUnderASignedHead { index, size } => write!(
70                f,
71                "the build record is record {index} of {size} in the log and no signed tree \
72                 head covers it. An inclusion proof with nothing signed over it is arithmetic \
73                 this server did about itself. Run `gunnar attest head --builds`."
74            ),
75            Refusal::LogRewritten { signed, actual } => write!(
76                f,
77                "THE BUILD LOG HAS BEEN REWRITTEN: a signed head swore to {signed} and the \
78                 records now hash to {actual}"
79            ),
80        }
81    }
82}
83
84/// What an admitted artifact came with.
85#[derive(Debug, Clone)]
86pub struct Admitted {
87    /// The build record that covers it.
88    pub record: gunnar_core::BuildRecord,
89    /// Where it sits in the build log.
90    pub index: usize,
91    /// The tree head that covers it.
92    pub head: gunnar_core::TreeHead,
93}
94
95/// **May these bytes land?**
96///
97/// `Ok(Admitted)` only when a build record names the digest, verifies against
98/// the builder's key, sits in the build log, and is covered by a tree head this
99/// server's log key signed. Anything less is an [`Refusal`] naming which of the
100/// four it was.
101pub fn admit(stores: &ServerStores, artifact_sha256: &str) -> Result<Result<Admitted, Refusal>> {
102    let builds = stores.build_log();
103    let builder_key = stores.provenance_key()?;
104    let log_key = stores.transparency_key()?;
105
106    let signed: Vec<gunnar_core::SignedBuild> = builds
107        .lines_of()?
108        .iter()
109        .map(|line| serde_json::from_str(line).context("decoding a build record"))
110        .collect::<Result<_>>()?;
111
112    let Some(index) = signed
113        .iter()
114        .position(|build| build.record.produced(artifact_sha256))
115    else {
116        return Ok(Err(Refusal::NotAttested));
117    };
118    let build = &signed[index];
119
120    if let Err(problem) = gunnar_core::provenance::verify_build(build, builder_key.public_key()) {
121        return Ok(Err(Refusal::ProvenanceForged(problem.to_string())));
122    }
123
124    let Some(inclusion) = builds.prove(index)? else {
125        return Ok(Err(Refusal::NotLogged));
126    };
127    let heads = builds.heads()?;
128    let covering = heads.iter().rev().find(|head| {
129        head.head.size as usize >= inclusion.size
130            && gunnar_core::transparency::verify_tree_head(head, log_key.public_key()).is_ok()
131    });
132    let Some(head) = covering else {
133        return Ok(Err(Refusal::NotUnderASignedHead {
134            index: inclusion.index,
135            size: inclusion.size,
136        }));
137    };
138    if head.head.root != inclusion.root {
139        return Ok(Err(Refusal::LogRewritten {
140            signed: head.head.root.clone(),
141            actual: inclusion.root,
142        }));
143    }
144
145    Ok(Ok(Admitted {
146        record: build.record.clone(),
147        index,
148        head: head.head.clone(),
149    }))
150}
151
152// ── the wire ───────────────────────────────────────────────────────────────
153
154#[cfg(feature = "http")]
155mod wire {
156    use super::*;
157
158    /// **The OCI Distribution surface, backed by this server's stores.**
159    ///
160    /// `gunnar-http` owns the shape of the protocol and knows nothing about
161    /// provenance; this owns the decision and knows nothing about HTTP. The seam
162    /// between them is [`gunnar_http::registry::RegistryBackend`], and it exists so
163    /// a transport cannot have an opinion about who may write — the same reason
164    /// `GitTransport` and `LfsTransport` exist.
165    pub struct StoreRegistry {
166        stores: std::sync::Arc<ServerStores>,
167        registry: crate::oci::Registry,
168    }
169
170    impl StoreRegistry {
171        /// Serve the registry that lives under `stores`.
172        pub fn new(stores: std::sync::Arc<ServerStores>) -> StoreRegistry {
173            let registry = crate::oci::Registry::under(stores.root());
174            StoreRegistry { stores, registry }
175        }
176    }
177
178    impl gunnar_http::registry::RegistryBackend for StoreRegistry {
179        fn blob(&self, digest: &str) -> std::result::Result<Option<Vec<u8>>, String> {
180            self.registry
181                .get_blob(digest)
182                .map_err(|err| format!("{err:#}"))
183        }
184
185        fn manifest(
186            &self,
187            name: &str,
188            reference: &str,
189        ) -> std::result::Result<Option<(String, Vec<u8>)>, String> {
190            let Some(digest) = self
191                .registry
192                .resolve(name, reference)
193                .map_err(|err| format!("{err:#}"))?
194            else {
195                return Ok(None);
196            };
197            let Some(bytes) = self
198                .registry
199                .get_blob(&digest)
200                .map_err(|err| format!("{err:#}"))?
201            else {
202                // A tag that resolves to a blob that is not there. Reported as
203                // absent rather than as a fault, because that is what the client
204                // can act on — but logged, because it means the store lost a blob a
205                // tag still names.
206                tracing::warn!(
207                    name,
208                    reference,
209                    %digest,
210                    "a tag resolves to a manifest that is not in the blob store"
211                );
212                return Ok(None);
213            };
214            Ok(Some((digest, bytes)))
215        }
216
217        fn put_blob(&self, bytes: &[u8]) -> std::result::Result<String, String> {
218            self.registry
219                .put_blob(bytes)
220                .map_err(|err| format!("{err:#}"))
221        }
222
223        fn put_manifest(
224            &self,
225            name: &str,
226            reference: &str,
227            bytes: &[u8],
228        ) -> std::result::Result<std::result::Result<String, String>, String> {
229            match self
230                .registry
231                .put_manifest(&self.stores, name, reference, bytes)
232                .map_err(|err| format!("{err:#}"))?
233            {
234                Ok(landed) => Ok(Ok(landed.digest)),
235                // The refusal's own sentence, not a code: it is what an operator
236                // reads out of `docker push` and it says what to do next.
237                Err(refusal) => Ok(Err(format!("{refusal}"))),
238            }
239        }
240    }
241}
242
243#[cfg(feature = "http")]
244pub use wire::StoreRegistry;