diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 68037a1..5e0dbd3 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -46,8 +46,60 @@ jobs: mode=dry-run fi + package_name=h3x + package_version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json, sys; print(json.load(sys.stdin)["packages"][0]["version"])')" + + crate_state="$( + python3 - <<'PY' "$package_name" "$package_version" + import sys + import urllib.error + import urllib.request + + name, version = sys.argv[1], sys.argv[2] + headers = {"User-Agent": "genmeta h3x publish workflow"} + version_url = f"https://crates.io/api/v1/crates/{name}/{version}" + version_request = urllib.request.Request(version_url, headers=headers) + try: + with urllib.request.urlopen(version_request, timeout=20) as response: + if response.status == 200: + print("published_version") + else: + raise SystemExit(f"unexpected crates.io status for {name} {version}: {response.status}") + except urllib.error.HTTPError as error: + if error.code == 404: + crate_url = f"https://crates.io/api/v1/crates/{name}" + crate_request = urllib.request.Request(crate_url, headers=headers) + try: + with urllib.request.urlopen(crate_request, timeout=20) as response: + if response.status == 200: + print("missing_version") + else: + raise SystemExit(f"unexpected crates.io crate status for {name}: {response.status}") + except urllib.error.HTTPError as crate_error: + if crate_error.code == 404: + print("missing_crate") + else: + raise + else: + raise + PY + )" + + if [[ "$crate_state" == "published_version" ]]; then + echo "skip $package_name $package_version (already on crates.io)" + exit 0 + fi + + if [[ "$crate_state" == "missing_crate" ]]; then + echo "skip $package_name $package_version (crate not yet initialized on crates.io)" + exit 0 + fi + if [[ "$mode" == "dry-run" ]]; then + echo "dry-run $package_name $package_version" cargo publish --dry-run - else - cargo publish + exit 0 fi + + echo "publish $package_name $package_version" + cargo publish diff --git a/Cargo.toml b/Cargo.toml index 1019350..31cdbda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "h3x" description = "Peer-to-peer DHTTP/3 transport over QUIC" -version = "0.3.1" +version = "0.4.0" edition = "2024" readme = "README.md" repository = "https://github.com/genmeta/h3x" @@ -28,7 +28,7 @@ tokio-util = { version = "0.7", features = ["codec", "io", "rt"] } tracing = "0.1" tower-service = "0.3" -dhttp-identity = "0.1.0" +dhttp-identity = "0.2.0" # feature dquic dquic = { version = "0.5.1", optional = true } diff --git a/README.md b/README.md index eb68dc3..dfa65e6 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,26 @@ Peer-to-peer DHTTP/3 transport over QUIC, implemented in Rust. h3x includes `dquic` as its built-in QUIC backend (feature `dquic`, enabled by default). Wrap a `QuicEndpoint` in an `H3Endpoint` to get HTTP/3 client and server semantics on top of QUIC. ```rust,no_run +use bytes::Bytes; use h3x::{dquic::QuicEndpoint, endpoint::H3Endpoint}; +use http_body_util::{BodyExt, Empty}; #[tokio::main] async fn main() -> Result<(), Box> { let endpoint = H3Endpoint::new(QuicEndpoint::new().await); - let mut response = endpoint.get("https://example.com:4433/hello".parse()?).await?; + let connection = endpoint.connect("example.com:4433".parse()?).await?; + let request = http::Request::get("https://example.com:4433/hello") + .body(Empty::::new())?; + let response = connection.execute_hyper_request(request).await?; assert_eq!(response.status(), http::StatusCode::OK); - println!("{}", response.read_to_string().await?); + let body = response.into_body().collect().await?.to_bytes(); + println!("{}", String::from_utf8_lossy(&body)); Ok(()) } ``` -```rust,no_run +```rust,ignore use std::sync::Arc; use axum::{Router as AxumRouter, routing::get}; use h3x::{ @@ -43,7 +49,7 @@ use h3x::{ #[tokio::main] async fn main() -> Result<(), Box> { let identity = Arc::new(Identity { - name: Name::from_static("localhost")?, + name: "localhost".parse()?, certs: todo!("load your certificate chain"), key: todo!("load your private key"), ocsp: Arc::new(None), @@ -73,7 +79,7 @@ h3x provides adapters to bridge the Tower / hyper service ecosystem into DHTTP/3 > - `h3x::hyper::upgrade` — stream takeover for Extended CONNECT tunnels (instead of `hyper::upgrade`) > - `h3x::hyper::ext::Protocol` — protocol indication in CONNECT requests (instead of `hyper::ext::Protocol`) -```rust,no_run +```rust,ignore use std::sync::Arc; use axum::{Router as AxumRouter, routing::get}; use h3x::{ @@ -89,7 +95,7 @@ async fn main() -> Result<(), Box> { let service = TowerService(router.into_service()); let identity = Arc::new(Identity { - name: Name::from_static("localhost")?, + name: "localhost".parse()?, certs: todo!("load your certificate chain"), key: todo!("load your private key"), ocsp: Arc::new(None), @@ -107,15 +113,20 @@ async fn main() -> Result<(), Box> { ``` ```rust,no_run +use bytes::Bytes; use h3x::{dquic::QuicEndpoint, endpoint::H3Endpoint}; +use http_body_util::{BodyExt, Empty}; #[tokio::main] async fn main() -> Result<(), Box> { let endpoint = H3Endpoint::new(QuicEndpoint::new().await); - let mut response = endpoint.get("https://example.com:4433/hello".parse()?).await?; + let connection = endpoint.connect("example.com:4433".parse()?).await?; + let request = http::Request::get("https://example.com:4433/hello") + .body(Empty::::new())?; + let response = connection.execute_hyper_request(request).await?; assert_eq!(response.status(), http::StatusCode::OK); - let body = response.read_to_bytes().await?; + let body = response.into_body().collect().await?.to_bytes(); println!("{}", String::from_utf8_lossy(&body)); Ok(()) } diff --git a/src/connection.rs b/src/connection.rs index d36028f..806b17f 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -713,21 +713,17 @@ pub(crate) mod tests { use futures::{Sink, SinkExt, future::BoxFuture, stream::Stream}; use tracing::Instrument; - #[cfg(feature = "dquic")] - use super::ConnectionBuilder; - use super::{Connection, ConnectionState, LifecycleExt, StreamError}; - #[cfg(feature = "dquic")] + use super::{Connection, ConnectionBuilder, ConnectionState, LifecycleExt, StreamError}; use crate::{ codec::{BoxPeekableStreamReader, BoxStreamWriter}, - dhttp::settings::{MaxFieldSectionSize, Settings}, - protocol::{ProductProtocol, Protocol, StreamVerdict}, - }; - use crate::{ + dhttp::settings::Settings, error::{Code, H3MessageError, H3MissingSettings}, - protocol::Protocols, + protocol::{Protocol, Protocols, StreamVerdict}, quic::{self, ConnectionError, ResetStreamExt, StopStreamExt}, varint::VarInt, }; + #[cfg(feature = "dquic")] + use crate::{dhttp::settings::MaxFieldSectionSize, protocol::ProductProtocol}; #[derive(Debug)] pub(crate) struct TestLocalAuthority; @@ -879,6 +875,7 @@ pub(crate) mod tests { .store(true, std::sync::atomic::Ordering::Relaxed); } + #[cfg(feature = "dquic")] pub(crate) fn disable_stream_ops(&self) { self.state .stream_ops_available @@ -1049,11 +1046,9 @@ pub(crate) mod tests { } /// Local mock protocol for builder tests. - #[cfg(feature = "dquic")] #[derive(Debug)] struct MockProtocol; - #[cfg(feature = "dquic")] impl Protocol for MockProtocol { fn accept_uni<'a>( &'a self, diff --git a/src/dhttp.rs b/src/dhttp.rs index 3572443..490cfbb 100644 --- a/src/dhttp.rs +++ b/src/dhttp.rs @@ -5,4 +5,5 @@ pub mod message; pub mod protocol; pub mod settings; pub mod stream; +#[cfg(feature = "webtransport")] pub mod webtransport; diff --git a/src/dhttp/settings.rs b/src/dhttp/settings.rs index 5b4a231..fe36654 100644 --- a/src/dhttp/settings.rs +++ b/src/dhttp/settings.rs @@ -73,8 +73,18 @@ impl H3ConnectionError for InvalidSettingValue { const fn is_boolean_setting(id: VarInt) -> bool { let id = id.into_inner(); id == crate::extended_connect::settings::EnableConnectProtocol::ID.into_inner() - || id == crate::dhttp::webtransport::settings::EnableWebTransport::ID.into_inner() || id == crate::dhttp::datagram::settings::H3Datagram::ID.into_inner() + || is_webtransport_boolean_setting(id) +} + +#[cfg(feature = "webtransport")] +const fn is_webtransport_boolean_setting(id: u64) -> bool { + id == crate::dhttp::webtransport::settings::EnableWebTransport::ID.into_inner() +} + +#[cfg(not(feature = "webtransport"))] +const fn is_webtransport_boolean_setting(_id: u64) -> bool { + false } impl DecodeFrom for Setting { @@ -354,10 +364,12 @@ mod tests { use tokio::io::AsyncWriteExt; use super::*; + #[cfg(feature = "webtransport")] + use crate::dhttp::webtransport::settings::EnableWebTransport; use crate::{ codec::{DecodeError, DecodeExt, EncodeExt}, connection, - dhttp::{datagram::settings::H3Datagram, webtransport::settings::EnableWebTransport}, + dhttp::datagram::settings::H3Datagram, extended_connect::settings::EnableConnectProtocol, quic, varint::VarInt, @@ -513,16 +525,20 @@ mod tests { #[test] fn boolean_setting_validation_uses_new_owner_modules() { - for id in [ - EnableConnectProtocol::ID, - EnableWebTransport::ID, - H3Datagram::ID, - ] { + for id in [EnableConnectProtocol::ID, H3Datagram::ID] { let err = Setting::new(id, VarInt::from_u32(2)) .check() .expect_err("boolean setting value 2 must be rejected"); assert!(matches!(err, InvalidSettingValue::BoolSetting { .. })); } + + #[cfg(feature = "webtransport")] + { + let err = Setting::new(EnableWebTransport::ID, VarInt::from_u32(2)) + .check() + .expect_err("webtransport boolean setting value 2 must be rejected"); + assert!(matches!(err, InvalidSettingValue::BoolSetting { .. })); + } } #[test] @@ -596,6 +612,7 @@ mod tests { ); assert!(settings.enable_connect_protocol()); assert!(settings.h3_datagram()); + #[cfg(feature = "webtransport")] assert!(!settings.enable_webtransport()); let borrowed: Vec<_> = (&settings).into_iter().collect(); @@ -754,7 +771,7 @@ mod tests { let mut invalid = BufList::new(); invalid - .encode_one(Setting::new(EnableWebTransport::ID, VarInt::from_u32(2))) + .encode_one(Setting::new(H3Datagram::ID, VarInt::from_u32(2))) .await .expect("setting encoding into buflist is infallible"); let error = invalid @@ -765,6 +782,24 @@ mod tests { assert_h3_connection_code(error, Code::H3_SETTINGS_ERROR); } + #[cfg(feature = "webtransport")] + #[tokio::test] + async fn setting_decode_rejects_invalid_webtransport_bool_when_feature_enabled() { + let mut invalid = BufList::new(); + invalid + .encode_one(Setting::new(EnableWebTransport::ID, VarInt::from_u32(2))) + .await + .expect("setting encoding into buflist is infallible"); + + let error = invalid + .decode::() + .await + .err() + .expect("invalid webtransport boolean setting must fail to decode"); + + assert_h3_connection_code(error, Code::H3_SETTINGS_ERROR); + } + #[tokio::test] async fn settings_encode_to_frame_and_decode_payload() { let settings = Settings::from_iter([ diff --git a/src/dquic/endpoint.rs b/src/dquic/endpoint.rs index ff3a995..36c86f8 100644 --- a/src/dquic/endpoint.rs +++ b/src/dquic/endpoint.rs @@ -217,6 +217,14 @@ impl QuicEndpoint { } } +impl quic::WithLocalAuthority for QuicEndpoint { + type LocalAuthority = Identity; + + async fn local_authority(&self) -> Result, quic::ConnectionError> { + Ok(self.identity().as_deref().cloned()) + } +} + #[bon] impl QuicEndpoint { #[builder] @@ -968,12 +976,21 @@ impl QuicEndpoint { mod tests { use std::any::TypeId; + use dhttp_identity::identity::LocalAuthority as IdentityLocalAuthority; use rustls::{RootCertStore, client::WebPkiServerVerifier, pki_types::PrivateKeyDer}; use super::*; - use crate::dquic::{cert::handy::ToCertificate, resolver::handy::SystemResolver}; + use crate::{ + dquic::{ + cert::handy::{ToCertificate, ToPrivateKey}, + resolver::handy::SystemResolver, + }, + quic::WithLocalAuthority as _, + }; const CA_CERT: &[u8] = include_bytes!("../../tests/keychain/localhost/ca.cert"); + const SERVER_CERT: &[u8] = include_bytes!("../../tests/keychain/localhost/server.cert"); + const SERVER_KEY: &[u8] = include_bytes!("../../tests/keychain/localhost/server.key"); fn root_store_with_ca() -> RootCertStore { let mut store = RootCertStore::empty(); @@ -981,6 +998,14 @@ mod tests { store } + fn make_signing_identity(name: &str) -> Identity { + Identity::new( + name.parse().expect("valid identity name"), + SERVER_CERT.to_certificate(), + SERVER_KEY.to_private_key(), + ) + } + #[tokio::test] async fn test_quic_endpoint_construction() { let network = Network::builder().build(); @@ -1019,6 +1044,57 @@ mod tests { assert!(matches!(result, Err(AcceptError::ServerUnavailable))); } + #[tokio::test] + async fn local_authority_is_none_for_anonymous_endpoint() { + let endpoint = make_endpoint().await; + + let local = endpoint + .local_authority() + .await + .expect("endpoint local authority lookup should not fail"); + + assert!(local.is_none()); + } + + #[tokio::test] + async fn local_authority_returns_configured_identity() { + let identity = Arc::new(make_signing_identity("localhost")); + let endpoint = QuicEndpoint::builder() + .network(Network::builder().build()) + .resolver(Arc::new(SystemResolver)) + .identity(identity.clone()) + .client(ClientQuicConfig::default()) + .server(ServerQuicConfig::default()) + .build() + .await; + + let local = endpoint + .local_authority() + .await + .expect("endpoint local authority lookup should not fail") + .expect("configured endpoint should expose local authority"); + + assert_eq!(IdentityLocalAuthority::name(&local), identity.name.as_str()); + assert_eq!( + IdentityLocalAuthority::cert_chain(&local), + identity.cert_chain() + ); + + let signature = IdentityLocalAuthority::sign(&local, b"payload") + .await + .expect("signature"); + assert!( + IdentityLocalAuthority::verify(&local, b"payload", &signature) + .await + .expect("verification should run") + ); + assert!( + !IdentityLocalAuthority::verify(&local, b"different payload", &signature) + .await + .expect("verification should run") + ); + } + #[tokio::test] async fn test_network_locations_accessor() { // Verify that the built-in QUIC locations accessor is available.