diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index f63d84f..5324945 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -25,6 +25,12 @@ jobs: - name: Install Rust stable toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Check formatting + run: cargo fmt -- --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Test workspace run: cargo test --workspace --all-features --all-targets @@ -126,10 +132,10 @@ jobs: fi if [[ "$mode" == "dry-run" ]]; then - publish_args=(cargo publish --dry-run --locked) + publish_args=(cargo publish --dry-run) echo "dry-run publish packages: ${packages_to_publish[*]}" else - publish_args=(cargo publish --locked) + publish_args=(cargo publish) echo "publish packages: ${packages_to_publish[*]}" fi diff --git a/Cargo.toml b/Cargo.toml index 223aeeb..5a58595 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["dhttp", "identity", "home", "api", "access"] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/genmeta/dhttp" @@ -39,8 +39,8 @@ chrono = { version = "0.4", features = ["serde"] } # Keep same-repository workspace members as path dependencies. Cross-repo # release dependencies resolve through crates.io in the formal release graph. dhttp-identity = "0.2.0" -dhttp-home = { path = "home", version = "0.2.0" } -ddns = { package = "dyns", version = "0.4.0", features = [ +dhttp-home = { path = "home", version = "0.3.0" } +ddns = { package = "dyns", version = "0.5.0", features = [ "resolvers", "publishers", "h3", @@ -48,8 +48,8 @@ ddns = { package = "dyns", version = "0.4.0", features = [ "mdns", "dquic-network", ] } -h3x = { version = "0.4.0", features = [ +h3x = { version = "0.5.0", features = [ "dquic", ] } -dhttp = { path = "dhttp", version = "0.2.0" } +dhttp = { path = "dhttp", version = "0.3.0" } dhttp-access = { path = "access", version = "0.2.0" } diff --git a/access/Cargo.toml b/access/Cargo.toml index db608d2..76ff073 100644 --- a/access/Cargo.toml +++ b/access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-access" description = "Identity-aware access control primitives for DHttp" -version.workspace = true +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/access/src/db/identity.rs b/access/src/db/identity.rs index ebe7f84..d6394ec 100644 --- a/access/src/db/identity.rs +++ b/access/src/db/identity.rs @@ -1,2 +1,2 @@ -pub use dhttp_home::{DhttpHome, LocateDhttpHomeError}; +pub use dhttp_home::{DhttpHome, LoadDhttpHomeError}; pub use dhttp_identity::name::{DhttpName as Name, InvalidDhttpName as InvalidName}; diff --git a/access/src/db/mod.rs b/access/src/db/mod.rs index a8f2aa2..1b39b29 100644 --- a/access/src/db/mod.rs +++ b/access/src/db/mod.rs @@ -28,7 +28,7 @@ pub const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000; pub enum AccessDbError { #[snafu(display("failed to locate DHTTP_HOME"))] LocateDhttpHome { - source: identity::LocateDhttpHomeError, + source: identity::LoadDhttpHomeError, }, #[snafu(display("access store does not exist at `{}`", path.display()))] @@ -52,7 +52,7 @@ pub enum AccessDbError { } pub fn load_dhttp_home() -> Result { - DhttpHome::load_from_environment().context(LocateDhttpHomeSnafu) + DhttpHome::load(dhttp_home::HomeScope::User).context(LocateDhttpHomeSnafu) } pub fn access_db_path(home: &DhttpHome, identity: identity::Name<'_>) -> PathBuf { diff --git a/api/package-lock.json b/api/package-lock.json index 285f365..8811917 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@genmeta/dhttp", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@genmeta/dhttp", - "version": "0.2.0", + "version": "0.3.0", "devDependencies": { "@napi-rs/cli": "^3.3.5" } diff --git a/api/package.json b/api/package.json index ca72753..cca3b77 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@genmeta/dhttp", - "version": "0.2.0", + "version": "0.3.0", "description": "The True Internet", "license": "Apache-2.0", "homepage": "https://dhttp.net/", diff --git a/api/src/home.rs b/api/src/home.rs index d8ae20c..24701f5 100644 --- a/api/src/home.rs +++ b/api/src/home.rs @@ -14,7 +14,7 @@ pub struct IdentityProfile(dhttp::home::identity::IdentityProfile); impl DhttpHome { pub fn load() -> Result { - dhttp::home::DhttpHome::load_from_environment() + dhttp::home::DhttpHome::load(dhttp::home::HomeScope::User) .map(Self) .map_err(|error| DhttpError::from_error("home.load", error)) } diff --git a/dhttp/build.rs b/dhttp/build.rs index 115a959..88a2131 100644 --- a/dhttp/build.rs +++ b/dhttp/build.rs @@ -3,7 +3,7 @@ use std::{env, fs, path::PathBuf}; const ROOT_CA_ENV: &str = "DHTTP_ROOT_CA"; const STUN_SERVER_ENV: &str = "DHTTP_STUN_SERVER"; -const DEFAULT_STUN_SERVER: &str = "dhttp.example.net:1"; +const DEFAULT_STUN_SERVER: &str = "stun.dhttp.example.net"; const DEFAULT_ROOT_CA_PEM: &str = "\ -----BEGIN CERTIFICATE-----\n\ MIIDKTCCAhGgAwIBAgIUHNScq6R2U5QYUzxkEkNDaOJt4yMwDQYJKoZIhvcNAQEL\n\ @@ -84,7 +84,7 @@ mod tests { assert_eq!( env_or_default(&name, DEFAULT_STUN_SERVER), - "dhttp.example.net:1" + "stun.dhttp.example.net" ); } diff --git a/dhttp/src/ddns.rs b/dhttp/src/ddns.rs index 3f47405..5446ac4 100644 --- a/dhttp/src/ddns.rs +++ b/dhttp/src/ddns.rs @@ -108,6 +108,46 @@ impl DhttpDnsPlan { } type DeferredEndpointResolver = resolvers::deferred::DeferredResolver; +type EndpointH3Client = Arc>; + +#[derive(Clone)] +struct EndpointH3Clients { + resolver: EndpointH3Client, + publisher: EndpointH3Client, +} + +#[derive(Clone)] +struct EndpointH3Connector { + quic: QuicEndpoint, +} + +impl EndpointH3Connector { + fn new(quic: QuicEndpoint) -> Self { + Self { quic } + } +} + +impl crate::h3x::quic::Connect for EndpointH3Connector { + type Connection = crate::dquic::connection::Connection; + type Error = crate::dquic::ConnectError; + + async fn connect( + &self, + server: &::http::uri::Authority, + ) -> Result, Self::Error> { + crate::h3x::quic::Connect::connect(&self.quic, server).await + } +} + +impl crate::h3x::quic::WithLocalAuthority for EndpointH3Connector { + type LocalAuthority = crate::dquic::Identity; + + async fn local_authority( + &self, + ) -> Result, crate::h3x::quic::ConnectionError> { + crate::h3x::quic::WithLocalAuthority::local_authority(&self.quic).await + } +} #[derive(Debug, snafu::Snafu)] #[snafu(module(build_dhttp_network_with_dns_error))] @@ -187,12 +227,8 @@ async fn network_stun_resolver_from_plan( let operations = dns_plan.effective_ops(); let h3_resolver = if uses_h3(&operations) { let h3_underlay = network_h3_underlay(&operations, network.clone(), bind.clone()).await?; - let h3_quic = QuicEndpoint::builder() - .network(network.clone()) - .resolver(h3_underlay) - .bind(bind.clone()) - .build() - .await; + let h3_quic = + dedicated_network_h3_client_quic(network.clone(), bind.clone(), h3_underlay).await; Some(Arc::new(h3_resolver_for_network( h3_dns_server.as_ref(), h3_quic, @@ -237,10 +273,7 @@ async fn endpoint_dns_from_quic( ) -> Result<(resolvers::Resolvers, publishers::Publishers), BuildQuicEndpointWithDnsError> { let operations = dns_plan.effective_ops(); let endpoint_h3 = if uses_h3(&operations) { - let h3_underlay = endpoint_h3_underlay(&operations, endpoint).await?; - let mut h3_quic = endpoint.clone(); - h3_quic.set_resolver(h3_underlay); - Some(Arc::new(H3Endpoint::new(h3_quic))) + Some(endpoint_h3_clients_from_quic(&operations, endpoint).await?) } else { None }; @@ -277,14 +310,18 @@ async fn endpoint_dns_from_quic( let h3_endpoint = endpoint_h3 .clone() .expect("BUG: endpoint H3 endpoint exists when H3 DNS is used"); - let h3 = Arc::new(h3_resolver_for_endpoint( + let h3_resolver = Arc::new(h3_resolver_for_endpoint( + h3_dns_server.as_ref(), + h3_endpoint.resolver, + )?); + let h3_publisher = Arc::new(h3_resolver_for_endpoint( h3_dns_server.as_ref(), - h3_endpoint, + h3_endpoint.publisher, )?); - resolver_builder = resolver_builder.resolver(h3.clone()); + resolver_builder = resolver_builder.resolver(h3_resolver); publishers.push(publishers::Publisher::new( publishers::PublishScope::WideArea, - h3, + h3_publisher, )); } DhttpDnsOp::Resolver(resolver) => { @@ -300,6 +337,55 @@ async fn endpoint_dns_from_quic( Ok((resolvers, publishers)) } +async fn endpoint_h3_clients_from_quic( + operations: &[DhttpDnsOp], + endpoint: &QuicEndpoint, +) -> Result { + let h3_underlay = endpoint_h3_underlay(operations, endpoint).await?; + let resolver_quic = dedicated_h3_client_quic(endpoint, h3_underlay.clone()).await; + let publisher_quic = dedicated_h3_client_quic(endpoint, h3_underlay).await; + + Ok(EndpointH3Clients { + // Endpoint-facing DNS resolution and publication can run concurrently + // while the endpoint is also serving traffic. Keep separate H3 pools + // and dedicated QUIC endpoints. The H3 DNS clients must always use + // DHTTP's H3-capable trust/ALPN defaults instead of inheriting an + // arbitrary serving endpoint transport config; callers such as pishoo + // may construct the serving QUIC endpoint directly and omit H3 ALPNs. + // Preserve the serving identity so authenticated H3 DNS publish can + // still sign requests and present client certificates. + resolver: Arc::new(H3Endpoint::new(EndpointH3Connector::new(resolver_quic))), + publisher: Arc::new(H3Endpoint::new(EndpointH3Connector::new(publisher_quic))), + }) +} + +async fn dedicated_h3_client_quic(endpoint: &QuicEndpoint, resolver: ArcResolver) -> QuicEndpoint { + QuicEndpoint::builder() + .network(endpoint.network().clone()) + .maybe_identity(endpoint.identity()) + .resolver(resolver) + .client(crate::trust::default_client_quic_config()) + .server(crate::trust::default_server_quic_config()) + .bind(endpoint.bind_patterns().clone()) + .build() + .await +} + +async fn dedicated_network_h3_client_quic( + network: Arc, + bind: Arc>, + resolver: ArcResolver, +) -> QuicEndpoint { + QuicEndpoint::builder() + .network(network) + .resolver(resolver) + .client(crate::trust::default_client_quic_config()) + .server(crate::trust::default_server_quic_config()) + .bind(bind) + .build() + .await +} + async fn endpoint_h3_underlay( operations: &[DhttpDnsOp], endpoint: &QuicEndpoint, @@ -369,8 +455,8 @@ fn h3_resolver_for_network( fn h3_resolver_for_endpoint( h3_dns_server: &str, - h3: Arc>, -) -> Result, BuildQuicEndpointWithDnsError> { + h3: EndpointH3Client, +) -> Result, BuildQuicEndpointWithDnsError> { resolvers::H3Resolver::from_endpoint(h3_dns_server, h3) .context(build_quic_endpoint_with_dns_error::InvalidH3DnsServerSnafu) } @@ -435,6 +521,7 @@ fn has_system_dns(operations: &[DhttpDnsOp]) -> bool { #[cfg(test)] mod tests { + use std::str::FromStr; use std::{ fmt, sync::{ @@ -580,4 +667,84 @@ mod tests { ); assert!(publishers.iter().next().is_none()); } + + #[tokio::test] + async fn endpoint_h3_dns_clients_split_resolver_and_publisher_connectors() { + let endpoint = crate::dquic::QuicEndpoint::builder().build().await; + let operations = vec![DhttpDnsOp::Dns(resolvers::DnsScheme::H3)]; + let mut source_quic = endpoint.clone(); + let source_client = (*source_quic.client_config_mut()).clone(); + let source_server = (*source_quic.server_config_mut()).clone(); + + let clients = endpoint_h3_clients_from_quic(&operations, &endpoint) + .await + .expect("h3 dns clients should build"); + + assert!( + !Arc::ptr_eq(&clients.resolver, &clients.publisher), + "resolver and publisher must not share the same H3 endpoint pool" + ); + assert!( + Arc::ptr_eq(clients.resolver.quic().quic.network(), endpoint.network()), + "resolver h3 dns client should stay on the serving endpoint network" + ); + assert!( + Arc::ptr_eq(clients.publisher.quic().quic.network(), endpoint.network()), + "publisher h3 dns client should stay on the serving endpoint network" + ); + + let mut resolver_quic = clients.resolver.quic().quic.clone(); + let resolver_client = (*resolver_quic.client_config_mut()).clone(); + let resolver_server = (*resolver_quic.server_config_mut()).clone(); + let mut publisher_quic = clients.publisher.quic().quic.clone(); + let publisher_client = (*publisher_quic.client_config_mut()).clone(); + let publisher_server = (*publisher_quic.server_config_mut()).clone(); + + assert_eq!(resolver_client, crate::trust::default_client_quic_config()); + assert_eq!(publisher_client, crate::trust::default_client_quic_config()); + assert_eq!(resolver_server, crate::trust::default_server_quic_config()); + assert_eq!(publisher_server, crate::trust::default_server_quic_config()); + assert!( + source_client.alpns.is_empty() && source_server.alpns.is_empty(), + "test source endpoint should keep the raw quic defaults so dedicated H3 DNS clients prove they install DHTTP H3 defaults independently" + ); + assert!( + Arc::ptr_eq( + clients.resolver.quic().quic.resolver(), + clients.publisher.quic().quic.resolver() + ), + "resolver and publisher h3 dns clients should share the same dedicated underlay resolver chain" + ); + assert!( + !Arc::ptr_eq( + clients.publisher.quic().quic.resolver(), + endpoint.resolver() + ), + "publisher h3 dns client should override the serving endpoint resolver with the dedicated underlay resolver" + ); + } + + #[tokio::test] + async fn network_h3_stun_resolver_quic_uses_dhttp_h3_defaults() { + let network = crate::dquic::Network::builder().build(); + let bind = Arc::new(vec![ + crate::dquic::binds::BindPattern::from_str("*") + .expect("wildcard bind pattern should parse"), + ]); + let operations = vec![DhttpDnsOp::Dns(resolvers::DnsScheme::H3)]; + let h3_underlay = network_h3_underlay(&operations, network.clone(), bind.clone()) + .await + .expect("network h3 underlay should build"); + + let mut quic = dedicated_network_h3_client_quic(network, bind, h3_underlay).await; + + assert_eq!( + (*quic.client_config_mut()).clone(), + crate::trust::default_client_quic_config() + ); + assert_eq!( + (*quic.server_config_mut()).clone(), + crate::trust::default_server_quic_config() + ); + } } diff --git a/dhttp/src/endpoint.rs b/dhttp/src/endpoint.rs index 6fe5997..e8cf4e7 100644 --- a/dhttp/src/endpoint.rs +++ b/dhttp/src/endpoint.rs @@ -88,10 +88,11 @@ pub enum CreateEndpointPublicationLoopError { AnonymousEndpoint, } -/// Default STUN server for NAT traversal. +/// Default STUN bootstrap name for NAT traversal. /// -/// STUN server resolution uses this authority so the well-known port remains -/// part of the query. +/// DDNS resolution of this name returns the actual socket addresses and ports +/// from endpoint `E` records; the bootstrap value itself is a logical lookup +/// name, not a raw `host:port` transport authority. pub const STUN_SERVER: &str = crate::bootstrap::DHTTP_STUN_SERVER; fn normalize_bind(bind: Arc>) -> Arc> { @@ -228,7 +229,7 @@ where InvalidName { source: E }, #[snafu(display("failed to locate dhttp home"))] NoHome { - source: crate::home::LocateDhttpHomeError, + source: crate::home::LoadDhttpHomeError, }, #[snafu(display("failed to resolve identity profile"))] ResolveIdentityProfile { @@ -393,7 +394,7 @@ impl Endpoint { let name = name .try_into() .context(load_endpoint_error::InvalidNameSnafu)?; - let home = crate::home::DhttpHome::load_from_environment() + let home = crate::home::DhttpHome::load(crate::home::HomeScope::User) .context(load_endpoint_error::NoHomeSnafu)?; let profile = home @@ -621,6 +622,13 @@ mod tests { } } + #[test] + fn stun_server_placeholder_is_plain_name_when_compile_time_env_is_absent() { + if option_env!("DHTTP_STUN_SERVER").is_none() { + assert_eq!(STUN_SERVER, "stun.dhttp.example.net"); + } + } + #[tokio::test] async fn check_builder_api() { let endpoint = Arc::new( @@ -1067,7 +1075,7 @@ mod tests { .network() .quic() .stun_resolver() - .lookup("stun.example.test:3478") + .lookup("stun.example.test") .await .expect("custom STUN resolver should be called"); diff --git a/dhttp/src/message.rs b/dhttp/src/message.rs index d7cc0e0..ad3ec81 100644 --- a/dhttp/src/message.rs +++ b/dhttp/src/message.rs @@ -2060,6 +2060,15 @@ mod tests { assert_eq!(authority.as_str(), "alice@reimu.pilot.dhttp.net:443"); } + #[test] + fn into_authority_expands_dhttp_shorthand_selector_with_base() { + let self_name = "self.host".parse::().unwrap(); + + let authority = "reimu.hakurei~:0".into_authority(Some(&self_name)).unwrap(); + + assert_eq!(authority.as_str(), "reimu.hakurei.dhttp.net:0"); + } + #[test] fn into_authority_rejects_bare_tilde_without_base() { let error = "~".into_authority(None).unwrap_err(); diff --git a/dhttp/src/network.rs b/dhttp/src/network.rs index 70d3c9f..9c628b2 100644 --- a/dhttp/src/network.rs +++ b/dhttp/src/network.rs @@ -9,7 +9,9 @@ use crate::ddns::{ use crate::dquic::{ Network, binds::BindPattern, - net::{Devices, InterfaceManager, Locations, ProductIO, QuicRouter, handy::DEFAULT_IO_FACTORY}, + net::{ + Devices, InterfaceManager, LocalEndpoints, ProductIO, QuicRouter, handy::DEFAULT_IO_FACTORY, + }, }; pub(crate) type ArcResolvers = Arc; @@ -104,7 +106,7 @@ impl DhttpNetwork { >, #[builder(default = Arc::new(DEFAULT_IO_FACTORY))] io_factory: Arc, #[builder(default = Arc::new(QuicRouter::new()))] quic_router: Arc, - #[builder(default = Arc::new(Locations::new()))] locations: Arc, + #[builder(default = Arc::new(LocalEndpoints::new()))] local_endpoints: Arc, ) -> Result { let stun_server = stun_server.unwrap_or_else(|| Some(Arc::::from(crate::endpoint::STUN_SERVER))); @@ -117,7 +119,7 @@ impl DhttpNetwork { .iface_manager(iface_manager) .io_factory(io_factory) .quic_router(quic_router) - .locations(locations) + .local_endpoints(local_endpoints) .build(); return Ok(Self { network, @@ -135,7 +137,7 @@ impl DhttpNetwork { .iface_manager(iface_manager) .io_factory(io_factory) .quic_router(quic_router) - .locations(locations) + .local_endpoints(local_endpoints) .build() }, &dns_plan, @@ -242,14 +244,14 @@ mod tests { #[tokio::test] async fn builder_allows_custom_stun_server() { let dhttp_network = DhttpNetwork::builder() - .stun_server(Some(Arc::from("custom.stun.example:3478"))) + .stun_server(Some(Arc::from("custom.stun.example"))) .build() .await .expect("network should build with custom stun server"); assert_eq!( dhttp_network.network().quic().stun_server().as_deref(), - Some("custom.stun.example:3478") + Some("custom.stun.example") ); } @@ -261,15 +263,15 @@ mod tests { let stun_resolver: Arc = Arc::new(crate::dquic::resolver::handy::SystemResolver); let quic_router = Arc::new(crate::dquic::net::QuicRouter::new()); - let locations = Arc::new(crate::dquic::net::Locations::new()); + let local_endpoints = Arc::new(crate::dquic::net::LocalEndpoints::new()); let dhttp_network = DhttpNetwork::builder() .iface_manager(iface_manager.clone()) .io_factory(io_factory.clone()) .stun_resolver(stun_resolver.clone()) - .stun_server(Some(Arc::from("builder.stun.example:3478"))) + .stun_server(Some(Arc::from("builder.stun.example"))) .quic_router(quic_router.clone()) - .locations(locations.clone()) + .local_endpoints(local_endpoints.clone()) .build() .await .expect("network should build with forwarded options"); @@ -278,16 +280,13 @@ mod tests { assert!(Arc::ptr_eq(&quic.iface_manager(), &iface_manager)); assert!(Arc::ptr_eq(&quic.io_factory(), &io_factory)); assert!(Arc::ptr_eq(&quic.stun_resolver(), &stun_resolver)); - assert_eq!( - quic.stun_server().as_deref(), - Some("builder.stun.example:3478") - ); + assert_eq!(quic.stun_server().as_deref(), Some("builder.stun.example")); assert!(Arc::ptr_eq(&quic.quic_router(), &quic_router)); - assert!(Arc::ptr_eq(&quic.locations(), &locations)); + assert!(Arc::ptr_eq(&quic.local_endpoints(), &local_endpoints)); } #[tokio::test] - async fn builder_derives_stun_resolver_from_custom_resolver() { + async fn builder_derives_stun_resolver_from_custom_resolver_without_literal_port() { use futures::StreamExt; let calls = Arc::new(AtomicUsize::new(0)); @@ -304,7 +303,7 @@ mod tests { .network() .quic() .stun_resolver() - .lookup("stun.example.test:3478") + .lookup("stun.example.test") .await .expect("custom resolver should resolve STUN server"); diff --git a/home/Cargo.toml b/home/Cargo.toml index 05024c0..2816e42 100644 --- a/home/Cargo.toml +++ b/home/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-home" description = "Local identity home and profile management for DHttp" -version.workspace = true +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/home/build.rs b/home/build.rs new file mode 100644 index 0000000..4ff37a0 --- /dev/null +++ b/home/build.rs @@ -0,0 +1,24 @@ +use std::{env, fs, path::PathBuf}; + +const GLOBAL_HOME_ENV: &str = "DHTTP_GLOBAL_HOME"; + +fn main() { + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo")); + let bootstrap = format!( + "// @generated by build.rs; do not edit.\n\ + pub const DHTTP_GLOBAL_HOME: Option<&str> = {};\n", + option_expr(env::var(GLOBAL_HOME_ENV).ok().as_deref()) + ); + + fs::write(out_dir.join("bootstrap.rs"), bootstrap) + .expect("failed to write generated dhttp-home bootstrap constants"); + + println!("cargo::rerun-if-env-changed={GLOBAL_HOME_ENV}"); +} + +fn option_expr(value: Option<&str>) -> String { + match value { + Some(value) => format!("Some({value:?})"), + None => "None".to_owned(), + } +} diff --git a/home/src/bootstrap.rs b/home/src/bootstrap.rs new file mode 100644 index 0000000..aa8c097 --- /dev/null +++ b/home/src/bootstrap.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/bootstrap.rs")); diff --git a/home/src/lib.rs b/home/src/lib.rs index ee91eda..a17b37b 100644 --- a/home/src/lib.rs +++ b/home/src/lib.rs @@ -1,10 +1,21 @@ pub mod identity; +mod bootstrap; + use std::path::{Path, PathBuf}; -#[cfg(any(unix, windows))] -use snafu::OptionExt; -use snafu::Snafu; +use snafu::{OptionExt, Snafu}; + +const USER_HOME_ENV: &str = "DHTTP_HOME"; +const GLOBAL_HOME_ENV: &str = "DHTTP_GLOBAL_HOME"; +#[cfg(any(target_os = "linux", target_os = "macos"))] +const DEFAULT_UNIX_GLOBAL_HOME: &str = "/etc/dhttp"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HomeScope { + User, + Global, +} /// A handle to the user's dhttp home directory (e.g. `~/.dhttp/`). /// @@ -18,10 +29,12 @@ pub struct DhttpHome { #[derive(Debug, Snafu)] #[snafu(module)] -pub enum LocateDhttpHomeError { +pub enum LoadDhttpHomeError { #[cfg(any(unix, windows))] #[snafu(display("cannot locate user home directory"))] NoUserHome {}, + #[snafu(display("global dhttp home is not configured"))] + GlobalHomeNotConfigured {}, #[snafu(display( "dhttp home cannot be automatically located on this platform, try setting DHTTP_HOME environment variable" ))] @@ -39,18 +52,18 @@ impl DhttpHome { Self::new(home_dir.into().join(Self::DIR_NAME)) } - pub fn load_from_environment() -> Result { - if let Some(path) = std::env::var_os("DHTTP_HOME") { - return Ok(Self::new(PathBuf::from(path))); + pub fn load(scope: HomeScope) -> Result { + match scope { + HomeScope::User => Ok(Self::new(resolve_user_home_path( + std::env::var_os(USER_HOME_ENV).map(PathBuf::from), + user_home_dir(), + )?)), + HomeScope::Global => Ok(Self::new(resolve_global_home_path( + std::env::var_os(GLOBAL_HOME_ENV).map(PathBuf::from), + bootstrap::DHTTP_GLOBAL_HOME, + platform_default_global_home(), + )?)), } - - #[cfg(any(unix, windows))] - return Ok(Self::for_user_home_dir( - dirs::home_dir().context(locate_dhttp_home_error::NoUserHomeSnafu)?, - )); - - #[allow(unreachable_code)] - locate_dhttp_home_error::UnsupportedPlatformSnafu.fail() } pub fn as_path(&self) -> &Path { @@ -67,3 +80,123 @@ impl AsRef for DhttpHome { self.as_path() } } + +fn resolve_user_home_path( + runtime_home: Option, + user_home_dir: Option, +) -> Result { + if let Some(path) = runtime_home { + return Ok(path); + } + + #[cfg(any(unix, windows))] + let home_dir = user_home_dir.context(load_dhttp_home_error::NoUserHomeSnafu)?; + + #[cfg(not(any(unix, windows)))] + let home_dir = user_home_dir.context(load_dhttp_home_error::UnsupportedPlatformSnafu)?; + + Ok(home_dir.join(DhttpHome::DIR_NAME)) +} + +fn resolve_global_home_path( + runtime_home: Option, + compiled_home: Option<&str>, + default_home: Option<&str>, +) -> Result { + if let Some(path) = runtime_home { + return Ok(path); + } + if let Some(path) = compiled_home { + return Ok(PathBuf::from(path)); + } + if let Some(path) = default_home { + return Ok(PathBuf::from(path)); + } + + load_dhttp_home_error::GlobalHomeNotConfiguredSnafu.fail() +} + +fn user_home_dir() -> Option { + #[cfg(any(unix, windows))] + { + return dirs::home_dir(); + } + + #[allow(unreachable_code)] + None +} + +fn platform_default_global_home() -> Option<&'static str> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + return Some(DEFAULT_UNIX_GLOBAL_HOME); + } + + #[allow(unreachable_code)] + None +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{LoadDhttpHomeError, resolve_global_home_path, resolve_user_home_path}; + + #[test] + fn user_scope_path_prefers_runtime_home_env() { + let path = resolve_user_home_path( + Some(PathBuf::from("/runtime/dhttp-home")), + Some(PathBuf::from("/home/reimu")), + ) + .expect("user path should resolve"); + + assert_eq!(path, PathBuf::from("/runtime/dhttp-home")); + } + + #[test] + fn user_scope_path_falls_back_to_user_home_dir() { + let path = resolve_user_home_path(None, Some(PathBuf::from("/home/reimu"))) + .expect("user path should resolve"); + + assert_eq!(path, PathBuf::from("/home/reimu/.dhttp")); + } + + #[test] + fn global_scope_path_prefers_runtime_env_over_compile_time_and_default() { + let path = resolve_global_home_path( + Some(PathBuf::from("/runtime/global")), + Some("/compiled/global"), + Some("/etc/dhttp"), + ) + .expect("global path should resolve"); + + assert_eq!(path, PathBuf::from("/runtime/global")); + } + + #[test] + fn global_scope_path_uses_compile_time_home_when_runtime_is_missing() { + let path = resolve_global_home_path(None, Some("/compiled/global"), Some("/etc/dhttp")) + .expect("global path should resolve"); + + assert_eq!(path, PathBuf::from("/compiled/global")); + } + + #[test] + fn global_scope_path_uses_platform_default_when_overrides_are_missing() { + let path = resolve_global_home_path(None, None, Some("/etc/dhttp")) + .expect("global path should resolve"); + + assert_eq!(path, PathBuf::from("/etc/dhttp")); + } + + #[test] + fn global_scope_path_errors_when_no_source_is_available() { + let error = resolve_global_home_path(None, None, None) + .expect_err("missing global path sources must fail"); + + assert!(matches!( + error, + LoadDhttpHomeError::GlobalHomeNotConfigured {} + )); + } +}