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