Skip to main content

artifactize_core/
server.rs

1//! `artifactize server`: the shared remote review store over its own SQLite file.
2
3mod 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
23/// Loopback only by default: a review store is reachable from other machines only when its
24/// owner says so with `--listen`. 8417 is an unassigned port, fixed so that clients and docs
25/// agree on one default URL.
26pub const DEFAULT_LISTEN: &str = "127.0.0.1:8417";
27/// Bound one lookup's SQL work and response fan-out; clients batch larger key sets.
28pub(crate) const MAX_LOOKUP_KEYS: usize = 1000;
29/// Keep Axum's gross allocation cap slightly above a full record, so per-record
30/// validation can return the existing precise size error within that headroom.
31const BODY_LIMIT_HEADROOM: usize = 64 * 1024;
32
33/// The fields this store indexes or authorizes are typed at the HTTP edge. The
34/// remaining record is an opaque, extensible payload validated by its consumers.
35#[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        // Store errors never contain tokens; only their SHA-256 reaches SQLite.
66        // Logging is best effort: a failing stderr must not turn this error into a panic.
67        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
132/// What a client from before the 0.5 reuse key gets for its requests.
133const 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
146/// Legacy object keys request an upgrade before current validation, even in mixed arrays
147/// or with unknown fields. Map parsing also retains the previous last-duplicate-key behavior.
148enum LookupRequest {
149    Current(Lookup),
150    Legacy,
151    Invalid,
152    TooMany,
153    MalformedKey,
154}
155impl LookupRequest {
156    /// Classify the complete envelope before validating identities: shape errors precede
157    /// count/key errors, and map-only legacy objects are handled by the visitor first.
158    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                // The former Lookup derive accepted one positional keys field. Objects here
198                // were ordinary invalid strings, never the map-only legacy upgrade path.
199                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    // Stored records are already JSON; splice them without reparsing.
276    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    // The server, not the client, says who published and when.
331    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
358/// The 0.4 publish route, keyed by Eval definition hash and fingerprint.
359async 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
376/// Serve until cancelled; `ready` receives the bound address once listening.
377pub 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}