OXYGENHUB

Coverage / crates/oxy-server/src/lib.rs 86.6%

259 of 299 lines run, 44 of 90 functions · run #359 on main · run missed

1
//! Reference HTTP server for the Oxygen wire protocol (spec §15.1, §16).
2
//!
3
//! Serves every repository directory under a root at `/{repo}/oxy/v1/...`,
4
//! over TLS when a certificate and key are given, else plain HTTP for use
5
//! behind a TLS-terminating proxy or on a loopback interface (spec §15.1).
6
//!
7
//! Authentication: when bearer tokens are configured, `push` requires one;
8
//! reads are open. With no tokens configured everything is open (development).
9
//!
10
//! Hosts embed the protocol by implementing [`Backend`] (name resolution
11
//! and authorization) and routing to [`Server::handle`]; `router` is the
12
//! standalone server built on [`DirBackend`].
13
14
pub mod witness;
15
16
use axum::body::Bytes;
17
use axum::extract::{Path, State};
18
use axum::http::{header, HeaderMap, Method, StatusCode};
19
use axum::response::{IntoResponse, Response};
20
use axum::routing::any;
21
use axum::Router;
22
use oxy_core::protocol::{wire, CONTENT_TYPE};
23
use oxy_core::repo::Repo;
24
use std::collections::HashMap;
25
use std::path::PathBuf;
26
use std::sync::{Arc, Mutex};
27
28
#[derive(Clone, Debug)]
29
pub struct ServerConfig {
30
    /// Directory containing one repository per subdirectory.
31
    pub root: PathBuf,
32
    /// Bearer tokens allowed to push; empty means no authentication.
33
    pub tokens: Vec<String>,
34
    /// Serve HTTPS with this certificate chain and private key (PEM files).
35
    pub tls: Option<TlsFiles>,
36
}
37
38
#[derive(Clone, Debug)]
39
pub struct TlsFiles {
40
    pub cert: PathBuf,
41
    pub key: PathBuf,
42
}
43
44
/// Which kind of access a request needs (spec §15.1: reads may be open;
45
/// a push must be authenticated when any credential is configured).
46
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47
pub enum Access {
48
    Read,
49
    Push,
50
}
51
52
/// What a host provides to the protocol server: how a repository name maps
53
/// to a repository on disk and who may do what. `oxy serve` uses
54
/// [`DirBackend`]; a hub supplies its own with accounts and roles.
55
pub trait Backend: Send + Sync + 'static {
56
    /// Open the repository called `name`, or fail with the status to
57
    /// answer. An unknown name is `404`; a name the caller may not see
58
    /// SHOULD also be `404`, so private repositories are not enumerable.
59
    fn open(&self, name: &str) -> Result<Repo, Failure>;
60
61
    /// Decide whether this request may perform `access` on `name`.
62
    fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>;
63
64
    /// Called after a push was processed, with its per-reference results
65
    /// (accepted or refused), the states each accepted reference moved
66
    /// between, and the changes stored, for logs and cache invalidation.
6731×
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
68
69
    /// Vet a push by what it adds before anything of it is stored or any
70
    /// reference moves (spec §16.1): `bundle` is the push as decoded, its
71
    /// changes with their lines, `updates` the references it would move.
72
    /// A host refuses the whole push by returning a [`Failure`], whose
73
    /// text reaches the client as the push's error, as it is, several
74
    /// lines included; it should answer neither 401, 403 nor 404, which a
75
    /// client takes for a credential it should change. Called for every
76
    /// part of a push sent in parts (each part's changes are its own),
77
    /// outside the repository's push lock, so a slow vet holds up no
78
    /// other push. The default accepts everything at no cost.
7931×
    fn vet_push(
8031×
        &self,
8131×
        _name: &str,
8231×
        _repo: &Repo,
8331×
        _bundle: &oxy_core::protocol::Bundle,
8431×
        _updates: &[oxy_core::protocol::RefUpdate],
8531×
        _headers: &HeaderMap,
8631×
    ) -> Result<(), Failure> {
8731×
        Ok(())
8831×
    }
89
90
    /// Add the host's transparency log to a refs answer (spec §14.8): its
91
    /// checkpoint, and each attestation's entry and inclusion proof. A host
92
    /// without a log leaves it as it is.
93123×
    fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {}
94
95
    /// The bookmark of `name` that pushes may never delete (its default),
96
    /// when the host keeps one apart from the repository's own config.
978×
    fn default_bookmark(&self, _name: &str) -> Option<String> {
988×
        None
998×
    }
100
}
101
102
/// A protocol server over any [`Backend`], serialising the pushes to each
103
/// repository. Hosts route requests to [`Server::handle`].
104
pub struct Server {
105
    backend: Box<dyn Backend>,
106
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
107
}
108
109
/// An error response: a status and a plain-text body.
110
#[derive(Debug)]
111
pub struct Failure(pub StatusCode, pub String);
112
113
impl IntoResponse for Failure {
1149×
    fn into_response(self) -> Response {
1159×
        (self.0, self.1).into_response()
1169×
    }
117
}
118
119
impl From<oxy_core::repo::RepoError> for Failure {
1200×
    fn from(e: oxy_core::repo::RepoError) -> Failure {
121
        use oxy_core::repo::RepoError;
122
        // The server's own storage failing is not the client's fault.
1230×
        let status = match &e {
1240×
            RepoError::Io(_) | RepoError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
1250×
            _ => StatusCode::BAD_REQUEST,
126
        };
1270×
        Failure(status, e.to_string())
1280×
    }
129
}
130
131
/// The wire protocol's endpoints (spec §16.1), named as in the URL.
132
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133
pub enum Endpoint {
134
    Capabilities,
135
    Refs,
136
    Negotiate,
137
    Fetch,
138
    Objects,
139
    Checkpoint,
140
    Shallow,
141
    Push,
142
}
143
144
impl Endpoint {
145387×
    pub fn parse(name: &str) -> Option<Endpoint> {
146387×
        Some(match name {
147387×
            "capabilities" => Endpoint::Capabilities,
148258×
            "refs" => Endpoint::Refs,
149135×
            "negotiate" => Endpoint::Negotiate,
15087×
            "fetch" => Endpoint::Fetch,
15139×
            "objects" => Endpoint::Objects,
15238×
            "checkpoint" => Endpoint::Checkpoint,
15338×
            "shallow" => Endpoint::Shallow,
15438×
            "push" => Endpoint::Push,
1550×
            _ => return None,
156
        })
157387×
    }
158
1593×
    pub fn name(self) -> &'static str {
1603×
        match self {
1612×
            Endpoint::Capabilities => "capabilities",
1620×
            Endpoint::Refs => "refs",
1630×
            Endpoint::Negotiate => "negotiate",
1640×
            Endpoint::Fetch => "fetch",
1650×
            Endpoint::Objects => "objects",
1660×
            Endpoint::Checkpoint => "checkpoint",
1670×
            Endpoint::Shallow => "shallow",
1681×
            Endpoint::Push => "push",
169
        }
1703×
    }
171
172
    /// The HTTP method the endpoint answers to.
173387×
    pub fn method(self) -> Method {
174387×
        match self {
175252×
            Endpoint::Capabilities | Endpoint::Refs => Method::GET,
176135×
            _ => Method::POST,
177
        }
178387×
    }
179
180387×
    fn access(self) -> Access {
181387×
        match self {
18238×
            Endpoint::Push => Access::Push,
183349×
            _ => Access::Read,
184
        }
185387×
    }
186
}
187
188378×
fn ok(body: Vec<u8>) -> Response {
189378×
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
190378×
}
191
192517×
pub fn valid_repo_name(name: &str) -> bool {
193517×
    !name.is_empty()
194517×
        && !name.starts_with('.')
195516×
        && name
196516×
            .chars()
1972835×
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
198517×
}
199
200
impl Server {
20113×
    pub fn new(backend: impl Backend) -> Arc<Server> {
20213×
        Arc::new(Server {
20313×
            backend: Box::new(backend),
20413×
            locks: Mutex::new(HashMap::new()),
20513×
        })
20613×
    }
207
2080×
    pub fn backend(&self) -> &dyn Backend {
2090×
        self.backend.as_ref()
2100×
    }
211
21232×
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
21332×
        self.locks
21432×
            .lock()
21532×
            .unwrap()
21632×
            .entry(name.to_string())
21732×
            .or_default()
21832×
            .clone()
21932×
    }
220
221
    /// Answer one protocol request for repository `name`. `method` is
222
    /// checked against the endpoint; the body is the raw CBOR request.
223387×
    pub async fn handle(
224387×
        self: &Arc<Self>,
225387×
        name: String,
226387×
        endpoint: Endpoint,
227387×
        method: &Method,
228387×
        headers: &HeaderMap,
229387×
        body: Bytes,
230387×
    ) -> Response {
231387×
        if *method != endpoint.method() {
2320×
            return Failure(
2330×
                StatusCode::METHOD_NOT_ALLOWED,
2340×
                format!("{} is {}", endpoint.name(), endpoint.method()),
2350×
            )
2360×
            .into_response();
237387×
        }
238387×
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
2396×
            return f.into_response();
240381×
        }
241381×
        let claimed = headers
242381×
            .get(oxy_core::protocol::REPO_ID_HEADER)
243381×
            .and_then(|v| v.to_str().ok())
244381×
            .and_then(hex_id);
245381×
        let server = Arc::clone(self);
246381×
        let request_headers = headers.clone();
247381×
        let repo = name.clone();
248381×
        let result = tokio::task::spawn_blocking(move || {
249381×
            server.respond(&repo, endpoint, body, claimed, &request_headers)
250381×
        })
251381×
        .await;
252381×
        let failure = match result {
253378×
            Ok(Ok(r)) => return r,
2543×
            Ok(Err(f)) => f,
2550×
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
256
        };
257
        // Name the operation and repository, in the log and for the client.
2583×
        let f = Failure(
2593×
            failure.0,
2603×
            format!("{} of {name}: {}", endpoint.name(), failure.1),
2613×
        );
2623×
        eprintln!("oxy-server: {}: {}", f.0.as_u16(), f.1);
2633×
        f.into_response()
264387×
    }
265
266381×
    fn respond(
267381×
        &self,
268381×
        name: &str,
269381×
        endpoint: Endpoint,
270381×
        body: Bytes,
271381×
        claimed: Option<[u8; 16]>,
272381×
        headers: &HeaderMap,
273381×
    ) -> Result<Response, Failure> {
274381×
        let r = self.backend.open(name)?;
275379×
        Ok(match endpoint {
276126×
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
277
            Endpoint::Refs => {
278123×
                let mut refs = r.remote_refs()?;
279123×
                if let Some(d) = self.backend.default_bookmark(name) {
280117×
                    refs.default_bookmark = Some(d);
281117×
                }
282123×
                self.backend.add_log(name, &mut refs);
283123×
                ok(wire::encode_refs(r.id()?, &refs))
284
            }
285
            Endpoint::Negotiate => {
28648×
                let (have, want) = wire::decode_negotiate(&body)?;
28748×
                ok(wire::encode_hashes(&r.negotiate(&have, &want)?))
288
            }
289
            Endpoint::Fetch => {
29048×
                let (changes, max_blob) = wire::decode_fetch(&body)?;
29148×
                ok(wire::encode_bundle(
29248×
                    &r.bundle_for_filtered(&changes, max_blob)?,
293
                ))
294
            }
295
            Endpoint::Objects => {
2961×
                let refs = wire::decode_hashes(&body)?;
2971×
                let bundle = oxy_core::protocol::Bundle {
2981×
                    objects: r.objects_for(&refs)?,
2991×
                    ..Default::default()
300
                };
3011×
                ok(wire::encode_bundle(&bundle))
302
            }
303
            Endpoint::Checkpoint => {
3040×
                let heads = wire::decode_hashes(&body)?;
3050×
                let lock = self.lock_for(name);
3060×
                let _guard = lock.lock().unwrap();
3070×
                ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))
308
            }
309
            Endpoint::Shallow => {
3100×
                let (heads, depth) = wire::decode_shallow(&body)?;
3110×
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
312
            }
313
            Endpoint::Push => {
31433×
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
315
                // The host vets what the push adds before anything is stored
316
                // or bound: a refused push leaves the repository as it was.
31733×
                self.backend
31833×
                    .vet_push(name, &r, &bundle, &updates, headers)?;
31932×
                let lock = self.lock_for(name);
32032×
                let _guard = lock.lock().unwrap();
32132×
                if r.is_unbound() {
322
                    // The first push binds the repository to the pusher's id (spec §16.1).
3233×
                    match claimed {
3243×
                        Some(id) => r.bind_id(id)?,
325
                        None => {
3260×
                            return Err(Failure(
3270×
                                StatusCode::BAD_REQUEST,
3280×
                                format!(
3290×
                                    "this repository is unbound; push with a client that sends {}",
3300×
                                    oxy_core::protocol::REPO_ID_HEADER
3310×
                                ),
3320×
                            ))
333
                        }
334
                    }
33529×
                }
33632×
                let protected = self.backend.default_bookmark(name);
33732×
                let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| {
33832×
                    u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str())
33932×
                });
34032×
                let mut report = r.receive_push_report(bundle, updates, policy, tags)?;
341
                // The request named references, all refused here: it was no
342
                // push of objects alone, whatever the repository saw.
34332×
                if !refused.is_empty() {
3441×
                    report
3451×
                        .results
3461×
                        .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT);
34731×
                }
34832×
                for u in refused {
3491×
                    report.results.push(oxy_core::protocol::RefResult {
3501×
                        name: u.name.clone(),
3511×
                        ok: false,
3521×
                        reason: format!(
3531×
                            "{:?} is this repository's default bookmark and cannot be deleted",
3541×
                            u.name
3551×
                        ),
3561×
                    });
3571×
                }
35833×
                for r in report.results.iter().filter(|r| !r.ok) {
35911×
                    eprintln!(
36011×
                        "oxy-server: push to {name}: {} refused: {}",
36111×
                        r.name, r.reason
36211×
                    );
36311×
                }
36432×
                self.backend.after_push(name, &report, headers);
36532×
                ok(wire::encode_push_response(&report.results))
366
            }
367
        })
368381×
    }
369
}
370
371
/// The backend of `oxy serve`: one repository per subdirectory of a root,
372
/// open reads, and pushes gated by a list of bearer tokens.
373
pub struct DirBackend {
374
    pub root: PathBuf,
375
    pub tokens: Vec<String>,
376
}
377
378
impl Backend for DirBackend {
379517×
    fn open(&self, name: &str) -> Result<Repo, Failure> {
380517×
        if !valid_repo_name(name) {
3811×
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
382516×
        }
383516×
        let dir = self.root.join(name);
384516×
        if !dir.join(".oxy").is_dir() {
3851×
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
386515×
        }
387515×
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
388517×
    }
389
390147×
    fn default_bookmark(&self, name: &str) -> Option<String> {
391147×
        self.open(name).ok().map(|r| r.config.bookmark.clone())
392147×
    }
393
394374×
    fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
395374×
        if access == Access::Read || self.tokens.is_empty() {
396368×
            return Ok(());
3976×
        }
3986×
        match bearer_token(headers) {
3995×
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
4004×
            _ => Err(Failure(
4014×
                StatusCode::UNAUTHORIZED,
4024×
                "a valid bearer token is required to push".into(),
4034×
            )),
404
        }
405374×
    }
406
}
407
408358×
fn hex_id(h: &str) -> Option<[u8; 16]> {
409358×
    let h = h.trim();
410358×
    if h.len() != 32 {
4110×
        return None;
412358×
    }
413358×
    let mut out = [0u8; 16];
4145728×
    for (i, byte) in out.iter_mut().enumerate() {
4155728×
        *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?;
416
    }
417358×
    Some(out)
418358×
}
419
420
/// The token of an `Authorization: Bearer` header, if any.
42119×
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
42219×
    headers
42319×
        .get(header::AUTHORIZATION)
42419×
        .and_then(|v| v.to_str().ok())
42519×
        .and_then(|v| v.strip_prefix("Bearer "))
42619×
        .map(str::trim)
42719×
}
428
429368×
async fn dispatch(
430368×
    State(server): State<Arc<Server>>,
431368×
    Path((repo, endpoint)): Path<(String, String)>,
432368×
    method: Method,
433368×
    headers: HeaderMap,
434368×
    body: Bytes,
435368×
) -> Response {
436368×
    match Endpoint::parse(&endpoint) {
437368×
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
4380×
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
439
    }
440368×
}
441
442
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
44311×
pub fn router(cfg: ServerConfig) -> Router {
44411×
    let server = Server::new(DirBackend {
44511×
        root: cfg.root,
44611×
        tokens: cfg.tokens,
44711×
    });
44811×
    Router::new()
44911×
        .route("/{repo}/oxy/v1/{endpoint}", any(dispatch))
45011×
        .with_state(server)
45111×
}
452
453
/// Serve on an already-bound listener until the process ends; HTTPS when
454
/// `cfg.tls` is set.
45511×
pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
45611×
    listener.set_nonblocking(true)?;
45711×
    match cfg.tls.clone() {
4581×
        Some(tls) => {
4591×
            let _ = rustls::crypto::ring::default_provider().install_default();
4601×
            let rustls_cfg =
4611×
                axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
4621×
                    .await
4631×
                    .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
4641×
            axum_server::from_tcp_rustls(listener, rustls_cfg)?
4651×
                .serve(router(cfg).into_make_service())
4661×
                .await
467
        }
468
        None => {
46910×
            let listener = tokio::net::TcpListener::from_std(listener)?;
47010×
            axum::serve(listener, router(cfg)).await
471
        }
472
    }
4730×
}
474
475
/// Build a runtime and serve; for the CLI and for tests on a background thread.
47611×
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
47711×
    let rt = tokio::runtime::Builder::new_multi_thread()
47811×
        .enable_all()
47911×
        .build()?;
48011×
    rt.block_on(serve(listener, cfg))
48111×
}