diff --git a/.github/workflows/oci.yml b/.github/workflows/oci.yml new file mode 100644 index 00000000..c950c212 --- /dev/null +++ b/.github/workflows/oci.yml @@ -0,0 +1,75 @@ +name: oci + +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +permissions: + contents: read + id-token: write + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: "1" + +jobs: + oci: + runs-on: ubuntu-22.04 + defaults: + run: + shell: nix develop .#oci --command bash {0} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v13 + + - name: Setup Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v7 + + - name: Configure SCCache + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN); + + - name: Initialize Nix shell + run: nix develop .#oci + + - name: Run OCI tests + run: | + set -eufo pipefail + cd examples/oci + cargo build + cargo run & + PID="${!}" + sleep 3 + oci-distribution-spec-conformance + kill "${PID}" + + - name: Upload test results to Codecov + if: always() && !cancelled() + uses: codecov/test-results-action@v1 + with: + files: examples/oci/junit.xml + fail_ci_if_error: true + token: "${{ secrets.CODECOV_TOKEN }}" + + - name: Upload test report to GitHub + if: always() && !cancelled() + uses: actions/upload-artifact@v4 + with: + name: report + path: examples/oci/report.html + + - name: Show SCCache stats + if: always() && !cancelled() + run: sccache --show-stats diff --git a/.gitignore b/.gitignore index 004f10cd..e8c4adb6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ corpus artifacts coverage +# OCI +junit.xml +report.html + # Nix result result-dev diff --git a/CHANGELOG.md b/CHANGELOG.md index df6fe6ae..5e54e5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Added OCI example. + ### Fixed - Router display no longer relies on generic being displayable. diff --git a/Cargo.lock b/Cargo.lock index 82b78c68..f4dff811 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,15 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -245,6 +254,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "cpufeatures" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -312,6 +330,40 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "divan" version = "0.1.14" @@ -410,6 +462,27 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gimli" version = "0.29.0" @@ -736,6 +809,16 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -766,6 +849,12 @@ version = "11.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1034,6 +1123,26 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1127,6 +1236,36 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -1139,9 +1278,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.39.3" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", @@ -1214,9 +1353,21 @@ checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.32" @@ -1224,6 +1375,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -1232,12 +1409,33 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +dependencies = [ + "getrandom", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "version_check" version = "0.9.5" @@ -1351,18 +1549,26 @@ dependencies = [ ] [[package]] -name = "wayfind-hyper-example" +name = "wayfind-oci-example" version = "0.2.0" dependencies = [ "anyhow", "bytes", + "dashmap", "http 1.1.0", "http-body-util", "hyper", "hyper-util", + "percent-encoding", + "regex", "serde", "serde_json", + "sha2", + "thiserror", "tokio", + "tracing", + "tracing-subscriber", + "uuid", "wayfind", ] @@ -1376,6 +1582,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -1385,6 +1607,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/examples/hyper/src/main.rs b/examples/hyper/src/main.rs deleted file mode 100644 index a51b76a7..00000000 --- a/examples/hyper/src/main.rs +++ /dev/null @@ -1,133 +0,0 @@ -#![allow(clippy::unused_async)] - -use bytes::Bytes; -use http_body_util::{combinators::BoxBody, BodyExt, Full}; -use hyper::{body::Incoming, header, service::service_fn, Request, Response, StatusCode}; -use hyper_util::{ - rt::{TokioExecutor, TokioIo}, - server::conn::auto::Builder, -}; -use std::{ - convert::Infallible, - future::Future, - net::{IpAddr, Ipv4Addr, SocketAddr}, - pin::Pin, - sync::Arc, -}; -use tokio::{net::TcpListener, task::JoinSet}; -use wayfind::{Parameter, Path, Router}; - -type BoxFuture<'a> = Pin< - Box< - dyn Future>, anyhow::Error>> - + Send - + 'a, - >, ->; - -type HandlerFn = - Arc Fn(&'a str, &'a [Parameter<'_, 'a>]) -> BoxFuture<'a> + Send + Sync>; - -#[tokio::main] -async fn main() -> Result<(), anyhow::Error> { - let mut router: Router = Router::new(); - router.insert( - "/", - Arc::new(move |path, parameters| Box::pin(index_route(path, parameters))), - )?; - router.insert( - "/hello/{name}", - Arc::new(move |path, parameters| Box::pin(hello_route(path, parameters))), - )?; - router.insert( - "{*catch_all}", - Arc::new(move |path, parameters| Box::pin(not_found(path, parameters))), - )?; - - let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1337); - let listener = TcpListener::bind(&socket).await?; - println!("Listening on http://{socket}"); - - let router = Arc::new(router); - let mut join_set = JoinSet::new(); - - loop { - let Ok((stream, _)) = listener.accept().await else { - continue; - }; - - let router = Arc::clone(&router); - join_set.spawn(async move { - let _unused = Builder::new(TokioExecutor::new()) - .serve_connection( - TokioIo::new(stream), - service_fn(move |request: Request| { - let router = Arc::clone(&router); - async move { - let path = request.uri().path(); - let wayfind_path = Path::new(path).expect("Invalid path!"); - let matches = router - .search(&wayfind_path) - .expect("Failed to search!") - .expect("No match found!"); - - let handler = &matches.data.value; - let parameters = &matches.parameters; - handler(path, parameters).await - } - }), - ) - .await; - }); - } -} - -async fn index_route( - _: &'_ str, - _: &'_ [Parameter<'_, '_>], -) -> Result>, anyhow::Error> { - let json = serde_json::json!({ - "hello": "world" - }); - - let body = Full::new(Bytes::from(json.to_string())); - let response = Response::builder() - .header(header::CONTENT_TYPE, "application/json") - .body(body.boxed())?; - - Ok(response) -} - -async fn hello_route( - _: &'_ str, - parameters: &'_ [Parameter<'_, '_>], -) -> Result>, anyhow::Error> { - let name = parameters[0].value; - let json = serde_json::json!({ - "hello": name, - }); - - let body = Full::new(Bytes::from(json.to_string())); - let response = Response::builder() - .header(header::CONTENT_TYPE, "application/json") - .body(body.boxed())?; - - Ok(response) -} - -async fn not_found( - path: &'_ str, - _: &'_ [Parameter<'_, '_>], -) -> Result>, anyhow::Error> { - let json = serde_json::json!({ - "error": "route_not_found", - "route": path, - }); - - let body = Full::new(Bytes::from(json.to_string())); - let response = Response::builder() - .status(StatusCode::NOT_FOUND) - .body(body.boxed())?; - - Ok(response) -} diff --git a/examples/hyper/Cargo.toml b/examples/oci/Cargo.toml similarity index 50% rename from examples/hyper/Cargo.toml rename to examples/oci/Cargo.toml index 2f381e65..d42bc5fc 100644 --- a/examples/hyper/Cargo.toml +++ b/examples/oci/Cargo.toml @@ -1,13 +1,12 @@ # https://doc.rust-lang.org/cargo/reference/manifest.html [package] -name = "wayfind-hyper-example" -description = "Example of using `wayfind` with `hyper`." +name = "wayfind-oci-example" +description = "Example of using `wayfind` as an OCI registry." publish = false version.workspace = true authors.workspace = true edition.workspace = true -rust-version.workspace = true repository.workspace = true license.workspace = true keywords.workspace = true @@ -19,12 +18,37 @@ workspace = true [dependencies] wayfind = { path = "../.." } -tokio = { version = "1", features = ["full"] } -hyper = { version = "1.4", features = ["full"] } -hyper-util = { version = "0.1", features = ["full"] } -http = "1.1" +# Web +bytes = "1.5" +http = "1.0" http-body-util = "0.1" -bytes = "1.7" +hyper = { version = "1.1", features = ["full"] } +hyper-util = { version = "0.1", features = ["full"] } +tokio = { version = "1.40", features = ["full"] } + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" + +# Serde serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" + +# Errors anyhow = "1.0" +thiserror = "1.0" + +# UUID +uuid = { version = "1.10", features = ["v4"] } + +# Hash +sha2 = "0.10" + +# Encoding +percent-encoding = "2.3" + +# Regex +regex = "1.10" + +# Data Structures +dashmap = "6.0" diff --git a/examples/oci/README.md b/examples/oci/README.md new file mode 100644 index 00000000..517d49eb --- /dev/null +++ b/examples/oci/README.md @@ -0,0 +1,11 @@ +![oci](https://github.com/DuskSystems/wayfind/actions/workflows/oci.yml/badge.svg) + +# `oci` example + +This is a toy implementation of an [Open Container Initiative (OCI) Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md) registry. + +Request handling is inspired by the `hyper-util` examples. + +API structure is inspired by `axum`, but without use of `tower`. + +All 'pull' [conformance tests](https://github.com/opencontainers/distribution-spec/tree/v1.1.0/conformance) pass. diff --git a/examples/oci/src/constraints.rs b/examples/oci/src/constraints.rs new file mode 100644 index 00000000..c427f91a --- /dev/null +++ b/examples/oci/src/constraints.rs @@ -0,0 +1 @@ +pub mod name; diff --git a/examples/oci/src/constraints/name.rs b/examples/oci/src/constraints/name.rs new file mode 100644 index 00000000..9d0ce64e --- /dev/null +++ b/examples/oci/src/constraints/name.rs @@ -0,0 +1,21 @@ +use regex::Regex; +use std::sync::LazyLock; +use wayfind::Constraint; + +/// Regex for validating path, lifted from Distribution Specification. +/// Note the addition of boundaries `^` and `$`, to ensure the entire segment matches. +static NAME_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*$") + .expect("failed to build regex") +}); + +/// Constraint ensures that the `` parameter in URLs adheres to the OCI Distribution Specification for repository names. +pub struct NameConstraint; + +impl Constraint for NameConstraint { + const NAME: &'static str = "name"; + + fn check(segment: &str) -> bool { + NAME_REGEX.is_match(segment) + } +} diff --git a/examples/oci/src/extract.rs b/examples/oci/src/extract.rs new file mode 100644 index 00000000..099ae1ed --- /dev/null +++ b/examples/oci/src/extract.rs @@ -0,0 +1,79 @@ +use crate::{response::IntoResponse, state::SharedAppState}; +use http::{request::Parts, Request}; +use hyper::body::Incoming; +use std::convert::Infallible; +use std::future::Future; + +pub mod body; +pub mod method; +pub mod path; +pub mod query; + +/// Type alias for the expected HTTP request. +pub type AppRequest = Request; + +/// Marker types for controlling `FromRequest` implementations. +/// See `Handler` for more details. +mod private { + #[derive(Debug, Clone, Copy)] + pub enum ViaParts {} + + #[derive(Debug, Clone, Copy)] + pub enum ViaRequest {} +} + +/// Trait for extracting data from request parts (headers, method, URI, ...) +/// +/// All `FromRequestParts` implementations can also be extracted via `FromRequest` too. +pub trait FromRequestParts: Sized { + type Rejection: IntoResponse; + + fn from_request_parts( + parts: &mut Parts, + state: &SharedAppState, + ) -> impl Future> + Send; +} + +impl FromRequestParts for SharedAppState { + type Rejection = Infallible; + + async fn from_request_parts( + _: &mut Parts, + state: &SharedAppState, + ) -> Result { + Ok(state.clone()) + } +} + +/// Trait for extracting data from a full request, consuming it in the process. +pub trait FromRequest: Sized { + type Rejection: IntoResponse; + + fn from_request( + req: AppRequest, + state: &SharedAppState, + ) -> impl Future> + Send; +} + +impl FromRequest for T +where + T: FromRequestParts, +{ + type Rejection = ::Rejection; + + async fn from_request( + req: AppRequest, + state: &SharedAppState, + ) -> Result { + let (mut parts, _) = req.into_parts(); + T::from_request_parts(&mut parts, state).await + } +} + +impl FromRequest<()> for AppRequest { + type Rejection = Infallible; + + async fn from_request(req: AppRequest, _: &SharedAppState) -> Result { + Ok(req) + } +} diff --git a/examples/oci/src/extract/body.rs b/examples/oci/src/extract/body.rs new file mode 100644 index 00000000..da7ab966 --- /dev/null +++ b/examples/oci/src/extract/body.rs @@ -0,0 +1,62 @@ +use super::FromRequest; +use crate::{ + response::{AppResponse, IntoResponse}, + state::SharedAppState, +}; +use bytes::Bytes; +use http::{header::CONTENT_TYPE, Request, Response, StatusCode}; +use http_body_util::{BodyExt, Full}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BodyError { + #[error("Failed to read request body: {0}")] + Hyper(#[from] hyper::Error), +} + +impl BodyError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::Hyper(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for BodyError { + fn into_response(self) -> AppResponse { + let status = self.status_code(); + let body = json!({ + "error": { + "code": status.as_u16().to_string(), + "message": self.to_string() + } + }); + + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap() + } +} + +/// Access to the given request body. +/// Consumes entire body into bytes. +/// Likely inefficient, but works for simple use cases. +pub struct Body(pub Bytes); + +impl FromRequest for Body { + type Rejection = BodyError; + + async fn from_request( + req: Request, + _: &SharedAppState, + ) -> Result { + let body = req.into_body(); + let bytes = body.collect().await.map_err(BodyError::Hyper)?.to_bytes(); + + Ok(Self(bytes)) + } +} diff --git a/examples/oci/src/extract/headers.rs b/examples/oci/src/extract/headers.rs new file mode 100644 index 00000000..9b1b22d4 --- /dev/null +++ b/examples/oci/src/extract/headers.rs @@ -0,0 +1,20 @@ +use super::FromRequestParts; +use crate::state::SharedAppState; +use http::{request::Parts, HeaderMap}; +use std::convert::Infallible; + +/// Access to the given request headers. +/// +/// TODO: Replace with `wayfind` native headers parsing, once implemented. +pub struct Headers(pub HeaderMap); + +impl FromRequestParts for Headers { + type Rejection = Infallible; + + async fn from_request_parts( + parts: &mut Parts, + _: &SharedAppState, + ) -> Result { + Ok(Self(parts.headers.clone())) + } +} diff --git a/examples/oci/src/extract/method.rs b/examples/oci/src/extract/method.rs new file mode 100644 index 00000000..588a37f4 --- /dev/null +++ b/examples/oci/src/extract/method.rs @@ -0,0 +1,20 @@ +use super::FromRequestParts; +use crate::state::SharedAppState; +use http::request::Parts; +use std::convert::Infallible; + +/// Access to the given request method. +/// +/// TODO: Replace with `wayfind` native method parsing, once implemented. +pub struct Method(pub http::Method); + +impl FromRequestParts for Method { + type Rejection = Infallible; + + async fn from_request_parts( + parts: &mut Parts, + _: &SharedAppState, + ) -> Result { + Ok(Self(parts.method.clone())) + } +} diff --git a/examples/oci/src/extract/path.rs b/examples/oci/src/extract/path.rs new file mode 100644 index 00000000..80b2731a --- /dev/null +++ b/examples/oci/src/extract/path.rs @@ -0,0 +1,140 @@ +use super::FromRequestParts; +use crate::response::{AppResponse, IntoResponse}; +use crate::state::SharedAppState; +use bytes::Bytes; +use http::Response; +use http::{request::Parts, StatusCode}; +use http_body_util::Full; +use serde_json::json; +use std::{fmt::Display, str::FromStr, sync::Arc}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PathError { + #[error("Missing path parameters")] + MissingPathParams, + + #[error("Failed to parse path parameter: {0}")] + ParseError(String), + + #[error("Wrong number of path parameters. Expected {expected}, got {actual}")] + WrongNumberOfParameters { expected: usize, actual: usize }, +} + +impl PathError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::MissingPathParams => StatusCode::INTERNAL_SERVER_ERROR, + Self::ParseError(_) | Self::WrongNumberOfParameters { .. } => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for PathError { + fn into_response(self) -> AppResponse { + let status = self.status_code(); + let body = json!({ + "error": { + "code": status.as_u16().to_string(), + "message": self.to_string() + } + }); + + Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap() + } +} + +/// Private marker trait needed, so that the single impl doesn't conflict with the tuples. +mod private { + pub trait Marker {} + impl Marker for String {} +} + +/// Access to the given request path parameters. +/// Only supports Stringable types, for now. +/// Order of parameters should match definition in routes. +pub struct Path(pub T); + +impl FromRequestParts for Path +where + T: FromPath, +{ + type Rejection = PathError; + + async fn from_request_parts( + parts: &mut Parts, + _: &SharedAppState, + ) -> Result { + let params = parts + .extensions + .get::>>() + .ok_or(PathError::MissingPathParams)?; + + T::from_path(params).map(Path) + } +} + +/// Trait for types that can be constructed from path parameters. +pub trait FromPath: Sized { + fn from_path(params: &[(String, String)]) -> Result; +} + +impl FromPath for T1 +where + T1: private::Marker + FromStr, + T1::Err: Display, +{ + fn from_path(params: &[(String, String)]) -> Result { + let mut params = params.iter(); + if params.len() != 1 { + return Err(PathError::WrongNumberOfParameters { + expected: 1, + actual: params.len(), + }); + } + + params + .next() + .ok_or(PathError::MissingPathParams)? + .1 + .parse::() + .map_err(|err| PathError::ParseError(err.to_string())) + } +} + +impl FromPath for (T1, T2) +where + T1: FromStr, + T1::Err: Display, + T2: FromStr, + T2::Err: Display, +{ + fn from_path(params: &[(String, String)]) -> Result { + let mut params = params.iter(); + if params.len() != 2 { + return Err(PathError::WrongNumberOfParameters { + expected: 2, + actual: params.len(), + }); + } + Ok(( + params + .next() + .ok_or(PathError::MissingPathParams)? + .1 + .parse::() + .map_err(|err| PathError::ParseError(err.to_string()))?, + params + .next() + .ok_or(PathError::MissingPathParams)? + .1 + .parse::() + .map_err(|err| PathError::ParseError(err.to_string()))?, + )) + } +} diff --git a/examples/oci/src/extract/query.rs b/examples/oci/src/extract/query.rs new file mode 100644 index 00000000..abf32959 --- /dev/null +++ b/examples/oci/src/extract/query.rs @@ -0,0 +1,91 @@ +use super::FromRequestParts; +use crate::{ + response::{AppResponse, IntoResponse}, + state::SharedAppState, +}; +use bytes::Bytes; +use http::{request::Parts, Response, StatusCode}; +use http_body_util::Full; +use percent_encoding::percent_decode_str; +use serde_json::json; +use std::{collections::HashMap, str::Utf8Error}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum QueryError { + #[error("Invalid query string")] + InvalidQuery, + + #[error("Failed to decode query parameter: {0}")] + DecodingError(#[from] Utf8Error), +} + +impl QueryError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::InvalidQuery | Self::DecodingError(_) => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for QueryError { + fn into_response(self) -> AppResponse { + let status = self.status_code(); + let body = json!({ + "error": { + "code": status.as_u16().to_string(), + "message": self.to_string() + } + }); + + Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap() + } +} + +/// Access to the given request parameters. +/// Params are parsed and decoded. +/// +/// TODO: Replace with `wayfind` native query parsing, once implemented. +pub struct Query(pub HashMap); + +impl FromRequestParts for Query { + type Rejection = QueryError; + + async fn from_request_parts( + parts: &mut Parts, + _: &SharedAppState, + ) -> Result { + let mut params = HashMap::new(); + + let Some(query_string) = parts.uri.query() else { + return Ok(Self(params)); + }; + + for pair in query_string.split('&') { + if pair.is_empty() { + return Err(QueryError::InvalidQuery); + } + + let (key, value) = pair.split_once('=').ok_or(QueryError::InvalidQuery)?; + + let decoded_key = percent_decode_str(key) + .decode_utf8() + .map_err(QueryError::DecodingError)?; + + let decoded_value = percent_decode_str(value) + .decode_utf8() + .map_err(QueryError::DecodingError)?; + + params + .entry(decoded_key.to_string()) + .or_insert_with(|| decoded_value.to_string()); + } + + Ok(Self(params)) + } +} diff --git a/examples/oci/src/handler.rs b/examples/oci/src/handler.rs new file mode 100644 index 00000000..3cff1488 --- /dev/null +++ b/examples/oci/src/handler.rs @@ -0,0 +1,213 @@ +use crate::{ + extract::{AppRequest, FromRequest, FromRequestParts}, + response::{AppResponse, IntoResponse}, + state::SharedAppState, +}; +use std::{future::Future, pin::Pin}; + +/// Trait for request handlers in the application. +/// +/// Allows for flexible handler signatures with different numbers of parameters. +/// Only the final parameter in a handler can consume the request body. +pub trait Handler: Clone + Send + Sized + 'static { + type Future: Future + Send + 'static; + + fn call(self, req: AppRequest, state: SharedAppState) -> Self::Future; +} + +impl Handler<()> for F +where + F: FnOnce() -> Fut + Clone + Send + 'static, + Fut: Future + Send, + Res: IntoResponse, +{ + type Future = Pin + Send>>; + + fn call(self, _: AppRequest, _: SharedAppState) -> Self::Future { + Box::pin(async move { self().await.into_response() }) + } +} + +impl Handler<(M, T1)> for F +where + F: FnOnce(T1) -> Fut + Clone + Send + 'static, + Fut: Future + Send, + Res: IntoResponse, + T1: FromRequest + Send, +{ + type Future = Pin + Send>>; + + fn call(self, req: AppRequest, state: SharedAppState) -> Self::Future { + Box::pin(async move { + let (parts, body) = req.into_parts(); + let req = AppRequest::from_parts(parts, body); + + let t1 = match T1::from_request(req, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + self(t1).await.into_response() + }) + } +} + +impl Handler<(M, T1, T2)> for F +where + F: FnOnce(T1, T2) -> Fut + Clone + Send + 'static, + Fut: Future + Send, + Res: IntoResponse, + T1: FromRequestParts + Send, + T2: FromRequest + Send, +{ + type Future = Pin + Send>>; + + fn call(self, req: AppRequest, state: SharedAppState) -> Self::Future { + Box::pin(async move { + let (mut parts, body) = req.into_parts(); + + let t1 = match T1::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let req = AppRequest::from_parts(parts, body); + + let t2 = match T2::from_request(req, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + self(t1, t2).await.into_response() + }) + } +} + +impl Handler<(M, T1, T2, T3)> for F +where + F: FnOnce(T1, T2, T3) -> Fut + Clone + Send + 'static, + Fut: Future + Send, + Res: IntoResponse, + T1: FromRequestParts + Send, + T2: FromRequestParts + Send, + T3: FromRequest + Send, +{ + type Future = Pin + Send>>; + + fn call(self, req: AppRequest, state: SharedAppState) -> Self::Future { + Box::pin(async move { + let (mut parts, body) = req.into_parts(); + + let t1 = match T1::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let t2 = match T2::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let req = AppRequest::from_parts(parts, body); + + let t3 = match T3::from_request(req, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + self(t1, t2, t3).await.into_response() + }) + } +} + +impl Handler<(M, T1, T2, T3, T4)> for F +where + F: FnOnce(T1, T2, T3, T4) -> Fut + Clone + Send + 'static, + Fut: Future + Send, + Res: IntoResponse, + T1: FromRequestParts + Send, + T2: FromRequestParts + Send, + T3: FromRequestParts + Send, + T4: FromRequest + Send, +{ + type Future = Pin + Send>>; + + fn call(self, req: AppRequest, state: SharedAppState) -> Self::Future { + Box::pin(async move { + let (mut parts, body) = req.into_parts(); + + let t1 = match T1::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let t2 = match T2::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let t3 = match T3::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let req = AppRequest::from_parts(parts, body); + + let t4 = match T4::from_request(req, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + self(t1, t2, t3, t4).await.into_response() + }) + } +} + +impl Handler<(M, T1, T2, T3, T4, T5)> for F +where + F: FnOnce(T1, T2, T3, T4, T5) -> Fut + Clone + Send + 'static, + Fut: Future + Send, + Res: IntoResponse, + T1: FromRequestParts + Send, + T2: FromRequestParts + Send, + T3: FromRequestParts + Send, + T4: FromRequestParts + Send, + T5: FromRequest + Send, +{ + type Future = Pin + Send>>; + + fn call(self, req: AppRequest, state: SharedAppState) -> Self::Future { + Box::pin(async move { + let (mut parts, body) = req.into_parts(); + + let t1 = match T1::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let t2 = match T2::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let t3 = match T3::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let t4 = match T4::from_request_parts(&mut parts, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + let req = AppRequest::from_parts(parts, body); + + let t5 = match T5::from_request(req, &state).await { + Ok(value) => value, + Err(rejection) => return rejection.into_response(), + }; + + self(t1, t2, t3, t4, t5).await.into_response() + }) + } +} diff --git a/examples/oci/src/lib.rs b/examples/oci/src/lib.rs new file mode 100644 index 00000000..66704cb0 --- /dev/null +++ b/examples/oci/src/lib.rs @@ -0,0 +1,215 @@ +#![allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] + +use anyhow::Error; +use constraints::name::NameConstraint; +use http::Method; +use hyper::service::service_fn; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder, +}; +use router::AppRouter; +use state::AppState; +use std::{convert::Infallible, sync::Arc}; +use tokio::{net::TcpListener, task::JoinSet}; + +pub mod constraints; +pub mod extract; +pub mod handler; +pub mod response; +pub mod router; +pub mod routes; +pub mod state; +pub mod types; + +#[allow(clippy::too_many_lines)] +pub async fn start_server(listener: TcpListener) -> Result<(), Error> { + tracing::info!( + address = %listener.local_addr()?, + "listening on http" + ); + + let state = Arc::new(AppState::new()); + + // TODO: Enable `wayfind` trailing slash support, when implemented. + // TODO: Enable `wayfind` method routing, when implemented. + let mut router = AppRouter::new(); + router.constraint::(); + + // end-1 + router.route(Method::GET, "/v2", routes::root::handle_root_get); + router.route(Method::GET, "/v2/", routes::root::handle_root_get); + + // end-2 + router.route( + Method::GET, + "/v2/{*name:name}/blobs/{digest}", + routes::blob::handle_blob_pull, + ); + router.route( + Method::GET, + "/v2/{*name:name}/blobs/{digest}/", + routes::blob::handle_blob_pull, + ); + router.route( + Method::HEAD, + "/v2/{*name:name}/blobs/{digest}", + routes::blob::handle_blob_pull, + ); + router.route( + Method::HEAD, + "/v2/{*name:name}/blobs/{digest}/", + routes::blob::handle_blob_pull, + ); + + // end-3 + router.route( + Method::GET, + "/v2/{*name:name}/manifests/{reference}", + routes::manifest::handle_manifest_pull, + ); + router.route( + Method::GET, + "/v2/{*name:name}/manifests/{reference}/", + routes::manifest::handle_manifest_pull, + ); + router.route( + Method::HEAD, + "/v2/{*name:name}/manifests/{reference}", + routes::manifest::handle_manifest_pull, + ); + router.route( + Method::HEAD, + "/v2/{*name:name}/manifests/{reference}/", + routes::manifest::handle_manifest_pull, + ); + + // end-4a / end-4b + router.route( + Method::POST, + "/v2/{*name:name}/blobs/uploads", + routes::blob::handle_blob_push_post, + ); + router.route( + Method::POST, + "/v2/{*name:name}/blobs/uploads/", + routes::blob::handle_blob_push_post, + ); + + // end-6 + router.route( + Method::PUT, + "/v2/{*name:name}/blobs/uploads/{reference}", + routes::blob::handle_blob_push_put, + ); + router.route( + Method::PUT, + "/v2/{*name:name}/blobs/uploads/{reference}/", + routes::blob::handle_blob_push_put, + ); + + // end-7 + router.route( + Method::PUT, + "/v2/{*name:name}/manifests/{reference}", + routes::manifest::handle_manifest_put, + ); + router.route( + Method::PUT, + "/v2/{*name:name}/manifests/{reference}/", + routes::manifest::handle_manifest_put, + ); + + // end-8a + router.route( + Method::GET, + "/v2/{*name:name}/tags/list", + routes::tags::handle_tags_get, + ); + router.route( + Method::GET, + "/v2/{*name:name}/tags/list/", + routes::tags::handle_tags_get, + ); + + // end-9 + router.route( + Method::DELETE, + "/v2/{*name:name}/manifests/{reference}", + routes::manifest::handle_manifest_delete, + ); + router.route( + Method::DELETE, + "/v2/{*name:name}/manifests/{reference}/", + routes::manifest::handle_manifest_delete, + ); + + // end-10 + router.route( + Method::DELETE, + "/v2/{*name:name}/blobs/{digest}", + routes::blob::handle_blob_delete, + ); + router.route( + Method::DELETE, + "/v2/{*name:name}/blobs/{digest}/", + routes::blob::handle_blob_delete, + ); + + let router = Arc::new(router); + + let mut join_set = JoinSet::new(); + + loop { + let (stream, peer_addr) = match listener.accept().await { + Ok(x) => x, + Err(err) => { + tracing::error!( + error = %err, + "failed to accept connection" + ); + + continue; + } + }; + + let router_clone = Arc::clone(&router); + let state_clone = Arc::clone(&state); + + // Spawn a new task for each connection + let serve_connection = async move { + tracing::info!( + peer_addr = %peer_addr, + "handling a request" + ); + + let service = service_fn(move |req| { + let router = Arc::clone(&router_clone); + let state = Arc::clone(&state_clone); + async move { + let response = router.handle(req, Arc::clone(&state)).await; + Ok::<_, Infallible>(response) + } + }); + + let result = Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await; + + if let Err(err) = result { + tracing::error!( + error = %err, + peer_addr = %peer_addr, + "error serving connection" + ); + } + + tracing::info!( + peer_addr = %peer_addr, + "handled a request" + ); + }; + + join_set.spawn(serve_connection); + } +} diff --git a/examples/oci/src/main.rs b/examples/oci/src/main.rs new file mode 100644 index 00000000..e9b59993 --- /dev/null +++ b/examples/oci/src/main.rs @@ -0,0 +1,13 @@ +use anyhow::Error; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use tokio::net::TcpListener; +use wayfind_oci_example::start_server; + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt::init(); + + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8000); + let listener = TcpListener::bind(&socket).await?; + start_server(listener).await +} diff --git a/examples/oci/src/response.rs b/examples/oci/src/response.rs new file mode 100644 index 00000000..15c48091 --- /dev/null +++ b/examples/oci/src/response.rs @@ -0,0 +1,46 @@ +use bytes::Bytes; +use http::{Response, StatusCode}; +use http_body_util::Full; +use std::convert::Infallible; + +/// Represents a HTTP response with a full body of bytes. +pub type AppResponse = Response>; + +/// Trait for types that can be converted into an `AppResponse`. +pub trait IntoResponse { + fn into_response(self) -> AppResponse; +} + +impl IntoResponse for AppResponse { + fn into_response(self) -> AppResponse { + self + } +} + +impl IntoResponse for Infallible { + fn into_response(self) -> AppResponse { + unreachable!() + } +} + +impl IntoResponse for StatusCode { + fn into_response(self) -> AppResponse { + Response::builder() + .status(self) + .body(Full::new(Bytes::new())) + .unwrap() + } +} + +impl IntoResponse for Result +where + T: IntoResponse, + E: IntoResponse, +{ + fn into_response(self) -> AppResponse { + match self { + Ok(response) => response.into_response(), + Err(err) => err.into_response(), + } + } +} diff --git a/examples/oci/src/router.rs b/examples/oci/src/router.rs new file mode 100644 index 00000000..7753a343 --- /dev/null +++ b/examples/oci/src/router.rs @@ -0,0 +1,113 @@ +use crate::{ + extract::AppRequest, + handler::Handler, + response::{AppResponse, IntoResponse}, + state::SharedAppState, +}; +use bytes::Bytes; +use http::{Method, Response, StatusCode}; +use http_body_util::Full; +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +use wayfind::Constraint; + +/// Type alias for async handlers. +type ArcHandler = Arc< + dyn Fn(AppRequest, SharedAppState) -> Pin + Send>> + + Send + + Sync, +>; + +pub struct AppRouter { + /// Maps HTTP methods to their respective `wayfind` Routers. + /// TODO: Replace with native `wayfind` method routing, when implemented. + routes: HashMap>, +} + +impl AppRouter { + /// Creates a new `AppRouter` with empty route tables for all HTTP methods. + #[must_use] + pub fn new() -> Self { + let mut router = Self { + routes: HashMap::new(), + }; + + for method in [ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::HEAD, + Method::OPTIONS, + Method::CONNECT, + Method::PATCH, + Method::TRACE, + ] { + router.routes.insert(method, wayfind::Router::new()); + } + + router + } + + /// Registers a constraint to all route tables. + pub fn constraint(&mut self) { + for router in self.routes.values_mut() { + router.constraint::().unwrap(); + } + } + + /// Adds a new route with the specified method, path, and handler. + pub fn route(&mut self, method: Method, path: &str, handler: H) + where + H: Handler + Send + Sync + 'static, + { + let handler: ArcHandler = Arc::new(move |req, state| { + let handler = handler.clone(); + Box::pin(async move { handler.call(req, state).await }) + }); + + if let Some(router) = self.routes.get_mut(&method) { + router.insert(path, handler).unwrap(); + } else { + let mut new_router = wayfind::Router::new(); + new_router.insert(path, handler).unwrap(); + self.routes.insert(method, new_router); + } + } + + /// Handles an incoming request by routing it to the appropriate handler. + pub async fn handle(&self, mut req: AppRequest, state: SharedAppState) -> AppResponse { + let method = req.method(); + let path = req.uri().path(); + + let Ok(path) = wayfind::Path::new(path) else { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found"))) + .unwrap(); + }; + + let Some(router) = self.routes.get(method) else { + return StatusCode::METHOD_NOT_ALLOWED.into_response(); + }; + + let Ok(Some(search)) = router.search(&path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + + let handler = &search.data.value; + let parameters: Vec<(String, String)> = search + .parameters + .into_iter() + .map(|p| (p.key.to_string(), p.value.to_string())) + .collect(); + + req.extensions_mut().insert(Arc::new(parameters)); + handler(req, state).await + } +} + +impl Default for AppRouter { + fn default() -> Self { + Self::new() + } +} diff --git a/examples/oci/src/routes.rs b/examples/oci/src/routes.rs new file mode 100644 index 00000000..51907765 --- /dev/null +++ b/examples/oci/src/routes.rs @@ -0,0 +1,6 @@ +#![allow(clippy::unused_async)] + +pub mod blob; +pub mod manifest; +pub mod root; +pub mod tags; diff --git a/examples/oci/src/routes/blob.rs b/examples/oci/src/routes/blob.rs new file mode 100644 index 00000000..87e9ab67 --- /dev/null +++ b/examples/oci/src/routes/blob.rs @@ -0,0 +1,202 @@ +use crate::{ + extract::{body::Body, method::Method, path::Path, query::Query}, + response::{AppResponse, IntoResponse}, + state::{AppStateError, SharedAppState}, + types::digest::{Digest, DigestError}, +}; +use bytes::Bytes; +use http::{ + header::{CONTENT_LENGTH, CONTENT_TYPE}, + Response, StatusCode, +}; +use http_body_util::Full; +use serde_json::json; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum BlobError { + #[error(transparent)] + AppStateError(#[from] AppStateError), + + #[error(transparent)] + DigestError(#[from] DigestError), + + #[error("Digest mismatch. Expected: {expected} - Actual: {actual}")] + DigestMismatch { expected: String, actual: String }, + + #[error("Missing digest parameter")] + MissingDigestParameter, + + #[error("Invalid upload reference")] + InvalidUploadReference, +} + +impl BlobError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::AppStateError(err) => err.status_code(), + Self::DigestError(err) => err.status_code(), + Self::DigestMismatch { .. } + | Self::MissingDigestParameter + | Self::InvalidUploadReference => StatusCode::BAD_REQUEST, + } + } +} + +impl IntoResponse for BlobError { + fn into_response(self) -> AppResponse { + let status = self.status_code(); + let body = json!({ + "error": { + "code": status.as_u16(), + "message": self.to_string() + } + }); + + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap() + } +} + +pub async fn handle_blob_pull( + state: SharedAppState, + Method(method): Method, + Path((name, digest)): Path<(String, String)>, +) -> Result { + tracing::info!( + oci = "end-2", + path = "/v2/{*name:name}/blobs/{digest}", + method = %method, + name = %name, + digest = %digest, + "Handling request" + ); + + let digest = Digest::try_from(digest.as_ref())?; + let blob = state.get_blob(&name, &digest)?; + + if method == http::Method::GET { + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONTENT_LENGTH, blob.len().to_string()) + .body(Full::new(Bytes::from(blob))) + .unwrap()) + } else { + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONTENT_LENGTH, blob.len().to_string()) + .body(Full::new(Bytes::new())) + .unwrap()) + } +} + +pub async fn handle_blob_push_post( + state: SharedAppState, + Method(method): Method, + Path(name): Path, + Query(query): Query, + Body(body): Body, +) -> Result { + if let Some(digest) = query.get("digest") { + tracing::info!( + oci = "end-4b", + path = "/v2/{*name:name}/blobs/uploads?digest={digest}", + method = %method, + name = %name, + digest = %digest, + "Handling request" + ); + + let digest = Digest::try_from(digest.as_ref())?; + let actual_digest = Digest::sha256(&body); + if actual_digest != digest { + return Err(BlobError::DigestMismatch { + expected: digest.to_string(), + actual: actual_digest.to_string(), + }); + } + + state.add_blob(name.clone(), digest.clone(), body.to_vec()); + + Ok(Response::builder() + .status(StatusCode::CREATED) + .header("Location", format!("/v2/{name}/blobs/{digest}")) + .body(Full::new(Bytes::new())) + .unwrap()) + } else { + tracing::info!( + oci = "end-4a", + path = "/v2/{*name:name}/blobs/uploads", + method = %method, + name = %name, + "Handling request" + ); + + let upload_id = state.start_upload(name.clone()); + + Ok(Response::builder() + .status(StatusCode::ACCEPTED) + .header("Location", format!("/v2/{name}/blobs/uploads/{upload_id}")) + .body(Full::new(Bytes::new())) + .unwrap()) + } +} + +pub async fn handle_blob_push_put( + state: SharedAppState, + Method(method): Method, + Path((name, reference)): Path<(String, String)>, + Query(query): Query, + Body(body): Body, +) -> Result { + tracing::info!( + oci = "end-6", + path = "/v2/{*name:name}/blobs/uploads/{reference}", + method = %method, + name = %name, + reference = %reference, + "Handling request" + ); + + let digest = query + .get("digest") + .ok_or(BlobError::MissingDigestParameter)?; + let digest = Digest::try_from(digest.as_ref())?; + + let upload_id = Uuid::parse_str(&reference).map_err(|_| BlobError::InvalidUploadReference)?; + state.update_upload(upload_id, &body)?; + state.complete_upload(upload_id, digest.clone())?; + + Ok(Response::builder() + .status(StatusCode::CREATED) + .header("Location", format!("/v2/{name}/blobs/{digest}")) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Full::new(Bytes::new())) + .unwrap()) +} + +pub async fn handle_blob_delete( + state: SharedAppState, + Method(method): Method, + Path((name, digest)): Path<(String, String)>, +) -> Result { + tracing::info!( + oci = "end-10", + path = "/v2/{*name:name}/blobs/{digest}", + method = %method, + name = %name, + digest = %digest, + "Handling request" + ); + + let digest = Digest::try_from(digest.as_ref())?; + state.delete_blob(&name, &digest)?; + Ok(StatusCode::ACCEPTED) +} diff --git a/examples/oci/src/routes/manifest.rs b/examples/oci/src/routes/manifest.rs new file mode 100644 index 00000000..caf69b29 --- /dev/null +++ b/examples/oci/src/routes/manifest.rs @@ -0,0 +1,124 @@ +use crate::{ + extract::{body::Body, method::Method, path::Path}, + response::{AppResponse, IntoResponse}, + state::{AppStateError, SharedAppState}, + types::digest::Digest, +}; +use bytes::Bytes; +use http::{ + header::{CONTENT_LENGTH, CONTENT_TYPE}, + Response, StatusCode, +}; +use http_body_util::Full; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ManifestError { + #[error(transparent)] + AppStateError(#[from] AppStateError), +} + +impl ManifestError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::AppStateError(err) => err.status_code(), + } + } +} + +impl IntoResponse for ManifestError { + fn into_response(self) -> AppResponse { + let status = self.status_code(); + let body = json!({ + "error": { + "code": status.as_u16(), + "message": self.to_string() + } + }); + + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap() + } +} + +pub async fn handle_manifest_pull( + state: SharedAppState, + Method(method): Method, + Path((name, reference)): Path<(String, String)>, +) -> Result { + tracing::info!( + oci = "end-3", + path = "/v2/{*name:name}/manifests/{reference}", + method = %method, + name = %name, + reference = %reference, + "Handling request" + ); + + let manifest = state.get_manifest(&name, &reference)?; + + if method == http::Method::GET { + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/vnd.oci.image.manifest.v1+json") + .header(CONTENT_LENGTH, manifest.len().to_string()) + .body(Full::new(Bytes::from(manifest))) + .unwrap()) + } else { + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/vnd.oci.image.manifest.v1+json") + .header(CONTENT_LENGTH, manifest.len().to_string()) + .body(Full::new(Bytes::new())) + .unwrap()) + } +} + +pub async fn handle_manifest_put( + state: SharedAppState, + Method(method): Method, + Path((name, reference)): Path<(String, String)>, + Body(body): Body, +) -> Result { + tracing::info!( + oci = "end-7", + path = "/v2/{*name:name}/manifests/{reference}", + method = %method, + name = %name, + reference = %reference, + "Handling request" + ); + + let digest = Digest::sha256(&body); + state.add_manifest(name, reference, &digest, &body, None); + + Ok(Response::builder() + .status(StatusCode::CREATED) + .header(CONTENT_TYPE, "application/vnd.oci.image.manifest.v1+json") + .header(CONTENT_LENGTH, body.len().to_string()) + .body(Full::new(Bytes::new())) + .unwrap()) +} + +pub async fn handle_manifest_delete( + state: SharedAppState, + Method(method): Method, + Path((name, reference)): Path<(String, String)>, +) -> Result { + tracing::info!( + oci = "end-7", + path = "/v2/{*name:name}/manifests/{reference}", + method = %method, + name = %name, + reference = %reference, + "Handling request" + ); + + state.delete_manifest(&name, &reference)?; + Ok(StatusCode::ACCEPTED) +} diff --git a/examples/oci/src/routes/root.rs b/examples/oci/src/routes/root.rs new file mode 100644 index 00000000..6decf1e4 --- /dev/null +++ b/examples/oci/src/routes/root.rs @@ -0,0 +1,13 @@ +use crate::{extract::method::Method, response::IntoResponse}; +use http::StatusCode; + +pub async fn handle_root_get(Method(method): Method) -> impl IntoResponse { + tracing::info!( + oci = "end-1", + path = "/v2", + method = %method, + "Handling request" + ); + + StatusCode::OK +} diff --git a/examples/oci/src/routes/tags.rs b/examples/oci/src/routes/tags.rs new file mode 100644 index 00000000..58c3a5ce --- /dev/null +++ b/examples/oci/src/routes/tags.rs @@ -0,0 +1,69 @@ +use crate::{ + extract::{method::Method, path::Path}, + response::{AppResponse, IntoResponse}, + state::{AppStateError, SharedAppState}, +}; +use bytes::Bytes; +use http::{header::CONTENT_TYPE, Response, StatusCode}; +use http_body_util::Full; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error(transparent)] + AppStateError(#[from] AppStateError), +} + +impl RegistryError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::AppStateError(err) => err.status_code(), + } + } +} + +impl IntoResponse for RegistryError { + fn into_response(self) -> AppResponse { + let status = self.status_code(); + let body = json!({ + "error": { + "code": status.as_u16(), + "message": self.to_string() + } + }); + + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap() + } +} + +pub async fn handle_tags_get( + state: SharedAppState, + Method(method): Method, + Path(name): Path, +) -> Result { + tracing::info!( + oci = "end-8a", + path = "/v2/{*name:name}/tags/list", + method = %method, + name = %name, + "Handling request" + ); + + let tags = state.list_tags(&name)?; + let response = json!({ + "name": name, + "tags": tags + }); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(response.to_string()))) + .unwrap()) +} diff --git a/examples/oci/src/state.rs b/examples/oci/src/state.rs new file mode 100644 index 00000000..edf965ba --- /dev/null +++ b/examples/oci/src/state.rs @@ -0,0 +1,274 @@ +use crate::types::digest::Digest; +use dashmap::DashMap; +use http::StatusCode; +use std::sync::Arc; +use thiserror::Error; +use uuid::Uuid; + +pub type SharedAppState = Arc; + +#[derive(Debug, Error)] +pub enum AppStateError { + #[error("Repository not found: {0}")] + RepositoryNotFound(String), + + #[error("Blob not found: {digest} in repository {repository}")] + BlobNotFound { repository: String, digest: Digest }, + + #[error("Upload not found: {0}")] + UploadNotFound(Uuid), + + #[error("Digest mismatch. Expected: {expected} - Actual: {actual}")] + DigestMismatch { expected: String, actual: String }, + + #[error("Manifest not found: {reference} in repository {repository}")] + ManifestNotFound { + repository: String, + reference: String, + }, +} + +impl AppStateError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::RepositoryNotFound(_) + | Self::BlobNotFound { .. } + | Self::UploadNotFound(_) + | Self::DigestMismatch { .. } + | Self::ManifestNotFound { .. } => StatusCode::NOT_FOUND, + } + } +} + +pub struct AppState { + repositories: DashMap, + uploads: DashMap, +} + +#[derive(Debug, Clone)] +pub struct Repository { + pub blobs: DashMap>, + pub manifests: DashMap, + pub tags: DashMap, +} + +impl Repository { + fn new() -> Self { + Self { + blobs: DashMap::new(), + manifests: DashMap::new(), + tags: DashMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Manifest { + pub digest: Digest, + pub content: Vec, + pub subject: Option, +} + +#[derive(Debug, Clone)] +pub struct Upload { + pub repository: String, + pub data: Vec, +} + +impl AppState { + #[must_use] + pub fn new() -> Self { + Self { + repositories: DashMap::new(), + uploads: DashMap::new(), + } + } + + #[must_use] + pub fn repository_exists(&self, repository: &str) -> bool { + self.repositories.contains_key(repository) + } + + pub fn get_blob(&self, repository: &str, digest: &Digest) -> Result, AppStateError> { + let repo = self + .repositories + .get(repository) + .ok_or_else(|| AppStateError::RepositoryNotFound(repository.to_string()))?; + + repo.blobs + .get(digest) + .map(|blob| blob.clone()) + .ok_or_else(|| AppStateError::BlobNotFound { + repository: repository.to_string(), + digest: digest.clone(), + }) + } + + pub fn add_blob(&self, repository: String, digest: Digest, blob: Vec) { + self.repositories + .entry(repository) + .or_insert_with(Repository::new) + .blobs + .insert(digest, blob); + } + + #[must_use] + pub fn start_upload(&self, repository: String) -> Uuid { + let upload_id = Uuid::new_v4(); + self.uploads.insert( + upload_id, + Upload { + repository, + data: vec![], + }, + ); + + upload_id + } + + pub fn update_upload(&self, upload_id: Uuid, chunk: &[u8]) -> Result { + self.uploads + .get_mut(&upload_id) + .map(|mut upload| { + upload.data.extend_from_slice(chunk); + upload.data.len() + }) + .ok_or(AppStateError::UploadNotFound(upload_id)) + } + + pub fn complete_upload(&self, upload_id: Uuid, digest: Digest) -> Result<(), AppStateError> { + let (_, upload) = self + .uploads + .remove(&upload_id) + .ok_or(AppStateError::UploadNotFound(upload_id))?; + + let actual = Digest::sha256(&upload.data); + if actual != digest { + return Err(AppStateError::DigestMismatch { + expected: digest.to_string(), + actual: actual.to_string(), + }); + } + + self.add_blob(upload.repository, digest, upload.data); + Ok(()) + } + + pub fn add_manifest( + &self, + repository: String, + reference: String, + digest: &Digest, + content: &[u8], + subject: Option, + ) { + let repo = self + .repositories + .entry(repository) + .or_insert_with(Repository::new); + + repo.manifests.insert( + digest.to_string(), + Manifest { + digest: digest.clone(), + content: content.to_vec(), + subject, + }, + ); + + if Digest::try_from(reference.as_str()).is_err() { + repo.tags.insert(reference, digest.to_string()); + } + } + + pub fn get_manifest( + &self, + repository: &str, + reference: &str, + ) -> Result, AppStateError> { + let repo = self + .repositories + .get(repository) + .ok_or_else(|| AppStateError::RepositoryNotFound(repository.to_string()))?; + + if let Some(manifest) = repo.manifests.get(reference) { + return Ok(manifest.content.clone()); + } + + if let Some(digest) = repo.tags.get(reference) { + if let Some(manifest) = repo.manifests.get(digest.value()) { + return Ok(manifest.content.clone()); + } + } + + if Digest::try_from(reference).is_err() { + for manifest in &repo.manifests { + if manifest.key() == reference { + return Ok(manifest.value().content.clone()); + } + } + } + + Err(AppStateError::ManifestNotFound { + repository: repository.to_string(), + reference: reference.to_string(), + }) + } + + pub fn list_tags(&self, repository: &str) -> Result, AppStateError> { + self.repositories + .get(repository) + .map(|repo| repo.tags.iter().map(|t| t.key().clone()).collect()) + .ok_or_else(|| AppStateError::RepositoryNotFound(repository.to_string())) + } + + pub fn delete_blob(&self, repository: &str, digest: &Digest) -> Result<(), AppStateError> { + self.repositories + .get(repository) + .ok_or_else(|| AppStateError::RepositoryNotFound(repository.to_string()))? + .blobs + .remove(digest); + + Ok(()) + } + + pub fn delete_manifest(&self, repository: &str, reference: &str) -> Result<(), AppStateError> { + let repo = self + .repositories + .get(repository) + .ok_or_else(|| AppStateError::RepositoryNotFound(repository.to_string()))?; + + if let Some(digest_str) = repo.tags.remove(reference) { + repo.manifests.remove(&digest_str.1); + } else { + repo.manifests.remove(reference); + } + + drop(repo); + Ok(()) + } + + pub fn get_referrers( + &self, + repository: &str, + digest: &Digest, + ) -> Result, AppStateError> { + self.repositories + .get(repository) + .map(|repo| { + repo.manifests + .iter() + .filter(|r| r.value().subject.as_ref() == Some(digest)) + .map(|r| r.value().clone()) + .collect() + }) + .ok_or_else(|| AppStateError::RepositoryNotFound(repository.to_string())) + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} diff --git a/examples/oci/src/types.rs b/examples/oci/src/types.rs new file mode 100644 index 00000000..e7c41608 --- /dev/null +++ b/examples/oci/src/types.rs @@ -0,0 +1 @@ +pub mod digest; diff --git a/examples/oci/src/types/digest.rs b/examples/oci/src/types/digest.rs new file mode 100644 index 00000000..92fbfa3d --- /dev/null +++ b/examples/oci/src/types/digest.rs @@ -0,0 +1,142 @@ +use http::StatusCode; +use sha2::{Digest as ShaDigest, Sha256, Sha512}; +use std::fmt::Display; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DigestError { + #[error("Invalid digest format")] + InvalidFormat, + + #[error("Invalid digest algorithm")] + InvalidAlgorithm, + + #[error("Invalid hash length")] + InvalidHashLength, + + #[error("Invalid hash characters")] + InvalidHashCharacters, +} + +impl DigestError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::InvalidFormat + | Self::InvalidAlgorithm + | Self::InvalidHashLength + | Self::InvalidHashCharacters => StatusCode::BAD_REQUEST, + } + } +} + +/// Represents a content digest as defined in the OCI Image Specification. +/// +/// +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct Digest { + pub algorithm: DigestAlgorithm, + pub hash: String, +} + +impl Digest { + /// Computes the SHA256 digest for the given bytes. + #[must_use] + pub fn sha256(data: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(data); + + Self { + algorithm: DigestAlgorithm::Sha256, + hash: format!("{:x}", hasher.finalize()), + } + } + + /// Computes the SHA512 digest for the given data + #[must_use] + pub fn sha512(data: &[u8]) -> Self { + let mut hasher = Sha512::new(); + hasher.update(data); + + Self { + algorithm: DigestAlgorithm::Sha512, + hash: format!("{:x}", hasher.finalize()), + } + } +} + +impl TryFrom<&str> for Digest { + type Error = DigestError; + + fn try_from(value: &str) -> Result { + let (algorithm, hash) = value.split_once(':').ok_or(DigestError::InvalidFormat)?; + + let algorithm = DigestAlgorithm::try_from(algorithm)?; + algorithm.validate_hash(hash)?; + + Ok(Self { + algorithm, + hash: hash.to_string(), + }) + } +} + +impl Display for Digest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.algorithm, self.hash) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum DigestAlgorithm { + Sha256, + Sha512, +} + +impl DigestAlgorithm { + /// Validates the hash string for the given algorithm. + pub fn validate_hash(&self, hash: &str) -> Result<(), DigestError> { + let expected_length = match self { + Self::Sha256 => 64, + Self::Sha512 => 128, + }; + + if hash.len() != expected_length { + return Err(DigestError::InvalidHashLength); + } + + if !hash + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + { + return Err(DigestError::InvalidHashCharacters); + } + + Ok(()) + } +} + +impl Display for DigestAlgorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Sha256 => "sha256", + Self::Sha512 => "sha512", + } + ) + } +} + +impl TryFrom<&str> for DigestAlgorithm { + type Error = DigestError; + + fn try_from(value: &str) -> Result { + match value { + "sha256" => Ok(Self::Sha256), + "sha512" => Ok(Self::Sha512), + _ => Err(DigestError::InvalidAlgorithm), + } + } +} diff --git a/flake.nix b/flake.nix index e9b3d712..db49fcc7 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ (self: super: { cargo-codspeed = pkgs.callPackage ./nix/pkgs/cargo-codspeed { }; cargo-insta = pkgs.callPackage ./nix/pkgs/cargo-insta { }; + oci-distribution-spec-conformance = pkgs.callPackage ./nix/pkgs/oci-distribution-spec-conformance { }; }) ]; }; @@ -50,9 +51,22 @@ name = "wayfind-shell"; NIX_PATH = "nixpkgs=${nixpkgs.outPath}"; + RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache"; CARGO_INCREMENTAL = "0"; + OCI_ROOT_URL = "http://127.0.0.1:8000"; + OCI_NAMESPACE = "myorg/myrepo"; + OCI_CROSSMOUNT_NAMESPACE = "myorg/other"; + OCI_USERNAME = "myuser"; + OCI_PASSWORD = "mypass"; + OCI_TEST_PULL = 1; + OCI_TEST_PUSH = 0; + OCI_TEST_CONTENT_DISCOVERY = 0; + OCI_TEST_CONTENT_MANAGEMENT = 0; + OCI_DEBUG = 1; + OCI_HIDE_SKIPPED_WORKFLOWS = 1; + buildInputs = with pkgs; [ # Rust (rust-bin.stable."1.80.1".minimal.override { @@ -75,6 +89,9 @@ # Release cargo-semver-checks + # OCI + oci-distribution-spec-conformance + # Nix nixfmt-rfc-style nixd @@ -166,6 +183,32 @@ sccache ]; }; + + # nix develop .#oci + oci = pkgs.mkShell { + name = "wayfind-oci-shell"; + + RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache"; + CARGO_INCREMENTAL = "0"; + + OCI_ROOT_URL = "http://127.0.0.1:8000"; + OCI_NAMESPACE = "myorg/myrepo"; + OCI_CROSSMOUNT_NAMESPACE = "myorg/other"; + OCI_USERNAME = "myuser"; + OCI_PASSWORD = "mypass"; + OCI_TEST_PULL = 1; + OCI_TEST_PUSH = 0; + OCI_TEST_CONTENT_DISCOVERY = 0; + OCI_TEST_CONTENT_MANAGEMENT = 0; + OCI_DEBUG = 1; + OCI_HIDE_SKIPPED_WORKFLOWS = 1; + + buildInputs = with pkgs; [ + (rust-bin.stable."1.80.1".minimal) + sccache + oci-distribution-spec-conformance + ]; + }; }; } ); diff --git a/nix/pkgs/cargo-insta/default.nix b/nix/pkgs/cargo-insta/default.nix index da505b8e..0a48baed 100644 --- a/nix/pkgs/cargo-insta/default.nix +++ b/nix/pkgs/cargo-insta/default.nix @@ -20,7 +20,7 @@ rustPlatform.buildRustPackage rec { description = "A Cargo subcommand for snapshot testing."; mainProgram = "cargo-insta"; homepage = "https://github.com/mitsuhiko/insta"; - changelog = "https://github.com/mitsuhiko/insta/blob/${version}/CHANGELOG.md"; + changelog = "https://github.com/mitsuhiko/insta/releases"; license = licenses.asl20; platforms = platforms.all; }; diff --git a/nix/pkgs/oci-distribution-spec-conformance/default.nix b/nix/pkgs/oci-distribution-spec-conformance/default.nix new file mode 100644 index 00000000..a96a2bb4 --- /dev/null +++ b/nix/pkgs/oci-distribution-spec-conformance/default.nix @@ -0,0 +1,38 @@ +{ + lib, + buildGoModule, + fetchFromGitHub, +}: +buildGoModule rec { + pname = "oci-distribution-spec-conformance"; + version = "1.1.0"; + + src = fetchFromGitHub { + owner = "opencontainers"; + repo = "distribution-spec"; + rev = "v${version}"; + hash = "sha256-GL28YUwDRicxS65E7SDR/Q3tJOWN4iwgq4AGBjwVPzA="; + }; + + sourceRoot = "source/conformance"; + vendorHash = "sha256-5gn9RpjCALZB/GFjlJHDqPs2fIHl7NJr5QjPmsLnnO4="; + + CGO_ENABLED = 0; + + postInstall = '' + go test -c ./... -o oci-distribution-spec-conformance + mkdir -p $out/bin + mv oci-distribution-spec-conformance $out/bin + ''; + + doCheck = false; + + meta = with lib; { + description = " OCI Distribution Specification Conformance Tests"; + mainProgram = "oci-distribution-spec-conformance"; + homepage = "https://opencontainers.org"; + changelog = "https://github.com/opencontainers/distribution-spec/releases"; + license = licenses.asl20; + platforms = platforms.all; + }; +} diff --git a/src/node/search.rs b/src/node/search.rs index 8d6043a4..0dbdac4b 100644 --- a/src/node/search.rs +++ b/src/node/search.rs @@ -16,6 +16,8 @@ pub struct Match<'router, 'path, T> { /// /// The key of the parameter is tied to the lifetime of the router, since it is a ref to the prefix of a given node. /// Meanwhile, the value is extracted from the path. +/// +/// TODO: Consider simply storing these as Strings. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Parameter<'router, 'path> { pub key: &'router str,