1mod store;
4
5pub use store::{DATABASE, MAX_BYTES, MAX_ENTRIES, Revocation, Scope, Store, Token};
6
7use std::net::SocketAddr;
8
9use axum::{
10 Json, Router,
11 body::Bytes,
12 extract::{DefaultBodyLimit, Path, State},
13 http::{HeaderMap, HeaderValue, StatusCode, header},
14 response::{IntoResponse, Response},
15 routing::{get, post, put},
16};
17use serde::Deserialize;
18use serde_json::{Value, json};
19use tokio_util::sync::CancellationToken;
20
21use crate::remote::{MAX_FULL_BYTES, MAX_SUMMARY_BYTES, SCHEMA, valid_hash};
22
23pub const DEFAULT_LISTEN: &str = "127.0.0.1:8417";
27pub(crate) const MAX_LOOKUP_KEYS: usize = 1000;
29const BODY_LIMIT_HEADROOM: usize = 64 * 1024;
32
33#[derive(serde::Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37struct PublishRecord {
38 schema: u32,
39 key: crate::types::ReuseKey,
40 verdict: crate::types::ExecutionStatus,
41 execution_id: crate::types::ExecutionId,
42 completed_at: String,
43 profile: PublishProfile,
44 #[serde(default, skip_serializing_if = "crate::config::Field::missing")]
45 execution: crate::config::Field<Value>,
46 #[serde(flatten)]
47 payload: serde_json::Map<String, Value>,
48}
49
50#[derive(serde::Serialize, Deserialize)]
51struct PublishProfile {
52 kind: crate::config::ProfileKind,
53 #[serde(flatten)]
54 options: serde_json::Map<String, Value>,
55}
56
57struct ApiError(StatusCode, String);
58
59impl ApiError {
60 fn new(status: StatusCode, message: impl Into<String>) -> Self {
61 Self(status, message.into())
62 }
63
64 fn internal(error: String) -> Self {
65 use std::io::Write as _;
68 let _ = writeln!(std::io::stderr().lock(), "Review store error: {error}");
69 Self::new(
70 StatusCode::INTERNAL_SERVER_ERROR,
71 "Internal review store error.",
72 )
73 }
74}
75
76impl IntoResponse for ApiError {
77 fn into_response(self) -> Response {
78 let mut response = (self.0, Json(json!({"error": self.1}))).into_response();
79 if self.0 == StatusCode::UNAUTHORIZED {
80 response
81 .headers_mut()
82 .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
83 }
84 response
85 }
86}
87
88struct Principal {
89 name: crate::types::TokenName,
90 scopes: Vec<Scope>,
91}
92
93async fn authenticate(
94 store: &Store,
95 headers: &HeaderMap,
96 required: Option<Scope>,
97) -> Result<Principal, ApiError> {
98 let token = headers
99 .get(header::AUTHORIZATION)
100 .and_then(|value| value.to_str().ok())
101 .and_then(|value| value.split_once(' '))
102 .filter(|(scheme, token)| scheme.eq_ignore_ascii_case("bearer") && !token.is_empty())
103 .map(|(_, token)| token.trim())
104 .ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "A bearer token is required."))?;
105 let (name, scopes) = store
106 .authenticate(token)
107 .await
108 .map_err(ApiError::internal)?
109 .ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "Invalid or revoked token."))?;
110 if let Some(scope) = required
111 && !scopes.contains(&scope)
112 {
113 return Err(ApiError::new(
114 StatusCode::FORBIDDEN,
115 format!("This token lacks the {} scope.", scope.name()),
116 ));
117 }
118 Ok(Principal { name, scopes })
119}
120
121fn valid_key(key: &str) -> Result<(), ApiError> {
122 if valid_hash(key) {
123 Ok(())
124 } else {
125 Err(ApiError::new(
126 StatusCode::BAD_REQUEST,
127 "Keys are lowercase SHA-256 reuse keys.",
128 ))
129 }
130}
131
132const UPGRADE: &str = "This review store holds artifactize 0.5 reuse keys (store schema 3); upgrade this client to artifactize 0.5 or later.";
134
135async fn whoami(State(store): State<Store>, headers: HeaderMap) -> Result<Json<Value>, ApiError> {
136 let principal = authenticate(&store, &headers, None).await?;
137 Ok(Json(
138 json!({"principal": principal.name, "scopes": principal.scopes}),
139 ))
140}
141
142struct Lookup {
143 keys: Vec<crate::types::ReuseKey>,
144}
145
146enum LookupRequest {
149 Current(Lookup),
150 Legacy,
151 Invalid,
152 TooMany,
153 MalformedKey,
154}
155impl LookupRequest {
156 fn current(keys: Vec<LookupKey>) -> Self {
159 if keys.iter().any(|key| !matches!(key, LookupKey::Current(_))) {
160 return Self::Invalid;
161 }
162 if keys.len() > MAX_LOOKUP_KEYS {
163 return Self::TooMany;
164 }
165 let keys = keys
166 .into_iter()
167 .map(|key| match key {
168 LookupKey::Current(key) => key.parse::<crate::types::ReuseKey>(),
169 _ => unreachable!("current lookup strings checked above"),
170 })
171 .collect::<Result<Vec<_>, _>>();
172 match keys {
173 Ok(keys) => Self::Current(Lookup { keys }),
174 Err(_) => Self::MalformedKey,
175 }
176 }
177}
178#[derive(Deserialize)]
179#[serde(untagged)]
180enum LookupKey {
181 Current(String),
182 Legacy(crate::json::Object<std::collections::BTreeMap<String, crate::json::Ignored>>),
183 Invalid(crate::json::Ignored),
184}
185impl<'de> Deserialize<'de> for LookupRequest {
186 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187 struct RequestVisitor;
188 impl<'de> serde::de::Visitor<'de> for RequestVisitor {
189 type Value = LookupRequest;
190 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 formatter.write_str("a lookup object")
192 }
193 fn visit_seq<S: serde::de::SeqAccess<'de>>(
194 self,
195 mut sequence: S,
196 ) -> Result<Self::Value, S::Error> {
197 let keys = sequence.next_element::<crate::json::Optional<Vec<LookupKey>>>()?;
200 let mut extra = false;
201 while sequence.next_element::<crate::json::Ignored>()?.is_some() {
202 extra = true;
203 }
204 match keys.and_then(|keys| keys.0) {
205 Some(keys) if !extra => Ok(LookupRequest::current(keys)),
206 _ => Ok(LookupRequest::Invalid),
207 }
208 }
209 fn visit_map<M: serde::de::MapAccess<'de>>(
210 self,
211 mut map: M,
212 ) -> Result<Self::Value, M::Error> {
213 let mut keys = crate::json::Optional::<Vec<LookupKey>>::default();
214 let mut unknown = false;
215 while let Some(field) = map.next_key::<String>()? {
216 if field == "keys" {
217 keys = map.next_value()?;
218 } else {
219 unknown = true;
220 map.next_value::<crate::json::Ignored>()?;
221 }
222 }
223 let Some(keys) = keys.0 else {
224 return Ok(LookupRequest::Invalid);
225 };
226 if keys
227 .iter()
228 .any(|key| matches!(key, LookupKey::Legacy(crate::json::Object(_))))
229 {
230 return Ok(LookupRequest::Legacy);
231 }
232 if unknown {
233 return Ok(LookupRequest::Invalid);
234 }
235 Ok(LookupRequest::current(keys))
236 }
237 }
238 deserializer.deserialize_any(RequestVisitor)
239 }
240}
241
242fn parse_lookup(body: &[u8]) -> Result<Lookup, ApiError> {
243 let expected = || ApiError::new(StatusCode::BAD_REQUEST, "Expected {\"keys\":[KEY,...]}.");
244 match serde_json::from_slice::<LookupRequest>(body).map_err(|_| expected())? {
245 LookupRequest::Current(lookup) => Ok(lookup),
246 LookupRequest::Legacy => Err(ApiError::new(StatusCode::GONE, UPGRADE)),
247 LookupRequest::Invalid => Err(expected()),
248 LookupRequest::TooMany => Err(ApiError::new(
249 StatusCode::BAD_REQUEST,
250 format!("At most {MAX_LOOKUP_KEYS} keys per lookup."),
251 )),
252 LookupRequest::MalformedKey => Err(ApiError::new(
253 StatusCode::BAD_REQUEST,
254 "Keys are lowercase SHA-256 reuse keys.",
255 )),
256 }
257}
258
259#[cfg(test)]
260mod lookup_identity_tests;
261#[cfg(test)]
262mod lookup_tests;
263
264async fn lookup(
265 State(store): State<Store>,
266 headers: HeaderMap,
267 body: Bytes,
268) -> Result<Response, ApiError> {
269 authenticate(&store, &headers, Some(Scope::Read)).await?;
270 let lookup = parse_lookup(&body)?;
271 let entries = store
272 .lookup(lookup.keys)
273 .await
274 .map_err(ApiError::internal)?;
275 Ok((
277 [(header::CONTENT_TYPE, "application/json")],
278 format!("{{\"entries\":[{}]}}", entries.join(",")),
279 )
280 .into_response())
281}
282
283async fn publish(
284 State(store): State<Store>,
285 Path(key): Path<String>,
286 headers: HeaderMap,
287 body: Bytes,
288) -> Result<(StatusCode, Json<Value>), ApiError> {
289 let principal = authenticate(&store, &headers, Some(Scope::Publish)).await?;
290 valid_key(&key)?;
291 let mut record: PublishRecord = serde_json::from_slice(&body)
292 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "Expected a JSON record."))?;
293 let limit = if !matches!(record.execution, crate::config::Field::Missing) {
294 MAX_FULL_BYTES
295 } else {
296 MAX_SUMMARY_BYTES
297 };
298 let completed_at = record.completed_at.parse::<crate::types::Timestamp>().ok();
299 let (true, Some(completed_at)) = (
300 record.schema == SCHEMA
301 && record.key.as_str() == key
302 && matches!(
303 record.verdict,
304 crate::types::ExecutionStatus::Green | crate::types::ExecutionStatus::Red
305 )
306 && record.execution_id.valid_wire(),
307 completed_at,
308 ) else {
309 return Err(ApiError::new(
310 StatusCode::BAD_REQUEST,
311 format!(
312 "Records need schema {SCHEMA}, the path's key, a GREEN/RED verdict, a profile kind, an execution ID and an RFC 3339 completion time."
313 ),
314 ));
315 };
316 if body.len() > limit {
317 return Err(ApiError::new(
318 StatusCode::PAYLOAD_TOO_LARGE,
319 format!("Record exceeds {limit} bytes."),
320 ));
321 }
322 if record.profile.kind == crate::config::ProfileKind::Human
323 && !principal.scopes.contains(&Scope::Human)
324 {
325 return Err(ApiError::new(
326 StatusCode::FORBIDDEN,
327 "Human sign-offs require the human scope.",
328 ));
329 }
330 record
332 .payload
333 .insert("publisher".into(), json!(principal.name));
334 record
335 .payload
336 .insert("publishedAt".into(), json!(crate::broker::now()));
337 let created = store
338 .insert(
339 &record.key,
340 &record.execution_id,
341 &principal.name,
342 completed_at,
343 serde_json::to_string(&record)
344 .map_err(|error| ApiError::internal(error.to_string()))?,
345 )
346 .await
347 .map_err(ApiError::internal)?;
348 Ok((
349 if created {
350 StatusCode::CREATED
351 } else {
352 StatusCode::OK
353 },
354 Json(json!({"created": created})),
355 ))
356}
357
358async fn legacy_publish() -> ApiError {
360 ApiError::new(StatusCode::GONE, UPGRADE)
361}
362
363pub fn router(store: Store) -> Router {
364 Router::new()
365 .route("/v1/whoami", get(whoami))
366 .route("/v1/lookup", post(lookup))
367 .route("/v1/entries/{key}", put(publish))
368 .route(
369 "/v1/entries/{eval_def_hash}/{fingerprint}",
370 put(legacy_publish),
371 )
372 .layer(DefaultBodyLimit::max(MAX_FULL_BYTES + BODY_LIMIT_HEADROOM))
373 .with_state(store)
374}
375
376pub async fn serve(
378 store: Store,
379 listen: SocketAddr,
380 ready: impl FnOnce(SocketAddr) -> Result<(), String>,
381 cancellation: CancellationToken,
382) -> Result<(), String> {
383 let listener = tokio::net::TcpListener::bind(listen)
384 .await
385 .map_err(|e| format!("Cannot listen on {listen}: {e}"))?;
386 ready(listener.local_addr().map_err(|e| e.to_string())?)?;
387 axum::serve(listener, router(store))
388 .with_graceful_shutdown(cancellation.cancelled_owned())
389 .await
390 .map_err(|e| e.to_string())
391}