From 0055bc0b5184b12528a98c9acbc0fb1f7f6bc874 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:11:49 +0800 Subject: [PATCH 01/18] refactor(config): add role-aware parser domains --- gateway/src/parse.rs | 86 ++++++ gateway/src/parse/builtin.rs | 1 + gateway/src/parse/builtin/location.rs | 65 +++-- gateway/src/parse/builtin/pishoo.rs | 67 ++++- gateway/src/parse/builtin/proxy.rs | 24 +- gateway/src/parse/builtin/server.rs | 123 +++++++-- gateway/src/parse/builtin/stun.rs | 23 +- gateway/src/parse/document.rs | 5 + gateway/src/parse/domain.rs | 183 +++++++++++++ gateway/src/parse/error.rs | 102 +++++++ gateway/src/parse/fragment.rs | 145 ++++++++++ gateway/src/parse/normalize.rs | 34 ++- gateway/src/parse/registry.rs | 282 +++++++++++++++++-- gateway/src/parse/source.rs | 28 +- gateway/src/parse/tests.rs | 376 +++++++++++++++++++++++++- 15 files changed, 1432 insertions(+), 112 deletions(-) create mode 100644 gateway/src/parse/domain.rs create mode 100644 gateway/src/parse/fragment.rs diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index dc46b0ee..e15c3e38 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -7,7 +7,9 @@ pub mod ast; pub mod builtin; pub mod diagnostic; pub mod document; +pub mod domain; pub mod error; +pub mod fragment; pub mod grammar; pub mod include; pub mod normalize; @@ -17,6 +19,90 @@ pub mod source; pub mod types; pub mod value; +pub struct ConfigDocumentParser<'registry> { + registry: &'registry registry::ConfigRegistry, + next_document_index: usize, +} + +impl<'registry> ConfigDocumentParser<'registry> { + pub fn new(registry: &'registry registry::ConfigRegistry) -> Self { + Self { + registry, + next_document_index: 0, + } + } + + pub fn parse_text( + &mut self, + text: &str, + source_path: &Path, + role: domain::ConfigDocumentRole<'_>, + ) -> Result { + let document_id = match domain::ConfigDocumentId::try_from_index(self.next_document_index) { + Ok(document_id) => document_id, + Err(source) => { + return Err(error::ConfigLoadFailure { + error: error::LoadConfigError::DocumentId { source }, + source_map: Arc::new(source::SourceMap::default()), + }); + } + }; + self.next_document_index = self.next_document_index.saturating_add(1); + + let role_kind = role.kind(); + let options = role.build_options(); + let source_path = source_path.to_path_buf(); + let mut source_map = source::SourceMap::with_document_id(document_id); + let source_id = source_map.add_source( + Some(source_path.clone()), + Arc::from(text), + source_path.parent().map(Path::to_path_buf), + None, + ); + + let directives = match grammar::parse_source(text, source_id) + .context(error::load_config_error::ParseFileSnafu { source_id }) + { + Ok(directives) => directives, + Err(error) => { + return Err(error::ConfigLoadFailure { + error, + source_map: Arc::new(source_map), + }); + } + }; + + let directives = + match include::expand_includes(directives, &mut source_map, source_path.parent()) + .context(error::load_config_error::ResolveIncludeSnafu) + { + Ok(directives) => directives, + Err(error) => { + return Err(error::ConfigLoadFailure { + error, + source_map: Arc::new(source_map), + }); + } + }; + + let source_map = Arc::new(source_map); + match self + .registry + .build_for_role(Arc::clone(&source_map), directives, options, role_kind) + { + Ok(document) => Ok(document), + Err(registry::RoleDocumentBuildError::Role(source)) => Err(error::ConfigLoadFailure { + error: error::LoadConfigError::DocumentRole { source }, + source_map, + }), + Err(registry::RoleDocumentBuildError::Build(source)) => Err(error::ConfigLoadFailure { + error: error::LoadConfigError::BuildDocument { source }, + source_map, + }), + } + } +} + #[cfg(test)] mod tests; diff --git a/gateway/src/parse/builtin.rs b/gateway/src/parse/builtin.rs index 97811c29..688552f3 100644 --- a/gateway/src/parse/builtin.rs +++ b/gateway/src/parse/builtin.rs @@ -12,6 +12,7 @@ pub mod stun; use crate::parse::registry::ConfigRegistry; pub fn register_gateway_directives(registry: &mut ConfigRegistry) { + // Each builtin owns its duplicate, cascade, transport, and reload metadata. pishoo::register(registry); server::register(registry); location::register(registry); diff --git a/gateway/src/parse/builtin/location.rs b/gateway/src/parse/builtin/location.rs index 4a56dce9..03af12a4 100644 --- a/gateway/src/parse/builtin/location.rs +++ b/gateway/src/parse/builtin/location.rs @@ -5,8 +5,8 @@ use crate::parse::{ document::ConfigNode, pattern::{ParsePatternError, Pattern}, registry::{ - BuildOptions, ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, MergePolicy, - context, + BuildOptions, CascadePolicy, ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, + DuplicatePolicy, ReloadImpact, TransportPolicy, context, }, source::SourceSpan, types::{ @@ -50,50 +50,52 @@ pub fn register(registry: &mut ConfigRegistry) { "location", vec![context::SERVER], context::LOCATION, - MergePolicy::Append, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, ), ); - register_leaf::(registry, "root", MergePolicy::RejectDuplicate); - register_leaf::(registry, "alias", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_vary", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_min_length", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_comp_level", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_types", MergePolicy::RejectDuplicate); - register_leaf::(registry, "index", MergePolicy::RejectDuplicate); - register_leaf::(registry, "add_header", MergePolicy::Append); - register_leaf::(registry, "proxy_set_header", MergePolicy::Append); - register_leaf::(registry, "proxy_pass", MergePolicy::RejectDuplicate); - register_leaf::( - registry, - "proxy_ssl_certificate", - MergePolicy::RejectDuplicate, - ); + register_leaf::(registry, "root", DuplicatePolicy::Reject); + register_leaf::(registry, "alias", DuplicatePolicy::Reject); + register_leaf::(registry, "gzip", DuplicatePolicy::Reject); + register_leaf::(registry, "gzip_vary", DuplicatePolicy::Reject); + register_leaf::(registry, "gzip_min_length", DuplicatePolicy::Reject); + register_leaf::(registry, "gzip_comp_level", DuplicatePolicy::Reject); + register_leaf::(registry, "gzip_types", DuplicatePolicy::Reject); + register_leaf::(registry, "index", DuplicatePolicy::Reject); + register_leaf::(registry, "add_header", DuplicatePolicy::Append); + register_leaf::(registry, "proxy_set_header", DuplicatePolicy::Append); + register_leaf::(registry, "proxy_pass", DuplicatePolicy::Reject); + register_leaf::(registry, "proxy_ssl_certificate", DuplicatePolicy::Reject); register_leaf::( registry, "proxy_ssl_certificate_key", - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, ); register_leaf::( registry, "proxy_ssl_trusted_certificate", - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, ); - register_leaf::(registry, "ssh_login", MergePolicy::RejectDuplicate); - register_leaf::(registry, "ssh_ssl_user", MergePolicy::Append); - register_leaf::(registry, "ssh_deny", MergePolicy::RejectDuplicate); - register_leaf::(registry, "default_type", MergePolicy::RejectDuplicate); + register_leaf::(registry, "ssh_login", DuplicatePolicy::Reject); + register_leaf::(registry, "ssh_ssl_user", DuplicatePolicy::Append); + register_leaf::(registry, "ssh_deny", DuplicatePolicy::Reject); + register_leaf::(registry, "default_type", DuplicatePolicy::Reject); registry.register_directive( context::LOCATION, DirectiveSpec::raw_value::( "types", vec![context::LOCATION], - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, ), ); } -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str, merge: MergePolicy) +fn register_leaf(registry: &mut ConfigRegistry, name: &'static str, duplicate: DuplicatePolicy) where T: crate::parse::registry::DirectiveValue, for<'input, 'directive> T: TryFrom< @@ -103,7 +105,14 @@ where { registry.register_directive( context::LOCATION, - DirectiveSpec::leaf_value::(name, vec![context::LOCATION], merge), + DirectiveSpec::leaf_value::( + name, + vec![context::LOCATION], + duplicate, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), ); } diff --git a/gateway/src/parse/builtin/pishoo.rs b/gateway/src/parse/builtin/pishoo.rs index 6f226150..648062ae 100644 --- a/gateway/src/parse/builtin/pishoo.rs +++ b/gateway/src/parse/builtin/pishoo.rs @@ -1,5 +1,8 @@ use crate::parse::{ - registry::{ConfigRegistry, DirectiveSpec, MergePolicy, context}, + registry::{ + CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, ReloadImpact, + TransportPolicy, context, + }, types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, PathConfig, StringList, @@ -17,30 +20,36 @@ pub fn register(registry: &mut ConfigRegistry) { "pishoo", vec![context::ROOT], context::PISHOO, - MergePolicy::Append, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::Supervisor, ), ); - register_leaf::(registry, "pid"); - register_leaf::(registry, "workers"); - register_leaf::(registry, "groups"); - register_leaf::(registry, "access_rules"); - register_leaf::(registry, "gzip"); - register_leaf::(registry, "gzip_vary"); - register_leaf::(registry, "gzip_min_length"); - register_leaf::(registry, "gzip_comp_level"); - register_leaf::(registry, "gzip_types"); - register_leaf::(registry, "default_type"); + register_hypervisor_leaf::(registry, "pid"); + register_hypervisor_leaf::(registry, "workers"); + register_hypervisor_leaf::(registry, "groups"); + register_inheritable_leaf::(registry, "access_rules"); + register_inheritable_leaf::(registry, "gzip"); + register_inheritable_leaf::(registry, "gzip_vary"); + register_inheritable_leaf::(registry, "gzip_min_length"); + register_inheritable_leaf::(registry, "gzip_comp_level"); + register_inheritable_leaf::(registry, "gzip_types"); + register_inheritable_leaf::(registry, "default_type"); registry.register_directive( context::PISHOO, DirectiveSpec::raw_value::( "types", vec![context::PISHOO], - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, ), ); } -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str) +fn register_hypervisor_leaf(registry: &mut ConfigRegistry, name: &'static str) where T: crate::parse::registry::DirectiveValue, for<'input, 'directive> T: TryFrom< @@ -50,7 +59,35 @@ where { registry.register_directive( context::PISHOO, - DirectiveSpec::leaf_value::(name, vec![context::PISHOO], MergePolicy::RejectDuplicate), + DirectiveSpec::leaf_value::( + name, + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::Supervisor, + ), + ); +} + +fn register_inheritable_leaf(registry: &mut ConfigRegistry, name: &'static str) +where + T: crate::parse::registry::DirectiveValue, + for<'input, 'directive> T: TryFrom< + &'input crate::parse::registry::DirectiveInput<'directive>, + Error = ::Error, + >, +{ + registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + name, + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), ); } diff --git a/gateway/src/parse/builtin/proxy.rs b/gateway/src/parse/builtin/proxy.rs index cb90a4da..99c217e3 100644 --- a/gateway/src/parse/builtin/proxy.rs +++ b/gateway/src/parse/builtin/proxy.rs @@ -2,7 +2,10 @@ use snafu::{Snafu, ensure}; use crate::parse::{ document::ConfigNode, - registry::{BuildOptions, ConfigRegistry, DirectiveSpec, MergePolicy, context}, + registry::{ + BuildOptions, CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, ReloadImpact, + TransportPolicy, context, + }, source::SourceSpan, types::{ ClientNameConfig, DefaultType, MimeTypes, PathConfig, ResolverConfig, SocketAddrs, @@ -28,7 +31,10 @@ pub fn register(registry: &mut ConfigRegistry) { "proxy", vec![context::PISHOO], context::PROXY, - MergePolicy::Append, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::ListenerSet, ), ); register_leaf::(registry, "listen"); @@ -44,7 +50,10 @@ pub fn register(registry: &mut ConfigRegistry) { DirectiveSpec::raw_value::( "types", vec![context::PROXY], - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, ), ); } @@ -59,7 +68,14 @@ where { registry.register_directive( context::PROXY, - DirectiveSpec::leaf_value::(name, vec![context::PROXY], MergePolicy::RejectDuplicate), + DirectiveSpec::leaf_value::( + name, + vec![context::PROXY], + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), ); } diff --git a/gateway/src/parse/builtin/server.rs b/gateway/src/parse/builtin/server.rs index b9e14970..3818c947 100644 --- a/gateway/src/parse/builtin/server.rs +++ b/gateway/src/parse/builtin/server.rs @@ -2,7 +2,10 @@ use snafu::{OptionExt, Snafu, ensure}; use crate::parse::{ document::ConfigNode, - registry::{BuildOptions, ConfigRegistry, ContextKey, DirectiveSpec, MergePolicy, context}, + registry::{ + BuildOptions, CascadePolicy, ConfigRegistry, ContextKey, DirectiveSpec, DuplicatePolicy, + ReloadImpact, TransportPolicy, context, + }, source::SourceSpan, types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, ListenConfig, @@ -29,48 +32,121 @@ pub fn register(registry: &mut ConfigRegistry) { }); registry.register_directive(context::ROOT, server_block(context::ROOT)); registry.register_directive(context::PISHOO, server_block(context::PISHOO)); - register_server_leaf::(registry, "listen", MergePolicy::Append); - register_server_leaf::(registry, "server_name", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "dns", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "gzip", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "gzip_vary", MergePolicy::RejectDuplicate); + register_server_leaf::( + registry, + "listen", + DuplicatePolicy::Append, + ReloadImpact::ListenerSet, + ); + register_server_leaf::( + registry, + "server_name", + DuplicatePolicy::Reject, + ReloadImpact::ListenerSet, + ); + register_server_leaf::( + registry, + "dns", + DuplicatePolicy::Reject, + ReloadImpact::ListenerSet, + ); + register_server_leaf::( + registry, + "gzip", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); + register_server_leaf::( + registry, + "gzip_vary", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); register_server_leaf::( registry, "gzip_min_length", - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, ); register_server_leaf::( registry, "gzip_comp_level", - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); + register_server_leaf::( + registry, + "gzip_types", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); + register_server_leaf::( + registry, + "ssl_certificate", + DuplicatePolicy::Reject, + ReloadImpact::ListenerSet, ); - register_server_leaf::(registry, "gzip_types", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "ssl_certificate", MergePolicy::RejectDuplicate); register_server_leaf::( registry, "ssl_certificate_key", - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + ReloadImpact::ListenerSet, + ); + register_server_leaf::( + registry, + "default_type", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); + register_server_leaf::( + registry, + "access_rules", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); + register_server_leaf::( + registry, + "relay", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, + ); + register_server_leaf::( + registry, + "stun", + DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, ); - register_server_leaf::(registry, "default_type", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "access_rules", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "relay", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "stun", MergePolicy::RejectDuplicate); registry.register_directive( context::SERVER, DirectiveSpec::raw_value::( "types", vec![context::SERVER], - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, ), ); } fn server_block(parent: ContextKey) -> DirectiveSpec { - DirectiveSpec::context_empty("server", vec![parent], context::SERVER, MergePolicy::Append) + DirectiveSpec::context_empty( + "server", + vec![parent], + context::SERVER, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::ListenerSet, + ) } -fn register_server_leaf(registry: &mut ConfigRegistry, name: &'static str, merge: MergePolicy) -where +fn register_server_leaf( + registry: &mut ConfigRegistry, + name: &'static str, + duplicate: DuplicatePolicy, + reload: ReloadImpact, +) where T: crate::parse::registry::DirectiveValue, for<'input, 'directive> T: TryFrom< &'input crate::parse::registry::DirectiveInput<'directive>, @@ -79,7 +155,14 @@ where { registry.register_directive( context::SERVER, - DirectiveSpec::leaf_value::(name, vec![context::SERVER], merge), + DirectiveSpec::leaf_value::( + name, + vec![context::SERVER], + duplicate, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + reload, + ), ); } diff --git a/gateway/src/parse/builtin/stun.rs b/gateway/src/parse/builtin/stun.rs index 5f64fee3..d335b285 100644 --- a/gateway/src/parse/builtin/stun.rs +++ b/gateway/src/parse/builtin/stun.rs @@ -5,7 +5,8 @@ use snafu::{ResultExt, Snafu}; use crate::parse::{ builtin::core::{first_arg_span, only_arg}, registry::{ - ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, MergePolicy, context, + CascadePolicy, ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, + DuplicatePolicy, ReloadImpact, TransportPolicy, context, }, source::SourceSpan, types::{SocketAddrs, StunBindConfigValue, StunChangePort}, @@ -152,7 +153,10 @@ pub fn register(registry: &mut ConfigRegistry) { "stun_server", vec![context::SERVER], context::STUN_SERVER, - MergePolicy::Append, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, ), ); registry.register_directive( @@ -160,7 +164,10 @@ pub fn register(registry: &mut ConfigRegistry) { DirectiveSpec::leaf_value::( "bind", vec![context::STUN_SERVER], - MergePolicy::Append, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, ), ); for name in ["outer_addr", "change_addr"] { @@ -169,7 +176,10 @@ pub fn register(registry: &mut ConfigRegistry) { DirectiveSpec::leaf_value::( name, vec![context::STUN_SERVER], - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, ), ); } @@ -178,7 +188,10 @@ pub fn register(registry: &mut ConfigRegistry) { DirectiveSpec::leaf_value::( "change_port", vec![context::STUN_SERVER], - MergePolicy::RejectDuplicate, + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, ), ); } diff --git a/gateway/src/parse/document.rs b/gateway/src/parse/document.rs index 65507988..0907ab00 100644 --- a/gateway/src/parse/document.rs +++ b/gateway/src/parse/document.rs @@ -7,6 +7,7 @@ use tokio::sync::OnceCell; use crate::parse::{ ast::Spanned, + domain::ConfigDocumentId, error::{ConfigQueryError, config_query_error}, registry::ContextKey, source::{SourceMap, SourceSpan}, @@ -39,6 +40,10 @@ impl ConfigDocument { pub fn new(source_map: Arc, root: Arc) -> Self { Self { source_map, root } } + + pub fn document_id(&self) -> ConfigDocumentId { + self.source_map.document_id() + } } impl ConfigNode { diff --git a/gateway/src/parse/domain.rs b/gateway/src/parse/domain.rs new file mode 100644 index 00000000..db42e6dd --- /dev/null +++ b/gateway/src/parse/domain.rs @@ -0,0 +1,183 @@ +use std::{ + fmt, + path::{Path, PathBuf}, +}; + +use dhttp::home::{DhttpHome, identity::IdentityProfile}; +use snafu::{ResultExt, Snafu}; + +use crate::parse::{registry::BuildOptions, source::SourceSpan}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigDocumentId(u32); + +impl ConfigDocumentId { + pub(crate) fn try_from_index(index: usize) -> Result { + let index = + u32::try_from(index).context(config_document_id_error::IndexOverflowSnafu { index })?; + Ok(Self(index)) + } +} + +impl fmt::Display for ConfigDocumentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "document:{}", self.0) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ConfigDocumentIdError { + #[snafu(display("configuration document index exceeds the supported range"))] + IndexOverflow { + index: usize, + source: std::num::TryFromIntError, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigSourceSpan { + document_id: ConfigDocumentId, + span: SourceSpan, +} + +impl ConfigSourceSpan { + pub(crate) fn new(document_id: ConfigDocumentId, span: SourceSpan) -> Self { + Self { document_id, span } + } + + pub fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub fn start(&self) -> usize { + self.span.start + } + + pub fn end(&self) -> usize { + self.span.end + } + + pub fn is_empty(&self) -> bool { + self.span.is_empty() + } + + pub(crate) fn source_span(&self) -> SourceSpan { + self.span + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DirectiveName(&'static str); + +impl DirectiveName { + pub(crate) const fn new(name: &'static str) -> Self { + Self(name) + } + + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for DirectiveName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} + +#[derive(Debug)] +pub enum ConfigDocumentRole<'a> { + HypervisorRoot { + home: Option<&'a DhttpHome>, + }, + WorkerPishoo { + home: &'a DhttpHome, + }, + IdentityServer { + home: &'a DhttpHome, + profile: &'a IdentityProfile, + }, +} + +impl ConfigDocumentRole<'_> { + pub(crate) fn kind(&self) -> ConfigDocumentRoleKind { + match self { + Self::HypervisorRoot { .. } => ConfigDocumentRoleKind::HypervisorRoot, + Self::WorkerPishoo { .. } => ConfigDocumentRoleKind::WorkerPishoo, + Self::IdentityServer { .. } => ConfigDocumentRoleKind::IdentityServer, + } + } + + pub(crate) fn build_options(&self) -> BuildOptions<'_> { + match self { + Self::HypervisorRoot { home } => BuildOptions { + dhttp_home: *home, + identity_profile: None, + }, + Self::WorkerPishoo { home } => BuildOptions { + dhttp_home: Some(home), + identity_profile: None, + }, + Self::IdentityServer { home, profile } => BuildOptions { + dhttp_home: Some(home), + identity_profile: Some(profile), + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConfigDocumentRoleKind { + HypervisorRoot, + WorkerPishoo, + IdentityServer, +} + +impl ConfigDocumentRoleKind { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::HypervisorRoot => "hypervisor root", + Self::WorkerPishoo => "worker pishoo", + Self::IdentityServer => "identity server", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ResolvedConfigPath(PathBuf); + +impl TryFrom for ResolvedConfigPath { + type Error = ResolvedConfigPathError; + + fn try_from(path: PathBuf) -> Result { + if !path.is_absolute() { + return Err(ResolvedConfigPathError::Relative { path }); + } + if path.as_os_str().as_encoded_bytes().contains(&0) { + return Err(ResolvedConfigPathError::Nul { path }); + } + Ok(Self(path)) + } +} + +impl AsRef for ResolvedConfigPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl From for PathBuf { + fn from(path: ResolvedConfigPath) -> Self { + path.0 + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ResolvedConfigPathError { + #[snafu(display("resolved configuration path must be absolute"))] + Relative { path: PathBuf }, + #[snafu(display("resolved configuration path contains a NUL byte"))] + Nul { path: PathBuf }, +} diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index 0f9549c1..d1af3f49 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -3,6 +3,10 @@ use std::{path::PathBuf, sync::Arc}; use snafu::Snafu; use crate::parse::{ + domain::{ + ConfigDocumentId, ConfigDocumentIdError, ConfigDocumentRoleKind, ConfigSourceSpan, + DirectiveName, + }, grammar::ParseSyntaxError, source::{SourceId, SourceMap, SourceSpan}, }; @@ -10,6 +14,9 @@ use crate::parse::{ #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum LoadConfigError { + #[snafu(display("failed to allocate configuration document identity"))] + DocumentId { source: ConfigDocumentIdError }, + #[snafu(display("failed to read configuration source"))] ReadSource { path: PathBuf, @@ -27,6 +34,95 @@ pub enum LoadConfigError { #[snafu(display("failed to build configuration document"))] BuildDocument { source: BuildDocumentError }, + + #[snafu(display("configuration document is invalid for its role"))] + DocumentRole { + source: Box, + }, +} + +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +pub enum ConfigDocumentRoleError { + #[snafu(display("directive `{directive}` is not allowed in a {role} configuration document"))] + DirectiveNotAllowed { + directive: DirectiveName, + role: &'static str, + span: ConfigSourceSpan, + source: Box, + }, + + #[snafu(display( + "{role} configuration document must contain exactly one top-level pishoo block, found {found}" + ))] + ExpectedSinglePishoo { + role: &'static str, + found: usize, + span: ConfigSourceSpan, + source: Box, + }, + + #[snafu(display( + "identity server configuration document must contain at least one server block" + ))] + MissingIdentityServer { + span: ConfigSourceSpan, + source: Box, + }, +} + +impl ConfigDocumentRoleError { + pub(crate) fn directive_not_allowed( + directive: DirectiveName, + role: ConfigDocumentRoleKind, + span: ConfigSourceSpan, + ) -> Self { + Self::DirectiveNotAllowed { + directive, + role: role.as_str(), + span, + source: Box::new(BuildDocumentError::InvalidContext { + directive: directive.as_str().to_owned(), + context: role.as_str(), + span: span.source_span(), + }), + } + } + + pub(crate) fn expected_single_pishoo( + role: ConfigDocumentRoleKind, + found: usize, + span: ConfigSourceSpan, + first: Option, + ) -> Self { + let source = match first { + Some(first) => BuildDocumentError::DuplicateDirective { + directive: "pishoo".to_owned(), + first, + duplicate: span.source_span(), + }, + None => BuildDocumentError::MissingRequiredDirective { + directive: "pishoo", + context_span: span.source_span(), + }, + }; + Self::ExpectedSinglePishoo { + role: role.as_str(), + found, + span, + source: Box::new(source), + } + } + + pub(crate) fn missing_identity_server(span: ConfigSourceSpan) -> Self { + Self::MissingIdentityServer { + span, + source: Box::new(BuildDocumentError::MissingRequiredDirective { + directive: "server", + context_span: span.source_span(), + }), + } + } } #[derive(Debug, Snafu)] @@ -156,6 +252,12 @@ impl std::error::Error for ConfigLoadFailure { } } +impl ConfigLoadFailure { + pub fn document_id(&self) -> ConfigDocumentId { + self.source_map.document_id() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/gateway/src/parse/fragment.rs b/gateway/src/parse/fragment.rs new file mode 100644 index 00000000..35f11ea3 --- /dev/null +++ b/gateway/src/parse/fragment.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use crate::parse::{ + document::ConfigNode, + domain::{ConfigDocumentId, ConfigSourceSpan}, + source::SourceMap, +}; + +#[derive(Debug)] +pub enum ParsedConfigDocument { + HypervisorRoot(ParsedPishooFragment), + WorkerPishoo(ParsedPishooFragment), + IdentityServers(Box<[ParsedServerFragment]>), +} + +#[derive(Debug)] +pub struct ParsedPishooFragment { + document_id: ConfigDocumentId, + source_map: Arc, + node: Arc, + servers: Box<[ParsedServerFragment]>, +} + +#[derive(Debug)] +pub struct ParsedServerFragment { + document_id: ConfigDocumentId, + source_map: Arc, + node: Arc, + locations: Box<[ParsedLocationFragment]>, +} + +#[derive(Debug)] +pub struct ParsedLocationFragment { + document_id: ConfigDocumentId, + source_map: Arc, + node: Arc, +} + +impl ParsedPishooFragment { + pub(crate) fn new(source_map: Arc, node: Arc) -> Self { + let document_id = source_map.document_id(); + let servers = node + .children_optional("server") + .iter() + .cloned() + .map(|server| ParsedServerFragment::new(Arc::clone(&source_map), server)) + .collect(); + Self { + document_id, + source_map, + node, + servers, + } + } + + pub fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub fn span(&self) -> ConfigSourceSpan { + self.source_map.config_span(self.node.span) + } + + pub fn servers(&self) -> &[ParsedServerFragment] { + &self.servers + } + + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage + pub(crate) fn source_map(&self) -> &SourceMap { + &self.source_map + } + + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage + pub(crate) fn node(&self) -> &Arc { + &self.node + } +} + +impl ParsedServerFragment { + pub(crate) fn new(source_map: Arc, node: Arc) -> Self { + let document_id = source_map.document_id(); + let locations = node + .children_optional("location") + .iter() + .cloned() + .map(|location| ParsedLocationFragment::new(Arc::clone(&source_map), location)) + .collect(); + Self { + document_id, + source_map, + node, + locations, + } + } + + pub fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub fn span(&self) -> ConfigSourceSpan { + self.source_map.config_span(self.node.span) + } + + pub fn locations(&self) -> &[ParsedLocationFragment] { + &self.locations + } + + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage + pub(crate) fn source_map(&self) -> &SourceMap { + &self.source_map + } + + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage + pub(crate) fn node(&self) -> &Arc { + &self.node + } +} + +impl ParsedLocationFragment { + fn new(source_map: Arc, node: Arc) -> Self { + Self { + document_id: source_map.document_id(), + source_map, + node, + } + } + + pub fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub fn span(&self) -> ConfigSourceSpan { + self.source_map.config_span(self.node.span) + } + + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage + pub(crate) fn source_map(&self) -> &SourceMap { + &self.source_map + } + + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage + pub(crate) fn node(&self) -> &Arc { + &self.node + } +} diff --git a/gateway/src/parse/normalize.rs b/gateway/src/parse/normalize.rs index 81c1b85f..482982ec 100644 --- a/gateway/src/parse/normalize.rs +++ b/gateway/src/parse/normalize.rs @@ -1,8 +1,9 @@ use std::path::{Path, PathBuf}; -use snafu::{OptionExt, Snafu}; +use snafu::{OptionExt, ResultExt, Snafu}; use crate::parse::{ + domain::{ResolvedConfigPath, ResolvedConfigPathError}, source::{SourceMap, SourceSpan}, types::PathConfig, value::TypedValue, @@ -13,6 +14,28 @@ use crate::parse::{ pub enum NormalizeDirectiveValueError { #[snafu(display("relative path requires a configuration file base directory"))] MissingBaseDir { span: SourceSpan }, + #[snafu(display("configuration path is not a resolved absolute path"))] + InvalidResolvedPath { + span: SourceSpan, + source: ResolvedConfigPathError, + }, +} + +pub fn resolve_config_path( + path: &Path, + span: SourceSpan, + source_map: &SourceMap, +) -> Result { + let path: PathBuf = if path.is_absolute() { + path.components().collect() + } else { + let base_dir = source_map + .base_dir_for_span(span) + .context(normalize_directive_value_error::MissingBaseDirSnafu { span })?; + base_dir.join(path).components().collect() + }; + ResolvedConfigPath::try_from(path) + .context(normalize_directive_value_error::InvalidResolvedPathSnafu { span }) } pub fn normalize_path( @@ -20,14 +43,7 @@ pub fn normalize_path( span: SourceSpan, source_map: &SourceMap, ) -> Result { - if path.is_absolute() { - return Ok(path.components().collect()); - } - - let base_dir = source_map - .base_dir_for_span(span) - .context(normalize_directive_value_error::MissingBaseDirSnafu { span })?; - Ok(base_dir.join(path).components().collect()) + resolve_config_path(path, span, source_map).map(Into::into) } pub fn normalize_slot_value( diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 83361e77..5c9982c3 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -5,7 +5,9 @@ use snafu::{OptionExt, ResultExt}; use crate::parse::{ ast::{AstBody, AstDirective}, document::{ConfigDocument, ConfigNode}, - error::{BuildDocumentError, build_document_error}, + domain::{ConfigDocumentRoleKind, DirectiveName}, + error::{BuildDocumentError, ConfigDocumentRoleError, build_document_error}, + fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, normalize, source::{SourceMap, SourceSpan}, value::{ConfigValue, TypedValue}, @@ -37,11 +39,14 @@ pub struct ContextSpec { } pub struct DirectiveSpec { - pub name: &'static str, + pub name: DirectiveName, pub allowed_in: Vec, pub shape: DirectiveShape, parser: DirectiveParserFn, - pub merge: MergePolicy, + pub duplicate: DuplicatePolicy, + pub cascade: CascadePolicy, + pub transport: TransportPolicy, + pub reload: ReloadImpact, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -61,12 +66,32 @@ pub enum PayloadMode { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MergePolicy { - RejectDuplicate, +pub enum DuplicatePolicy { + Reject, LastWins, Append, - InheritIfMissing, - MergeWithParent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CascadePolicy { + None, + NearestWins, + ReplaceWhole, + MergeByKey, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportPolicy { + HypervisorOnly, + WorkerInheritable, + WorkerLocalOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReloadImpact { + Supervisor, + ListenerSet, + RuntimeState, } type DirectiveParserFn = @@ -98,7 +123,10 @@ impl DirectiveSpec { pub fn leaf_value( name: &'static str, allowed_in: Vec, - merge: MergePolicy, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, ) -> Self where T: DirectiveValue, @@ -106,26 +134,39 @@ impl DirectiveSpec { TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, { Self { - name, + name: DirectiveName::new(name), allowed_in, shape: DirectiveShape::Leaf, parser: slot_value_parser::, - merge, + duplicate, + cascade, + transport, + reload, } } - pub fn raw_value(name: &'static str, allowed_in: Vec, merge: MergePolicy) -> Self + pub fn raw_value( + name: &'static str, + allowed_in: Vec, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self where T: DirectiveValue, for<'input, 'directive> T: TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, { Self { - name, + name: DirectiveName::new(name), allowed_in, shape: DirectiveShape::RawBlock, parser: slot_value_parser::, - merge, + duplicate, + cascade, + transport, + reload, } } @@ -133,17 +174,23 @@ impl DirectiveSpec { name: &'static str, allowed_in: Vec, child_context: ContextKey, - merge: MergePolicy, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, ) -> Self { Self { - name, + name: DirectiveName::new(name), allowed_in, shape: DirectiveShape::ContextBlock { child_context, payload: PayloadMode::None, }, parser: empty_parser, - merge, + duplicate, + cascade, + transport, + reload, } } @@ -151,7 +198,10 @@ impl DirectiveSpec { name: &'static str, allowed_in: Vec, child_context: ContextKey, - merge: MergePolicy, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, ) -> Self where T: DirectiveValue, @@ -159,14 +209,17 @@ impl DirectiveSpec { TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, { Self { - name, + name: DirectiveName::new(name), allowed_in, shape: DirectiveShape::ContextBlock { child_context, payload: PayloadMode::Parser, }, parser: payload_value_parser::, - merge, + duplicate, + cascade, + transport, + reload, } } } @@ -229,7 +282,16 @@ impl ConfigRegistry { } pub fn register_directive(&mut self, context: ContextKey, spec: DirectiveSpec) { - self.directives.insert((context, spec.name), spec); + self.directives.insert((context, spec.name.as_str()), spec); + } + + #[cfg(test)] + pub(crate) fn directive_spec(&self, context: ContextKey, name: &str) -> Option<&DirectiveSpec> { + self.directives + .iter() + .find_map(|((registered_context, registered_name), spec)| { + (*registered_context == context && *registered_name == name).then_some(spec) + }) } pub fn build( @@ -259,6 +321,93 @@ impl ConfigRegistry { Ok(ConfigDocument::new(source_map, root)) } + pub(crate) fn build_for_role( + &self, + source_map: Arc, + directives: Vec, + options: BuildOptions<'_>, + role: ConfigDocumentRoleKind, + ) -> Result { + validate_document_shape(&source_map, &directives, role)?; + self.validate_role_directives(&source_map, &directives, context::ROOT, role)?; + + let span = document_span(&directives); + let mut root = ConfigNode::new(context::ROOT, None, span); + self.build_into( + source_map.as_ref(), + &mut root, + context::ROOT, + directives, + &options, + ) + .map_err(RoleDocumentBuildError::Build)?; + + match role { + ConfigDocumentRoleKind::HypervisorRoot | ConfigDocumentRoleKind::WorkerPishoo => { + let pishoo = root + .children_optional("pishoo") + .first() + .cloned() + .expect("document role validation requires one pishoo block"); + let fragment = ParsedPishooFragment::new(source_map, pishoo); + Ok(match role { + ConfigDocumentRoleKind::HypervisorRoot => { + ParsedConfigDocument::HypervisorRoot(fragment) + } + ConfigDocumentRoleKind::WorkerPishoo => { + ParsedConfigDocument::WorkerPishoo(fragment) + } + ConfigDocumentRoleKind::IdentityServer => { + unreachable!("identity role handled by the other match arm") + } + }) + } + ConfigDocumentRoleKind::IdentityServer => { + let servers = root + .children_optional("server") + .iter() + .cloned() + .map(|server| ParsedServerFragment::new(Arc::clone(&source_map), server)) + .collect(); + Ok(ParsedConfigDocument::IdentityServers(servers)) + } + } + } + + fn validate_role_directives( + &self, + source_map: &SourceMap, + directives: &[AstDirective], + context: ContextKey, + role: ConfigDocumentRoleKind, + ) -> Result<(), RoleDocumentBuildError> { + for directive in directives { + let Some(spec) = self + .directives + .get(&(context, directive.name.value.as_str())) + else { + continue; + }; + if !directive_allowed_for_role(spec, context, role) { + return Err(RoleDocumentBuildError::Role(Box::new( + ConfigDocumentRoleError::directive_not_allowed( + spec.name, + role, + source_map.config_span(directive.name.span), + ), + ))); + } + if let ( + DirectiveShape::ContextBlock { child_context, .. }, + AstBody::Block { children, .. }, + ) = (spec.shape, &directive.body) + { + self.validate_role_directives(source_map, children, child_context, role)?; + } + } + Ok(()) + } + fn build_into( &self, source_map: &SourceMap, @@ -311,7 +460,7 @@ impl ConfigRegistry { }; self.build_into(source_map, &mut child, child_context, children, options)?; self.finalize(&mut child, child_context, options)?; - node.insert_child(spec.name, Arc::new(child)); + node.insert_child(spec.name.as_str(), Arc::new(child)); } } } @@ -360,32 +509,107 @@ fn insert_slot( let span = value.span(); let value = normalize::normalize_slot_value(value, source_map).context( build_document_error::NormalizeDirectiveValueSnafu { - directive: spec.name.to_owned(), + directive: spec.name.as_str().to_owned(), span, }, )?; - match spec.merge { - MergePolicy::RejectDuplicate if !node.get_all_untyped(spec.name).is_empty() => { - let first = node.get_all_untyped(spec.name)[0].span(); + match spec.duplicate { + DuplicatePolicy::Reject if !node.get_all_untyped(spec.name.as_str()).is_empty() => { + let first = node.get_all_untyped(spec.name.as_str())[0].span(); build_document_error::DuplicateDirectiveSnafu { - directive: spec.name.to_owned(), + directive: spec.name.as_str().to_owned(), first, duplicate: value.span(), } .fail() } - MergePolicy::LastWins => { - node.replace_slot(spec.name, value); + DuplicatePolicy::LastWins => { + node.replace_slot(spec.name.as_str(), value); Ok(()) } _ => { - node.insert_slot(spec.name, value); + node.insert_slot(spec.name.as_str(), value); Ok(()) } } } +fn document_span(directives: &[AstDirective]) -> SourceSpan { + directives + .first() + .map(|directive| directive.span) + .unwrap_or(SourceSpan { + source_id: crate::parse::source::SourceId(0), + start: 0, + end: 0, + }) +} + +fn validate_document_shape( + source_map: &SourceMap, + directives: &[AstDirective], + role: ConfigDocumentRoleKind, +) -> Result<(), RoleDocumentBuildError> { + match role { + ConfigDocumentRoleKind::HypervisorRoot | ConfigDocumentRoleKind::WorkerPishoo => { + let pishoo = directives + .iter() + .filter(|directive| directive.name.value == "pishoo") + .collect::>(); + if pishoo.len() != 1 { + let span = pishoo + .get(1) + .copied() + .or_else(|| directives.first()) + .map_or_else(|| document_span(directives), |directive| directive.span); + return Err(RoleDocumentBuildError::Role(Box::new( + ConfigDocumentRoleError::expected_single_pishoo( + role, + pishoo.len(), + source_map.config_span(span), + pishoo.first().map(|directive| directive.span), + ), + ))); + } + } + ConfigDocumentRoleKind::IdentityServer => { + if !directives + .iter() + .any(|directive| directive.name.value == "server") + { + return Err(RoleDocumentBuildError::Role(Box::new( + ConfigDocumentRoleError::missing_identity_server( + source_map.config_span(document_span(directives)), + ), + ))); + } + } + } + Ok(()) +} + +fn directive_allowed_for_role( + spec: &DirectiveSpec, + context: ContextKey, + role: ConfigDocumentRoleKind, +) -> bool { + match role { + ConfigDocumentRoleKind::HypervisorRoot => { + context != context::ROOT || spec.name.as_str() == "pishoo" + } + ConfigDocumentRoleKind::WorkerPishoo => spec.transport != TransportPolicy::HypervisorOnly, + ConfigDocumentRoleKind::IdentityServer => { + context != context::ROOT || spec.name.as_str() == "server" + } + } +} + +pub(crate) enum RoleDocumentBuildError { + Role(Box), + Build(BuildDocumentError), +} + fn put_parent_recursively(node: &Arc, parent: Option<&Arc>) { node.set_parent(parent.map(Arc::downgrade)); for children in node.child_groups() { diff --git a/gateway/src/parse/source.rs b/gateway/src/parse/source.rs index f322886c..b54e72a3 100644 --- a/gateway/src/parse/source.rs +++ b/gateway/src/parse/source.rs @@ -4,6 +4,8 @@ use std::{ sync::Arc, }; +use crate::parse::domain::{ConfigDocumentId, ConfigSourceSpan}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SourceId(pub(crate) u32); @@ -44,8 +46,9 @@ pub struct SourceFile { pub included_from: Option, } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct SourceMap { + document_id: ConfigDocumentId, sources: Vec, } @@ -56,6 +59,21 @@ pub struct LineColumn { } impl SourceMap { + pub(crate) fn with_document_id(document_id: ConfigDocumentId) -> Self { + Self { + document_id, + sources: Vec::new(), + } + } + + pub fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub fn config_span(&self, span: SourceSpan) -> ConfigSourceSpan { + ConfigSourceSpan::new(self.document_id, span) + } + pub fn add_source( &mut self, path: Option, @@ -112,6 +130,14 @@ impl SourceMap { } } +impl Default for SourceMap { + fn default() -> Self { + Self::with_document_id( + ConfigDocumentId::try_from_index(0).expect("zero is a valid configuration document id"), + ) + } +} + impl fmt::Display for SourceId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "source:{}", self.0) diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 2381316c..6f5ca3ee 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -6,7 +6,18 @@ use std::{ }, }; -use crate::parse::document::{ConfigDocument, ConfigNode}; +use dhttp::home::DhttpHome; + +use crate::parse::{ + ConfigDocumentParser, + document::{ConfigDocument, ConfigNode}, + domain::{ConfigDocumentRole, ResolvedConfigPath}, + error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, + fragment::ParsedConfigDocument, + registry::{CascadePolicy, DuplicatePolicy, ReloadImpact, TransportPolicy, context}, + source::SourceId, + types::PathConfig, +}; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); @@ -90,3 +101,366 @@ pub(crate) fn assert_error_chain_display_single_line(error: &(dyn std::error::Er current = error.source(); } } + +fn parse_role_document( + text: &str, + source_path: &Path, + role: ConfigDocumentRole<'_>, +) -> Result { + let registry = crate::parse::default_registry(); + ConfigDocumentParser::new(®istry).parse_text(text, source_path, role) +} + +fn worker_home() -> DhttpHome { + DhttpHome::new(PathBuf::from("/tmp/worker/.dhttp")) +} + +fn worker_source_path() -> &'static Path { + Path::new("/tmp/worker/.dhttp/pishoo.conf") +} + +fn expect_role_error(failure: &ConfigLoadFailure) -> &ConfigDocumentRoleError { + let LoadConfigError::DocumentRole { source } = &failure.error else { + panic!("expected document role error, got {:#?}", failure.error); + }; + source +} + +#[test] +fn worker_role_rejects_hypervisor_only_directives() { + let home = worker_home(); + let failure = parse_role_document( + "pishoo { pid /run/pishoo.pid; }", + worker_source_path(), + ConfigDocumentRole::WorkerPishoo { home: &home }, + ) + .expect_err("worker config must reject supervisor directives"); + + let ConfigDocumentRoleError::DirectiveNotAllowed { + directive, span, .. + } = expect_role_error(&failure) + else { + panic!("expected role-rejected directive"); + }; + assert_eq!(directive.as_str(), "pid"); + assert_eq!(span.document_id(), failure.document_id()); + assert!(!span.is_empty()); +} + +#[test] +fn worker_role_rejects_direct_server_children() { + let home = worker_home(); + let failure = parse_role_document( + "pishoo { server { listen all 443; } }", + worker_source_path(), + ConfigDocumentRole::WorkerPishoo { home: &home }, + ) + .expect_err("worker config must reject direct server declarations"); + + let ConfigDocumentRoleError::DirectiveNotAllowed { + directive, span, .. + } = expect_role_error(&failure) + else { + panic!("expected role-rejected directive"); + }; + assert_eq!(directive.as_str(), "server"); + assert_eq!(span.document_id(), failure.document_id()); + assert!(!span.is_empty()); +} + +#[test] +fn hypervisor_root_requires_exactly_one_pishoo() { + let source = Path::new("/tmp/root/pishoo.conf"); + for (text, expected) in [("", 0), ("pishoo {} pishoo {}", 2)] { + let failure = parse_role_document( + text, + source, + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect_err("hypervisor root must contain exactly one pishoo block"); + + let ConfigDocumentRoleError::ExpectedSinglePishoo { found, span, .. } = + expect_role_error(&failure) + else { + panic!("expected pishoo cardinality error"); + }; + assert_eq!(*found, expected); + assert_eq!(span.document_id(), failure.document_id()); + } +} + +#[test] +fn existing_worker_document_requires_exactly_one_pishoo() { + let home = worker_home(); + for (text, expected) in [("", 0), ("pishoo {} pishoo {}", 2)] { + let failure = parse_role_document( + text, + worker_source_path(), + ConfigDocumentRole::WorkerPishoo { home: &home }, + ) + .expect_err("an existing worker document must contain exactly one pishoo block"); + + let ConfigDocumentRoleError::ExpectedSinglePishoo { found, span, .. } = + expect_role_error(&failure) + else { + panic!("expected pishoo cardinality error"); + }; + assert_eq!(*found, expected); + assert_eq!(span.document_id(), failure.document_id()); + } +} + +#[test] +fn worker_unknown_directive_is_a_configuration_error() { + let home = worker_home(); + let failure = parse_role_document( + "pishoo { unknown_setting on; }", + worker_source_path(), + ConfigDocumentRole::WorkerPishoo { home: &home }, + ) + .expect_err("worker config must reject unknown directives"); + + let report = snafu::Report::from_error(&failure.error).to_string(); + assert!(report.contains("unknown directive `unknown_setting`")); + assert_eq!(failure.document_id(), failure.source_map.document_id()); + assert!( + failure + .diagnostic() + .to_string() + .contains("/tmp/worker/.dhttp/pishoo.conf") + ); +} + +#[test] +fn default_root_uses_global_home_source_context() { + let home = DhttpHome::new(PathBuf::from("/tmp/root")); + let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( + "pishoo {}", + Path::new("/tmp/root/pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: Some(&home) }, + ) + .expect("default root should parse") else { + panic!("expected hypervisor root fragment"); + }; + + let source = fragment + .source_map() + .get(SourceId(0)) + .expect("root source should exist"); + assert_eq!( + source.path.as_deref(), + Some(Path::new("/tmp/root/pishoo.conf")) + ); + assert_eq!(source.base_dir.as_deref(), Some(Path::new("/tmp/root"))); +} + +#[test] +fn explicit_root_uses_explicit_file_source_context() { + let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( + "pishoo {}", + Path::new("/tmp/root/pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect("explicit root should parse") else { + panic!("expected hypervisor root fragment"); + }; + + let source = fragment + .source_map() + .get(SourceId(0)) + .expect("root source should exist"); + assert_eq!( + source.path.as_deref(), + Some(Path::new("/tmp/root/pishoo.conf")) + ); + assert_eq!(source.base_dir.as_deref(), Some(Path::new("/tmp/root"))); +} + +#[test] +fn identity_document_returns_detached_server_fragments() { + let home = worker_home(); + let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) + .expect("identity name should be valid"); + let profile = home.identity_profile(name); + let ParsedConfigDocument::IdentityServers(servers) = parse_role_document( + "server { listen all 443; } server { listen all 444; }", + Path::new("/tmp/worker/.dhttp/identities/alice/server.conf"), + ConfigDocumentRole::IdentityServer { + home: &home, + profile: &profile, + }, + ) + .expect("identity server document should parse") else { + panic!("expected identity server fragments"); + }; + + assert_eq!(servers.len(), 2); + assert!( + servers + .iter() + .all(|server| server.node().parent().is_none()) + ); +} + +#[test] +fn identity_document_requires_at_least_one_server() { + let home = worker_home(); + let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) + .expect("identity name should be valid"); + let profile = home.identity_profile(name); + let failure = parse_role_document( + "", + Path::new("/tmp/worker/.dhttp/identities/alice/server.conf"), + ConfigDocumentRole::IdentityServer { + home: &home, + profile: &profile, + }, + ) + .expect_err("identity server document must not be empty"); + + let ConfigDocumentRoleError::MissingIdentityServer { span, .. } = expect_role_error(&failure) + else { + panic!("expected missing identity server error"); + }; + assert_eq!(span.document_id(), failure.document_id()); +} + +#[test] +fn resolved_config_path_is_absolute_and_source_anchored() { + let include_dir = PathBuf::from("/tmp/root/includes"); + let include_name = format!( + "paths-{}-{}.conf", + std::process::id(), + NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + ); + let include_path = include_dir.join(&include_name); + let destination = include_dir.join("run/pishoo.pid"); + std::fs::create_dir_all(&include_dir).expect("create include fixture directory"); + let _ = std::fs::remove_file(&destination); + std::fs::write(&include_path, "pid run/pishoo.pid;").expect("write include fixture"); + + let text = format!("pishoo {{ include includes/{include_name}; }}"); + let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( + &text, + Path::new("/tmp/root/pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect("included path should parse") else { + panic!("expected hypervisor root fragment"); + }; + let path = fragment + .node() + .require::("pid") + .expect("pid should be typed") + .0 + .clone(); + let resolved = ResolvedConfigPath::try_from(path).expect("path should be resolved"); + + assert_eq!(resolved.as_ref(), destination); + assert!(resolved.as_ref().is_absolute()); + assert!( + !destination.exists(), + "parser must not create the destination" + ); + assert!(ResolvedConfigPath::try_from(PathBuf::from("relative/path")).is_err()); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + let nul_path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/nul\0path".to_vec())); + assert!(ResolvedConfigPath::try_from(nul_path).is_err()); + } + + std::fs::remove_file(include_path).expect("remove include fixture"); +} + +#[test] +fn document_ids_keep_equal_source_spans_distinct() { + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let ParsedConfigDocument::HypervisorRoot(root) = parser + .parse_text( + "pishoo {}", + Path::new("/tmp/root/pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect("root document should parse") + else { + panic!("expected hypervisor root fragment"); + }; + let home = worker_home(); + let ParsedConfigDocument::WorkerPishoo(worker) = parser + .parse_text( + "pishoo {}", + worker_source_path(), + ConfigDocumentRole::WorkerPishoo { home: &home }, + ) + .expect("worker document should parse") + else { + panic!("expected worker pishoo fragment"); + }; + + assert_eq!(root.span().start(), worker.span().start()); + assert_eq!(root.span().end(), worker.span().end()); + assert_ne!(root.span(), worker.span()); + assert_ne!(root.document_id(), worker.document_id()); +} + +#[test] +fn registry_v1_metadata_is_orthogonal() { + let registry = crate::parse::default_registry(); + for name in ["pid", "workers", "groups"] { + let spec = registry + .directive_spec(context::PISHOO, name) + .expect("supervisor directive should be registered"); + assert_eq!(spec.duplicate, DuplicatePolicy::Reject); + assert_eq!(spec.cascade, CascadePolicy::None); + assert_eq!(spec.transport, TransportPolicy::HypervisorOnly); + assert_eq!(spec.reload, ReloadImpact::Supervisor); + } + for name in [ + "access_rules", + "gzip", + "gzip_vary", + "gzip_min_length", + "gzip_comp_level", + "gzip_types", + "default_type", + ] { + let spec = registry + .directive_spec(context::PISHOO, name) + .expect("runtime default should be registered"); + assert_eq!(spec.duplicate, DuplicatePolicy::Reject); + assert_eq!(spec.cascade, CascadePolicy::NearestWins); + assert_eq!(spec.transport, TransportPolicy::WorkerInheritable); + assert_eq!(spec.reload, ReloadImpact::RuntimeState); + } + let types = registry + .directive_spec(context::PISHOO, "types") + .expect("types should be registered"); + assert_eq!(types.cascade, CascadePolicy::ReplaceWhole); + + let server = registry + .directive_spec(context::PISHOO, "server") + .expect("root server declaration should be registered"); + assert_eq!(server.duplicate, DuplicatePolicy::Append); + assert_eq!(server.cascade, CascadePolicy::None); + assert_eq!(server.transport, TransportPolicy::HypervisorOnly); + assert_eq!(server.reload, ReloadImpact::ListenerSet); +} + +#[test] +fn document_id_index_conversion_rejects_overflow() { + if usize::BITS > u32::BITS { + assert!( + crate::parse::domain::ConfigDocumentId::try_from_index(u32::MAX as usize + 1).is_err() + ); + } +} + +#[test] +fn loose_document_exposes_document_namespace() { + let document = parse_doc("pishoo {}"); + + assert_eq!(document.document_id(), document.source_map.document_id()); +} From 6338d5af8e00468621f28cf0f3f0dc073fe1fb46 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:22:01 +0800 Subject: [PATCH 02/18] fix(config): prevent document id reuse --- gateway/src/parse.rs | 8 +++--- gateway/src/parse/domain.rs | 53 +++++++++++++++++++++++++++++++++++-- gateway/src/parse/tests.rs | 8 +++--- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index e15c3e38..d093be75 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -21,14 +21,14 @@ pub mod value; pub struct ConfigDocumentParser<'registry> { registry: &'registry registry::ConfigRegistry, - next_document_index: usize, + document_ids: domain::ConfigDocumentIdAllocator, } impl<'registry> ConfigDocumentParser<'registry> { pub fn new(registry: &'registry registry::ConfigRegistry) -> Self { Self { registry, - next_document_index: 0, + document_ids: domain::ConfigDocumentIdAllocator::new(), } } @@ -38,7 +38,7 @@ impl<'registry> ConfigDocumentParser<'registry> { source_path: &Path, role: domain::ConfigDocumentRole<'_>, ) -> Result { - let document_id = match domain::ConfigDocumentId::try_from_index(self.next_document_index) { + let document_id = match self.document_ids.allocate() { Ok(document_id) => document_id, Err(source) => { return Err(error::ConfigLoadFailure { @@ -47,8 +47,6 @@ impl<'registry> ConfigDocumentParser<'registry> { }); } }; - self.next_document_index = self.next_document_index.saturating_add(1); - let role_kind = role.kind(); let options = role.build_options(); let source_path = source_path.to_path_buf(); diff --git a/gateway/src/parse/domain.rs b/gateway/src/parse/domain.rs index db42e6dd..608ae251 100644 --- a/gateway/src/parse/domain.rs +++ b/gateway/src/parse/domain.rs @@ -12,13 +12,37 @@ use crate::parse::{registry::BuildOptions, source::SourceSpan}; pub struct ConfigDocumentId(u32); impl ConfigDocumentId { - pub(crate) fn try_from_index(index: usize) -> Result { + pub(crate) fn try_from_index(index: u64) -> Result { let index = u32::try_from(index).context(config_document_id_error::IndexOverflowSnafu { index })?; Ok(Self(index)) } } +pub(crate) struct ConfigDocumentIdAllocator { + next_index: u64, +} + +impl ConfigDocumentIdAllocator { + pub(crate) fn new() -> Self { + Self { next_index: 0 } + } + + #[cfg(test)] + fn with_next_index(next_index: u64) -> Self { + Self { next_index } + } + + pub(crate) fn allocate(&mut self) -> Result { + let document_id = ConfigDocumentId::try_from_index(self.next_index)?; + self.next_index = self + .next_index + .checked_add(1) + .expect("a valid u32 document index always has a u64 successor"); + Ok(document_id) + } +} + impl fmt::Display for ConfigDocumentId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "document:{}", self.0) @@ -30,7 +54,7 @@ impl fmt::Display for ConfigDocumentId { pub enum ConfigDocumentIdError { #[snafu(display("configuration document index exceeds the supported range"))] IndexOverflow { - index: usize, + index: u64, source: std::num::TryFromIntError, }, } @@ -181,3 +205,28 @@ pub enum ResolvedConfigPathError { #[snafu(display("resolved configuration path contains a NUL byte"))] Nul { path: PathBuf }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_id_allocator_errors_after_allocating_maximum_id() { + let maximum_index = u64::from(u32::MAX); + let mut allocator = ConfigDocumentIdAllocator::with_next_index(maximum_index); + + assert_eq!( + allocator.allocate().expect("maximum id should allocate"), + ConfigDocumentId::try_from_index(maximum_index) + .expect("maximum index should be a valid document id") + ); + let error = allocator + .allocate() + .expect_err("allocator must not reuse the maximum id"); + assert!(matches!( + error, + ConfigDocumentIdError::IndexOverflow { index, .. } + if index == maximum_index + 1 + )); + } +} diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 6f5ca3ee..45ec93f2 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -451,11 +451,9 @@ fn registry_v1_metadata_is_orthogonal() { #[test] fn document_id_index_conversion_rejects_overflow() { - if usize::BITS > u32::BITS { - assert!( - crate::parse::domain::ConfigDocumentId::try_from_index(u32::MAX as usize + 1).is_err() - ); - } + assert!( + crate::parse::domain::ConfigDocumentId::try_from_index(u64::from(u32::MAX) + 1).is_err() + ); } #[test] From 7de8490d4d818c0104549ffb030d837f828b7fb9 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:49:19 +0800 Subject: [PATCH 03/18] fix(config): harden role parser contracts --- gateway/src/parse.rs | 33 +++- gateway/src/parse/diagnostic.rs | 17 ++ gateway/src/parse/document.rs | 4 +- gateway/src/parse/domain.rs | 24 ++- gateway/src/parse/error.rs | 93 +++++---- gateway/src/parse/fragment.rs | 53 ++--- gateway/src/parse/normalize.rs | 6 +- gateway/src/parse/registry.rs | 163 ++++++++++++---- gateway/src/parse/source.rs | 47 ++--- gateway/src/parse/tests.rs | 334 +++++++++++++++++++++++++------- 10 files changed, 556 insertions(+), 218 deletions(-) diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index d093be75..df46ace6 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -32,6 +32,17 @@ impl<'registry> ConfigDocumentParser<'registry> { } } + #[cfg(test)] + pub(crate) fn with_next_document_index( + registry: &'registry registry::ConfigRegistry, + next_index: u64, + ) -> Self { + Self { + registry, + document_ids: domain::ConfigDocumentIdAllocator::with_next_index(next_index), + } + } + pub fn parse_text( &mut self, text: &str, @@ -44,13 +55,14 @@ impl<'registry> ConfigDocumentParser<'registry> { return Err(error::ConfigLoadFailure { error: error::LoadConfigError::DocumentId { source }, source_map: Arc::new(source::SourceMap::default()), + document_id: None, }); } }; let role_kind = role.kind(); let options = role.build_options(); let source_path = source_path.to_path_buf(); - let mut source_map = source::SourceMap::with_document_id(document_id); + let mut source_map = source::SourceMap::default(); let source_id = source_map.add_source( Some(source_path.clone()), Arc::from(text), @@ -66,6 +78,7 @@ impl<'registry> ConfigDocumentParser<'registry> { return Err(error::ConfigLoadFailure { error, source_map: Arc::new(source_map), + document_id: Some(document_id), }); } }; @@ -79,23 +92,30 @@ impl<'registry> ConfigDocumentParser<'registry> { return Err(error::ConfigLoadFailure { error, source_map: Arc::new(source_map), + document_id: Some(document_id), }); } }; let source_map = Arc::new(source_map); + let document_sources = Arc::new(source::ConfigDocumentSourceMap::new( + document_id, + Arc::clone(&source_map), + )); match self .registry - .build_for_role(Arc::clone(&source_map), directives, options, role_kind) + .build_for_role(document_sources, directives, options, role_kind) { Ok(document) => Ok(document), Err(registry::RoleDocumentBuildError::Role(source)) => Err(error::ConfigLoadFailure { error: error::LoadConfigError::DocumentRole { source }, source_map, + document_id: Some(document_id), }), Err(registry::RoleDocumentBuildError::Build(source)) => Err(error::ConfigLoadFailure { error: error::LoadConfigError::BuildDocument { source }, source_map, + document_id: Some(document_id), }), } } @@ -173,6 +193,7 @@ fn load_config_text_inner( return Err(error::ConfigLoadFailure { error, source_map: Arc::new(source_map), + document_id: None, }); } }; @@ -185,6 +206,7 @@ fn load_config_text_inner( return Err(error::ConfigLoadFailure { error, source_map: Arc::new(source_map), + document_id: None, }); } }; @@ -195,7 +217,11 @@ fn load_config_text_inner( .context(error::load_config_error::BuildDocumentSnafu) { Ok(document) => Ok(document), - Err(error) => Err(error::ConfigLoadFailure { error, source_map }), + Err(error) => Err(error::ConfigLoadFailure { + error, + source_map, + document_id: None, + }), } } @@ -214,6 +240,7 @@ pub async fn load_config_file( source, }, source_map: Arc::new(source::SourceMap::default()), + document_id: None, }); } }; diff --git a/gateway/src/parse/diagnostic.rs b/gateway/src/parse/diagnostic.rs index 26f7c5c3..c6f01d34 100644 --- a/gateway/src/parse/diagnostic.rs +++ b/gateway/src/parse/diagnostic.rs @@ -99,6 +99,23 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { crate::parse::grammar::ParseSyntaxError::Syntax { span, .. } => Some(*span), }; } + if let Some(error) = error.downcast_ref::() { + let span = match error { + crate::parse::error::ConfigDocumentRoleError::DirectiveNotAllowed { span, .. } + | crate::parse::error::ConfigDocumentRoleError::ExpectedSinglePishoo { span, .. } + | crate::parse::error::ConfigDocumentRoleError::MissingIdentityServer { + span, .. + } + | crate::parse::error::ConfigDocumentRoleError::InvalidDirectiveRegistration { + span, + .. + } + | crate::parse::error::ConfigDocumentRoleError::MissingBuiltDirective { + span, .. + } => span, + }; + return Some(span.source_span()); + } if let Some(error) = error.downcast_ref::() { return match error { crate::parse::error::BuildDocumentError::UnknownDirective { span, .. } diff --git a/gateway/src/parse/document.rs b/gateway/src/parse/document.rs index 0907ab00..cd67f835 100644 --- a/gateway/src/parse/document.rs +++ b/gateway/src/parse/document.rs @@ -41,8 +41,8 @@ impl ConfigDocument { Self { source_map, root } } - pub fn document_id(&self) -> ConfigDocumentId { - self.source_map.document_id() + pub fn document_id(&self) -> Option { + None } } diff --git a/gateway/src/parse/domain.rs b/gateway/src/parse/domain.rs index 608ae251..f555fc54 100644 --- a/gateway/src/parse/domain.rs +++ b/gateway/src/parse/domain.rs @@ -29,16 +29,18 @@ impl ConfigDocumentIdAllocator { } #[cfg(test)] - fn with_next_index(next_index: u64) -> Self { + pub(crate) fn with_next_index(next_index: u64) -> Self { Self { next_index } } pub(crate) fn allocate(&mut self) -> Result { let document_id = ConfigDocumentId::try_from_index(self.next_index)?; - self.next_index = self - .next_index - .checked_add(1) - .expect("a valid u32 document index always has a u64 successor"); + self.next_index = + self.next_index + .checked_add(1) + .ok_or(ConfigDocumentIdError::CounterOverflow { + index: self.next_index, + })?; Ok(document_id) } } @@ -57,6 +59,8 @@ pub enum ConfigDocumentIdError { index: u64, source: std::num::TryFromIntError, }, + #[snafu(display("configuration document counter cannot advance past index {index}"))] + CounterOverflow { index: u64 }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -152,14 +156,14 @@ impl ConfigDocumentRole<'_> { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ConfigDocumentRoleKind { +pub enum ConfigDocumentRoleKind { HypervisorRoot, WorkerPishoo, IdentityServer, } impl ConfigDocumentRoleKind { - pub(crate) const fn as_str(self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::HypervisorRoot => "hypervisor root", Self::WorkerPishoo => "worker pishoo", @@ -168,6 +172,12 @@ impl ConfigDocumentRoleKind { } } +impl fmt::Display for ConfigDocumentRoleKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ResolvedConfigPath(PathBuf); diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index d1af3f49..fd22b61f 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -8,6 +8,7 @@ use crate::parse::{ DirectiveName, }, grammar::ParseSyntaxError, + registry::ContextKey, source::{SourceId, SourceMap, SourceSpan}, }; @@ -36,9 +37,7 @@ pub enum LoadConfigError { BuildDocument { source: BuildDocumentError }, #[snafu(display("configuration document is invalid for its role"))] - DocumentRole { - source: Box, - }, + DocumentRole { source: ConfigDocumentRoleError }, } #[derive(Debug, Snafu)] @@ -47,27 +46,42 @@ pub enum ConfigDocumentRoleError { #[snafu(display("directive `{directive}` is not allowed in a {role} configuration document"))] DirectiveNotAllowed { directive: DirectiveName, - role: &'static str, + role: ConfigDocumentRoleKind, span: ConfigSourceSpan, - source: Box, }, #[snafu(display( "{role} configuration document must contain exactly one top-level pishoo block, found {found}" ))] ExpectedSinglePishoo { - role: &'static str, + role: ConfigDocumentRoleKind, found: usize, span: ConfigSourceSpan, - source: Box, }, #[snafu(display( "identity server configuration document must contain at least one server block" ))] MissingIdentityServer { + role: ConfigDocumentRoleKind, + span: ConfigSourceSpan, + }, + + #[snafu(display( + "directive `{directive}` has an invalid registry contract for a {role} configuration document; expected a top-level `{expected_child_context}` context block" + ))] + InvalidDirectiveRegistration { + directive: DirectiveName, + role: ConfigDocumentRoleKind, + expected_child_context: ContextKey, + span: ConfigSourceSpan, + }, + + #[snafu(display("directive `{directive}` was not built for a {role} configuration document"))] + MissingBuiltDirective { + directive: DirectiveName, + role: ConfigDocumentRoleKind, span: ConfigSourceSpan, - source: Box, }, } @@ -79,13 +93,8 @@ impl ConfigDocumentRoleError { ) -> Self { Self::DirectiveNotAllowed { directive, - role: role.as_str(), + role, span, - source: Box::new(BuildDocumentError::InvalidContext { - directive: directive.as_str().to_owned(), - context: role.as_str(), - span: span.source_span(), - }), } } @@ -93,34 +102,40 @@ impl ConfigDocumentRoleError { role: ConfigDocumentRoleKind, found: usize, span: ConfigSourceSpan, - first: Option, ) -> Self { - let source = match first { - Some(first) => BuildDocumentError::DuplicateDirective { - directive: "pishoo".to_owned(), - first, - duplicate: span.source_span(), - }, - None => BuildDocumentError::MissingRequiredDirective { - directive: "pishoo", - context_span: span.source_span(), - }, - }; - Self::ExpectedSinglePishoo { - role: role.as_str(), - found, + Self::ExpectedSinglePishoo { role, found, span } + } + + pub(crate) fn missing_identity_server( + role: ConfigDocumentRoleKind, + span: ConfigSourceSpan, + ) -> Self { + Self::MissingIdentityServer { role, span } + } + + pub(crate) fn invalid_directive_registration( + directive: DirectiveName, + role: ConfigDocumentRoleKind, + expected_child_context: ContextKey, + span: ConfigSourceSpan, + ) -> Self { + Self::InvalidDirectiveRegistration { + directive, + role, + expected_child_context, span, - source: Box::new(source), } } - pub(crate) fn missing_identity_server(span: ConfigSourceSpan) -> Self { - Self::MissingIdentityServer { + pub(crate) fn missing_built_directive( + directive: DirectiveName, + role: ConfigDocumentRoleKind, + span: ConfigSourceSpan, + ) -> Self { + Self::MissingBuiltDirective { + directive, + role, span, - source: Box::new(BuildDocumentError::MissingRequiredDirective { - directive: "server", - context_span: span.source_span(), - }), } } } @@ -238,6 +253,7 @@ pub enum ConfigQueryError { pub struct ConfigLoadFailure { pub error: LoadConfigError, pub source_map: Arc, + pub(crate) document_id: Option, } impl std::fmt::Display for ConfigLoadFailure { @@ -253,8 +269,8 @@ impl std::error::Error for ConfigLoadFailure { } impl ConfigLoadFailure { - pub fn document_id(&self) -> ConfigDocumentId { - self.source_map.document_id() + pub fn document_id(&self) -> Option { + self.document_id } } @@ -273,6 +289,7 @@ mod tests { }, }, source_map: Arc::new(SourceMap::default()), + document_id: None, }; assert_eq!(failure.to_string(), "failed to load configuration"); diff --git a/gateway/src/parse/fragment.rs b/gateway/src/parse/fragment.rs index 35f11ea3..ee4e868e 100644 --- a/gateway/src/parse/fragment.rs +++ b/gateway/src/parse/fragment.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::parse::{ document::ConfigNode, domain::{ConfigDocumentId, ConfigSourceSpan}, - source::SourceMap, + source::{ConfigDocumentSourceMap, SourceMap}, }; #[derive(Debug)] @@ -15,50 +15,45 @@ pub enum ParsedConfigDocument { #[derive(Debug)] pub struct ParsedPishooFragment { - document_id: ConfigDocumentId, - source_map: Arc, + sources: Arc, node: Arc, servers: Box<[ParsedServerFragment]>, } #[derive(Debug)] pub struct ParsedServerFragment { - document_id: ConfigDocumentId, - source_map: Arc, + sources: Arc, node: Arc, locations: Box<[ParsedLocationFragment]>, } #[derive(Debug)] pub struct ParsedLocationFragment { - document_id: ConfigDocumentId, - source_map: Arc, + sources: Arc, node: Arc, } impl ParsedPishooFragment { - pub(crate) fn new(source_map: Arc, node: Arc) -> Self { - let document_id = source_map.document_id(); + pub(crate) fn new(sources: Arc, node: Arc) -> Self { let servers = node .children_optional("server") .iter() .cloned() - .map(|server| ParsedServerFragment::new(Arc::clone(&source_map), server)) + .map(|server| ParsedServerFragment::new(Arc::clone(&sources), server)) .collect(); Self { - document_id, - source_map, + sources, node, servers, } } pub fn document_id(&self) -> ConfigDocumentId { - self.document_id + self.sources.document_id() } pub fn span(&self) -> ConfigSourceSpan { - self.source_map.config_span(self.node.span) + self.sources.config_span(self.node.span) } pub fn servers(&self) -> &[ParsedServerFragment] { @@ -67,7 +62,7 @@ impl ParsedPishooFragment { #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn source_map(&self) -> &SourceMap { - &self.source_map + self.sources.source_map() } #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage @@ -77,28 +72,26 @@ impl ParsedPishooFragment { } impl ParsedServerFragment { - pub(crate) fn new(source_map: Arc, node: Arc) -> Self { - let document_id = source_map.document_id(); + pub(crate) fn new(sources: Arc, node: Arc) -> Self { let locations = node .children_optional("location") .iter() .cloned() - .map(|location| ParsedLocationFragment::new(Arc::clone(&source_map), location)) + .map(|location| ParsedLocationFragment::new(Arc::clone(&sources), location)) .collect(); Self { - document_id, - source_map, + sources, node, locations, } } pub fn document_id(&self) -> ConfigDocumentId { - self.document_id + self.sources.document_id() } pub fn span(&self) -> ConfigSourceSpan { - self.source_map.config_span(self.node.span) + self.sources.config_span(self.node.span) } pub fn locations(&self) -> &[ParsedLocationFragment] { @@ -107,7 +100,7 @@ impl ParsedServerFragment { #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn source_map(&self) -> &SourceMap { - &self.source_map + self.sources.source_map() } #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage @@ -117,25 +110,21 @@ impl ParsedServerFragment { } impl ParsedLocationFragment { - fn new(source_map: Arc, node: Arc) -> Self { - Self { - document_id: source_map.document_id(), - source_map, - node, - } + fn new(sources: Arc, node: Arc) -> Self { + Self { sources, node } } pub fn document_id(&self) -> ConfigDocumentId { - self.document_id + self.sources.document_id() } pub fn span(&self) -> ConfigSourceSpan { - self.source_map.config_span(self.node.span) + self.sources.config_span(self.node.span) } #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn source_map(&self) -> &SourceMap { - &self.source_map + self.sources.source_map() } #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage diff --git a/gateway/src/parse/normalize.rs b/gateway/src/parse/normalize.rs index 482982ec..c36d6606 100644 --- a/gateway/src/parse/normalize.rs +++ b/gateway/src/parse/normalize.rs @@ -21,7 +21,7 @@ pub enum NormalizeDirectiveValueError { }, } -pub fn resolve_config_path( +fn resolve_config_path( path: &Path, span: SourceSpan, source_map: &SourceMap, @@ -38,7 +38,7 @@ pub fn resolve_config_path( .context(normalize_directive_value_error::InvalidResolvedPathSnafu { span }) } -pub fn normalize_path( +pub(crate) fn normalize_path( path: &Path, span: SourceSpan, source_map: &SourceMap, @@ -46,7 +46,7 @@ pub fn normalize_path( resolve_config_path(path, span, source_map).map(Into::into) } -pub fn normalize_slot_value( +pub(crate) fn normalize_slot_value( value: TypedValue, source_map: &SourceMap, ) -> Result { diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 5c9982c3..729b0d00 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -9,13 +9,19 @@ use crate::parse::{ error::{BuildDocumentError, ConfigDocumentRoleError, build_document_error}, fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, normalize, - source::{SourceMap, SourceSpan}, + source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, value::{ConfigValue, TypedValue}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ContextKey(pub &'static str); +impl std::fmt::Display for ContextKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + pub mod context { use super::ContextKey; @@ -323,18 +329,19 @@ impl ConfigRegistry { pub(crate) fn build_for_role( &self, - source_map: Arc, + sources: Arc, directives: Vec, options: BuildOptions<'_>, role: ConfigDocumentRoleKind, ) -> Result { - validate_document_shape(&source_map, &directives, role)?; - self.validate_role_directives(&source_map, &directives, context::ROOT, role)?; + self.validate_role_registration(&sources, &directives, role)?; + validate_document_shape(&sources, &directives, role)?; + self.validate_role_directives(&sources, &directives, context::ROOT, role)?; let span = document_span(&directives); let mut root = ConfigNode::new(context::ROOT, None, span); self.build_into( - source_map.as_ref(), + sources.source_map(), &mut root, context::ROOT, directives, @@ -343,40 +350,107 @@ impl ConfigRegistry { .map_err(RoleDocumentBuildError::Build)?; match role { - ConfigDocumentRoleKind::HypervisorRoot | ConfigDocumentRoleKind::WorkerPishoo => { - let pishoo = root - .children_optional("pishoo") - .first() - .cloned() - .expect("document role validation requires one pishoo block"); - let fragment = ParsedPishooFragment::new(source_map, pishoo); - Ok(match role { - ConfigDocumentRoleKind::HypervisorRoot => { - ParsedConfigDocument::HypervisorRoot(fragment) - } - ConfigDocumentRoleKind::WorkerPishoo => { - ParsedConfigDocument::WorkerPishoo(fragment) - } - ConfigDocumentRoleKind::IdentityServer => { - unreachable!("identity role handled by the other match arm") - } - }) + ConfigDocumentRoleKind::HypervisorRoot => { + let pishoo = self.required_built_child(&sources, &root, "pishoo", role)?; + Ok(ParsedConfigDocument::HypervisorRoot( + ParsedPishooFragment::new(sources, pishoo), + )) + } + ConfigDocumentRoleKind::WorkerPishoo => { + let pishoo = self.required_built_child(&sources, &root, "pishoo", role)?; + Ok(ParsedConfigDocument::WorkerPishoo( + ParsedPishooFragment::new(sources, pishoo), + )) } ConfigDocumentRoleKind::IdentityServer => { - let servers = root + let servers: Box<[_]> = root .children_optional("server") .iter() .cloned() - .map(|server| ParsedServerFragment::new(Arc::clone(&source_map), server)) + .map(|server| ParsedServerFragment::new(Arc::clone(&sources), server)) .collect(); + if servers.is_empty() { + return Err(RoleDocumentBuildError::Role( + ConfigDocumentRoleError::missing_built_directive( + DirectiveName::new("server"), + role, + sources.config_span(root.span), + ), + )); + } Ok(ParsedConfigDocument::IdentityServers(servers)) } } } + fn validate_role_registration( + &self, + sources: &ConfigDocumentSourceMap, + directives: &[AstDirective], + role: ConfigDocumentRoleKind, + ) -> Result<(), RoleDocumentBuildError> { + let (directive, expected_child_context) = match role { + ConfigDocumentRoleKind::HypervisorRoot | ConfigDocumentRoleKind::WorkerPishoo => { + (DirectiveName::new("pishoo"), context::PISHOO) + } + ConfigDocumentRoleKind::IdentityServer => { + (DirectiveName::new("server"), context::SERVER) + } + }; + let spec = self.directives.get(&(context::ROOT, directive.as_str())); + let valid = spec.is_some_and(|spec| { + spec.allowed_in.contains(&context::ROOT) + && matches!( + spec.shape, + DirectiveShape::ContextBlock { + child_context, + payload: PayloadMode::None, + } if child_context == expected_child_context + ) + && self.contexts.contains_key(&expected_child_context) + }); + if valid { + return Ok(()); + } + let span = directives + .iter() + .find(|candidate| candidate.name.value == directive.as_str()) + .map_or_else( + || document_span(directives), + |candidate| candidate.name.span, + ); + Err(RoleDocumentBuildError::Role( + ConfigDocumentRoleError::invalid_directive_registration( + directive, + role, + expected_child_context, + sources.config_span(span), + ), + )) + } + + fn required_built_child( + &self, + sources: &ConfigDocumentSourceMap, + root: &ConfigNode, + directive: &'static str, + role: ConfigDocumentRoleKind, + ) -> Result, RoleDocumentBuildError> { + root.children_optional(directive) + .first() + .cloned() + .ok_or_else(|| { + RoleDocumentBuildError::Role(ConfigDocumentRoleError::missing_built_directive( + DirectiveName::new(directive), + role, + sources.config_span(root.span), + )) + }) + } + fn validate_role_directives( &self, - source_map: &SourceMap, + sources: &ConfigDocumentSourceMap, directives: &[AstDirective], context: ContextKey, role: ConfigDocumentRoleKind, @@ -389,20 +463,20 @@ impl ConfigRegistry { continue; }; if !directive_allowed_for_role(spec, context, role) { - return Err(RoleDocumentBuildError::Role(Box::new( + return Err(RoleDocumentBuildError::Role( ConfigDocumentRoleError::directive_not_allowed( spec.name, role, - source_map.config_span(directive.name.span), + sources.config_span(directive.name.span), ), - ))); + )); } if let ( DirectiveShape::ContextBlock { child_context, .. }, AstBody::Block { children, .. }, ) = (spec.shape, &directive.body) { - self.validate_role_directives(source_map, children, child_context, role)?; + self.validate_role_directives(sources, children, child_context, role)?; } } Ok(()) @@ -455,8 +529,15 @@ impl ConfigRegistry { { child.set_payload(value); } - let AstBody::Block { children, .. } = directive.body else { - unreachable!("shape checked block before child build"); + let children = match directive.body { + AstBody::Block { children, .. } => children, + AstBody::Leaf { .. } => { + return build_document_error::InvalidDirectiveShapeSnafu { + directive: directive_name, + span: directive.span, + } + .fail(); + } }; self.build_into(source_map, &mut child, child_context, children, options)?; self.finalize(&mut child, child_context, options)?; @@ -547,7 +628,7 @@ fn document_span(directives: &[AstDirective]) -> SourceSpan { } fn validate_document_shape( - source_map: &SourceMap, + sources: &ConfigDocumentSourceMap, directives: &[AstDirective], role: ConfigDocumentRoleKind, ) -> Result<(), RoleDocumentBuildError> { @@ -563,14 +644,13 @@ fn validate_document_shape( .copied() .or_else(|| directives.first()) .map_or_else(|| document_span(directives), |directive| directive.span); - return Err(RoleDocumentBuildError::Role(Box::new( + return Err(RoleDocumentBuildError::Role( ConfigDocumentRoleError::expected_single_pishoo( role, pishoo.len(), - source_map.config_span(span), - pishoo.first().map(|directive| directive.span), + sources.config_span(span), ), - ))); + )); } } ConfigDocumentRoleKind::IdentityServer => { @@ -578,11 +658,12 @@ fn validate_document_shape( .iter() .any(|directive| directive.name.value == "server") { - return Err(RoleDocumentBuildError::Role(Box::new( + return Err(RoleDocumentBuildError::Role( ConfigDocumentRoleError::missing_identity_server( - source_map.config_span(document_span(directives)), + role, + sources.config_span(document_span(directives)), ), - ))); + )); } } } @@ -606,7 +687,7 @@ fn directive_allowed_for_role( } pub(crate) enum RoleDocumentBuildError { - Role(Box), + Role(ConfigDocumentRoleError), Build(BuildDocumentError), } diff --git a/gateway/src/parse/source.rs b/gateway/src/parse/source.rs index b54e72a3..b1f34a44 100644 --- a/gateway/src/parse/source.rs +++ b/gateway/src/parse/source.rs @@ -46,12 +46,17 @@ pub struct SourceFile { pub included_from: Option, } -#[derive(Debug)] +#[derive(Debug, Default)] pub struct SourceMap { - document_id: ConfigDocumentId, sources: Vec, } +#[derive(Debug)] +pub(crate) struct ConfigDocumentSourceMap { + document_id: ConfigDocumentId, + source_map: Arc, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LineColumn { pub line: usize, @@ -59,21 +64,6 @@ pub struct LineColumn { } impl SourceMap { - pub(crate) fn with_document_id(document_id: ConfigDocumentId) -> Self { - Self { - document_id, - sources: Vec::new(), - } - } - - pub fn document_id(&self) -> ConfigDocumentId { - self.document_id - } - - pub fn config_span(&self, span: SourceSpan) -> ConfigSourceSpan { - ConfigSourceSpan::new(self.document_id, span) - } - pub fn add_source( &mut self, path: Option, @@ -130,11 +120,24 @@ impl SourceMap { } } -impl Default for SourceMap { - fn default() -> Self { - Self::with_document_id( - ConfigDocumentId::try_from_index(0).expect("zero is a valid configuration document id"), - ) +impl ConfigDocumentSourceMap { + pub(crate) fn new(document_id: ConfigDocumentId, source_map: Arc) -> Self { + Self { + document_id, + source_map, + } + } + + pub(crate) fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub(crate) fn config_span(&self, span: SourceSpan) -> ConfigSourceSpan { + ConfigSourceSpan::new(self.document_id, span) + } + + pub(crate) fn source_map(&self) -> &SourceMap { + &self.source_map } } diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 45ec93f2..32c27d70 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -1,4 +1,5 @@ use std::{ + fs, path::{Path, PathBuf}, sync::{ Arc, @@ -11,16 +12,53 @@ use dhttp::home::DhttpHome; use crate::parse::{ ConfigDocumentParser, document::{ConfigDocument, ConfigNode}, - domain::{ConfigDocumentRole, ResolvedConfigPath}, + domain::{ConfigDocumentRole, ConfigDocumentRoleKind, ResolvedConfigPath}, error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, fragment::ParsedConfigDocument, - registry::{CascadePolicy, DuplicatePolicy, ReloadImpact, TransportPolicy, context}, + registry::{ + CascadePolicy, DirectiveSpec, DuplicatePolicy, ReloadImpact, TransportPolicy, context, + }, source::SourceId, types::PathConfig, }; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); +#[derive(Debug)] +struct TempConfigDir { + path: PathBuf, +} + +impl TempConfigDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "gateway_parse_{prefix}_{}_{}", + std::process::id(), + NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).expect("create temporary config fixture directory"); + Self { path } + } + + fn join(&self, path: impl AsRef) -> PathBuf { + self.path.join(path) + } + + fn worker_home(&self) -> DhttpHome { + DhttpHome::new(self.join(".dhttp")) + } + + fn worker_source_path(&self) -> PathBuf { + self.join(".dhttp/pishoo.conf") + } +} + +impl Drop for TempConfigDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + pub(crate) fn create_temp_file(prefix: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( "gateway_{prefix}_{}_{}.pem", @@ -111,14 +149,6 @@ fn parse_role_document( ConfigDocumentParser::new(®istry).parse_text(text, source_path, role) } -fn worker_home() -> DhttpHome { - DhttpHome::new(PathBuf::from("/tmp/worker/.dhttp")) -} - -fn worker_source_path() -> &'static Path { - Path::new("/tmp/worker/.dhttp/pishoo.conf") -} - fn expect_role_error(failure: &ConfigLoadFailure) -> &ConfigDocumentRoleError { let LoadConfigError::DocumentRole { source } = &failure.error else { panic!("expected document role error, got {:#?}", failure.error); @@ -128,115 +158,147 @@ fn expect_role_error(failure: &ConfigLoadFailure) -> &ConfigDocumentRoleError { #[test] fn worker_role_rejects_hypervisor_only_directives() { - let home = worker_home(); + let fixture = TempConfigDir::new("worker_hypervisor_only"); + let home = fixture.worker_home(); + let source_path = fixture.worker_source_path(); let failure = parse_role_document( "pishoo { pid /run/pishoo.pid; }", - worker_source_path(), + &source_path, ConfigDocumentRole::WorkerPishoo { home: &home }, ) .expect_err("worker config must reject supervisor directives"); let ConfigDocumentRoleError::DirectiveNotAllowed { - directive, span, .. + directive, + role, + span, } = expect_role_error(&failure) else { panic!("expected role-rejected directive"); }; assert_eq!(directive.as_str(), "pid"); - assert_eq!(span.document_id(), failure.document_id()); + assert_eq!(Some(span.document_id()), failure.document_id()); + assert_eq!(*role, ConfigDocumentRoleKind::WorkerPishoo); assert!(!span.is_empty()); + let cause = std::error::Error::source(&failure.error).expect("role error should be the cause"); + let role_cause = cause + .downcast_ref::() + .expect("load error should expose the genuine role error cause"); + assert!(std::error::Error::source(role_cause).is_none()); + assert!( + failure + .diagnostic() + .to_string() + .contains(&source_path.display().to_string()) + ); } #[test] fn worker_role_rejects_direct_server_children() { - let home = worker_home(); + let fixture = TempConfigDir::new("worker_server_child"); + let home = fixture.worker_home(); + let source_path = fixture.worker_source_path(); let failure = parse_role_document( "pishoo { server { listen all 443; } }", - worker_source_path(), + &source_path, ConfigDocumentRole::WorkerPishoo { home: &home }, ) .expect_err("worker config must reject direct server declarations"); let ConfigDocumentRoleError::DirectiveNotAllowed { - directive, span, .. + directive, + role, + span, } = expect_role_error(&failure) else { panic!("expected role-rejected directive"); }; assert_eq!(directive.as_str(), "server"); - assert_eq!(span.document_id(), failure.document_id()); + assert_eq!(Some(span.document_id()), failure.document_id()); + assert_eq!(*role, ConfigDocumentRoleKind::WorkerPishoo); assert!(!span.is_empty()); } #[test] fn hypervisor_root_requires_exactly_one_pishoo() { - let source = Path::new("/tmp/root/pishoo.conf"); + let fixture = TempConfigDir::new("root_pishoo_cardinality"); + let source = fixture.join("pishoo.conf"); for (text, expected) in [("", 0), ("pishoo {} pishoo {}", 2)] { let failure = parse_role_document( text, - source, + &source, ConfigDocumentRole::HypervisorRoot { home: None }, ) .expect_err("hypervisor root must contain exactly one pishoo block"); - let ConfigDocumentRoleError::ExpectedSinglePishoo { found, span, .. } = - expect_role_error(&failure) + let ConfigDocumentRoleError::ExpectedSinglePishoo { + role, found, span, .. + } = expect_role_error(&failure) else { panic!("expected pishoo cardinality error"); }; assert_eq!(*found, expected); - assert_eq!(span.document_id(), failure.document_id()); + assert_eq!(Some(span.document_id()), failure.document_id()); + assert_eq!(*role, ConfigDocumentRoleKind::HypervisorRoot); } } #[test] fn existing_worker_document_requires_exactly_one_pishoo() { - let home = worker_home(); + let fixture = TempConfigDir::new("worker_pishoo_cardinality"); + let home = fixture.worker_home(); + let source_path = fixture.worker_source_path(); for (text, expected) in [("", 0), ("pishoo {} pishoo {}", 2)] { let failure = parse_role_document( text, - worker_source_path(), + &source_path, ConfigDocumentRole::WorkerPishoo { home: &home }, ) .expect_err("an existing worker document must contain exactly one pishoo block"); - let ConfigDocumentRoleError::ExpectedSinglePishoo { found, span, .. } = - expect_role_error(&failure) + let ConfigDocumentRoleError::ExpectedSinglePishoo { + role, found, span, .. + } = expect_role_error(&failure) else { panic!("expected pishoo cardinality error"); }; assert_eq!(*found, expected); - assert_eq!(span.document_id(), failure.document_id()); + assert_eq!(Some(span.document_id()), failure.document_id()); + assert_eq!(*role, ConfigDocumentRoleKind::WorkerPishoo); } } #[test] fn worker_unknown_directive_is_a_configuration_error() { - let home = worker_home(); + let fixture = TempConfigDir::new("worker_unknown"); + let home = fixture.worker_home(); + let source_path = fixture.worker_source_path(); let failure = parse_role_document( "pishoo { unknown_setting on; }", - worker_source_path(), + &source_path, ConfigDocumentRole::WorkerPishoo { home: &home }, ) .expect_err("worker config must reject unknown directives"); let report = snafu::Report::from_error(&failure.error).to_string(); assert!(report.contains("unknown directive `unknown_setting`")); - assert_eq!(failure.document_id(), failure.source_map.document_id()); + assert!(failure.document_id().is_some()); assert!( failure .diagnostic() .to_string() - .contains("/tmp/worker/.dhttp/pishoo.conf") + .contains(&source_path.display().to_string()) ); } #[test] fn default_root_uses_global_home_source_context() { - let home = DhttpHome::new(PathBuf::from("/tmp/root")); + let fixture = TempConfigDir::new("default_root_source"); + let home = DhttpHome::new(fixture.path.clone()); + let source_path = fixture.join("pishoo.conf"); let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( - "pishoo {}", - Path::new("/tmp/root/pishoo.conf"), + "pishoo { server { listen all 443; } }", + &source_path, ConfigDocumentRole::HypervisorRoot { home: Some(&home) }, ) .expect("default root should parse") else { @@ -247,44 +309,43 @@ fn default_root_uses_global_home_source_context() { .source_map() .get(SourceId(0)) .expect("root source should exist"); - assert_eq!( - source.path.as_deref(), - Some(Path::new("/tmp/root/pishoo.conf")) - ); - assert_eq!(source.base_dir.as_deref(), Some(Path::new("/tmp/root"))); + assert_eq!(source.path.as_deref(), Some(source_path.as_path())); + assert_eq!(source.base_dir.as_deref(), Some(fixture.path.as_path())); } #[test] fn explicit_root_uses_explicit_file_source_context() { - let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( - "pishoo {}", - Path::new("/tmp/root/pishoo.conf"), + let fixture = TempConfigDir::new("explicit_root_source"); + let source_path = fixture.join("custom/pishoo.conf"); + let failure = parse_role_document( + "pishoo { server { listen all 443; } }", + &source_path, ConfigDocumentRole::HypervisorRoot { home: None }, ) - .expect("explicit root should parse") else { - panic!("expected hypervisor root fragment"); - }; + .expect_err("an explicit root without home context must provide TLS paths"); - let source = fragment - .source_map() - .get(SourceId(0)) - .expect("root source should exist"); - assert_eq!( - source.path.as_deref(), - Some(Path::new("/tmp/root/pishoo.conf")) + assert!(matches!( + failure.error, + LoadConfigError::BuildDocument { .. } + )); + assert!( + failure + .diagnostic() + .to_string() + .contains(&source_path.display().to_string()) ); - assert_eq!(source.base_dir.as_deref(), Some(Path::new("/tmp/root"))); } #[test] fn identity_document_returns_detached_server_fragments() { - let home = worker_home(); + let fixture = TempConfigDir::new("identity_fragments"); + let home = fixture.worker_home(); let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) .expect("identity name should be valid"); let profile = home.identity_profile(name); let ParsedConfigDocument::IdentityServers(servers) = parse_role_document( "server { listen all 443; } server { listen all 444; }", - Path::new("/tmp/worker/.dhttp/identities/alice/server.conf"), + &fixture.join(".dhttp/identities/alice/server.conf"), ConfigDocumentRole::IdentityServer { home: &home, profile: &profile, @@ -304,13 +365,14 @@ fn identity_document_returns_detached_server_fragments() { #[test] fn identity_document_requires_at_least_one_server() { - let home = worker_home(); + let fixture = TempConfigDir::new("identity_cardinality"); + let home = fixture.worker_home(); let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) .expect("identity name should be valid"); let profile = home.identity_profile(name); let failure = parse_role_document( "", - Path::new("/tmp/worker/.dhttp/identities/alice/server.conf"), + &fixture.join(".dhttp/identities/alice/server.conf"), ConfigDocumentRole::IdentityServer { home: &home, profile: &profile, @@ -318,16 +380,18 @@ fn identity_document_requires_at_least_one_server() { ) .expect_err("identity server document must not be empty"); - let ConfigDocumentRoleError::MissingIdentityServer { span, .. } = expect_role_error(&failure) + let ConfigDocumentRoleError::MissingIdentityServer { role, span } = expect_role_error(&failure) else { panic!("expected missing identity server error"); }; - assert_eq!(span.document_id(), failure.document_id()); + assert_eq!(*role, ConfigDocumentRoleKind::IdentityServer); + assert_eq!(Some(span.document_id()), failure.document_id()); } #[test] fn resolved_config_path_is_absolute_and_source_anchored() { - let include_dir = PathBuf::from("/tmp/root/includes"); + let fixture = TempConfigDir::new("resolved_config_path"); + let include_dir = fixture.join("includes"); let include_name = format!( "paths-{}-{}.conf", std::process::id(), @@ -342,7 +406,7 @@ fn resolved_config_path_is_absolute_and_source_anchored() { let text = format!("pishoo {{ include includes/{include_name}; }}"); let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( &text, - Path::new("/tmp/root/pishoo.conf"), + &fixture.join("pishoo.conf"), ConfigDocumentRole::HypervisorRoot { home: None }, ) .expect("included path should parse") else { @@ -370,29 +434,29 @@ fn resolved_config_path_is_absolute_and_source_anchored() { let nul_path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/nul\0path".to_vec())); assert!(ResolvedConfigPath::try_from(nul_path).is_err()); } - - std::fs::remove_file(include_path).expect("remove include fixture"); } #[test] fn document_ids_keep_equal_source_spans_distinct() { + let root_fixture = TempConfigDir::new("root_document_id"); + let worker_fixture = TempConfigDir::new("worker_document_id"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); let ParsedConfigDocument::HypervisorRoot(root) = parser .parse_text( "pishoo {}", - Path::new("/tmp/root/pishoo.conf"), + &root_fixture.join("pishoo.conf"), ConfigDocumentRole::HypervisorRoot { home: None }, ) .expect("root document should parse") else { panic!("expected hypervisor root fragment"); }; - let home = worker_home(); + let home = worker_fixture.worker_home(); let ParsedConfigDocument::WorkerPishoo(worker) = parser .parse_text( "pishoo {}", - worker_source_path(), + &worker_fixture.worker_source_path(), ConfigDocumentRole::WorkerPishoo { home: &home }, ) .expect("worker document should parse") @@ -406,6 +470,131 @@ fn document_ids_keep_equal_source_spans_distinct() { assert_ne!(root.document_id(), worker.document_id()); } +#[test] +fn role_parser_rejects_leaf_pishoo_registration_without_panicking() { + let fixture = TempConfigDir::new("invalid_pishoo_registration"); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::ROOT, + DirectiveSpec::leaf_value::( + "pishoo", + vec![context::ROOT], + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::Supervisor, + ), + ); + let text = format!("pishoo {};", fixture.join("value").display()); + let failure = ConfigDocumentParser::new(®istry) + .parse_text( + &text, + &fixture.join("pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect_err("a role-required pishoo registration must be a pishoo context block"); + + let ConfigDocumentRoleError::InvalidDirectiveRegistration { + directive, + role, + expected_child_context, + span, + } = expect_role_error(&failure) + else { + panic!("expected invalid role directive registration"); + }; + assert_eq!(directive.as_str(), "pishoo"); + assert_eq!(*role, ConfigDocumentRoleKind::HypervisorRoot); + assert_eq!(*expected_child_context, context::PISHOO); + assert_eq!(Some(span.document_id()), failure.document_id()); +} + +#[test] +fn identity_role_rejects_leaf_server_registration_without_bypassing_cardinality() { + let fixture = TempConfigDir::new("invalid_server_registration"); + let home = fixture.worker_home(); + let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) + .expect("identity name should be valid"); + let profile = home.identity_profile(name); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::ROOT, + DirectiveSpec::leaf_value::( + "server", + vec![context::ROOT], + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::ListenerSet, + ), + ); + let text = format!("server {};", fixture.join("value").display()); + let failure = ConfigDocumentParser::new(®istry) + .parse_text( + &text, + &fixture.join(".dhttp/identities/alice/server.conf"), + ConfigDocumentRole::IdentityServer { + home: &home, + profile: &profile, + }, + ) + .expect_err("a role-required server registration must be a server context block"); + + let ConfigDocumentRoleError::InvalidDirectiveRegistration { + directive, + role, + expected_child_context, + span, + } = expect_role_error(&failure) + else { + panic!("expected invalid role directive registration"); + }; + assert_eq!(directive.as_str(), "server"); + assert_eq!(*role, ConfigDocumentRoleKind::IdentityServer); + assert_eq!(*expected_child_context, context::SERVER); + assert_eq!(Some(span.document_id()), failure.document_id()); +} + +#[test] +fn document_id_exhaustion_has_no_document_namespace_or_source_span() { + let fixture = TempConfigDir::new("document_id_exhaustion"); + let registry = crate::parse::default_registry(); + let maximum_index = u64::from(u32::MAX); + let mut parser = ConfigDocumentParser::with_next_document_index(®istry, maximum_index); + let ParsedConfigDocument::HypervisorRoot(maximum) = parser + .parse_text( + "pishoo {}", + &fixture.join("maximum.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect("the maximum document id should allocate") + else { + panic!("expected hypervisor root fragment"); + }; + assert_eq!( + maximum.document_id(), + crate::parse::domain::ConfigDocumentId::try_from_index(maximum_index) + .expect("maximum index should be supported") + ); + + let failure = parser + .parse_text( + "pishoo {}", + &fixture.join("exhausted.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect_err("the document id allocator should be exhausted"); + + assert!(failure.document_id().is_none()); + assert!(failure.source_map.get(SourceId(0)).is_none()); + assert!(matches!( + failure.error, + LoadConfigError::DocumentId { + source: crate::parse::domain::ConfigDocumentIdError::IndexOverflow { index, .. }, + } if index == maximum_index + 1 + )); +} + #[test] fn registry_v1_metadata_is_orthogonal() { let registry = crate::parse::default_registry(); @@ -438,7 +627,10 @@ fn registry_v1_metadata_is_orthogonal() { let types = registry .directive_spec(context::PISHOO, "types") .expect("types should be registered"); + assert_eq!(types.duplicate, DuplicatePolicy::Reject); assert_eq!(types.cascade, CascadePolicy::ReplaceWhole); + assert_eq!(types.transport, TransportPolicy::WorkerInheritable); + assert_eq!(types.reload, ReloadImpact::RuntimeState); let server = registry .directive_spec(context::PISHOO, "server") @@ -457,8 +649,10 @@ fn document_id_index_conversion_rejects_overflow() { } #[test] -fn loose_document_exposes_document_namespace() { - let document = parse_doc("pishoo {}"); +fn loose_documents_do_not_expose_comparable_fake_namespaces() { + let first = parse_doc("pishoo {}"); + let second = parse_doc("pishoo {}"); - assert_eq!(document.document_id(), document.source_map.document_id()); + assert!(first.document_id().is_none()); + assert!(second.document_id().is_none()); } From a7788b75ef2c81785d5256e07ad1ee6db890dacc Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 02:42:44 +0800 Subject: [PATCH 04/18] refactor(config): seal inherited home configuration trees --- gateway/src/parse.rs | 3 + gateway/src/parse/builtin/access.rs | 10 +- gateway/src/parse/builtin/core.rs | 49 +- gateway/src/parse/builtin/http.rs | 32 +- gateway/src/parse/cascade.rs | 194 +++++++ gateway/src/parse/document.rs | 54 +- gateway/src/parse/registry.rs | 14 + gateway/src/parse/snapshot.rs | 771 ++++++++++++++++++++++++++++ gateway/src/parse/tests.rs | 550 +++++++++++++++++++- gateway/src/parse/tree.rs | 548 ++++++++++++++++++++ gateway/src/parse/types.rs | 122 ++++- 11 files changed, 2294 insertions(+), 53 deletions(-) create mode 100644 gateway/src/parse/cascade.rs create mode 100644 gateway/src/parse/snapshot.rs create mode 100644 gateway/src/parse/tree.rs diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index df46ace6..2cbaff8c 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -5,6 +5,7 @@ use snafu::ResultExt; pub mod ast; pub mod builtin; +pub mod cascade; pub mod diagnostic; pub mod document; pub mod domain; @@ -15,7 +16,9 @@ pub mod include; pub mod normalize; pub mod pattern; pub mod registry; +pub mod snapshot; pub mod source; +pub mod tree; pub mod types; pub mod value; diff --git a/gateway/src/parse/builtin/access.rs b/gateway/src/parse/builtin/access.rs index d7d07850..26bcbf35 100644 --- a/gateway/src/parse/builtin/access.rs +++ b/gateway/src/parse/builtin/access.rs @@ -7,7 +7,7 @@ use crate::parse::{ normalize, registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, - types::AccessRulesUri, + types::{AccessRulesUri, AccessRulesUriValidationError}, }; #[derive(Debug, Snafu)] @@ -33,6 +33,11 @@ pub enum AccessRulesUriError { span: SourceSpan, source: normalize::NormalizeDirectiveValueError, }, + #[snafu(display("invalid access_rules uri domain"))] + Domain { + span: SourceSpan, + source: AccessRulesUriValidationError, + }, } impl DirectiveValue for AccessRulesUri { @@ -84,7 +89,8 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for AccessR let uri = url::Url::parse(&normalized) .context(access_rules_uri_error::UriSnafu { span: arg.span })?; - Ok(Self(uri)) + AccessRulesUri::try_from(uri) + .context(access_rules_uri_error::DomainSnafu { span: arg.span }) } } diff --git a/gateway/src/parse/builtin/core.rs b/gateway/src/parse/builtin/core.rs index d17abf32..10c994e9 100644 --- a/gateway/src/parse/builtin/core.rs +++ b/gateway/src/parse/builtin/core.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, path::PathBuf}; +use std::path::PathBuf; use snafu::{Snafu, ensure}; @@ -6,7 +6,7 @@ use crate::parse::{ ast::{AstBody, AstDirective, Spanned}, registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, - types::{BoolConfig, PathConfig, StringConfig, StringList}, + types::{BoolConfig, GzipTypesValidationError, PathConfig, StringConfig, StringList}, }; pub fn only_arg(directive: &AstDirective) -> Option<&Spanned> { @@ -65,20 +65,39 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StringC } } +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum StringListError { + #[snafu(display("invalid gzip_types directive value"))] + GzipTypes { + span: SourceSpan, + source: GzipTypesValidationError, + }, +} + impl DirectiveValue for StringList { - type Error = Infallible; -} - -impl<'input, 'directive> From<&'input DirectiveInput<'directive>> for StringList { - fn from(input: &'input DirectiveInput<'directive>) -> Self { - Self( - input - .directive - .args - .iter() - .map(|arg| arg.value.clone()) - .collect(), - ) + type Error = StringListError; +} + +impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StringList { + type Error = StringListError; + + fn try_from(input: &'input DirectiveInput<'directive>) -> Result { + let values = input + .directive + .args + .iter() + .map(|arg| arg.value.clone()) + .collect(); + if input.directive.name.value == "gzip_types" { + return StringList::checked_gzip_types(values).map_err(|source| { + StringListError::GzipTypes { + span: input.directive.span, + source, + } + }); + } + Ok(Self(values)) } } diff --git a/gateway/src/parse/builtin/http.rs b/gateway/src/parse/builtin/http.rs index e9463051..558a626d 100644 --- a/gateway/src/parse/builtin/http.rs +++ b/gateway/src/parse/builtin/http.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use http::{HeaderName, HeaderValue, Uri}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; @@ -8,7 +6,8 @@ use crate::parse::{ registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{ - DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, ProxyPass, + DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, + MimeTypesValidationError, ProxyPass, }, }; @@ -47,9 +46,9 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for Default actual: input.directive.args.len(), }); }; - let value = HeaderValue::from_str(&arg.value) + let value = DefaultType::checked_from_bytes(arg.value.as_bytes()) .context(default_type_error::HeaderValueSnafu { span: arg.span })?; - Ok(Self(value)) + Ok(value) } } @@ -92,7 +91,7 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for GzipMin .value .parse::() .context(gzip_min_length_error::UnsignedIntegerSnafu { span: arg.span })?; - Ok(Self(value)) + Ok(Self::checked(value)) } } @@ -135,7 +134,7 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for GzipCom .value .parse::() .context(gzip_comp_level_error::SignedIntegerSnafu { span: arg.span })?; - Ok(Self(value)) + Ok(Self::checked(value)) } } @@ -291,10 +290,10 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for HeaderR pub enum MimeTypesError { #[snafu(display("types directive must be a block"))] InvalidBlock { span: SourceSpan }, - #[snafu(display("invalid MIME type header value"))] - HeaderValue { + #[snafu(display("invalid types directive entries"))] + Entries { span: SourceSpan, - source: http::header::InvalidHeaderValue, + source: MimeTypesValidationError, }, } @@ -311,18 +310,15 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for MimeTyp span: input.directive.span, }); }; - let mut map = HashMap::new(); + let mut entries = Vec::new(); for directive in children { - let value = HeaderValue::from_str(&directive.name.value).context( - mime_types_error::HeaderValueSnafu { - span: directive.name.span, - }, - )?; for arg in &directive.args { - map.insert(arg.value.clone(), value.clone()); + entries.push((arg.value.clone(), directive.name.value.as_bytes().to_vec())); } } - Ok(Self(map)) + MimeTypes::checked_from_bytes(entries).context(mime_types_error::EntriesSnafu { + span: input.directive.span, + }) } } diff --git a/gateway/src/parse/cascade.rs b/gateway/src/parse/cascade.rs new file mode 100644 index 00000000..779aad48 --- /dev/null +++ b/gateway/src/parse/cascade.rs @@ -0,0 +1,194 @@ +use std::{marker::PhantomData, num::NonZeroU32, path::Path, sync::Arc}; + +use crate::parse::{ + domain::{ConfigSourceSpan, DirectiveName}, + registry::CascadePolicy, + snapshot::RootConfigSnapshot, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, + StringList, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigOrigin { + Source(ConfigSourceSpan), + RootInherited { + directive: DirectiveName, + source: Option, + }, + Builtin { + directive: DirectiveName, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InheritedSourceLocation { + document: Option, + line: NonZeroU32, + column: NonZeroU32, +} + +impl InheritedSourceLocation { + pub(crate) fn new( + document: Option, + line: NonZeroU32, + column: NonZeroU32, + ) -> Self { + Self { + document, + line, + column, + } + } + + pub fn document(&self) -> Option<&Path> { + self.document.as_deref() + } + + pub const fn line(&self) -> NonZeroU32 { + self.line + } + + pub const fn column(&self) -> NonZeroU32 { + self.column + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CascadedValue { + effective: T, + lineage: Box<[ConfigOrigin]>, +} + +impl CascadedValue { + pub(crate) fn new(effective: T, lineage: Box<[ConfigOrigin]>) -> Self { + Self { effective, lineage } + } + + pub fn effective(&self) -> &T { + &self.effective + } + + pub fn lineage(&self) -> &[ConfigOrigin] { + &self.lineage + } +} + +type BuiltinValue = fn() -> Option>; +type SnapshotValue = fn(&RootConfigSnapshot, DirectiveName) -> Option<(Arc, ConfigOrigin)>; + +#[derive(Debug)] +pub struct DirectiveKey { + name: DirectiveName, + builtin: BuiltinValue, + snapshot: SnapshotValue, + cascade: CascadePolicy, + value: PhantomData T>, +} + +impl Clone for DirectiveKey { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for DirectiveKey {} + +impl DirectiveKey { + pub(crate) const fn new( + name: &'static str, + cascade: CascadePolicy, + builtin: BuiltinValue, + snapshot: SnapshotValue, + ) -> Self { + Self { + name: DirectiveName::new(name), + cascade, + builtin, + snapshot, + value: PhantomData, + } + } + + pub const fn name(self) -> DirectiveName { + self.name + } + + pub const fn cascade_policy(self) -> CascadePolicy { + self.cascade + } + + pub(crate) fn builtin(self) -> Option> { + (self.builtin)() + } + + pub(crate) fn snapshot(self, snapshot: &RootConfigSnapshot) -> Option<(Arc, ConfigOrigin)> { + (self.snapshot)(snapshot, self.name) + } +} + +fn absent() -> Option> { + None +} + +fn builtin_false() -> Option> { + Some(Arc::new(BoolConfig(false))) +} + +fn builtin_min_length() -> Option> { + Some(Arc::new(GzipMinLength::checked(20))) +} + +fn builtin_comp_level() -> Option> { + Some(Arc::new(GzipCompLevel::checked(1))) +} + +pub const ACCESS_RULES: DirectiveKey = DirectiveKey::new( + "access_rules", + CascadePolicy::NearestWins, + absent, + RootConfigSnapshot::cascade_access_rules, +); +pub const GZIP: DirectiveKey = DirectiveKey::new( + "gzip", + CascadePolicy::NearestWins, + builtin_false, + RootConfigSnapshot::cascade_gzip, +); +pub const GZIP_VARY: DirectiveKey = DirectiveKey::new( + "gzip_vary", + CascadePolicy::NearestWins, + builtin_false, + RootConfigSnapshot::cascade_gzip_vary, +); +pub const GZIP_MIN_LENGTH: DirectiveKey = DirectiveKey::new( + "gzip_min_length", + CascadePolicy::NearestWins, + builtin_min_length, + RootConfigSnapshot::cascade_gzip_min_length, +); +pub const GZIP_COMP_LEVEL: DirectiveKey = DirectiveKey::new( + "gzip_comp_level", + CascadePolicy::NearestWins, + builtin_comp_level, + RootConfigSnapshot::cascade_gzip_comp_level, +); +pub const GZIP_TYPES: DirectiveKey = DirectiveKey::new( + "gzip_types", + CascadePolicy::NearestWins, + absent, + RootConfigSnapshot::cascade_gzip_types, +); +pub const DEFAULT_TYPE: DirectiveKey = DirectiveKey::new( + "default_type", + CascadePolicy::NearestWins, + absent, + RootConfigSnapshot::cascade_default_type, +); +pub const TYPES: DirectiveKey = DirectiveKey::new( + "types", + CascadePolicy::ReplaceWhole, + absent, + RootConfigSnapshot::cascade_types, +); diff --git a/gateway/src/parse/document.rs b/gateway/src/parse/document.rs index cd67f835..96a08272 100644 --- a/gateway/src/parse/document.rs +++ b/gateway/src/parse/document.rs @@ -37,7 +37,7 @@ pub struct ConfigSlot { } impl ConfigDocument { - pub fn new(source_map: Arc, root: Arc) -> Self { + pub(crate) fn new(source_map: Arc, root: Arc) -> Self { Self { source_map, root } } @@ -47,7 +47,11 @@ impl ConfigDocument { } impl ConfigNode { - pub fn new(context: ContextKey, name: Option>, span: SourceSpan) -> Self { + pub(crate) fn new( + context: ContextKey, + name: Option>, + span: SourceSpan, + ) -> Self { Self { context, name, @@ -59,7 +63,7 @@ impl ConfigNode { } } - pub fn insert_slot(&mut self, name: &'static str, value: TypedValue) { + pub(crate) fn insert_slot(&mut self, name: &'static str, value: TypedValue) { self.slots .entry(name) .or_insert_with(|| ConfigSlot { values: Vec::new() }) @@ -67,7 +71,7 @@ impl ConfigNode { .push(value); } - pub fn replace_slot(&mut self, name: &'static str, value: TypedValue) { + pub(crate) fn replace_slot(&mut self, name: &'static str, value: TypedValue) { self.slots.insert( name, ConfigSlot { @@ -76,26 +80,26 @@ impl ConfigNode { ); } - pub fn get_all_untyped(&self, name: &str) -> &[TypedValue] { + pub(crate) fn get_all_untyped(&self, name: &str) -> &[TypedValue] { self.slots .get(name) .map(|slot| slot.values.as_slice()) .unwrap_or(&[]) } - pub fn insert_child(&mut self, name: &'static str, child: Arc) { + pub(crate) fn insert_child(&mut self, name: &'static str, child: Arc) { self.children.entry(name).or_default().push(child); } - pub fn child_groups(&self) -> impl Iterator]> { + pub(crate) fn child_groups(&self) -> impl Iterator]> { self.children.values().map(Vec::as_slice) } - pub fn set_payload(&mut self, payload: TypedValue) { + pub(crate) fn set_payload(&mut self, payload: TypedValue) { self.payload = Some(payload); } - pub fn set_parent(&self, parent: Option>) { + pub(crate) fn set_parent(&self, parent: Option>) { self.parent .set(parent) .expect("parent link set multiple times for config node"); @@ -134,6 +138,38 @@ impl ConfigNode { }) } + pub(crate) fn get_with_span( + &self, + name: &str, + ) -> Result, SourceSpan)>, ConfigQueryError> + where + T: ConfigValue, + { + let Some(slot) = self.slots.get(name) else { + return Ok(None); + }; + if slot.values.len() > 1 { + return config_query_error::MultipleValuesSnafu { + directive: name.to_owned(), + span: slot.values[1].span(), + } + .fail(); + } + let value = &slot.values[0]; + value + .downcast::() + .map(|value| Some((value, slot.values[0].span()))) + .ok_or_else(|| { + config_query_error::TypeMismatchSnafu { + directive: name.to_owned(), + expected: std::any::type_name::(), + actual: value.type_name(), + span: value.span(), + } + .build() + }) + } + pub fn require(&self, name: &str) -> Result, ConfigQueryError> where T: ConfigValue, diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 729b0d00..a294bdff 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -126,6 +126,10 @@ pub enum ParsedDirective { } impl DirectiveSpec { + pub fn matches_key(&self, key: crate::parse::cascade::DirectiveKey) -> bool { + self.name == key.name() && self.cascade == key.cascade_policy() + } + pub fn leaf_value( name: &'static str, allowed_in: Vec, @@ -300,6 +304,16 @@ impl ConfigRegistry { }) } + #[cfg(test)] + pub(crate) fn directive_matches_key( + &self, + context: ContextKey, + key: crate::parse::cascade::DirectiveKey, + ) -> bool { + self.directive_spec(context, key.name().as_str()) + .is_some_and(|spec| spec.matches_key(key)) + } + pub fn build( &self, source_map: Arc, diff --git a/gateway/src/parse/snapshot.rs b/gateway/src/parse/snapshot.rs new file mode 100644 index 00000000..355b82ad --- /dev/null +++ b/gateway/src/parse/snapshot.rs @@ -0,0 +1,771 @@ +use std::{collections::HashSet, num::NonZeroU32, path::PathBuf, sync::Arc}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _}; +use snafu::Snafu; + +use crate::parse::{ + cascade::{ + ACCESS_RULES, ConfigOrigin, DEFAULT_TYPE, GZIP, GZIP_COMP_LEVEL, GZIP_MIN_LENGTH, + GZIP_TYPES, GZIP_VARY, InheritedSourceLocation, TYPES, + }, + domain::{DirectiveName, ResolvedConfigPath, ResolvedConfigPathError}, + tree::HomeConfigTree, + types::{ + AccessRulesUri, AccessRulesUriValidationError, BoolConfig, DefaultType, GzipCompLevel, + GzipMinLength, GzipTypesValidationError, MimeTypes, MimeTypesValidationError, StringList, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootConfigSnapshot { + V1(RootInheritedConfigV1), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RootInheritedConfigV1 { + access_rules: InheritedValue>, + gzip: InheritedValue, + gzip_vary: InheritedValue, + gzip_min_length: InheritedValue, + gzip_comp_level: InheritedValue, + gzip_types: InheritedValue>, + default_type: InheritedValue>, + types: InheritedValue>, + access_log: InheritedValue>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InheritedValue { + value: T, + origin: InheritedOrigin, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InheritedOrigin { + Builtin, + Source(InheritedSourceLocation), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SnapshotAccessLogDirective { + Off, + ProfileDefault, + Resolved(ResolvedConfigPath), +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum RootConfigSnapshotError { + #[snafu(display("configuration source path must be absolute for root snapshot transport"))] + SourcePath { path: PathBuf }, + #[snafu(display("configuration source location exceeds the root snapshot range"))] + SourceLocation { line: usize, column: usize }, + #[snafu(display("root configuration value has no source location"))] + MissingSourceLocation, + #[snafu(display("required root snapshot fallback is missing for `{directive}`"))] + MissingFallback { directive: DirectiveName }, + #[snafu(display("failed to query root configuration value"))] + Query { + source: crate::parse::error::ConfigQueryError, + }, + #[snafu(display("invalid root snapshot access_rules value"))] + AccessRules { + source: AccessRulesUriValidationError, + }, + #[snafu(display("invalid root snapshot header value"))] + HeaderValue { + source: http::header::InvalidHeaderValue, + }, + #[snafu(display("invalid root snapshot gzip_types value"))] + GzipTypes { source: GzipTypesValidationError }, + #[snafu(display("invalid root snapshot MIME types value"))] + MimeTypes { source: MimeTypesValidationError }, + #[snafu(display("invalid root snapshot absolute path"))] + AbsolutePath { path: PathBuf }, + #[snafu(display("root snapshot path transport requires a Unix host"))] + UnsupportedPathPlatform, + #[snafu(display("invalid resolved root snapshot path"))] + ResolvedPath { source: ResolvedConfigPathError }, +} + +impl RootConfigSnapshot { + pub(crate) fn project(tree: &Arc) -> Result { + let pishoo = tree.pishoo(); + let access_rules = project_optional(tree, snapshot_query(pishoo.cascaded(ACCESS_RULES))?)?; + let gzip = project_required(tree, GZIP.name(), snapshot_query(pishoo.cascaded(GZIP))?)?; + let gzip_vary = project_required( + tree, + GZIP_VARY.name(), + snapshot_query(pishoo.cascaded(GZIP_VARY))?, + )?; + let gzip_min_length = project_required( + tree, + GZIP_MIN_LENGTH.name(), + snapshot_query(pishoo.cascaded(GZIP_MIN_LENGTH))?, + )?; + let gzip_comp_level = project_required( + tree, + GZIP_COMP_LEVEL.name(), + snapshot_query(pishoo.cascaded(GZIP_COMP_LEVEL))?, + )?; + let gzip_types = project_optional(tree, snapshot_query(pishoo.cascaded(GZIP_TYPES))?)?; + let default_type = project_optional(tree, snapshot_query(pishoo.cascaded(DEFAULT_TYPE))?)?; + let types = project_optional(tree, snapshot_query(pishoo.cascaded(TYPES))?)?; + Ok(Self::V1(RootInheritedConfigV1 { + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log: InheritedValue { + value: None, + origin: InheritedOrigin::Builtin, + }, + })) + } + + pub fn access_rules(&self) -> Option<&AccessRulesUri> { + self.v1().access_rules.value.as_ref() + } + + pub fn gzip(&self) -> &BoolConfig { + &self.v1().gzip.value + } + + pub fn gzip_vary(&self) -> &BoolConfig { + &self.v1().gzip_vary.value + } + + pub fn gzip_min_length(&self) -> &GzipMinLength { + &self.v1().gzip_min_length.value + } + + pub fn gzip_comp_level(&self) -> &GzipCompLevel { + &self.v1().gzip_comp_level.value + } + + pub fn gzip_types(&self) -> Option<&StringList> { + self.v1().gzip_types.value.as_ref() + } + + pub fn default_type(&self) -> Option<&DefaultType> { + self.v1().default_type.value.as_ref() + } + + pub fn types(&self) -> Option<&MimeTypes> { + self.v1().types.value.as_ref() + } + + pub(crate) fn cascade_access_rules( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_optional(&self.v1().access_rules, directive) + } + + pub(crate) fn cascade_gzip( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_required(&self.v1().gzip, directive) + } + + pub(crate) fn cascade_gzip_vary( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_required(&self.v1().gzip_vary, directive) + } + + pub(crate) fn cascade_gzip_min_length( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_required(&self.v1().gzip_min_length, directive) + } + + pub(crate) fn cascade_gzip_comp_level( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_required(&self.v1().gzip_comp_level, directive) + } + + pub(crate) fn cascade_gzip_types( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_optional(&self.v1().gzip_types, directive) + } + + pub(crate) fn cascade_default_type( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_optional(&self.v1().default_type, directive) + } + + pub(crate) fn cascade_types( + &self, + directive: DirectiveName, + ) -> Option<(Arc, ConfigOrigin)> { + cascade_optional(&self.v1().types, directive) + } + + fn v1(&self) -> &RootInheritedConfigV1 { + match self { + Self::V1(value) => value, + } + } +} + +fn snapshot_query( + result: Result< + Option>>, + crate::parse::error::ConfigQueryError, + >, +) -> Result>>, RootConfigSnapshotError> { + result.map_err(|source| RootConfigSnapshotError::Query { source }) +} + +fn project_required( + tree: &HomeConfigTree, + directive: DirectiveName, + value: Option>>, +) -> Result, RootConfigSnapshotError> { + let value = value.ok_or(RootConfigSnapshotError::MissingFallback { directive })?; + let origin = project_origin(tree, value.lineage().last())?; + Ok(InheritedValue { + value: value.effective().as_ref().clone(), + origin, + }) +} + +fn project_optional( + tree: &HomeConfigTree, + value: Option>>, +) -> Result>, RootConfigSnapshotError> { + let Some(value) = value else { + return Ok(InheritedValue { + value: None, + origin: InheritedOrigin::Builtin, + }); + }; + let origin = project_origin(tree, value.lineage().last())?; + Ok(InheritedValue { + value: Some(value.effective().as_ref().clone()), + origin, + }) +} + +fn project_origin( + tree: &HomeConfigTree, + origin: Option<&ConfigOrigin>, +) -> Result { + match origin { + None | Some(ConfigOrigin::Builtin { .. }) => Ok(InheritedOrigin::Builtin), + Some(ConfigOrigin::Source(span)) => tree + .inherited_source_location(*span) + .map(InheritedOrigin::Source), + Some(ConfigOrigin::RootInherited { source, .. }) => Ok(source + .clone() + .map_or(InheritedOrigin::Builtin, InheritedOrigin::Source)), + } +} + +fn cascade_required( + value: &InheritedValue, + directive: DirectiveName, +) -> Option<(Arc, ConfigOrigin)> { + match &value.origin { + InheritedOrigin::Builtin => None, + InheritedOrigin::Source(_) => Some(( + Arc::new(value.value.clone()), + inherited_origin(&value.origin, directive), + )), + } +} + +fn cascade_optional( + value: &InheritedValue>, + directive: DirectiveName, +) -> Option<(Arc, ConfigOrigin)> { + let origin = &value.origin; + value + .value + .as_ref() + .map(|value| (Arc::new(value.clone()), inherited_origin(origin, directive))) +} + +fn inherited_origin(origin: &InheritedOrigin, directive: DirectiveName) -> ConfigOrigin { + match origin { + InheritedOrigin::Builtin => ConfigOrigin::Builtin { directive }, + InheritedOrigin::Source(source) => ConfigOrigin::RootInherited { + directive, + source: Some(source.clone()), + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +enum RootConfigSnapshotWire { + V1(RootInheritedConfigV1Wire), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RootInheritedConfigV1Wire { + access_rules: InheritedValueV1>, + gzip: InheritedValueV1, + gzip_vary: InheritedValueV1, + gzip_min_length: InheritedValueV1, + gzip_comp_level: InheritedValueV1, + gzip_types: InheritedValueV1]>>>, + default_type: InheritedValueV1>, + types: InheritedValueV1>, + access_log: InheritedValueV1>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct InheritedValueV1 { + value: T, + origin: InheritedOriginV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +enum InheritedOriginV1 { + Builtin, + Source(InheritedSourceLocationV1), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct InheritedSourceLocationV1 { + document: Option, + line: NonZeroU32, + column: NonZeroU32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct HeaderValueV1(Box<[u8]>); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MimeTypeEntryV1 { + extension: Box, + value: HeaderValueV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MimeTypesV1(Box<[MimeTypeEntryV1]>); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AccessRulesSourceV1(Box); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AbsolutePathV1(Box<[u8]>); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ResolvedConfigPathV1(AbsolutePathV1); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +enum AccessLogDirectiveV1 { + Off, + ProfileDefault, + Resolved(ResolvedConfigPathV1), +} + +impl Serialize for RootConfigSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let wire = RootConfigSnapshotWire::try_from(self).map_err(S::Error::custom)?; + wire.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RootConfigSnapshot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = RootConfigSnapshotWire::deserialize(deserializer)?; + Self::try_from(wire).map_err(D::Error::custom) + } +} + +impl TryFrom<&RootConfigSnapshot> for RootConfigSnapshotWire { + type Error = RootConfigSnapshotError; + + fn try_from(snapshot: &RootConfigSnapshot) -> Result { + let value = snapshot.v1(); + Ok(Self::V1(RootInheritedConfigV1Wire { + access_rules: encode_inherited(&value.access_rules, |value| { + value + .as_ref() + .map(|value| AccessRulesSourceV1(value.0.as_str().into())) + })?, + gzip: encode_inherited(&value.gzip, |value| value.0)?, + gzip_vary: encode_inherited(&value.gzip_vary, |value| value.0)?, + gzip_min_length: encode_inherited(&value.gzip_min_length, |value| value.0)?, + gzip_comp_level: encode_inherited(&value.gzip_comp_level, |value| value.0)?, + gzip_types: encode_inherited(&value.gzip_types, |value| { + value + .as_ref() + .map(|value| value.0.iter().map(|value| value.as_str().into()).collect()) + })?, + default_type: encode_inherited(&value.default_type, |value| { + value + .as_ref() + .map(|value| HeaderValueV1(value.0.as_bytes().into())) + })?, + types: encode_inherited(&value.types, |value| value.as_ref().map(encode_mime_types))?, + access_log: encode_inherited(&value.access_log, |value| { + value.as_ref().map(encode_access_log) + })?, + })) + } +} + +impl TryFrom for RootConfigSnapshot { + type Error = RootConfigSnapshotError; + + fn try_from(wire: RootConfigSnapshotWire) -> Result { + let RootConfigSnapshotWire::V1(value) = wire; + Ok(Self::V1(RootInheritedConfigV1 { + access_rules: decode_inherited(value.access_rules, |value| { + value + .map(|value| { + let uri = url::Url::parse(&value.0).map_err(|_| { + RootConfigSnapshotError::AccessRules { + source: AccessRulesUriValidationError::UnsupportedSqliteForm, + } + })?; + AccessRulesUri::try_from(uri) + .map_err(|source| RootConfigSnapshotError::AccessRules { source }) + }) + .transpose() + })?, + gzip: decode_inherited(value.gzip, |value| Ok(BoolConfig(value)))?, + gzip_vary: decode_inherited(value.gzip_vary, |value| Ok(BoolConfig(value)))?, + gzip_min_length: decode_inherited(value.gzip_min_length, |value| { + Ok(GzipMinLength::checked(value)) + })?, + gzip_comp_level: decode_inherited(value.gzip_comp_level, |value| { + Ok(GzipCompLevel::checked(value)) + })?, + gzip_types: decode_inherited(value.gzip_types, |value| { + value + .map(|values| { + StringList::checked_gzip_types( + values.into_vec().into_iter().map(String::from).collect(), + ) + .map_err(|source| RootConfigSnapshotError::GzipTypes { source }) + }) + .transpose() + })?, + default_type: decode_inherited(value.default_type, |value| { + value + .map(|value| { + DefaultType::checked_from_bytes(&value.0) + .map_err(|source| RootConfigSnapshotError::HeaderValue { source }) + }) + .transpose() + })?, + types: decode_inherited(value.types, |value| { + value.map(decode_mime_types).transpose() + })?, + access_log: decode_inherited(value.access_log, |value| { + value.map(decode_access_log).transpose() + })?, + })) + } +} + +fn encode_inherited( + value: &InheritedValue, + encode: impl FnOnce(&T) -> W, +) -> Result, RootConfigSnapshotError> { + Ok(InheritedValueV1 { + value: encode(&value.value), + origin: encode_origin(&value.origin)?, + }) +} + +fn decode_inherited( + value: InheritedValueV1, + decode: impl FnOnce(W) -> Result, +) -> Result, RootConfigSnapshotError> { + Ok(InheritedValue { + value: decode(value.value)?, + origin: decode_origin(value.origin)?, + }) +} + +fn encode_origin(origin: &InheritedOrigin) -> Result { + match origin { + InheritedOrigin::Builtin => Ok(InheritedOriginV1::Builtin), + InheritedOrigin::Source(source) => { + Ok(InheritedOriginV1::Source(InheritedSourceLocationV1 { + document: source + .document() + .map(AbsolutePathV1::try_from) + .transpose()?, + line: source.line(), + column: source.column(), + })) + } + } +} + +fn decode_origin(origin: InheritedOriginV1) -> Result { + match origin { + InheritedOriginV1::Builtin => Ok(InheritedOrigin::Builtin), + InheritedOriginV1::Source(source) => { + Ok(InheritedOrigin::Source(InheritedSourceLocation::new( + source.document.map(PathBuf::try_from).transpose()?, + source.line, + source.column, + ))) + } + } +} + +fn encode_mime_types(types: &MimeTypes) -> MimeTypesV1 { + let mut entries = types + .0 + .iter() + .map(|(extension, value)| MimeTypeEntryV1 { + extension: extension.as_str().into(), + value: HeaderValueV1(value.as_bytes().into()), + }) + .collect::>(); + entries + .sort_unstable_by(|left, right| left.extension.as_bytes().cmp(right.extension.as_bytes())); + MimeTypesV1(entries.into_boxed_slice()) +} + +fn decode_mime_types(types: MimeTypesV1) -> Result { + let mut seen = HashSet::new(); + let mut entries = Vec::with_capacity(types.0.len()); + for entry in types.0 { + let extension = String::from(entry.extension); + if !seen.insert(extension.clone()) { + return Err(RootConfigSnapshotError::MimeTypes { + source: MimeTypesValidationError::DuplicateExtension { extension }, + }); + } + entries.push((extension, entry.value.0.into_vec())); + } + MimeTypes::checked_from_bytes(entries) + .map_err(|source| RootConfigSnapshotError::MimeTypes { source }) +} + +fn encode_access_log(value: &SnapshotAccessLogDirective) -> AccessLogDirectiveV1 { + match value { + SnapshotAccessLogDirective::Off => AccessLogDirectiveV1::Off, + SnapshotAccessLogDirective::ProfileDefault => AccessLogDirectiveV1::ProfileDefault, + SnapshotAccessLogDirective::Resolved(path) => { + AccessLogDirectiveV1::Resolved(ResolvedConfigPathV1( + AbsolutePathV1::try_from(path.as_ref()) + .expect("resolved configuration paths are checked when constructed"), + )) + } + } +} + +fn decode_access_log( + value: AccessLogDirectiveV1, +) -> Result { + Ok(match value { + AccessLogDirectiveV1::Off => SnapshotAccessLogDirective::Off, + AccessLogDirectiveV1::ProfileDefault => SnapshotAccessLogDirective::ProfileDefault, + AccessLogDirectiveV1::Resolved(path) => SnapshotAccessLogDirective::Resolved( + ResolvedConfigPath::try_from(PathBuf::try_from(path.0)?) + .map_err(|source| RootConfigSnapshotError::ResolvedPath { source })?, + ), + }) +} + +impl TryFrom<&std::path::Path> for AbsolutePathV1 { + type Error = RootConfigSnapshotError; + + fn try_from(path: &std::path::Path) -> Result { + if !path.is_absolute() || path.as_os_str().as_encoded_bytes().contains(&0) { + return Err(RootConfigSnapshotError::AbsolutePath { + path: path.to_path_buf(), + }); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(Self(path.as_os_str().as_bytes().into())) + } + #[cfg(not(unix))] + { + let _ = path; + Err(RootConfigSnapshotError::UnsupportedPathPlatform) + } + } +} + +impl TryFrom for PathBuf { + type Error = RootConfigSnapshotError; + + fn try_from(path: AbsolutePathV1) -> Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let path = PathBuf::from(std::ffi::OsString::from_vec(path.0.into_vec())); + if !path.is_absolute() + || path.as_os_str().as_encoded_bytes().is_empty() + || path.as_os_str().as_encoded_bytes().contains(&0) + { + return Err(RootConfigSnapshotError::AbsolutePath { path }); + } + Ok(path) + } + #[cfg(not(unix))] + { + let _ = path; + Err(RootConfigSnapshotError::UnsupportedPathPlatform) + } + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + + #[derive(Debug, PartialEq, Eq)] + pub(crate) struct SnapshotValues { + access_rules: Option, + gzip: BoolConfig, + gzip_vary: BoolConfig, + gzip_min_length: GzipMinLength, + gzip_comp_level: GzipCompLevel, + gzip_types: Option, + default_type: Option, + types: Option, + access_log: Option, + } + + pub(crate) fn wire_field_names(snapshot: &RootConfigSnapshot) -> [&'static str; 9] { + let RootConfigSnapshotWire::V1(RootInheritedConfigV1Wire { + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log, + }) = RootConfigSnapshotWire::try_from(snapshot).expect("snapshot should encode"); + drop(( + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log, + )); + [ + "access_rules", + "gzip", + "gzip_vary", + "gzip_min_length", + "gzip_comp_level", + "gzip_types", + "default_type", + "types", + "access_log", + ] + } + + pub(crate) fn checked_wire_round_trip( + snapshot: &RootConfigSnapshot, + ) -> Result { + RootConfigSnapshot::try_from(RootConfigSnapshotWire::try_from(snapshot)?) + } + + pub(crate) fn values_without_origins(snapshot: &RootConfigSnapshot) -> SnapshotValues { + let value = snapshot.v1(); + SnapshotValues { + access_rules: value.access_rules.value.clone(), + gzip: value.gzip.value.clone(), + gzip_vary: value.gzip_vary.value.clone(), + gzip_min_length: value.gzip_min_length.value.clone(), + gzip_comp_level: value.gzip_comp_level.value.clone(), + gzip_types: value.gzip_types.value.clone(), + default_type: value.default_type.value.clone(), + types: value.types.value.clone(), + access_log: value.access_log.value.clone(), + } + } + + pub(crate) fn round_trip_resolved_path( + path: ResolvedConfigPath, + ) -> Result { + let wire = ResolvedConfigPathV1(AbsolutePathV1::try_from(path.as_ref())?); + ResolvedConfigPath::try_from(PathBuf::try_from(wire.0)?) + .map_err(|source| RootConfigSnapshotError::ResolvedPath { source }) + } + + pub(crate) fn decode_absolute_path(bytes: &[u8]) -> Result { + PathBuf::try_from(AbsolutePathV1(bytes.into())) + } + + pub(crate) fn decode_schema(schema: u32) -> Result<(), serde::de::value::Error> { + let variant = format!("V{schema}"); + let deserializer = serde::de::value::StringDeserializer::new(variant); + RootConfigSnapshot::deserialize(deserializer).map(drop) + } + + pub(crate) fn decode_access_rules( + value: &str, + ) -> Result { + let uri = url::Url::parse(value).map_err(|_| RootConfigSnapshotError::AccessRules { + source: AccessRulesUriValidationError::UnsupportedSqliteForm, + })?; + AccessRulesUri::try_from(uri) + .map_err(|source| RootConfigSnapshotError::AccessRules { source }) + } + + pub(crate) fn decode_header_value( + value: &[u8], + ) -> Result { + DefaultType::checked_from_bytes(value) + .map_err(|source| RootConfigSnapshotError::HeaderValue { source }) + } + + pub(crate) fn mime_wire_extensions(types: &MimeTypes) -> Vec { + let wire = encode_mime_types(types); + wire.0 + .iter() + .map(|entry| entry.extension.to_string()) + .collect() + } + + pub(crate) fn decode_mime_entries( + entries: &[(&str, &[u8])], + ) -> Result { + decode_mime_types(MimeTypesV1( + entries + .iter() + .map(|(extension, value)| MimeTypeEntryV1 { + extension: (*extension).into(), + value: HeaderValueV1((*value).into()), + }) + .collect(), + )) + } + + pub(crate) fn has_access_log(snapshot: &RootConfigSnapshot) -> bool { + snapshot.v1().access_log.value.is_some() + } +} diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 32c27d70..ed6c41e0 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -11,15 +11,18 @@ use dhttp::home::DhttpHome; use crate::parse::{ ConfigDocumentParser, + cascade::{GZIP, TYPES}, document::{ConfigDocument, ConfigNode}, domain::{ConfigDocumentRole, ConfigDocumentRoleKind, ResolvedConfigPath}, error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, - fragment::ParsedConfigDocument, + fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, registry::{ CascadePolicy, DirectiveSpec, DuplicatePolicy, ReloadImpact, TransportPolicy, context, }, + snapshot::{RootConfigSnapshot, test_support as snapshot_test_support}, source::SourceId, - types::PathConfig, + tree::{ParentLink, build_global_tree, build_worker_tree}, + types::{MimeTypes, PathConfig}, }; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); @@ -149,6 +152,84 @@ fn parse_role_document( ConfigDocumentParser::new(®istry).parse_text(text, source_path, role) } +fn parse_root_fragment( + parser: &mut ConfigDocumentParser<'_>, + text: &str, + source_path: &Path, + home: Option<&DhttpHome>, +) -> ParsedPishooFragment { + let ParsedConfigDocument::HypervisorRoot(fragment) = parser + .parse_text( + text, + source_path, + ConfigDocumentRole::HypervisorRoot { home }, + ) + .expect("root document should parse") + else { + panic!("expected root pishoo fragment"); + }; + fragment +} + +fn parse_worker_fragment( + parser: &mut ConfigDocumentParser<'_>, + text: &str, + source_path: &Path, + home: &DhttpHome, +) -> ParsedPishooFragment { + let ParsedConfigDocument::WorkerPishoo(fragment) = parser + .parse_text(text, source_path, ConfigDocumentRole::WorkerPishoo { home }) + .expect("worker document should parse") + else { + panic!("expected worker pishoo fragment"); + }; + fragment +} + +fn parse_identity_fragments( + parser: &mut ConfigDocumentParser<'_>, + text: &str, + source_path: &Path, + home: &DhttpHome, + identity: &str, +) -> Vec { + let name = dhttp::name::DhttpName::try_from(identity.to_owned()) + .expect("identity fixture name should be valid"); + let profile = home.identity_profile(name); + let ParsedConfigDocument::IdentityServers(servers) = parser + .parse_text( + text, + source_path, + ConfigDocumentRole::IdentityServer { + home, + profile: &profile, + }, + ) + .expect("identity document should parse") + else { + panic!("expected identity server fragments"); + }; + servers.into_vec() +} + +fn root_snapshot_fixture( + parser: &mut ConfigDocumentParser<'_>, + fixture: &TempConfigDir, +) -> RootConfigSnapshot { + let root = parse_root_fragment( + parser, + "pishoo { gzip on; gzip_vary on; gzip_min_length 42; gzip_comp_level 4; types { text/root root; } }", + &fixture.join("root/pishoo.conf"), + None, + ); + let snapshot = build_global_tree(root) + .expect("root tree should seal") + .root_snapshot() + .expect("root snapshot should project"); + snapshot_test_support::checked_wire_round_trip(&snapshot) + .expect("root snapshot wire round trip should remain checked") +} + fn expect_role_error(failure: &ConfigLoadFailure) -> &ConfigDocumentRoleError { let LoadConfigError::DocumentRole { source } = &failure.error else { panic!("expected document role error, got {:#?}", failure.error); @@ -639,6 +720,11 @@ fn registry_v1_metadata_is_orthogonal() { assert_eq!(server.cascade, CascadePolicy::None); assert_eq!(server.transport, TransportPolicy::HypervisorOnly); assert_eq!(server.reload, ReloadImpact::ListenerSet); + + for context in [context::PISHOO, context::SERVER, context::LOCATION] { + assert!(registry.directive_matches_key(context, GZIP)); + assert!(registry.directive_matches_key(context, TYPES)); + } } #[test] @@ -656,3 +742,463 @@ fn loose_documents_do_not_expose_comparable_fake_namespaces() { assert!(first.document_id().is_none()); assert!(second.document_id().is_none()); } + +#[test] +fn missing_worker_fragment_still_builds_root_and_pishoo() { + let fixture = TempConfigDir::new("missing_worker_fragment"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let home = fixture.worker_home(); + let servers = parse_identity_fragments( + &mut parser, + "server { listen all 443; }", + &fixture.join("worker/identity/server.conf"), + &home, + "alice.dhttp.net", + ); + + let tree = build_worker_tree(snapshot, None, servers).expect("worker tree should seal"); + + assert_eq!( + tree.pishoo().parent_link(), + ParentLink::Node(tree.root().id()) + ); + let server = tree + .servers() + .next() + .expect("identity server should attach"); + assert_eq!( + server.node().parent_link(), + ParentLink::Node(tree.pishoo().id()) + ); +} + +#[test] +fn worker_scalar_override_wins_over_root_snapshot() { + let fixture = TempConfigDir::new("worker_override"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let home = fixture.worker_home(); + let worker = parse_worker_fragment( + &mut parser, + "pishoo { gzip off; }", + &fixture.worker_source_path(), + &home, + ); + let tree = build_worker_tree(snapshot, Some(worker), Vec::new()).expect("tree should seal"); + + let gzip = tree + .pishoo() + .cascaded(GZIP) + .expect("gzip query should succeed") + .expect("gzip has a builtin fallback"); + assert!(!gzip.effective().0); +} + +#[test] +fn types_replaces_the_whole_parent_map() { + let fixture = TempConfigDir::new("types_replace"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let home = fixture.worker_home(); + let servers = parse_identity_fragments( + &mut parser, + "server { listen all 443; types { text/server server; } }", + &fixture.join("worker/identity/server.conf"), + &home, + "alice.dhttp.net", + ); + let tree = build_worker_tree(snapshot, None, servers).expect("tree should seal"); + + let types = tree + .servers() + .next() + .expect("server should attach") + .node() + .cascaded(TYPES) + .expect("types query should succeed") + .expect("types should be configured"); + assert_eq!(types.effective().0.len(), 1); + assert!(types.effective().0.contains_key("server")); + assert!(!types.effective().0.contains_key("root")); +} + +#[test] +fn cascade_lineage_is_builtin_root_worker_server_location() { + let fixture = TempConfigDir::new("cascade_lineage"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let home = fixture.worker_home(); + let worker = parse_worker_fragment( + &mut parser, + "pishoo { gzip off; }", + &fixture.worker_source_path(), + &home, + ); + let servers = parse_identity_fragments( + &mut parser, + "server { listen all 443; gzip on; location / { gzip off; } }", + &fixture.join("worker/identity/server.conf"), + &home, + "alice.dhttp.net", + ); + let tree = build_worker_tree(snapshot, Some(worker), servers).expect("tree should seal"); + let location = tree + .servers() + .next() + .expect("server") + .locations() + .next() + .expect("location"); + let gzip = location + .node() + .cascaded(GZIP) + .expect("gzip query") + .expect("gzip fallback"); + + assert!(!gzip.effective().0); + assert_eq!(gzip.lineage().len(), 5); + assert!(matches!( + gzip.lineage()[0], + crate::parse::cascade::ConfigOrigin::Builtin { .. } + )); + assert!(matches!( + gzip.lineage()[1], + crate::parse::cascade::ConfigOrigin::RootInherited { .. } + )); + assert!( + gzip.lineage()[2..] + .iter() + .all(|origin| matches!(origin, crate::parse::cascade::ConfigOrigin::Source(_))) + ); +} + +#[test] +fn identity_server_parent_is_home_pishoo() { + let fixture = TempConfigDir::new("identity_parent"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let home = fixture.worker_home(); + let servers = parse_identity_fragments( + &mut parser, + "server { listen all 443; }", + &fixture.join("identity/server.conf"), + &home, + "alice.dhttp.net", + ); + let tree = build_worker_tree(snapshot, None, servers).expect("tree should seal"); + let server = tree.servers().next().expect("server"); + + assert_eq!( + server.node().parent_link(), + ParentLink::Node(tree.pishoo().id()) + ); +} + +#[test] +fn global_direct_server_parent_is_global_pishoo() { + let fixture = TempConfigDir::new("global_server_parent"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 443; } }", + &fixture.join("global-home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(root).expect("global tree should seal"); + let server = tree.servers().next().expect("server"); + + assert_eq!( + server.node().parent_link(), + ParentLink::Node(tree.pishoo().id()) + ); +} + +#[test] +fn location_parent_is_its_server() { + let fixture = TempConfigDir::new("location_parent"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 443; location / { root /tmp; } } }", + &fixture.join("global-home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(root).expect("global tree should seal"); + let server = tree.servers().next().expect("server"); + let location = server.locations().next().expect("location"); + + assert_eq!( + location.node().parent_link(), + ParentLink::Node(server.node().id()) + ); +} + +#[test] +fn tree_ref_keeps_source_and_parent_alive() { + let fixture = TempConfigDir::new("ref_lifetime"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let source_path = fixture.join("global-home/pishoo.conf"); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 443; } }", + &source_path, + Some(&home), + ); + let tree = build_global_tree(root).expect("tree should seal"); + let server = tree.servers().next().expect("server"); + let span = server.node().source_span().expect("source span"); + drop(tree); + + assert_eq!( + server.node().parent_link(), + ParentLink::Node(server.tree().pishoo().id()) + ); + assert_eq!(server.tree().source_path(span), Some(source_path.as_path())); +} + +#[test] +fn cross_document_diagnostics_keep_the_correct_source_bundle() { + let fixture = TempConfigDir::new("cross_document_sources"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let home = fixture.worker_home(); + let alice_path = fixture.join("alice/server.conf"); + let bob_path = fixture.join("bob/server.conf"); + let mut alice_parser = ConfigDocumentParser::new(®istry); + let mut servers = parse_identity_fragments( + &mut alice_parser, + "server { listen all 443; }", + &alice_path, + &home, + "alice.dhttp.net", + ); + let mut bob_parser = ConfigDocumentParser::new(®istry); + servers.extend(parse_identity_fragments( + &mut bob_parser, + "server { listen all 444; }", + &bob_path, + &home, + "bob.dhttp.net", + )); + let tree = build_worker_tree(snapshot, None, servers).expect("tree should seal"); + let paths = tree + .servers() + .map(|server| { + let span = server.node().source_span().expect("source span"); + tree.source_path(span).expect("source path").to_path_buf() + }) + .collect::>(); + + assert_eq!(paths, vec![alice_path, bob_path]); +} + +#[test] +fn root_snapshot_contains_only_worker_inheritable_values() { + let fixture = TempConfigDir::new("snapshot_allowlist"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { pid /run/pishoo.pid; workers alice; groups www; gzip on; server { listen all 443; } }", + &fixture.join("global-home/pishoo.conf"), + Some(&home), + ); + let snapshot = build_global_tree(root) + .expect("root tree") + .root_snapshot() + .expect("snapshot"); + + assert_eq!( + snapshot_test_support::wire_field_names(&snapshot), + [ + "access_rules", + "gzip", + "gzip_vary", + "gzip_min_length", + "gzip_comp_level", + "gzip_types", + "default_type", + "types", + "access_log", + ] + ); + assert!(snapshot.gzip().0); +} + +#[test] +fn root_direct_servers_are_not_serialized() { + let fixture = TempConfigDir::new("snapshot_no_servers"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let without_server = parse_root_fragment( + &mut parser, + "pishoo { gzip on; }", + &fixture.join("without/pishoo.conf"), + Some(&home), + ); + let with_server = parse_root_fragment( + &mut parser, + "pishoo { gzip on; server { listen all 443; } }", + &fixture.join("with/pishoo.conf"), + Some(&home), + ); + let first = build_global_tree(without_server) + .unwrap() + .root_snapshot() + .unwrap(); + let second = build_global_tree(with_server) + .unwrap() + .root_snapshot() + .unwrap(); + + assert_ne!(first, second, "source origin remains part of equality"); + assert_eq!( + snapshot_test_support::values_without_origins(&first), + snapshot_test_support::values_without_origins(&second) + ); +} + +#[test] +fn resolved_root_path_round_trips_without_worker_rebase() { + let root = ResolvedConfigPath::try_from(PathBuf::from("/srv/root/logs/access.log")).unwrap(); + let decoded = snapshot_test_support::round_trip_resolved_path(root.clone()).unwrap(); + + assert_eq!(decoded, root); +} + +#[cfg(unix)] +#[test] +fn resolved_root_non_utf8_path_round_trips_on_unix() { + use std::os::unix::ffi::OsStringExt; + + let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec())); + let resolved = ResolvedConfigPath::try_from(path).unwrap(); + let decoded = snapshot_test_support::round_trip_resolved_path(resolved.clone()).unwrap(); + + assert_eq!(decoded, resolved); +} + +#[test] +fn snapshot_absolute_path_rejects_relative_or_nul_bytes() { + assert!(snapshot_test_support::decode_absolute_path(b"relative/path").is_err()); + assert!(snapshot_test_support::decode_absolute_path(b"/tmp/nul\0path").is_err()); +} + +#[test] +fn root_path_without_source_base_is_a_configuration_error() { + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let failure = parser + .parse_text( + "pishoo { access_rules sqlite:relative/rules.db; }", + Path::new("pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect_err("unanchored root path must be rejected"); + let error = snafu::Report::from_error(&failure.error).to_string(); + + assert!(error.contains("resolve relative access_rules sqlite path")); +} + +#[test] +fn unknown_snapshot_schema_is_rejected() { + assert!(snapshot_test_support::decode_schema(2).is_err()); +} + +#[test] +fn snapshot_access_rules_revalidates_url_domain() { + assert!(snapshot_test_support::decode_access_rules("https://example.com/rules.db").is_err()); + assert!(snapshot_test_support::decode_access_rules("sqlite://host/tmp/rules.db").is_err()); +} + +#[test] +fn snapshot_header_values_revalidate_from_bytes() { + assert!(snapshot_test_support::decode_header_value(b"text/plain").is_ok()); + assert!(snapshot_test_support::decode_header_value(b"bad\nvalue").is_err()); +} + +#[test] +fn snapshot_mime_entries_are_sorted_and_reject_duplicates() { + let types = MimeTypes(std::collections::HashMap::from([ + ("z".to_owned(), http::HeaderValue::from_static("text/z")), + ("a".to_owned(), http::HeaderValue::from_static("text/a")), + ])); + assert_eq!( + snapshot_test_support::mime_wire_extensions(&types), + ["a", "z"] + ); + assert!( + snapshot_test_support::decode_mime_entries(&[("a", b"text/a"), ("a", b"text/b")]).is_err() + ); + assert!(snapshot_test_support::decode_mime_entries(&[("", b"text/a")]).is_err()); +} + +#[test] +fn snapshot_preserves_absence_and_gzip_fallbacks() { + let fixture = TempConfigDir::new("snapshot_fallbacks"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment( + &mut parser, + "pishoo {}", + &fixture.join("root/pishoo.conf"), + None, + ); + let snapshot = build_global_tree(root).unwrap().root_snapshot().unwrap(); + + assert!(snapshot.access_rules().is_none()); + assert!(!snapshot.gzip().0); + assert!(!snapshot.gzip_vary().0); + assert_eq!(snapshot.gzip_min_length().0, 20); + assert_eq!(snapshot.gzip_comp_level().0, 1); + assert!(snapshot.gzip_types().is_none()); + assert!(snapshot.default_type().is_none()); + assert!(snapshot.types().is_none()); + assert!(!snapshot_test_support::has_access_log(&snapshot)); +} + +#[test] +fn snapshot_equality_includes_origin() { + let fixture = TempConfigDir::new("snapshot_origin_equality"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let first = parse_root_fragment( + &mut parser, + "pishoo { gzip on; }", + &fixture.join("one/pishoo.conf"), + None, + ); + let second = parse_root_fragment( + &mut parser, + "pishoo {\n gzip on;\n}", + &fixture.join("two/pishoo.conf"), + None, + ); + let first = build_global_tree(first).unwrap().root_snapshot().unwrap(); + let second = build_global_tree(second).unwrap().root_snapshot().unwrap(); + + assert_eq!( + snapshot_test_support::checked_wire_round_trip(&first).unwrap(), + first + ); + assert_ne!(first, second); + assert_eq!( + snapshot_test_support::values_without_origins(&first), + snapshot_test_support::values_without_origins(&second) + ); +} diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs new file mode 100644 index 00000000..5d5ecbfa --- /dev/null +++ b/gateway/src/parse/tree.rs @@ -0,0 +1,548 @@ +use std::{num::NonZeroU32, path::Path, sync::Arc}; + +use snafu::Snafu; + +use crate::parse::{ + cascade::{CascadedValue, ConfigOrigin, DirectiveKey, InheritedSourceLocation}, + document::ConfigNode, + domain::{ + ConfigDocumentId, ConfigDocumentIdAllocator, ConfigDocumentIdError, ConfigSourceSpan, + }, + error::ConfigQueryError, + fragment::{ParsedPishooFragment, ParsedServerFragment}, + registry::context, + snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, + source::{SourceId, SourceMap, SourceSpan}, + value::ConfigValue, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigNodeId(usize); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParentLink { + Root, + Node(ConfigNodeId), +} + +#[derive(Debug)] +struct SealedConfigNode { + document_id: Option, + node: Arc, + parent: ParentLink, + children: Vec, +} + +#[derive(Debug)] +enum ConfigSourceFragment { + Pishoo(ParsedPishooFragment), + Server(ParsedServerFragment), +} + +impl ConfigSourceFragment { + fn source_map(&self) -> &SourceMap { + match self { + Self::Pishoo(fragment) => fragment.source_map(), + Self::Server(fragment) => fragment.source_map(), + } + } +} + +#[derive(Debug)] +struct ConfigSourceOwner { + document_id: ConfigDocumentId, + fragment: ConfigSourceFragment, +} + +impl ConfigSourceOwner { + fn source_map(&self) -> &SourceMap { + self.fragment.source_map() + } +} + +#[derive(Debug)] +pub struct HomeConfigTree { + nodes: Box<[SealedConfigNode]>, + root: ConfigNodeId, + pishoo: ConfigNodeId, + servers: Box<[ConfigNodeId]>, + inherited_root: Option>, + sources: ConfigSourceBundle, +} + +#[derive(Debug)] +pub struct ConfigSourceBundle { + owners: Box<[ConfigSourceOwner]>, +} + +#[derive(Debug, Clone)] +pub struct ConfigNodeRef { + tree: Arc, + node: ConfigNodeId, +} + +#[derive(Debug, Clone)] +pub struct ServerConfigRef(ConfigNodeRef); + +#[derive(Debug, Clone)] +pub struct LocationConfigRef(ConfigNodeRef); + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum HomeConfigTreeError { + #[snafu(display("failed to allocate a sealed configuration document identity"))] + DocumentId { source: ConfigDocumentIdError }, + #[snafu(display("non-root configuration node is missing its semantic parent"))] + MissingParent { node: ConfigNodeId }, + #[snafu(display("sealed configuration node has an invalid semantic parent"))] + InvalidParent { node: ConfigNodeId }, + #[snafu(display("sealed configuration node has an invalid context"))] + InvalidContext { node: ConfigNodeId }, + #[snafu(display("sealed configuration node has no owning source document"))] + MissingSource { node: ConfigNodeId }, +} + +pub fn build_global_tree( + root_fragment: ParsedPishooFragment, +) -> Result, HomeConfigTreeError> { + HomeConfigTreeBuilder::global(root_fragment)?.seal() +} + +pub fn build_worker_tree( + root_snapshot: RootConfigSnapshot, + worker_fragment: Option, + identity_fragments: I, +) -> Result, HomeConfigTreeError> +where + I: IntoIterator, +{ + HomeConfigTreeBuilder::worker( + root_snapshot, + worker_fragment, + identity_fragments.into_iter(), + )? + .seal() +} + +struct HomeConfigTreeBuilder { + nodes: Vec, + root: ConfigNodeId, + pishoo: ConfigNodeId, + servers: Vec, + inherited_root: Option>, + sources: Vec, + document_ids: ConfigDocumentIdAllocator, +} + +impl HomeConfigTreeBuilder { + fn global(fragment: ParsedPishooFragment) -> Result { + let synthetic_span = fragment.node().span; + let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); + let mut builder = Self { + nodes: Vec::new(), + root: ConfigNodeId(0), + pishoo: ConfigNodeId(0), + servers: Vec::new(), + inherited_root: None, + sources: Vec::new(), + document_ids: ConfigDocumentIdAllocator::new(), + }; + let document_id = builder.allocate_document_id()?; + builder.root = builder.push_node(None, root_node, ParentLink::Root); + builder.pishoo = builder.push_node( + Some(document_id), + Arc::clone(fragment.node()), + ParentLink::Node(builder.root), + ); + builder.nodes[builder.root.0].children.push(builder.pishoo); + for server in fragment.servers() { + builder.attach_server(server, document_id); + } + builder.sources.push(ConfigSourceOwner { + document_id, + fragment: ConfigSourceFragment::Pishoo(fragment), + }); + Ok(builder) + } + + fn worker( + root_snapshot: RootConfigSnapshot, + worker_fragment: Option, + identity_fragments: impl Iterator, + ) -> Result { + let synthetic_span = worker_fragment + .as_ref() + .map_or_else(synthetic_span, |fragment| fragment.node().span); + let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); + let pishoo_node = worker_fragment.as_ref().map_or_else( + || Arc::new(ConfigNode::new(context::PISHOO, None, synthetic_span)), + |fragment| Arc::clone(fragment.node()), + ); + let mut builder = Self { + nodes: Vec::new(), + root: ConfigNodeId(0), + pishoo: ConfigNodeId(0), + servers: Vec::new(), + inherited_root: Some(Arc::new(root_snapshot)), + sources: Vec::new(), + document_ids: ConfigDocumentIdAllocator::new(), + }; + builder.root = builder.push_node(None, root_node, ParentLink::Root); + let worker_document_id = worker_fragment + .as_ref() + .map(|_| builder.allocate_document_id()) + .transpose()?; + builder.pishoo = builder.push_node( + worker_document_id, + pishoo_node, + ParentLink::Node(builder.root), + ); + builder.nodes[builder.root.0].children.push(builder.pishoo); + if let Some(fragment) = worker_fragment { + let Some(document_id) = worker_document_id else { + return Err(HomeConfigTreeError::MissingSource { + node: builder.pishoo, + }); + }; + builder.sources.push(ConfigSourceOwner { + document_id, + fragment: ConfigSourceFragment::Pishoo(fragment), + }); + } + for fragment in identity_fragments { + if let Some(document_id) = builder.document_id(fragment.source_map()) { + builder.attach_server(&fragment, document_id); + } else { + let document_id = builder.allocate_document_id()?; + builder.attach_server(&fragment, document_id); + builder.sources.push(ConfigSourceOwner { + document_id, + fragment: ConfigSourceFragment::Server(fragment), + }); + } + } + Ok(builder) + } + + fn attach_server(&mut self, fragment: &ParsedServerFragment, document_id: ConfigDocumentId) { + let server = self.push_node( + Some(document_id), + Arc::clone(fragment.node()), + ParentLink::Node(self.pishoo), + ); + self.servers.push(server); + for location in fragment.locations() { + let location = self.push_node( + Some(document_id), + Arc::clone(location.node()), + ParentLink::Node(server), + ); + self.nodes[server.0].children.push(location); + } + self.nodes[self.pishoo.0].children.push(server); + } + + fn allocate_document_id(&mut self) -> Result { + self.document_ids + .allocate() + .map_err(|source| HomeConfigTreeError::DocumentId { source }) + } + + fn document_id(&self, source_map: &SourceMap) -> Option { + self.sources + .iter() + .find(|owner| std::ptr::eq(owner.source_map(), source_map)) + .map(|owner| owner.document_id) + } + + fn push_node( + &mut self, + document_id: Option, + node: Arc, + parent: ParentLink, + ) -> ConfigNodeId { + let id = ConfigNodeId(self.nodes.len()); + self.nodes.push(SealedConfigNode { + document_id, + node, + parent, + children: Vec::new(), + }); + id + } + + fn finalize_attached(&self) -> Result<(), HomeConfigTreeError> { + for (index, node) in self.nodes.iter().enumerate() { + let id = ConfigNodeId(index); + if id != self.root && !matches!(node.parent, ParentLink::Node(_)) { + return Err(HomeConfigTreeError::MissingParent { node: id }); + } + if node.document_id.is_some_and(|document_id| { + !self + .sources + .iter() + .any(|source| source.document_id == document_id) + }) { + return Err(HomeConfigTreeError::MissingSource { node: id }); + } + } + + self.verify_node(self.root, context::ROOT, ParentLink::Root)?; + self.verify_node(self.pishoo, context::PISHOO, ParentLink::Node(self.root))?; + if self.nodes[self.root.0].children.as_slice() != [self.pishoo] { + return Err(HomeConfigTreeError::InvalidParent { node: self.pishoo }); + } + if self.nodes[self.pishoo.0].children.as_slice() != self.servers.as_slice() { + return Err(HomeConfigTreeError::InvalidParent { node: self.pishoo }); + } + for &server in &self.servers { + self.verify_node(server, context::SERVER, ParentLink::Node(self.pishoo))?; + for &location in &self.nodes[server.0].children { + self.verify_node(location, context::LOCATION, ParentLink::Node(server))?; + } + } + Ok(()) + } + + fn verify_node( + &self, + node: ConfigNodeId, + context: crate::parse::registry::ContextKey, + parent: ParentLink, + ) -> Result<(), HomeConfigTreeError> { + let sealed = &self.nodes[node.0]; + if sealed.node.context != context { + return Err(HomeConfigTreeError::InvalidContext { node }); + } + if sealed.parent != parent { + return Err(HomeConfigTreeError::InvalidParent { node }); + } + Ok(()) + } + + fn seal(self) -> Result, HomeConfigTreeError> { + self.finalize_attached()?; + Ok(Arc::new(HomeConfigTree { + nodes: self.nodes.into_boxed_slice(), + root: self.root, + pishoo: self.pishoo, + servers: self.servers.into_boxed_slice(), + inherited_root: self.inherited_root, + sources: ConfigSourceBundle { + owners: self.sources.into_boxed_slice(), + }, + })) + } +} + +fn synthetic_span() -> SourceSpan { + SourceSpan::new(SourceId(0), 0, 0) +} + +impl HomeConfigTree { + pub fn root(self: &Arc) -> ConfigNodeRef { + ConfigNodeRef::new(Arc::clone(self), self.root) + } + + pub fn pishoo(self: &Arc) -> ConfigNodeRef { + ConfigNodeRef::new(Arc::clone(self), self.pishoo) + } + + pub fn servers(self: &Arc) -> impl Iterator + '_ { + self.servers + .iter() + .copied() + .map(|node| ServerConfigRef(ConfigNodeRef::new(Arc::clone(self), node))) + } + + pub fn root_snapshot(self: &Arc) -> Result { + RootConfigSnapshot::project(self) + } + + pub fn source_path(&self, span: ConfigSourceSpan) -> Option<&Path> { + self.sources.source_path(span) + } + + pub fn sources(&self) -> &ConfigSourceBundle { + &self.sources + } + + pub(crate) fn inherited_source_location( + &self, + span: ConfigSourceSpan, + ) -> Result { + self.sources.inherited_source_location(span) + } + + fn node(&self, id: ConfigNodeId) -> &SealedConfigNode { + &self.nodes[id.0] + } + + fn cascaded( + &self, + node: ConfigNodeId, + key: DirectiveKey, + ) -> Result>>, ConfigQueryError> + where + T: ConfigValue, + { + let mut lineage = Vec::new(); + let mut effective = key.builtin(); + if effective.is_some() { + lineage.push(ConfigOrigin::Builtin { + directive: key.name(), + }); + } + if let Some(snapshot) = &self.inherited_root + && let Some((value, origin)) = key.snapshot(snapshot) + { + effective = Some(value); + if !matches!(origin, ConfigOrigin::Builtin { .. }) { + lineage.push(origin); + } + } + + let mut chain = Vec::new(); + let mut current = node; + loop { + chain.push(current); + match self.node(current).parent { + ParentLink::Root => break, + ParentLink::Node(parent) => current = parent, + } + } + for id in chain.into_iter().rev() { + let node = self.node(id); + if let Some((value, span)) = node.node.get_with_span::(key.name().as_str())? { + effective = Some(value); + let origin = node.document_id.map_or( + ConfigOrigin::Builtin { + directive: key.name(), + }, + |document_id| ConfigOrigin::Source(ConfigSourceSpan::new(document_id, span)), + ); + lineage.push(origin); + } + } + Ok(effective.map(|effective| CascadedValue::new(effective, lineage.into_boxed_slice()))) + } +} + +impl ConfigSourceBundle { + pub fn source_path(&self, span: ConfigSourceSpan) -> Option<&Path> { + self.source_map(span.document_id()) + .and_then(|sources| sources.get(span.source_span().source_id)) + .and_then(|source| source.path.as_deref()) + } + + fn inherited_source_location( + &self, + span: ConfigSourceSpan, + ) -> Result { + let source_map = self + .source_map(span.document_id()) + .ok_or(RootConfigSnapshotError::MissingSourceLocation)?; + let source_span = span.source_span(); + let source = source_map + .get(source_span.source_id) + .ok_or(RootConfigSnapshotError::MissingSourceLocation)?; + if let Some(path) = &source.path + && (!path.is_absolute() || path.as_os_str().as_encoded_bytes().contains(&0)) + { + return Err(RootConfigSnapshotError::SourcePath { path: path.clone() }); + } + let location = source_map + .line_column(source_span) + .ok_or(RootConfigSnapshotError::MissingSourceLocation)?; + let line = u32::try_from(location.line) + .ok() + .and_then(NonZeroU32::new) + .ok_or(RootConfigSnapshotError::SourceLocation { + line: location.line, + column: location.column, + })?; + let column = u32::try_from(location.column) + .ok() + .and_then(NonZeroU32::new) + .ok_or(RootConfigSnapshotError::SourceLocation { + line: location.line, + column: location.column, + })?; + Ok(InheritedSourceLocation::new( + source.path.clone(), + line, + column, + )) + } + + fn source_map(&self, document_id: ConfigDocumentId) -> Option<&SourceMap> { + self.owners + .iter() + .find(|owner| owner.document_id == document_id) + .map(ConfigSourceOwner::source_map) + } +} + +impl ConfigNodeRef { + fn new(tree: Arc, node: ConfigNodeId) -> Self { + Self { tree, node } + } + + pub const fn id(&self) -> ConfigNodeId { + self.node + } + + pub fn parent_link(&self) -> ParentLink { + self.tree.node(self.node).parent + } + + pub fn source_span(&self) -> Option { + let node = self.tree.node(self.node); + node.document_id + .map(|document_id| ConfigSourceSpan::new(document_id, node.node.span)) + } + + pub fn tree(&self) -> &Arc { + &self.tree + } + + pub fn cascaded( + &self, + key: DirectiveKey, + ) -> Result>>, ConfigQueryError> + where + T: ConfigValue, + { + self.tree.cascaded(self.node, key) + } +} + +impl ServerConfigRef { + pub fn node(&self) -> &ConfigNodeRef { + &self.0 + } + + pub fn tree(&self) -> &Arc { + self.0.tree() + } + + pub fn locations(&self) -> impl Iterator + '_ { + self.0 + .tree + .node(self.0.node) + .children + .iter() + .copied() + .map(|node| LocationConfigRef(ConfigNodeRef::new(Arc::clone(&self.0.tree), node))) + } +} + +impl LocationConfigRef { + pub fn node(&self) -> &ConfigNodeRef { + &self.0 + } + + pub fn tree(&self) -> &Arc { + self.0.tree() + } +} diff --git a/gateway/src/parse/types.rs b/gateway/src/parse/types.rs index 8a511498..d3b5d69b 100644 --- a/gateway/src/parse/types.rs +++ b/gateway/src/parse/types.rs @@ -45,19 +45,19 @@ pub fn optional_server_identity( server_identity(node, server_name.0.clone()) } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct BoolConfig(pub bool); #[derive(Debug, Clone)] pub struct StringConfig(pub String); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StringList(pub Vec); #[derive(Debug, Clone)] pub struct PathConfig(pub std::path::PathBuf); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AccessRulesUri(pub url::Url); #[derive(Debug, Clone, PartialEq, Eq)] @@ -119,18 +119,126 @@ pub struct HeaderRule { #[derive(Debug, Clone)] pub struct HeaderRules(pub Vec); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MimeTypes(pub std::collections::HashMap); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct DefaultType(pub http::HeaderValue); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GzipMinLength(pub u64); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GzipCompLevel(pub i32); +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum AccessRulesUriValidationError { + #[snafu(display("unsupported access_rules uri scheme `{scheme}`"))] + UnsupportedScheme { scheme: String }, + #[snafu(display("unsupported sqlite access_rules uri form"))] + UnsupportedSqliteForm, + #[snafu(display("access_rules sqlite path must be absolute"))] + RelativePath, + #[snafu(display("access_rules sqlite path contains a NUL byte"))] + NulPath, +} + +impl TryFrom for AccessRulesUri { + type Error = AccessRulesUriValidationError; + + fn try_from(uri: url::Url) -> Result { + if uri.scheme() != "sqlite" { + return Err(AccessRulesUriValidationError::UnsupportedScheme { + scheme: uri.scheme().to_owned(), + }); + } + if uri.host_str().is_some() + || !uri.username().is_empty() + || uri.password().is_some() + || uri.port().is_some() + || uri.fragment().is_some() + { + return Err(AccessRulesUriValidationError::UnsupportedSqliteForm); + } + let path = std::path::Path::new(uri.path()); + if !path.is_absolute() { + return Err(AccessRulesUriValidationError::RelativePath); + } + if path.as_os_str().as_encoded_bytes().contains(&0) { + return Err(AccessRulesUriValidationError::NulPath); + } + Ok(Self(uri)) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum GzipTypesValidationError { + #[snafu(display("gzip_types contains an empty MIME token"))] + EmptyToken, +} + +impl StringList { + pub fn checked_gzip_types(values: Vec) -> Result { + if values.iter().any(String::is_empty) { + return Err(GzipTypesValidationError::EmptyToken); + } + Ok(Self(values)) + } +} + +impl GzipMinLength { + pub const fn checked(value: u64) -> Self { + Self(value) + } +} + +impl GzipCompLevel { + pub const fn checked(value: i32) -> Self { + Self(value) + } +} + +impl DefaultType { + pub fn checked_from_bytes(value: &[u8]) -> Result { + http::HeaderValue::from_bytes(value).map(Self) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum MimeTypesValidationError { + #[snafu(display("MIME extension must not be empty"))] + EmptyExtension, + #[snafu(display("duplicate MIME extension `{extension}`"))] + DuplicateExtension { extension: String }, + #[snafu(display("invalid MIME type header value"))] + HeaderValue { + source: http::header::InvalidHeaderValue, + }, +} + +impl MimeTypes { + pub fn checked_from_bytes(entries: I) -> Result + where + I: IntoIterator)>, + { + let mut values = std::collections::HashMap::new(); + for (extension, value) in entries { + if extension.is_empty() { + return Err(MimeTypesValidationError::EmptyExtension); + } + let value = http::HeaderValue::from_bytes(&value) + .map_err(|source| MimeTypesValidationError::HeaderValue { source })?; + if values.insert(extension.clone(), value).is_some() { + return Err(MimeTypesValidationError::DuplicateExtension { extension }); + } + } + Ok(Self(values)) + } +} + #[derive(Debug, Clone)] pub struct SshLoginMethods(pub Vec); From 70ff30e07fb6e2e7d306240b127d0cc8968ed395 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 03:05:10 +0800 Subject: [PATCH 05/18] fix(config): honor sealed tree registry contracts --- gateway/src/parse/builtin/server.rs | 27 ++++ gateway/src/parse/cascade.rs | 37 +---- gateway/src/parse/diagnostic.rs | 3 + gateway/src/parse/error.rs | 20 ++- gateway/src/parse/registry.rs | 47 ++++-- gateway/src/parse/snapshot.rs | 53 ++++++- gateway/src/parse/tests.rs | 213 ++++++++++++++++++++++++---- gateway/src/parse/tree.rs | 176 +++++++++++++++++++---- 8 files changed, 467 insertions(+), 109 deletions(-) diff --git a/gateway/src/parse/builtin/server.rs b/gateway/src/parse/builtin/server.rs index 3818c947..d9536b56 100644 --- a/gateway/src/parse/builtin/server.rs +++ b/gateway/src/parse/builtin/server.rs @@ -7,6 +7,7 @@ use crate::parse::{ ReloadImpact, TransportPolicy, context, }, source::SourceSpan, + tree::AttachedConfigNode, types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, ListenConfig, MimeTypes, PathConfig, ResolverConfig, ServerName, ServerNames, StringList, @@ -23,6 +24,10 @@ pub enum FinalizeServerError { MissingCertificate { span: SourceSpan }, #[snafu(display("missing ssl_certificate_key directive in server context"))] MissingCertificateKey { span: SourceSpan }, + #[snafu(display("server context is not attached to the home PISHOO context"))] + InvalidAttachedParent { span: SourceSpan }, + #[snafu(display("attached location context is missing its parsed pattern"))] + MissingAttachedLocationPattern { span: SourceSpan }, } pub fn register(registry: &mut ConfigRegistry) { @@ -30,6 +35,7 @@ pub fn register(registry: &mut ConfigRegistry) { key: context::SERVER, finalize: Some(finalize_server), }); + registry.register_attached_finalizer(context::SERVER, finalize_attached_server); registry.register_directive(context::ROOT, server_block(context::ROOT)); registry.register_directive(context::PISHOO, server_block(context::PISHOO)); register_server_leaf::( @@ -206,6 +212,27 @@ fn finalize_server( } } +fn finalize_attached_server( + node: AttachedConfigNode<'_>, +) -> Result<(), Box> { + ensure!( + node.parent() + .is_some_and(|parent| parent.context() == context::PISHOO), + finalize_server_error::InvalidAttachedParentSnafu { + span: node.config().span, + } + ); + for location in node.children() { + location + .config() + .payload::()? + .context(finalize_server_error::MissingAttachedLocationPatternSnafu { + span: location.config().span, + })?; + } + Ok(()) +} + #[cfg(test)] mod tests { use crate::parse::{ diff --git a/gateway/src/parse/cascade.rs b/gateway/src/parse/cascade.rs index 779aad48..afed8d20 100644 --- a/gateway/src/parse/cascade.rs +++ b/gateway/src/parse/cascade.rs @@ -2,7 +2,6 @@ use std::{marker::PhantomData, num::NonZeroU32, path::Path, sync::Arc}; use crate::parse::{ domain::{ConfigSourceSpan, DirectiveName}, - registry::CascadePolicy, snapshot::RootConfigSnapshot, types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, @@ -83,7 +82,6 @@ pub struct DirectiveKey { name: DirectiveName, builtin: BuiltinValue, snapshot: SnapshotValue, - cascade: CascadePolicy, value: PhantomData T>, } @@ -98,13 +96,11 @@ impl Copy for DirectiveKey {} impl DirectiveKey { pub(crate) const fn new( name: &'static str, - cascade: CascadePolicy, builtin: BuiltinValue, snapshot: SnapshotValue, ) -> Self { Self { name: DirectiveName::new(name), - cascade, builtin, snapshot, value: PhantomData, @@ -115,10 +111,6 @@ impl DirectiveKey { self.name } - pub const fn cascade_policy(self) -> CascadePolicy { - self.cascade - } - pub(crate) fn builtin(self) -> Option> { (self.builtin)() } @@ -146,49 +138,32 @@ fn builtin_comp_level() -> Option> { pub const ACCESS_RULES: DirectiveKey = DirectiveKey::new( "access_rules", - CascadePolicy::NearestWins, absent, RootConfigSnapshot::cascade_access_rules, ); -pub const GZIP: DirectiveKey = DirectiveKey::new( - "gzip", - CascadePolicy::NearestWins, - builtin_false, - RootConfigSnapshot::cascade_gzip, -); +pub const GZIP: DirectiveKey = + DirectiveKey::new("gzip", builtin_false, RootConfigSnapshot::cascade_gzip); pub const GZIP_VARY: DirectiveKey = DirectiveKey::new( "gzip_vary", - CascadePolicy::NearestWins, builtin_false, RootConfigSnapshot::cascade_gzip_vary, ); pub const GZIP_MIN_LENGTH: DirectiveKey = DirectiveKey::new( "gzip_min_length", - CascadePolicy::NearestWins, builtin_min_length, RootConfigSnapshot::cascade_gzip_min_length, ); pub const GZIP_COMP_LEVEL: DirectiveKey = DirectiveKey::new( "gzip_comp_level", - CascadePolicy::NearestWins, builtin_comp_level, RootConfigSnapshot::cascade_gzip_comp_level, ); -pub const GZIP_TYPES: DirectiveKey = DirectiveKey::new( - "gzip_types", - CascadePolicy::NearestWins, - absent, - RootConfigSnapshot::cascade_gzip_types, -); +pub const GZIP_TYPES: DirectiveKey = + DirectiveKey::new("gzip_types", absent, RootConfigSnapshot::cascade_gzip_types); pub const DEFAULT_TYPE: DirectiveKey = DirectiveKey::new( "default_type", - CascadePolicy::NearestWins, absent, RootConfigSnapshot::cascade_default_type, ); -pub const TYPES: DirectiveKey = DirectiveKey::new( - "types", - CascadePolicy::ReplaceWhole, - absent, - RootConfigSnapshot::cascade_types, -); +pub const TYPES: DirectiveKey = + DirectiveKey::new("types", absent, RootConfigSnapshot::cascade_types); diff --git a/gateway/src/parse/diagnostic.rs b/gateway/src/parse/diagnostic.rs index c6f01d34..7986a6e1 100644 --- a/gateway/src/parse/diagnostic.rs +++ b/gateway/src/parse/diagnostic.rs @@ -153,6 +153,9 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { | crate::parse::error::ConfigQueryError::TypeMismatch { span, .. } | crate::parse::error::ConfigQueryError::MultipleValues { span, .. } | crate::parse::error::ConfigQueryError::MissingChild { span, .. } => Some(*span), + crate::parse::error::ConfigQueryError::CascadePolicyMismatch { .. } + | crate::parse::error::ConfigQueryError::MissingCascadePolicy { .. } + | crate::parse::error::ConfigQueryError::UnsupportedCascadePolicy { .. } => None, }; } error.source().and_then(find_span) diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index fd22b61f..ff70761d 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -8,7 +8,7 @@ use crate::parse::{ DirectiveName, }, grammar::ParseSyntaxError, - registry::ContextKey, + registry::{CascadePolicy, ContextKey}, source::{SourceId, SourceMap, SourceSpan}, }; @@ -247,6 +247,24 @@ pub enum ConfigQueryError { #[snafu(display("missing child directive `{directive}`"))] MissingChild { directive: String, span: SourceSpan }, + + #[snafu(display( + "directive `{directive}` has inconsistent cascade policies ({inherited:?} and {local:?})" + ))] + CascadePolicyMismatch { + directive: String, + inherited: CascadePolicy, + local: CascadePolicy, + }, + + #[snafu(display("directive `{directive}` has no registered cascade policy"))] + MissingCascadePolicy { directive: String }, + + #[snafu(display("directive `{directive}` does not support typed cascading with {policy:?}"))] + UnsupportedCascadePolicy { + directive: String, + policy: CascadePolicy, + }, } #[derive(Debug)] diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index a294bdff..a569a455 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -10,6 +10,7 @@ use crate::parse::{ fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, normalize, source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, + tree::AttachedConfigNode, value::{ConfigValue, TypedValue}, }; @@ -37,6 +38,7 @@ pub mod context { pub struct ConfigRegistry { contexts: HashMap, directives: HashMap<(ContextKey, &'static str), DirectiveSpec>, + attached_finalizers: HashMap, } pub struct ContextSpec { @@ -104,6 +106,10 @@ type DirectiveParserFn = fn(&DirectiveInput<'_>) -> Result>; pub type ContextFinalizeFn = fn(&mut ConfigNode, &BuildOptions<'_>) -> Result<(), Box>; +type AttachedContextFinalizeFn = + for<'tree> fn( + AttachedConfigNode<'tree>, + ) -> Result<(), Box>; pub trait DirectiveValue: ConfigValue + Sized { type Error: Error + Send + Sync + 'static; @@ -126,10 +132,6 @@ pub enum ParsedDirective { } impl DirectiveSpec { - pub fn matches_key(&self, key: crate::parse::cascade::DirectiveKey) -> bool { - self.name == key.name() && self.cascade == key.cascade_policy() - } - pub fn leaf_value( name: &'static str, allowed_in: Vec, @@ -295,6 +297,24 @@ impl ConfigRegistry { self.directives.insert((context, spec.name.as_str()), spec); } + pub(crate) fn register_attached_finalizer( + &mut self, + context: ContextKey, + finalize: AttachedContextFinalizeFn, + ) { + self.attached_finalizers.insert(context, finalize); + } + + pub(crate) fn finalize_attached( + &self, + node: AttachedConfigNode<'_>, + ) -> Result<(), Box> { + if let Some(finalize) = self.attached_finalizers.get(&node.context()) { + finalize(node)?; + } + Ok(()) + } + #[cfg(test)] pub(crate) fn directive_spec(&self, context: ContextKey, name: &str) -> Option<&DirectiveSpec> { self.directives @@ -304,14 +324,16 @@ impl ConfigRegistry { }) } - #[cfg(test)] - pub(crate) fn directive_matches_key( + pub(crate) fn cascade_policies( &self, context: ContextKey, - key: crate::parse::cascade::DirectiveKey, - ) -> bool { - self.directive_spec(context, key.name().as_str()) - .is_some_and(|spec| spec.matches_key(key)) + ) -> Box<[(DirectiveName, CascadePolicy)]> { + self.directives + .iter() + .filter_map(|((registered_context, _), spec)| { + (*registered_context == context).then_some((spec.name, spec.cascade)) + }) + .collect() } pub fn build( @@ -554,16 +576,15 @@ impl ConfigRegistry { } }; self.build_into(source_map, &mut child, child_context, children, options)?; - self.finalize(&mut child, child_context, options)?; node.insert_child(spec.name.as_str(), Arc::new(child)); } } } - self.finalize(node, context, options)?; + self.finalize_local(node, context, options)?; Ok(()) } - fn finalize( + fn finalize_local( &self, node: &mut ConfigNode, context: ContextKey, diff --git a/gateway/src/parse/snapshot.rs b/gateway/src/parse/snapshot.rs index 355b82ad..c56360ce 100644 --- a/gateway/src/parse/snapshot.rs +++ b/gateway/src/parse/snapshot.rs @@ -280,13 +280,10 @@ fn cascade_required( value: &InheritedValue, directive: DirectiveName, ) -> Option<(Arc, ConfigOrigin)> { - match &value.origin { - InheritedOrigin::Builtin => None, - InheritedOrigin::Source(_) => Some(( - Arc::new(value.value.clone()), - inherited_origin(&value.origin, directive), - )), - } + Some(( + Arc::new(value.value.clone()), + inherited_origin(&value.origin, directive), + )) } fn cascade_optional( @@ -693,6 +690,48 @@ pub(crate) mod test_support { RootConfigSnapshot::try_from(RootConfigSnapshotWire::try_from(snapshot)?) } + pub(crate) fn snapshot_with_builtin_gzip(gzip: bool) -> RootConfigSnapshot { + let builtin = || InheritedOrigin::Builtin; + RootConfigSnapshot::V1(RootInheritedConfigV1 { + access_rules: InheritedValue { + value: None, + origin: builtin(), + }, + gzip: InheritedValue { + value: BoolConfig(gzip), + origin: builtin(), + }, + gzip_vary: InheritedValue { + value: BoolConfig(false), + origin: builtin(), + }, + gzip_min_length: InheritedValue { + value: GzipMinLength::checked(20), + origin: builtin(), + }, + gzip_comp_level: InheritedValue { + value: GzipCompLevel::checked(1), + origin: builtin(), + }, + gzip_types: InheritedValue { + value: None, + origin: builtin(), + }, + default_type: InheritedValue { + value: None, + origin: builtin(), + }, + types: InheritedValue { + value: None, + origin: builtin(), + }, + access_log: InheritedValue { + value: None, + origin: builtin(), + }, + }) + } + pub(crate) fn values_without_origins(snapshot: &RootConfigSnapshot) -> SnapshotValues { let value = snapshot.v1(); SnapshotValues { diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index ed6c41e0..8d894746 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -3,7 +3,7 @@ use std::{ path::{Path, PathBuf}, sync::{ Arc, - atomic::{AtomicU64, Ordering}, + atomic::{AtomicU64, AtomicUsize, Ordering}, }, }; @@ -17,15 +17,47 @@ use crate::parse::{ error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, registry::{ - CascadePolicy, DirectiveSpec, DuplicatePolicy, ReloadImpact, TransportPolicy, context, + BuildOptions, CascadePolicy, ContextSpec, DirectiveSpec, DuplicatePolicy, ReloadImpact, + TransportPolicy, context, }, snapshot::{RootConfigSnapshot, test_support as snapshot_test_support}, source::SourceId, - tree::{ParentLink, build_global_tree, build_worker_tree}, + tree::{AttachedConfigNode, ParentLink, build_global_tree, build_worker_tree}, types::{MimeTypes, PathConfig}, }; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); +static LOCAL_FINALIZER_CALLS: AtomicUsize = AtomicUsize::new(0); +static ATTACHED_FINALIZER_CALLS: AtomicUsize = AtomicUsize::new(0); + +fn count_local_server_finalizer( + _node: &mut ConfigNode, + _options: &BuildOptions<'_>, +) -> Result<(), Box> { + LOCAL_FINALIZER_CALLS.fetch_add(1, Ordering::Relaxed); + Ok(()) +} + +fn assert_attached_server_context( + node: AttachedConfigNode<'_>, +) -> Result<(), Box> { + let parent = node + .parent() + .ok_or_else(|| std::io::Error::other("server must be attached before finalization"))?; + if parent.context() != context::PISHOO { + return Err(std::io::Error::other("server parent must be PISHOO").into()); + } + if parent + .children() + .filter(|child| child.context() == context::SERVER) + .count() + != 2 + { + return Err(std::io::Error::other("finalizer must observe every attached sibling").into()); + } + ATTACHED_FINALIZER_CALLS.fetch_add(1, Ordering::Relaxed); + Ok(()) +} #[derive(Debug)] struct TempConfigDir { @@ -213,6 +245,7 @@ fn parse_identity_fragments( } fn root_snapshot_fixture( + registry: &crate::parse::registry::ConfigRegistry, parser: &mut ConfigDocumentParser<'_>, fixture: &TempConfigDir, ) -> RootConfigSnapshot { @@ -222,7 +255,7 @@ fn root_snapshot_fixture( &fixture.join("root/pishoo.conf"), None, ); - let snapshot = build_global_tree(root) + let snapshot = build_global_tree(registry, root, Vec::new()) .expect("root tree should seal") .root_snapshot() .expect("root snapshot should project"); @@ -720,11 +753,6 @@ fn registry_v1_metadata_is_orthogonal() { assert_eq!(server.cascade, CascadePolicy::None); assert_eq!(server.transport, TransportPolicy::HypervisorOnly); assert_eq!(server.reload, ReloadImpact::ListenerSet); - - for context in [context::PISHOO, context::SERVER, context::LOCATION] { - assert!(registry.directive_matches_key(context, GZIP)); - assert!(registry.directive_matches_key(context, TYPES)); - } } #[test] @@ -748,7 +776,7 @@ fn missing_worker_fragment_still_builds_root_and_pishoo() { let fixture = TempConfigDir::new("missing_worker_fragment"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); let home = fixture.worker_home(); let servers = parse_identity_fragments( &mut parser, @@ -758,7 +786,8 @@ fn missing_worker_fragment_still_builds_root_and_pishoo() { "alice.dhttp.net", ); - let tree = build_worker_tree(snapshot, None, servers).expect("worker tree should seal"); + let tree = + build_worker_tree(®istry, snapshot, None, servers).expect("worker tree should seal"); assert_eq!( tree.pishoo().parent_link(), @@ -779,7 +808,7 @@ fn worker_scalar_override_wins_over_root_snapshot() { let fixture = TempConfigDir::new("worker_override"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); let home = fixture.worker_home(); let worker = parse_worker_fragment( &mut parser, @@ -787,7 +816,8 @@ fn worker_scalar_override_wins_over_root_snapshot() { &fixture.worker_source_path(), &home, ); - let tree = build_worker_tree(snapshot, Some(worker), Vec::new()).expect("tree should seal"); + let tree = + build_worker_tree(®istry, snapshot, Some(worker), Vec::new()).expect("tree should seal"); let gzip = tree .pishoo() @@ -797,12 +827,33 @@ fn worker_scalar_override_wins_over_root_snapshot() { assert!(!gzip.effective().0); } +#[test] +fn worker_uses_required_snapshot_value_with_builtin_origin() { + let registry = crate::parse::default_registry(); + let snapshot = snapshot_test_support::snapshot_with_builtin_gzip(true); + + let tree = build_worker_tree(®istry, snapshot, None, Vec::new()).expect("tree should seal"); + let gzip = tree + .pishoo() + .cascaded(GZIP) + .expect("gzip query should succeed") + .expect("complete snapshot supplies gzip"); + + assert!(gzip.effective().0); + assert_eq!( + gzip.lineage(), + [crate::parse::cascade::ConfigOrigin::Builtin { + directive: GZIP.name(), + }] + ); +} + #[test] fn types_replaces_the_whole_parent_map() { let fixture = TempConfigDir::new("types_replace"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); let home = fixture.worker_home(); let servers = parse_identity_fragments( &mut parser, @@ -811,7 +862,7 @@ fn types_replaces_the_whole_parent_map() { &home, "alice.dhttp.net", ); - let tree = build_worker_tree(snapshot, None, servers).expect("tree should seal"); + let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); let types = tree .servers() @@ -826,12 +877,46 @@ fn types_replaces_the_whole_parent_map() { assert!(!types.effective().0.contains_key("root")); } +#[test] +fn cascade_query_rejects_registry_policy_mismatch() { + let fixture = TempConfigDir::new("cascade_policy_mismatch"); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::SERVER, + DirectiveSpec::raw_value::( + "types", + vec![context::SERVER], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(®istry); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); + let home = fixture.worker_home(); + let servers = parse_identity_fragments( + &mut parser, + "server { listen all 443; types { text/server server; } }", + &fixture.join("worker/identity/server.conf"), + &home, + "alice.dhttp.net", + ); + let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); + let server = tree.servers().next().expect("server should attach"); + + assert!( + server.node().cascaded(TYPES).is_err(), + "cascade policy mismatches must not use DirectiveKey hardcoding" + ); +} + #[test] fn cascade_lineage_is_builtin_root_worker_server_location() { let fixture = TempConfigDir::new("cascade_lineage"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); let home = fixture.worker_home(); let worker = parse_worker_fragment( &mut parser, @@ -846,7 +931,8 @@ fn cascade_lineage_is_builtin_root_worker_server_location() { &home, "alice.dhttp.net", ); - let tree = build_worker_tree(snapshot, Some(worker), servers).expect("tree should seal"); + let tree = + build_worker_tree(®istry, snapshot, Some(worker), servers).expect("tree should seal"); let location = tree .servers() .next() @@ -882,7 +968,7 @@ fn identity_server_parent_is_home_pishoo() { let fixture = TempConfigDir::new("identity_parent"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); let home = fixture.worker_home(); let servers = parse_identity_fragments( &mut parser, @@ -891,7 +977,7 @@ fn identity_server_parent_is_home_pishoo() { &home, "alice.dhttp.net", ); - let tree = build_worker_tree(snapshot, None, servers).expect("tree should seal"); + let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); let server = tree.servers().next().expect("server"); assert_eq!( @@ -912,7 +998,7 @@ fn global_direct_server_parent_is_global_pishoo() { &fixture.join("global-home/pishoo.conf"), Some(&home), ); - let tree = build_global_tree(root).expect("global tree should seal"); + let tree = build_global_tree(®istry, root, Vec::new()).expect("global tree should seal"); let server = tree.servers().next().expect("server"); assert_eq!( @@ -921,6 +1007,64 @@ fn global_direct_server_parent_is_global_pishoo() { ); } +#[test] +fn global_identity_server_parent_is_global_pishoo() { + let fixture = TempConfigDir::new("global_identity_parent"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let root = parse_root_fragment( + &mut parser, + "pishoo {}", + &fixture.join("global-home/pishoo.conf"), + Some(&home), + ); + let identities = parse_identity_fragments( + &mut parser, + "server { listen all 443; }", + &fixture.join("global-home/identities/alice/server.conf"), + &home, + "alice.dhttp.net", + ); + + let tree = build_global_tree(®istry, root, identities).expect("global tree should seal"); + let server = tree.servers().next().expect("global identity server"); + + assert_eq!( + server.node().parent_link(), + ParentLink::Node(tree.pishoo().id()) + ); +} + +#[test] +fn attached_registry_finalizer_observes_parent_and_complete_siblings() { + let fixture = TempConfigDir::new("attached_finalizer"); + let mut registry = crate::parse::default_registry(); + registry.register_context(ContextSpec { + key: context::SERVER, + finalize: Some(count_local_server_finalizer), + }); + registry.register_attached_finalizer(context::SERVER, assert_attached_server_context); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + LOCAL_FINALIZER_CALLS.store(0, Ordering::Relaxed); + ATTACHED_FINALIZER_CALLS.store(0, Ordering::Relaxed); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 443; } server { listen all 444; } }", + &fixture.join("global-home/pishoo.conf"), + Some(&home), + ); + + assert_eq!(LOCAL_FINALIZER_CALLS.load(Ordering::Relaxed), 2); + assert_eq!(ATTACHED_FINALIZER_CALLS.load(Ordering::Relaxed), 0); + let tree = build_global_tree(®istry, root, Vec::new()).expect("global tree should seal"); + + assert_eq!(tree.servers().count(), 2); + assert_eq!(LOCAL_FINALIZER_CALLS.load(Ordering::Relaxed), 2); + assert_eq!(ATTACHED_FINALIZER_CALLS.load(Ordering::Relaxed), 2); +} + #[test] fn location_parent_is_its_server() { let fixture = TempConfigDir::new("location_parent"); @@ -933,7 +1077,7 @@ fn location_parent_is_its_server() { &fixture.join("global-home/pishoo.conf"), Some(&home), ); - let tree = build_global_tree(root).expect("global tree should seal"); + let tree = build_global_tree(®istry, root, Vec::new()).expect("global tree should seal"); let server = tree.servers().next().expect("server"); let location = server.locations().next().expect("location"); @@ -956,7 +1100,7 @@ fn tree_ref_keeps_source_and_parent_alive() { &source_path, Some(&home), ); - let tree = build_global_tree(root).expect("tree should seal"); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); let server = tree.servers().next().expect("server"); let span = server.node().source_span().expect("source span"); drop(tree); @@ -973,7 +1117,7 @@ fn cross_document_diagnostics_keep_the_correct_source_bundle() { let fixture = TempConfigDir::new("cross_document_sources"); let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(&mut parser, &fixture); + let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); let home = fixture.worker_home(); let alice_path = fixture.join("alice/server.conf"); let bob_path = fixture.join("bob/server.conf"); @@ -993,7 +1137,7 @@ fn cross_document_diagnostics_keep_the_correct_source_bundle() { &home, "bob.dhttp.net", )); - let tree = build_worker_tree(snapshot, None, servers).expect("tree should seal"); + let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); let paths = tree .servers() .map(|server| { @@ -1017,7 +1161,7 @@ fn root_snapshot_contains_only_worker_inheritable_values() { &fixture.join("global-home/pishoo.conf"), Some(&home), ); - let snapshot = build_global_tree(root) + let snapshot = build_global_tree(®istry, root, Vec::new()) .expect("root tree") .root_snapshot() .expect("snapshot"); @@ -1057,11 +1201,11 @@ fn root_direct_servers_are_not_serialized() { &fixture.join("with/pishoo.conf"), Some(&home), ); - let first = build_global_tree(without_server) + let first = build_global_tree(®istry, without_server, Vec::new()) .unwrap() .root_snapshot() .unwrap(); - let second = build_global_tree(with_server) + let second = build_global_tree(®istry, with_server, Vec::new()) .unwrap() .root_snapshot() .unwrap(); @@ -1159,7 +1303,10 @@ fn snapshot_preserves_absence_and_gzip_fallbacks() { &fixture.join("root/pishoo.conf"), None, ); - let snapshot = build_global_tree(root).unwrap().root_snapshot().unwrap(); + let snapshot = build_global_tree(®istry, root, Vec::new()) + .unwrap() + .root_snapshot() + .unwrap(); assert!(snapshot.access_rules().is_none()); assert!(!snapshot.gzip().0); @@ -1189,8 +1336,14 @@ fn snapshot_equality_includes_origin() { &fixture.join("two/pishoo.conf"), None, ); - let first = build_global_tree(first).unwrap().root_snapshot().unwrap(); - let second = build_global_tree(second).unwrap().root_snapshot().unwrap(); + let first = build_global_tree(®istry, first, Vec::new()) + .unwrap() + .root_snapshot() + .unwrap(); + let second = build_global_tree(®istry, second, Vec::new()) + .unwrap() + .root_snapshot() + .unwrap(); assert_eq!( snapshot_test_support::checked_wire_round_trip(&first).unwrap(), diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index 5d5ecbfa..6d665a34 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -7,10 +7,11 @@ use crate::parse::{ document::ConfigNode, domain::{ ConfigDocumentId, ConfigDocumentIdAllocator, ConfigDocumentIdError, ConfigSourceSpan, + DirectiveName, }, error::ConfigQueryError, fragment::{ParsedPishooFragment, ParsedServerFragment}, - registry::context, + registry::{CascadePolicy, ConfigRegistry, context}, snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, source::{SourceId, SourceMap, SourceSpan}, value::ConfigValue, @@ -31,6 +32,42 @@ struct SealedConfigNode { node: Arc, parent: ParentLink, children: Vec, + cascade_policies: Box<[(DirectiveName, CascadePolicy)]>, +} + +#[derive(Clone, Copy)] +pub(crate) struct AttachedConfigNode<'tree> { + nodes: &'tree [SealedConfigNode], + node: ConfigNodeId, +} + +impl<'tree> AttachedConfigNode<'tree> { + fn new(nodes: &'tree [SealedConfigNode], node: ConfigNodeId) -> Self { + Self { nodes, node } + } + + pub(crate) fn context(self) -> crate::parse::registry::ContextKey { + self.nodes[self.node.0].node.context + } + + pub(crate) fn config(self) -> &'tree ConfigNode { + &self.nodes[self.node.0].node + } + + pub(crate) fn parent(self) -> Option { + match self.nodes[self.node.0].parent { + ParentLink::Root => None, + ParentLink::Node(parent) => Some(Self::new(self.nodes, parent)), + } + } + + pub(crate) fn children(self) -> impl Iterator + 'tree { + self.nodes[self.node.0] + .children + .iter() + .copied() + .map(|node| Self::new(self.nodes, node)) + } } #[derive(Debug)] @@ -100,15 +137,26 @@ pub enum HomeConfigTreeError { InvalidContext { node: ConfigNodeId }, #[snafu(display("sealed configuration node has no owning source document"))] MissingSource { node: ConfigNodeId }, + #[snafu(display("failed to finalize an attached configuration context"))] + FinalizeAttached { + node: ConfigNodeId, + source: Box, + }, } -pub fn build_global_tree( +pub fn build_global_tree( + registry: &ConfigRegistry, root_fragment: ParsedPishooFragment, -) -> Result, HomeConfigTreeError> { - HomeConfigTreeBuilder::global(root_fragment)?.seal() + identity_fragments: I, +) -> Result, HomeConfigTreeError> +where + I: IntoIterator, +{ + HomeConfigTreeBuilder::global(registry, root_fragment, identity_fragments.into_iter())?.seal() } pub fn build_worker_tree( + registry: &ConfigRegistry, root_snapshot: RootConfigSnapshot, worker_fragment: Option, identity_fragments: I, @@ -117,6 +165,7 @@ where I: IntoIterator, { HomeConfigTreeBuilder::worker( + registry, root_snapshot, worker_fragment, identity_fragments.into_iter(), @@ -124,7 +173,8 @@ where .seal() } -struct HomeConfigTreeBuilder { +struct HomeConfigTreeBuilder<'registry> { + registry: &'registry ConfigRegistry, nodes: Vec, root: ConfigNodeId, pishoo: ConfigNodeId, @@ -134,11 +184,16 @@ struct HomeConfigTreeBuilder { document_ids: ConfigDocumentIdAllocator, } -impl HomeConfigTreeBuilder { - fn global(fragment: ParsedPishooFragment) -> Result { +impl<'registry> HomeConfigTreeBuilder<'registry> { + fn global( + registry: &'registry ConfigRegistry, + fragment: ParsedPishooFragment, + identity_fragments: impl Iterator, + ) -> Result { let synthetic_span = fragment.node().span; let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); let mut builder = Self { + registry, nodes: Vec::new(), root: ConfigNodeId(0), pishoo: ConfigNodeId(0), @@ -162,10 +217,14 @@ impl HomeConfigTreeBuilder { document_id, fragment: ConfigSourceFragment::Pishoo(fragment), }); + for fragment in identity_fragments { + builder.attach_identity_fragment(fragment)?; + } Ok(builder) } fn worker( + registry: &'registry ConfigRegistry, root_snapshot: RootConfigSnapshot, worker_fragment: Option, identity_fragments: impl Iterator, @@ -179,6 +238,7 @@ impl HomeConfigTreeBuilder { |fragment| Arc::clone(fragment.node()), ); let mut builder = Self { + registry, nodes: Vec::new(), root: ConfigNodeId(0), pishoo: ConfigNodeId(0), @@ -210,20 +270,28 @@ impl HomeConfigTreeBuilder { }); } for fragment in identity_fragments { - if let Some(document_id) = builder.document_id(fragment.source_map()) { - builder.attach_server(&fragment, document_id); - } else { - let document_id = builder.allocate_document_id()?; - builder.attach_server(&fragment, document_id); - builder.sources.push(ConfigSourceOwner { - document_id, - fragment: ConfigSourceFragment::Server(fragment), - }); - } + builder.attach_identity_fragment(fragment)?; } Ok(builder) } + fn attach_identity_fragment( + &mut self, + fragment: ParsedServerFragment, + ) -> Result<(), HomeConfigTreeError> { + if let Some(document_id) = self.document_id(fragment.source_map()) { + self.attach_server(&fragment, document_id); + } else { + let document_id = self.allocate_document_id()?; + self.attach_server(&fragment, document_id); + self.sources.push(ConfigSourceOwner { + document_id, + fragment: ConfigSourceFragment::Server(fragment), + }); + } + Ok(()) + } + fn attach_server(&mut self, fragment: &ParsedServerFragment, document_id: ConfigDocumentId) { let server = self.push_node( Some(document_id), @@ -262,11 +330,13 @@ impl HomeConfigTreeBuilder { parent: ParentLink, ) -> ConfigNodeId { let id = ConfigNodeId(self.nodes.len()); + let cascade_policies = self.registry.cascade_policies(node.context); self.nodes.push(SealedConfigNode { document_id, node, parent, children: Vec::new(), + cascade_policies, }); id } @@ -301,6 +371,12 @@ impl HomeConfigTreeBuilder { self.verify_node(location, context::LOCATION, ParentLink::Node(server))?; } } + for index in 0..self.nodes.len() { + let node = ConfigNodeId(index); + self.registry + .finalize_attached(AttachedConfigNode::new(&self.nodes, node)) + .map_err(|source| HomeConfigTreeError::FinalizeAttached { node, source })?; + } Ok(()) } @@ -386,6 +462,27 @@ impl HomeConfigTree { where T: ConfigValue, { + let mut chain = Vec::new(); + let mut current = node; + loop { + chain.push(current); + match self.node(current).parent { + ParentLink::Root => break, + ParentLink::Node(parent) => current = parent, + } + } + chain.reverse(); + let policy = self.cascade_policy(&chain, key.name())?; + if !matches!( + policy, + CascadePolicy::NearestWins | CascadePolicy::ReplaceWhole + ) { + return Err(ConfigQueryError::UnsupportedCascadePolicy { + directive: key.name().as_str().to_owned(), + policy, + }); + } + let mut lineage = Vec::new(); let mut effective = key.builtin(); if effective.is_some() { @@ -402,16 +499,7 @@ impl HomeConfigTree { } } - let mut chain = Vec::new(); - let mut current = node; - loop { - chain.push(current); - match self.node(current).parent { - ParentLink::Root => break, - ParentLink::Node(parent) => current = parent, - } - } - for id in chain.into_iter().rev() { + for id in chain { let node = self.node(id); if let Some((value, span)) = node.node.get_with_span::(key.name().as_str())? { effective = Some(value); @@ -426,6 +514,40 @@ impl HomeConfigTree { } Ok(effective.map(|effective| CascadedValue::new(effective, lineage.into_boxed_slice()))) } + + fn cascade_policy( + &self, + chain: &[ConfigNodeId], + directive: DirectiveName, + ) -> Result { + let mut inherited = None; + for &node in chain { + let Some(local) = self.node(node).cascade_policy(directive) else { + continue; + }; + if let Some(inherited) = inherited + && inherited != local + { + return Err(ConfigQueryError::CascadePolicyMismatch { + directive: directive.as_str().to_owned(), + inherited, + local, + }); + } + inherited = Some(local); + } + inherited.ok_or_else(|| ConfigQueryError::MissingCascadePolicy { + directive: directive.as_str().to_owned(), + }) + } +} + +impl SealedConfigNode { + fn cascade_policy(&self, directive: DirectiveName) -> Option { + self.cascade_policies + .iter() + .find_map(|(name, policy)| (*name == directive).then_some(*policy)) + } } impl ConfigSourceBundle { From b773601ce596dd5d58fa50c40c1f7eba0e30f4d8 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 03:40:23 +0800 Subject: [PATCH 06/18] fix(config): enforce snapshot transport contracts --- gateway/src/parse/builtin/access.rs | 27 +-- gateway/src/parse/builtin/location.rs | 6 +- gateway/src/parse/diagnostic.rs | 5 +- gateway/src/parse/document.rs | 6 +- gateway/src/parse/error.rs | 3 + gateway/src/parse/fragment.rs | 8 + gateway/src/parse/registry.rs | 50 +++- gateway/src/parse/snapshot.rs | 333 +++++++++++++++++++++----- gateway/src/parse/tests.rs | 254 +++++++++++++++++++- gateway/src/parse/tree.rs | 48 ++-- gateway/src/parse/types.rs | 58 +++-- 11 files changed, 658 insertions(+), 140 deletions(-) diff --git a/gateway/src/parse/builtin/access.rs b/gateway/src/parse/builtin/access.rs index 26bcbf35..350d1af1 100644 --- a/gateway/src/parse/builtin/access.rs +++ b/gateway/src/parse/builtin/access.rs @@ -1,6 +1,4 @@ -use std::path::PathBuf; - -use snafu::{ResultExt, Snafu, ensure}; +use snafu::{ResultExt, Snafu}; use crate::parse::{ builtin::core::{first_arg_span, only_arg}, @@ -24,10 +22,6 @@ pub enum AccessRulesUriError { span: SourceSpan, source: url::ParseError, }, - #[snafu(display("unsupported access_rules uri scheme `{scheme}`"))] - UnsupportedScheme { span: SourceSpan, scheme: String }, - #[snafu(display("unsupported sqlite access_rules uri form"))] - UnsupportedSqliteForm { span: SourceSpan }, #[snafu(display("failed to resolve relative access_rules sqlite path"))] ResolveRelativePath { span: SourceSpan, @@ -61,23 +55,8 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for AccessR }; let uri = url::Url::parse(&arg.value) .context(access_rules_uri_error::UriSnafu { span: arg.span })?; - ensure!( - uri.scheme() == "sqlite", - access_rules_uri_error::UnsupportedSchemeSnafu { - span: arg.span, - scheme: uri.scheme().to_owned(), - } - ); - ensure!( - uri.host_str().is_none() - && uri.username().is_empty() - && uri.password().is_none() - && uri.port().is_none() - && uri.fragment().is_none(), - access_rules_uri_error::UnsupportedSqliteFormSnafu { span: arg.span } - ); - - let path = PathBuf::from(uri.path()); + let path = AccessRulesUri::decoded_sqlite_path(&uri) + .context(access_rules_uri_error::DomainSnafu { span: arg.span })?; let normalized_path = normalize::normalize_path(&path, arg.span, input.source_map) .context(access_rules_uri_error::ResolveRelativePathSnafu { span: arg.span })?; diff --git a/gateway/src/parse/builtin/location.rs b/gateway/src/parse/builtin/location.rs index 03af12a4..684e4d8b 100644 --- a/gateway/src/parse/builtin/location.rs +++ b/gateway/src/parse/builtin/location.rs @@ -1,5 +1,5 @@ use http::{HeaderName, HeaderValue}; -use snafu::{Snafu, ensure}; +use snafu::{OptionExt, Snafu, ensure}; use crate::parse::{ document::ConfigNode, @@ -19,6 +19,8 @@ use crate::parse::{ #[derive(Debug, Snafu)] #[snafu(module)] pub enum FinalizeLocationError { + #[snafu(display("location context is missing its parsed pattern payload"))] + MissingPattern { span: SourceSpan }, #[snafu(display( "proxy_ssl_certificate and proxy_ssl_certificate_key must be configured together" ))] @@ -122,7 +124,7 @@ fn finalize_location( ) -> Result<(), Box> { let pattern = node .payload::()? - .expect("location node should contain a pattern payload"); + .context(finalize_location_error::MissingPatternSnafu { span: node.span })?; let proxy_pass = node.get::("proxy_pass")?; let has_cert = node.get::("proxy_ssl_certificate")?.is_some(); let has_key = node diff --git a/gateway/src/parse/diagnostic.rs b/gateway/src/parse/diagnostic.rs index 7986a6e1..004d3489 100644 --- a/gateway/src/parse/diagnostic.rs +++ b/gateway/src/parse/diagnostic.rs @@ -132,7 +132,10 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { context_span, .. } => Some(*context_span), - crate::parse::error::BuildDocumentError::FinalizeContext { span, .. } => Some(*span), + crate::parse::error::BuildDocumentError::FinalizeContext { span, .. } + | crate::parse::error::BuildDocumentError::ParentAlreadyAssigned { span } => { + Some(*span) + } }; } if let Some(error) = error.downcast_ref::() { diff --git a/gateway/src/parse/document.rs b/gateway/src/parse/document.rs index 96a08272..086610f1 100644 --- a/gateway/src/parse/document.rs +++ b/gateway/src/parse/document.rs @@ -99,10 +99,8 @@ impl ConfigNode { self.payload = Some(payload); } - pub(crate) fn set_parent(&self, parent: Option>) { - self.parent - .set(parent) - .expect("parent link set multiple times for config node"); + pub(crate) fn set_parent(&self, parent: Option>) -> Result<(), SourceSpan> { + self.parent.set(parent).map_err(|_| self.span) } pub fn parent(&self) -> Option> { diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index ff70761d..cc89ccfb 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -226,6 +226,9 @@ pub enum BuildDocumentError { span: SourceSpan, source: Box, }, + + #[snafu(display("configuration node parent was assigned more than once"))] + ParentAlreadyAssigned { span: SourceSpan }, } #[derive(Debug, Snafu)] diff --git a/gateway/src/parse/fragment.rs b/gateway/src/parse/fragment.rs index ee4e868e..bd3d896a 100644 --- a/gateway/src/parse/fragment.rs +++ b/gateway/src/parse/fragment.rs @@ -65,6 +65,10 @@ impl ParsedPishooFragment { self.sources.source_map() } + pub(crate) fn source_owner(&self) -> Arc { + Arc::clone(&self.sources) + } + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn node(&self) -> &Arc { &self.node @@ -103,6 +107,10 @@ impl ParsedServerFragment { self.sources.source_map() } + pub(crate) fn source_owner(&self) -> Arc { + Arc::clone(&self.sources) + } + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn node(&self) -> &Arc { &self.node diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index a569a455..03a9608c 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, error::Error, sync::Arc}; +use std::{any::TypeId, collections::HashMap, error::Error, sync::Arc}; use snafu::{OptionExt, ResultExt}; @@ -55,6 +55,17 @@ pub struct DirectiveSpec { pub cascade: CascadePolicy, pub transport: TransportPolicy, pub reload: ReloadImpact, + value_type: Option, + value_type_name: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DirectiveContract { + pub(crate) shape: DirectiveShape, + pub(crate) cascade: CascadePolicy, + pub(crate) transport: TransportPolicy, + pub(crate) value_type: Option, + pub(crate) value_type_name: Option<&'static str>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -154,6 +165,8 @@ impl DirectiveSpec { cascade, transport, reload, + value_type: Some(TypeId::of::()), + value_type_name: Some(std::any::type_name::()), } } @@ -179,6 +192,8 @@ impl DirectiveSpec { cascade, transport, reload, + value_type: Some(TypeId::of::()), + value_type_name: Some(std::any::type_name::()), } } @@ -203,6 +218,8 @@ impl DirectiveSpec { cascade, transport, reload, + value_type: None, + value_type_name: None, } } @@ -232,6 +249,8 @@ impl DirectiveSpec { cascade, transport, reload, + value_type: Some(TypeId::of::()), + value_type_name: Some(std::any::type_name::()), } } } @@ -336,6 +355,22 @@ impl ConfigRegistry { .collect() } + pub(crate) fn directive_contract( + &self, + context: ContextKey, + name: &str, + ) -> Option { + self.directives + .get(&(context, name)) + .map(|spec| DirectiveContract { + shape: spec.shape, + cascade: spec.cascade, + transport: spec.transport, + value_type: spec.value_type, + value_type_name: spec.value_type_name, + }) + } + pub fn build( &self, source_map: Arc, @@ -359,7 +394,7 @@ impl ConfigRegistry { &options, )?; let root = Arc::new(root); - put_parent_recursively(&root, None); + put_parent_recursively(&root, None)?; Ok(ConfigDocument::new(source_map, root)) } @@ -726,11 +761,16 @@ pub(crate) enum RoleDocumentBuildError { Build(BuildDocumentError), } -fn put_parent_recursively(node: &Arc, parent: Option<&Arc>) { - node.set_parent(parent.map(Arc::downgrade)); +fn put_parent_recursively( + node: &Arc, + parent: Option<&Arc>, +) -> Result<(), BuildDocumentError> { + node.set_parent(parent.map(Arc::downgrade)) + .map_err(|span| BuildDocumentError::ParentAlreadyAssigned { span })?; for children in node.child_groups() { for child in children { - put_parent_recursively(child, Some(node)); + put_parent_recursively(child, Some(node))?; } } + Ok(()) } diff --git a/gateway/src/parse/snapshot.rs b/gateway/src/parse/snapshot.rs index c56360ce..6fa92197 100644 --- a/gateway/src/parse/snapshot.rs +++ b/gateway/src/parse/snapshot.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, num::NonZeroU32, path::PathBuf, sync::Arc}; +use std::{any::TypeId, collections::HashSet, num::NonZeroU32, path::PathBuf, sync::Arc}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _}; use snafu::Snafu; @@ -9,6 +9,7 @@ use crate::parse::{ GZIP_TYPES, GZIP_VARY, InheritedSourceLocation, TYPES, }, domain::{DirectiveName, ResolvedConfigPath, ResolvedConfigPathError}, + registry::{CascadePolicy, ConfigRegistry, DirectiveShape, TransportPolicy, context}, tree::HomeConfigTree, types::{ AccessRulesUri, AccessRulesUriValidationError, BoolConfig, DefaultType, GzipCompLevel, @@ -23,15 +24,140 @@ pub enum RootConfigSnapshot { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RootInheritedConfigV1 { - access_rules: InheritedValue>, - gzip: InheritedValue, - gzip_vary: InheritedValue, - gzip_min_length: InheritedValue, - gzip_comp_level: InheritedValue, - gzip_types: InheritedValue>, - default_type: InheritedValue>, - types: InheritedValue>, - access_log: InheritedValue>, + access_rules: InheritedValue>>, + gzip: InheritedValue>, + gzip_vary: InheritedValue>, + gzip_min_length: InheritedValue>, + gzip_comp_level: InheritedValue>, + gzip_types: InheritedValue>>, + default_type: InheritedValue>>, + types: InheritedValue>>, + access_log: InheritedValue>>, +} + +#[derive(Debug)] +pub(crate) struct RootSnapshotRegistryContract; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum RootSnapshotRegistryContractError { + #[snafu(display("root snapshot directive `{directive}` is not registered in PISHOO"))] + MissingDirective { directive: DirectiveName }, + #[snafu(display( + "root snapshot directive `{directive}` has value type `{actual}`, expected `{expected}`" + ))] + ValueType { + directive: DirectiveName, + expected: &'static str, + actual: &'static str, + }, + #[snafu(display("root snapshot directive `{directive}` has an incompatible shape"))] + Shape { directive: DirectiveName }, + #[snafu(display("root snapshot directive `{directive}` has an incompatible cascade policy"))] + Cascade { directive: DirectiveName }, + #[snafu(display( + "root snapshot directive `{directive}` is not registered as WorkerInheritable" + ))] + Transport { directive: DirectiveName }, + #[snafu(display( + "reserved root snapshot directive `{directive}` was registered before its checked domain" + ))] + PrematureReservedDirective { directive: DirectiveName }, +} + +impl RootSnapshotRegistryContract { + pub(crate) fn validate( + registry: &ConfigRegistry, + ) -> Result { + validate_snapshot_directive::( + registry, + ACCESS_RULES.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + GZIP.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + GZIP_VARY.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + GZIP_MIN_LENGTH.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + GZIP_COMP_LEVEL.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + GZIP_TYPES.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + DEFAULT_TYPE.name(), + DirectiveShape::Leaf, + CascadePolicy::NearestWins, + )?; + validate_snapshot_directive::( + registry, + TYPES.name(), + DirectiveShape::RawBlock, + CascadePolicy::ReplaceWhole, + )?; + let access_log = DirectiveName::new("access_log"); + if registry + .directive_contract(context::PISHOO, access_log.as_str()) + .is_some() + { + return Err( + RootSnapshotRegistryContractError::PrematureReservedDirective { + directive: access_log, + }, + ); + } + Ok(Self) + } +} + +fn validate_snapshot_directive( + registry: &ConfigRegistry, + directive: DirectiveName, + shape: DirectiveShape, + cascade: CascadePolicy, +) -> Result<(), RootSnapshotRegistryContractError> { + let contract = registry + .directive_contract(context::PISHOO, directive.as_str()) + .ok_or(RootSnapshotRegistryContractError::MissingDirective { directive })?; + if contract.value_type != Some(TypeId::of::()) { + return Err(RootSnapshotRegistryContractError::ValueType { + directive, + expected: std::any::type_name::(), + actual: contract.value_type_name.unwrap_or(""), + }); + } + if contract.shape != shape { + return Err(RootSnapshotRegistryContractError::Shape { directive }); + } + if contract.cascade != cascade { + return Err(RootSnapshotRegistryContractError::Cascade { directive }); + } + if contract.transport != TransportPolicy::WorkerInheritable { + return Err(RootSnapshotRegistryContractError::Transport { directive }); + } + Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -72,6 +198,8 @@ pub enum RootConfigSnapshotError { AccessRules { source: AccessRulesUriValidationError, }, + #[snafu(display("failed to parse root snapshot access_rules URL"))] + AccessRulesUrl { source: url::ParseError }, #[snafu(display("invalid root snapshot header value"))] HeaderValue { source: http::header::InvalidHeaderValue, @@ -128,7 +256,7 @@ impl RootConfigSnapshot { } pub fn access_rules(&self) -> Option<&AccessRulesUri> { - self.v1().access_rules.value.as_ref() + self.v1().access_rules.value.as_deref() } pub fn gzip(&self) -> &BoolConfig { @@ -148,15 +276,15 @@ impl RootConfigSnapshot { } pub fn gzip_types(&self) -> Option<&StringList> { - self.v1().gzip_types.value.as_ref() + self.v1().gzip_types.value.as_deref() } pub fn default_type(&self) -> Option<&DefaultType> { - self.v1().default_type.value.as_ref() + self.v1().default_type.value.as_deref() } pub fn types(&self) -> Option<&MimeTypes> { - self.v1().types.value.as_ref() + self.v1().types.value.as_deref() } pub(crate) fn cascade_access_rules( @@ -231,23 +359,23 @@ fn snapshot_query( result.map_err(|source| RootConfigSnapshotError::Query { source }) } -fn project_required( +fn project_required( tree: &HomeConfigTree, directive: DirectiveName, value: Option>>, -) -> Result, RootConfigSnapshotError> { +) -> Result>, RootConfigSnapshotError> { let value = value.ok_or(RootConfigSnapshotError::MissingFallback { directive })?; let origin = project_origin(tree, value.lineage().last())?; Ok(InheritedValue { - value: value.effective().as_ref().clone(), + value: Arc::clone(value.effective()), origin, }) } -fn project_optional( +fn project_optional( tree: &HomeConfigTree, value: Option>>, -) -> Result>, RootConfigSnapshotError> { +) -> Result>>, RootConfigSnapshotError> { let Some(value) = value else { return Ok(InheritedValue { value: None, @@ -256,7 +384,7 @@ fn project_optional( }; let origin = project_origin(tree, value.lineage().last())?; Ok(InheritedValue { - value: Some(value.effective().as_ref().clone()), + value: Some(Arc::clone(value.effective())), origin, }) } @@ -276,25 +404,25 @@ fn project_origin( } } -fn cascade_required( - value: &InheritedValue, +fn cascade_required( + value: &InheritedValue>, directive: DirectiveName, ) -> Option<(Arc, ConfigOrigin)> { Some(( - Arc::new(value.value.clone()), + Arc::clone(&value.value), inherited_origin(&value.origin, directive), )) } -fn cascade_optional( - value: &InheritedValue>, +fn cascade_optional( + value: &InheritedValue>>, directive: DirectiveName, ) -> Option<(Arc, ConfigOrigin)> { let origin = &value.origin; value .value .as_ref() - .map(|value| (Arc::new(value.clone()), inherited_origin(origin, directive))) + .map(|value| (Arc::clone(value), inherited_origin(origin, directive))) } fn inherited_origin(origin: &InheritedOrigin, directive: DirectiveName) -> ConfigOrigin { @@ -397,6 +525,15 @@ impl TryFrom<&RootConfigSnapshot> for RootConfigSnapshotWire { fn try_from(snapshot: &RootConfigSnapshot) -> Result { let value = snapshot.v1(); + let access_log = InheritedValueV1 { + value: value + .access_log + .value + .as_ref() + .map(|value| encode_access_log(value)) + .transpose()?, + origin: encode_origin(&value.access_log.origin)?, + }; Ok(Self::V1(RootInheritedConfigV1Wire { access_rules: encode_inherited(&value.access_rules, |value| { value @@ -417,10 +554,10 @@ impl TryFrom<&RootConfigSnapshot> for RootConfigSnapshotWire { .as_ref() .map(|value| HeaderValueV1(value.0.as_bytes().into())) })?, - types: encode_inherited(&value.types, |value| value.as_ref().map(encode_mime_types))?, - access_log: encode_inherited(&value.access_log, |value| { - value.as_ref().map(encode_access_log) + types: encode_inherited(&value.types, |value| { + value.as_ref().map(|value| encode_mime_types(value)) })?, + access_log, })) } } @@ -434,23 +571,21 @@ impl TryFrom for RootConfigSnapshot { access_rules: decode_inherited(value.access_rules, |value| { value .map(|value| { - let uri = url::Url::parse(&value.0).map_err(|_| { - RootConfigSnapshotError::AccessRules { - source: AccessRulesUriValidationError::UnsupportedSqliteForm, - } - })?; + let uri = url::Url::parse(&value.0) + .map_err(|source| RootConfigSnapshotError::AccessRulesUrl { source })?; AccessRulesUri::try_from(uri) + .map(Arc::new) .map_err(|source| RootConfigSnapshotError::AccessRules { source }) }) .transpose() })?, - gzip: decode_inherited(value.gzip, |value| Ok(BoolConfig(value)))?, - gzip_vary: decode_inherited(value.gzip_vary, |value| Ok(BoolConfig(value)))?, + gzip: decode_inherited(value.gzip, |value| Ok(Arc::new(BoolConfig(value))))?, + gzip_vary: decode_inherited(value.gzip_vary, |value| Ok(Arc::new(BoolConfig(value))))?, gzip_min_length: decode_inherited(value.gzip_min_length, |value| { - Ok(GzipMinLength::checked(value)) + Ok(Arc::new(GzipMinLength::checked(value))) })?, gzip_comp_level: decode_inherited(value.gzip_comp_level, |value| { - Ok(GzipCompLevel::checked(value)) + Ok(Arc::new(GzipCompLevel::checked(value))) })?, gzip_types: decode_inherited(value.gzip_types, |value| { value @@ -458,6 +593,7 @@ impl TryFrom for RootConfigSnapshot { StringList::checked_gzip_types( values.into_vec().into_iter().map(String::from).collect(), ) + .map(Arc::new) .map_err(|source| RootConfigSnapshotError::GzipTypes { source }) }) .transpose() @@ -466,15 +602,22 @@ impl TryFrom for RootConfigSnapshot { value .map(|value| { DefaultType::checked_from_bytes(&value.0) + .map(Arc::new) .map_err(|source| RootConfigSnapshotError::HeaderValue { source }) }) .transpose() })?, types: decode_inherited(value.types, |value| { - value.map(decode_mime_types).transpose() + value + .map(decode_mime_types) + .transpose() + .map(|value| value.map(Arc::new)) })?, access_log: decode_inherited(value.access_log, |value| { - value.map(decode_access_log).transpose() + value + .map(decode_access_log) + .transpose() + .map(|value| value.map(Arc::new)) })?, })) } @@ -559,17 +702,16 @@ fn decode_mime_types(types: MimeTypesV1) -> Result AccessLogDirectiveV1 { - match value { +fn encode_access_log( + value: &SnapshotAccessLogDirective, +) -> Result { + Ok(match value { SnapshotAccessLogDirective::Off => AccessLogDirectiveV1::Off, SnapshotAccessLogDirective::ProfileDefault => AccessLogDirectiveV1::ProfileDefault, - SnapshotAccessLogDirective::Resolved(path) => { - AccessLogDirectiveV1::Resolved(ResolvedConfigPathV1( - AbsolutePathV1::try_from(path.as_ref()) - .expect("resolved configuration paths are checked when constructed"), - )) - } - } + SnapshotAccessLogDirective::Resolved(path) => AccessLogDirectiveV1::Resolved( + ResolvedConfigPathV1(AbsolutePathV1::try_from(path.as_ref())?), + ), + }) } fn decode_access_log( @@ -633,6 +775,8 @@ impl TryFrom for PathBuf { #[cfg(test)] pub(crate) mod test_support { + use remoc::codec::Codec; + use super::*; #[derive(Debug, PartialEq, Eq)] @@ -690,6 +834,42 @@ pub(crate) mod test_support { RootConfigSnapshot::try_from(RootConfigSnapshotWire::try_from(snapshot)?) } + pub(crate) fn codec_round_trip( + snapshot: &RootConfigSnapshot, + ) -> Result { + let mut bytes = Vec::new(); + ::serialize(&mut bytes, snapshot) + .map_err(|error| error.to_string())?; + ::deserialize(bytes.as_slice()) + .map_err(|error| error.to_string()) + } + + pub(crate) fn codec_rejects_unknown_schema(snapshot: &RootConfigSnapshot) -> bool { + let mut bytes = Vec::new(); + if ::serialize(&mut bytes, snapshot).is_err() + || bytes.len() < 4 + { + return false; + } + bytes[..4].copy_from_slice(&1_u32.to_le_bytes()); + ::deserialize::<_, RootConfigSnapshot>(bytes.as_slice()) + .is_err() + } + + pub(crate) fn codec_rejects_malformed_access_rules(snapshot: &RootConfigSnapshot) -> bool { + let Ok(mut wire) = RootConfigSnapshotWire::try_from(snapshot) else { + return false; + }; + let RootConfigSnapshotWire::V1(value) = &mut wire; + value.access_rules.value = Some(AccessRulesSourceV1("not a URL".into())); + let mut bytes = Vec::new(); + if ::serialize(&mut bytes, &wire).is_err() { + return false; + } + ::deserialize::<_, RootConfigSnapshot>(bytes.as_slice()) + .is_err() + } + pub(crate) fn snapshot_with_builtin_gzip(gzip: bool) -> RootConfigSnapshot { let builtin = || InheritedOrigin::Builtin; RootConfigSnapshot::V1(RootInheritedConfigV1 { @@ -698,19 +878,19 @@ pub(crate) mod test_support { origin: builtin(), }, gzip: InheritedValue { - value: BoolConfig(gzip), + value: Arc::new(BoolConfig(gzip)), origin: builtin(), }, gzip_vary: InheritedValue { - value: BoolConfig(false), + value: Arc::new(BoolConfig(false)), origin: builtin(), }, gzip_min_length: InheritedValue { - value: GzipMinLength::checked(20), + value: Arc::new(GzipMinLength::checked(20)), origin: builtin(), }, gzip_comp_level: InheritedValue { - value: GzipCompLevel::checked(1), + value: Arc::new(GzipCompLevel::checked(1)), origin: builtin(), }, gzip_types: InheritedValue { @@ -732,18 +912,42 @@ pub(crate) mod test_support { }) } + pub(crate) fn gzip_types_arc(snapshot: &RootConfigSnapshot) -> Option> { + snapshot.v1().gzip_types.value.clone() + } + pub(crate) fn values_without_origins(snapshot: &RootConfigSnapshot) -> SnapshotValues { let value = snapshot.v1(); SnapshotValues { - access_rules: value.access_rules.value.clone(), - gzip: value.gzip.value.clone(), - gzip_vary: value.gzip_vary.value.clone(), - gzip_min_length: value.gzip_min_length.value.clone(), - gzip_comp_level: value.gzip_comp_level.value.clone(), - gzip_types: value.gzip_types.value.clone(), - default_type: value.default_type.value.clone(), - types: value.types.value.clone(), - access_log: value.access_log.value.clone(), + access_rules: value + .access_rules + .value + .as_ref() + .map(|value| value.as_ref().clone()), + gzip: value.gzip.value.as_ref().clone(), + gzip_vary: value.gzip_vary.value.as_ref().clone(), + gzip_min_length: value.gzip_min_length.value.as_ref().clone(), + gzip_comp_level: value.gzip_comp_level.value.as_ref().clone(), + gzip_types: value + .gzip_types + .value + .as_ref() + .map(|value| value.as_ref().clone()), + default_type: value + .default_type + .value + .as_ref() + .map(|value| value.as_ref().clone()), + types: value + .types + .value + .as_ref() + .map(|value| value.as_ref().clone()), + access_log: value + .access_log + .value + .as_ref() + .map(|value| value.as_ref().clone()), } } @@ -768,9 +972,8 @@ pub(crate) mod test_support { pub(crate) fn decode_access_rules( value: &str, ) -> Result { - let uri = url::Url::parse(value).map_err(|_| RootConfigSnapshotError::AccessRules { - source: AccessRulesUriValidationError::UnsupportedSqliteForm, - })?; + let uri = url::Url::parse(value) + .map_err(|source| RootConfigSnapshotError::AccessRulesUrl { source })?; AccessRulesUri::try_from(uri) .map_err(|source| RootConfigSnapshotError::AccessRules { source }) } diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 8d894746..0b24495e 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -11,7 +11,7 @@ use dhttp::home::DhttpHome; use crate::parse::{ ConfigDocumentParser, - cascade::{GZIP, TYPES}, + cascade::{GZIP, GZIP_TYPES, TYPES}, document::{ConfigDocument, ConfigNode}, domain::{ConfigDocumentRole, ConfigDocumentRoleKind, ResolvedConfigPath}, error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, @@ -23,7 +23,7 @@ use crate::parse::{ snapshot::{RootConfigSnapshot, test_support as snapshot_test_support}, source::SourceId, tree::{AttachedConfigNode, ParentLink, build_global_tree, build_worker_tree}, - types::{MimeTypes, PathConfig}, + types::{BoolConfig, MimeTypes, PathConfig, StringList}, }; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); @@ -623,6 +623,48 @@ fn role_parser_rejects_leaf_pishoo_registration_without_panicking() { assert_eq!(Some(span.document_id()), failure.document_id()); } +#[test] +fn payload_free_location_registration_returns_typed_error_without_panicking() { + let fixture = TempConfigDir::new("payload_free_location"); + let home = fixture.worker_home(); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::SERVER, + DirectiveSpec::context_empty( + "location", + vec![context::SERVER], + context::LOCATION, + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(®istry); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + parser.parse_text( + "server { listen all 443; location { } }", + &fixture.join("identity/server.conf"), + ConfigDocumentRole::IdentityServer { + home: &home, + profile: &home.identity_profile( + dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) + .expect("identity name"), + ), + }, + ) + })); + + assert!( + outcome.is_ok(), + "invalid public registry contracts must not panic" + ); + assert!( + outcome.unwrap().is_err(), + "invalid location shape must fail" + ); +} + #[test] fn identity_role_rejects_leaf_server_registration_without_bypassing_cardinality() { let fixture = TempConfigDir::new("invalid_server_registration"); @@ -848,6 +890,60 @@ fn worker_uses_required_snapshot_value_with_builtin_origin() { ); } +#[test] +fn snapshot_container_queries_share_arc_and_local_override_uses_local_arc() { + let fixture = TempConfigDir::new("snapshot_arc_sharing"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip_types text/plain application/json; }", + &fixture.join("root/pishoo.conf"), + None, + ); + let snapshot = build_global_tree(®istry, root, Vec::new()) + .expect("root tree") + .root_snapshot() + .expect("snapshot"); + let transported = snapshot_test_support::gzip_types_arc(&snapshot).expect("gzip_types"); + let snapshot_clone = snapshot.clone(); + assert!(Arc::ptr_eq( + &transported, + &snapshot_test_support::gzip_types_arc(&snapshot_clone).expect("cloned gzip_types") + )); + + let inherited_tree = + build_worker_tree(®istry, snapshot_clone, None, Vec::new()).expect("worker tree"); + let first = inherited_tree + .pishoo() + .cascaded(GZIP_TYPES) + .expect("query") + .expect("gzip_types"); + let second = inherited_tree + .pishoo() + .cascaded(GZIP_TYPES) + .expect("query") + .expect("gzip_types"); + assert!(Arc::ptr_eq(first.effective(), second.effective())); + assert!(Arc::ptr_eq(first.effective(), &transported)); + + let home = fixture.worker_home(); + let worker = parse_worker_fragment( + &mut parser, + "pishoo { gzip_types image/png; }", + &fixture.worker_source_path(), + &home, + ); + let local_tree = build_worker_tree(®istry, snapshot, Some(worker), Vec::new()) + .expect("local worker tree"); + let local = local_tree + .pishoo() + .cascaded(GZIP_TYPES) + .expect("query") + .expect("local gzip_types"); + assert!(!Arc::ptr_eq(local.effective(), &transported)); +} + #[test] fn types_replaces_the_whole_parent_map() { let fixture = TempConfigDir::new("types_replace"); @@ -911,6 +1007,93 @@ fn cascade_query_rejects_registry_policy_mismatch() { ); } +#[test] +fn snapshot_contract_rejects_worker_local_or_wrong_domain_registration() { + let fixture = TempConfigDir::new("snapshot_registry_contract"); + + let mut worker_local_registry = crate::parse::default_registry(); + worker_local_registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "gzip", + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(&worker_local_registry); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip on; }", + &fixture.join("worker-local/pishoo.conf"), + None, + ); + assert!( + build_global_tree(&worker_local_registry, root, Vec::new()).is_err(), + "WorkerLocalOnly values must not enter the V1 snapshot contract" + ); + assert!( + build_worker_tree( + &worker_local_registry, + snapshot_test_support::snapshot_with_builtin_gzip(true), + None, + Vec::new(), + ) + .is_err(), + "worker overlays must validate the same registry contract before applying a snapshot" + ); + + let mut wrong_domain_registry = crate::parse::default_registry(); + wrong_domain_registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "gzip", + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(&wrong_domain_registry); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip on; }", + &fixture.join("wrong-domain/pishoo.conf"), + None, + ); + assert!( + build_global_tree(&wrong_domain_registry, root, Vec::new()).is_err(), + "the V1 gzip field must remain bound to BoolConfig" + ); + + let mut premature_access_log_registry = crate::parse::default_registry(); + premature_access_log_registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "access_log", + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(&premature_access_log_registry); + let root = parse_root_fragment( + &mut parser, + "pishoo {}", + &fixture.join("reserved-access-log/pishoo.conf"), + None, + ); + assert!( + build_global_tree(&premature_access_log_registry, root, Vec::new()).is_err(), + "the reserved V1 access_log slot must remain absent until its checked domain is registered" + ); +} + #[test] fn cascade_lineage_is_builtin_root_worker_server_location() { let fixture = TempConfigDir::new("cascade_lineage"); @@ -1112,6 +1295,26 @@ fn tree_ref_keeps_source_and_parent_alive() { assert_eq!(server.tree().source_path(span), Some(source_path.as_path())); } +#[test] +fn sealed_source_bundle_retains_only_one_source_map_owner() { + let fixture = TempConfigDir::new("source_owner_weight"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("global-home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 443; location / {} } }", + &fixture.join("global-home/pishoo.conf"), + Some(&home), + ); + let source_owner = root.source_owner(); + + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + + assert_eq!(tree.servers().count(), 1); + assert_eq!(Arc::strong_count(&source_owner), 2); +} + #[test] fn cross_document_diagnostics_keep_the_correct_source_bundle() { let fixture = TempConfigDir::new("cross_document_sources"); @@ -1264,12 +1467,59 @@ fn unknown_snapshot_schema_is_rejected() { assert!(snapshot_test_support::decode_schema(2).is_err()); } +#[test] +fn snapshot_real_codec_round_trips_and_rejects_unknown_or_malformed_wire() { + let snapshot = snapshot_test_support::snapshot_with_builtin_gzip(true); + + assert_eq!( + snapshot_test_support::codec_round_trip(&snapshot).expect("real codec round trip"), + snapshot + ); + assert!(snapshot_test_support::codec_rejects_unknown_schema( + &snapshot + )); + assert!(snapshot_test_support::codec_rejects_malformed_access_rules( + &snapshot + )); +} + #[test] fn snapshot_access_rules_revalidates_url_domain() { + assert!(matches!( + snapshot_test_support::decode_access_rules("not a URL"), + Err( + crate::parse::snapshot::RootConfigSnapshotError::AccessRulesUrl { + source: url::ParseError::RelativeUrlWithoutBase, + } + ) + )); assert!(snapshot_test_support::decode_access_rules("https://example.com/rules.db").is_err()); assert!(snapshot_test_support::decode_access_rules("sqlite://host/tmp/rules.db").is_err()); } +#[test] +fn access_rules_rejects_literal_and_percent_encoded_nul_in_text_and_snapshot() { + for uri in ["sqlite:/tmp/rules\0.db", "sqlite:/tmp/rules%00.db"] { + let failure = + crate::parse::parse_config_str_for_test(&format!("pishoo {{ access_rules {uri}; }}")) + .expect_err("text access_rules path containing NUL must fail"); + assert!( + snafu::Report::from_error(&failure.error) + .to_string() + .contains("NUL byte") + ); + + let error = snapshot_test_support::decode_access_rules(uri) + .expect_err("snapshot access_rules path containing NUL must fail"); + assert!(matches!( + error, + crate::parse::snapshot::RootConfigSnapshotError::AccessRules { + source: crate::parse::types::AccessRulesUriValidationError::NulPath, + } + )); + } +} + #[test] fn snapshot_header_values_revalidate_from_bytes() { assert!(snapshot_test_support::decode_header_value(b"text/plain").is_ok()); diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index 6d665a34..e4b7d41e 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -12,8 +12,11 @@ use crate::parse::{ error::ConfigQueryError, fragment::{ParsedPishooFragment, ParsedServerFragment}, registry::{CascadePolicy, ConfigRegistry, context}, - snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, - source::{SourceId, SourceMap, SourceSpan}, + snapshot::{ + RootConfigSnapshot, RootConfigSnapshotError, RootSnapshotRegistryContract, + RootSnapshotRegistryContractError, + }, + source::{ConfigDocumentSourceMap, SourceId, SourceMap, SourceSpan}, value::ConfigValue, }; @@ -70,30 +73,15 @@ impl<'tree> AttachedConfigNode<'tree> { } } -#[derive(Debug)] -enum ConfigSourceFragment { - Pishoo(ParsedPishooFragment), - Server(ParsedServerFragment), -} - -impl ConfigSourceFragment { - fn source_map(&self) -> &SourceMap { - match self { - Self::Pishoo(fragment) => fragment.source_map(), - Self::Server(fragment) => fragment.source_map(), - } - } -} - #[derive(Debug)] struct ConfigSourceOwner { document_id: ConfigDocumentId, - fragment: ConfigSourceFragment, + sources: Arc, } impl ConfigSourceOwner { fn source_map(&self) -> &SourceMap { - self.fragment.source_map() + self.sources.source_map() } } @@ -105,6 +93,7 @@ pub struct HomeConfigTree { servers: Box<[ConfigNodeId]>, inherited_root: Option>, sources: ConfigSourceBundle, + _snapshot_contract: RootSnapshotRegistryContract, } #[derive(Debug)] @@ -142,6 +131,10 @@ pub enum HomeConfigTreeError { node: ConfigNodeId, source: Box, }, + #[snafu(display("registry is incompatible with the root snapshot schema"))] + SnapshotContract { + source: RootSnapshotRegistryContractError, + }, } pub fn build_global_tree( @@ -182,6 +175,7 @@ struct HomeConfigTreeBuilder<'registry> { inherited_root: Option>, sources: Vec, document_ids: ConfigDocumentIdAllocator, + snapshot_contract: RootSnapshotRegistryContract, } impl<'registry> HomeConfigTreeBuilder<'registry> { @@ -190,6 +184,8 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { fragment: ParsedPishooFragment, identity_fragments: impl Iterator, ) -> Result { + let snapshot_contract = RootSnapshotRegistryContract::validate(registry) + .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; let synthetic_span = fragment.node().span; let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); let mut builder = Self { @@ -201,6 +197,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { inherited_root: None, sources: Vec::new(), document_ids: ConfigDocumentIdAllocator::new(), + snapshot_contract, }; let document_id = builder.allocate_document_id()?; builder.root = builder.push_node(None, root_node, ParentLink::Root); @@ -213,9 +210,10 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { for server in fragment.servers() { builder.attach_server(server, document_id); } + let sources = fragment.source_owner(); builder.sources.push(ConfigSourceOwner { document_id, - fragment: ConfigSourceFragment::Pishoo(fragment), + sources, }); for fragment in identity_fragments { builder.attach_identity_fragment(fragment)?; @@ -229,6 +227,8 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { worker_fragment: Option, identity_fragments: impl Iterator, ) -> Result { + let snapshot_contract = RootSnapshotRegistryContract::validate(registry) + .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; let synthetic_span = worker_fragment .as_ref() .map_or_else(synthetic_span, |fragment| fragment.node().span); @@ -246,6 +246,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { inherited_root: Some(Arc::new(root_snapshot)), sources: Vec::new(), document_ids: ConfigDocumentIdAllocator::new(), + snapshot_contract, }; builder.root = builder.push_node(None, root_node, ParentLink::Root); let worker_document_id = worker_fragment @@ -264,9 +265,10 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { node: builder.pishoo, }); }; + let sources = fragment.source_owner(); builder.sources.push(ConfigSourceOwner { document_id, - fragment: ConfigSourceFragment::Pishoo(fragment), + sources, }); } for fragment in identity_fragments { @@ -284,9 +286,10 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { } else { let document_id = self.allocate_document_id()?; self.attach_server(&fragment, document_id); + let sources = fragment.source_owner(); self.sources.push(ConfigSourceOwner { document_id, - fragment: ConfigSourceFragment::Server(fragment), + sources, }); } Ok(()) @@ -407,6 +410,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { sources: ConfigSourceBundle { owners: self.sources.into_boxed_slice(), }, + _snapshot_contract: self.snapshot_contract, })) } } diff --git a/gateway/src/parse/types.rs b/gateway/src/parse/types.rs index d3b5d69b..16e05bd9 100644 --- a/gateway/src/parse/types.rs +++ b/gateway/src/parse/types.rs @@ -142,12 +142,26 @@ pub enum AccessRulesUriValidationError { RelativePath, #[snafu(display("access_rules sqlite path contains a NUL byte"))] NulPath, + #[snafu(display("access_rules sqlite path is not valid UTF-8 on this platform"))] + InvalidPathEncoding, } impl TryFrom for AccessRulesUri { type Error = AccessRulesUriValidationError; fn try_from(uri: url::Url) -> Result { + let path = Self::decoded_sqlite_path(&uri)?; + if !path.is_absolute() { + return Err(AccessRulesUriValidationError::RelativePath); + } + Ok(Self(uri)) + } +} + +impl AccessRulesUri { + pub(crate) fn decoded_sqlite_path( + uri: &url::Url, + ) -> Result { if uri.scheme() != "sqlite" { return Err(AccessRulesUriValidationError::UnsupportedScheme { scheme: uri.scheme().to_owned(), @@ -161,14 +175,21 @@ impl TryFrom for AccessRulesUri { { return Err(AccessRulesUriValidationError::UnsupportedSqliteForm); } - let path = std::path::Path::new(uri.path()); - if !path.is_absolute() { - return Err(AccessRulesUriValidationError::RelativePath); - } - if path.as_os_str().as_encoded_bytes().contains(&0) { + let path = percent_encoding::percent_decode_str(uri.path()).collect::>(); + if path.contains(&0) { return Err(AccessRulesUriValidationError::NulPath); } - Ok(Self(uri)) + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(std::ffi::OsString::from_vec(path).into()) + } + #[cfg(not(unix))] + { + String::from_utf8(path) + .map(PathBuf::from) + .map_err(|_| AccessRulesUriValidationError::InvalidPathEncoding) + } } } @@ -342,6 +363,11 @@ pub struct Listens { pub enum ListenBindPatternError { #[snafu(display("unsupported listen iface range `{range}`"))] UnsupportedIfaceRange { range: IfaceRange }, + #[snafu(display("failed to construct generated bind pattern `{input}`"))] + GeneratedBindPattern { + input: String, + source: peg::error::ParseError, + }, } impl Listens { @@ -355,17 +381,17 @@ impl Listens { } pub fn try_to_bind_patterns(&self) -> Result, ListenBindPatternError> { - fn parse_pattern(input: String) -> BindPattern { + fn parse_pattern(input: String) -> Result { input .parse() - .expect("generated bind pattern should be valid") + .map_err(|source| ListenBindPatternError::GeneratedBindPattern { input, source }) } if let Some(specific_addrs) = &self.specific_addrs { - return Ok(specific_addrs + return specific_addrs .iter() .map(|addr| parse_pattern(format!("inet://{addr}"))) - .collect()); + .collect(); } let host = match &self.range { @@ -374,12 +400,14 @@ impl Listens { IfaceRange::Internal => { return Ok(match self.families { IpFamilies::V4 => { - vec![parse_pattern(format!("inet://127.0.0.1:{}", self.port))] + vec![parse_pattern(format!("inet://127.0.0.1:{}", self.port))?] + } + IpFamilies::V6 => { + vec![parse_pattern(format!("inet://[::1]:{}", self.port))?] } - IpFamilies::V6 => vec![parse_pattern(format!("inet://[::1]:{}", self.port))], IpFamilies::Dual => vec![ - parse_pattern(format!("inet://127.0.0.1:{}", self.port)), - parse_pattern(format!("inet://[::1]:{}", self.port)), + parse_pattern(format!("inet://127.0.0.1:{}", self.port))?, + parse_pattern(format!("inet://[::1]:{}", self.port))?, ], }); } @@ -400,7 +428,7 @@ impl Listens { Ok(vec![parse_pattern(format!( "iface://{family_prefix}{host}:{}", self.port - ))]) + ))?]) } } From 4f4b045ffc60287a9f9d13f052d51572a2b1aa14 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 04:02:04 +0800 Subject: [PATCH 07/18] fix(config): make snapshot schema exhaustive --- gateway/src/parse/builtin/pishoo.rs | 45 +--- gateway/src/parse/registry.rs | 305 +++++++++++++++++++++++++--- gateway/src/parse/snapshot.rs | 216 +++++--------------- gateway/src/parse/tests.rs | 77 +++++++ gateway/src/parse/tree.rs | 31 +-- 5 files changed, 424 insertions(+), 250 deletions(-) diff --git a/gateway/src/parse/builtin/pishoo.rs b/gateway/src/parse/builtin/pishoo.rs index 648062ae..0bd1651f 100644 --- a/gateway/src/parse/builtin/pishoo.rs +++ b/gateway/src/parse/builtin/pishoo.rs @@ -3,10 +3,7 @@ use crate::parse::{ CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, ReloadImpact, TransportPolicy, context, }, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, - PathConfig, StringList, - }, + types::{PathConfig, StringList}, }; pub fn register(registry: &mut ConfigRegistry) { @@ -29,24 +26,7 @@ pub fn register(registry: &mut ConfigRegistry) { register_hypervisor_leaf::(registry, "pid"); register_hypervisor_leaf::(registry, "workers"); register_hypervisor_leaf::(registry, "groups"); - register_inheritable_leaf::(registry, "access_rules"); - register_inheritable_leaf::(registry, "gzip"); - register_inheritable_leaf::(registry, "gzip_vary"); - register_inheritable_leaf::(registry, "gzip_min_length"); - register_inheritable_leaf::(registry, "gzip_comp_level"); - register_inheritable_leaf::(registry, "gzip_types"); - register_inheritable_leaf::(registry, "default_type"); - registry.register_directive( - context::PISHOO, - DirectiveSpec::raw_value::( - "types", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - ); + registry.register_v1_snapshot_directives(); } fn register_hypervisor_leaf(registry: &mut ConfigRegistry, name: &'static str) @@ -70,27 +50,6 @@ where ); } -fn register_inheritable_leaf(registry: &mut ConfigRegistry, name: &'static str) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - name, - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - ); -} - #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 03a9608c..f80c7136 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -1,9 +1,13 @@ use std::{any::TypeId, collections::HashMap, error::Error, sync::Arc}; -use snafu::{OptionExt, ResultExt}; +use snafu::{OptionExt, ResultExt, Snafu}; use crate::parse::{ ast::{AstBody, AstDirective}, + cascade::{ + ACCESS_RULES, DEFAULT_TYPE, DirectiveKey, GZIP, GZIP_COMP_LEVEL, GZIP_MIN_LENGTH, + GZIP_TYPES, GZIP_VARY, TYPES, + }, document::{ConfigDocument, ConfigNode}, domain::{ConfigDocumentRoleKind, DirectiveName}, error::{BuildDocumentError, ConfigDocumentRoleError, build_document_error}, @@ -11,6 +15,10 @@ use crate::parse::{ normalize, source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, tree::AttachedConfigNode, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, + StringList, + }, value::{ConfigValue, TypedValue}, }; @@ -59,15 +67,6 @@ pub struct DirectiveSpec { value_type_name: Option<&'static str>, } -#[derive(Debug, Clone, Copy)] -pub(crate) struct DirectiveContract { - pub(crate) shape: DirectiveShape, - pub(crate) cascade: CascadePolicy, - pub(crate) transport: TransportPolicy, - pub(crate) value_type: Option, - pub(crate) value_type_name: Option<&'static str>, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DirectiveShape { Leaf, @@ -255,6 +254,222 @@ impl DirectiveSpec { } } +#[derive(Debug, Clone, Copy)] +enum V1SnapshotDirectiveShape { + Leaf, + RawBlock, +} + +impl V1SnapshotDirectiveShape { + const fn registry_shape(self) -> DirectiveShape { + match self { + Self::Leaf => DirectiveShape::Leaf, + Self::RawBlock => DirectiveShape::RawBlock, + } + } +} + +#[derive(Debug)] +pub(crate) struct V1SnapshotDirective { + key: DirectiveKey, + shape: V1SnapshotDirectiveShape, + cascade: CascadePolicy, +} + +impl Clone for V1SnapshotDirective { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for V1SnapshotDirective {} + +impl V1SnapshotDirective { + const fn new( + key: DirectiveKey, + shape: V1SnapshotDirectiveShape, + cascade: CascadePolicy, + ) -> Self { + Self { + key, + shape, + cascade, + } + } + + pub(crate) const fn key(self) -> DirectiveKey { + self.key + } + + fn erased(self) -> ErasedV1SnapshotDirective + where + T: 'static, + { + ErasedV1SnapshotDirective { + name: self.key.name(), + shape: self.shape.registry_shape(), + cascade: self.cascade, + value_type: TypeId::of::, + value_type_name: std::any::type_name::(), + } + } + + fn register(self, registry: &mut ConfigRegistry) + where + T: DirectiveValue, + for<'input, 'directive> T: + TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, + { + let name = self.key.name().as_str(); + let spec = match self.shape { + V1SnapshotDirectiveShape::Leaf => DirectiveSpec::leaf_value::( + name, + vec![context::PISHOO], + DuplicatePolicy::Reject, + self.cascade, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), + V1SnapshotDirectiveShape::RawBlock => DirectiveSpec::raw_value::( + name, + vec![context::PISHOO], + DuplicatePolicy::Reject, + self.cascade, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), + }; + registry.register_directive(context::PISHOO, spec); + } +} + +#[derive(Debug, Clone, Copy)] +struct ErasedV1SnapshotDirective { + name: DirectiveName, + shape: DirectiveShape, + cascade: CascadePolicy, + value_type: fn() -> TypeId, + value_type_name: &'static str, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ReservedV1SnapshotField { + name: DirectiveName, +} + +impl ReservedV1SnapshotField { + const fn new(name: &'static str) -> Self { + Self { + name: DirectiveName::new(name), + } + } + + pub(crate) const fn name(self) -> DirectiveName { + self.name + } +} + +macro_rules! define_v1_snapshot_schema { + ( + $( + $field:ident: $value_type:ty = + $key:expr, $shape:ident, $cascade:expr; + )+ + @reserved $reserved:ident = $reserved_name:literal; + ) => { + #[derive(Debug)] + pub(crate) struct V1SnapshotSchema { + $(pub(crate) $field: V1SnapshotDirective<$value_type>,)+ + pub(crate) $reserved: ReservedV1SnapshotField, + } + + impl V1SnapshotSchema { + fn active_directives( + &self, + ) -> impl Iterator + '_ { + [$(self.$field.erased(),)+].into_iter() + } + + fn register_active_directives(&self, registry: &mut ConfigRegistry) { + $(self.$field.register(registry);)+ + } + + #[cfg(test)] + pub(crate) const fn field_names(&self) -> [&'static str; 9] { + [$(self.$field.key().name().as_str(),)+ self.$reserved.name().as_str()] + } + } + + static V1_SNAPSHOT_SCHEMA: V1SnapshotSchema = V1SnapshotSchema { + $($field: V1SnapshotDirective::new( + $key, + V1SnapshotDirectiveShape::$shape, + $cascade, + ),)+ + $reserved: ReservedV1SnapshotField::new($reserved_name), + }; + }; +} + +define_v1_snapshot_schema! { + access_rules: AccessRulesUri = ACCESS_RULES, Leaf, CascadePolicy::NearestWins; + gzip: BoolConfig = GZIP, Leaf, CascadePolicy::NearestWins; + gzip_vary: BoolConfig = GZIP_VARY, Leaf, CascadePolicy::NearestWins; + gzip_min_length: GzipMinLength = GZIP_MIN_LENGTH, Leaf, CascadePolicy::NearestWins; + gzip_comp_level: GzipCompLevel = GZIP_COMP_LEVEL, Leaf, CascadePolicy::NearestWins; + gzip_types: StringList = GZIP_TYPES, Leaf, CascadePolicy::NearestWins; + default_type: DefaultType = DEFAULT_TYPE, Leaf, CascadePolicy::NearestWins; + types: MimeTypes = TYPES, RawBlock, CascadePolicy::ReplaceWhole; + @reserved access_log = "access_log"; +} + +#[cfg(test)] +pub(crate) const fn v1_snapshot_field_names() -> [&'static str; 9] { + V1_SNAPSHOT_SCHEMA.field_names() +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ValidatedV1SnapshotSchema { + schema: &'static V1SnapshotSchema, +} + +impl ValidatedV1SnapshotSchema { + pub(crate) const fn schema(self) -> &'static V1SnapshotSchema { + self.schema + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum V1SnapshotSchemaError { + #[snafu(display("root snapshot directive `{directive}` is not registered in PISHOO"))] + MissingDirective { directive: DirectiveName }, + #[snafu(display( + "root snapshot directive `{directive}` has value type `{actual}`, expected `{expected}`" + ))] + ValueType { + directive: DirectiveName, + expected: &'static str, + actual: &'static str, + }, + #[snafu(display("root snapshot directive `{directive}` has an incompatible shape"))] + Shape { directive: DirectiveName }, + #[snafu(display("root snapshot directive `{directive}` has an incompatible cascade policy"))] + Cascade { directive: DirectiveName }, + #[snafu(display( + "root snapshot directive `{directive}` is not registered as WorkerInheritable" + ))] + Transport { directive: DirectiveName }, + #[snafu(display( + "worker-inheritable PISHOO directive `{directive}` is not part of the V1 snapshot schema" + ))] + ExtraWorkerInheritableDirective { directive: DirectiveName }, + #[snafu(display( + "reserved root snapshot directive `{directive}` was registered before its checked domain" + ))] + PrematureReservedDirective { directive: DirectiveName }, +} + fn slot_value_parser( input: &DirectiveInput<'_>, ) -> Result> @@ -355,20 +570,64 @@ impl ConfigRegistry { .collect() } - pub(crate) fn directive_contract( + pub(crate) fn register_v1_snapshot_directives(&mut self) { + V1_SNAPSHOT_SCHEMA.register_active_directives(self); + } + + pub(crate) fn validate_v1_snapshot_schema( &self, - context: ContextKey, - name: &str, - ) -> Option { - self.directives - .get(&(context, name)) - .map(|spec| DirectiveContract { - shape: spec.shape, - cascade: spec.cascade, - transport: spec.transport, - value_type: spec.value_type, - value_type_name: spec.value_type_name, - }) + ) -> Result { + for ((registered_context, registered_name), spec) in &self.directives { + if *registered_context != context::PISHOO { + continue; + } + let directive = DirectiveName::new(registered_name); + if *registered_name == V1_SNAPSHOT_SCHEMA.access_log.name().as_str() { + return Err(V1SnapshotSchemaError::PrematureReservedDirective { directive }); + } + if spec.transport == TransportPolicy::WorkerInheritable + && !V1_SNAPSHOT_SCHEMA + .active_directives() + .any(|expected| expected.name.as_str() == *registered_name) + { + return Err(V1SnapshotSchemaError::ExtraWorkerInheritableDirective { directive }); + } + } + + for expected in V1_SNAPSHOT_SCHEMA.active_directives() { + let spec = self + .directives + .get(&(context::PISHOO, expected.name.as_str())) + .ok_or(V1SnapshotSchemaError::MissingDirective { + directive: expected.name, + })?; + if spec.value_type != Some((expected.value_type)()) { + return Err(V1SnapshotSchemaError::ValueType { + directive: expected.name, + expected: expected.value_type_name, + actual: spec.value_type_name.unwrap_or(""), + }); + } + if spec.shape != expected.shape { + return Err(V1SnapshotSchemaError::Shape { + directive: expected.name, + }); + } + if spec.cascade != expected.cascade { + return Err(V1SnapshotSchemaError::Cascade { + directive: expected.name, + }); + } + if spec.transport != TransportPolicy::WorkerInheritable { + return Err(V1SnapshotSchemaError::Transport { + directive: expected.name, + }); + } + } + + Ok(ValidatedV1SnapshotSchema { + schema: &V1_SNAPSHOT_SCHEMA, + }) } pub fn build( diff --git a/gateway/src/parse/snapshot.rs b/gateway/src/parse/snapshot.rs index 6fa92197..af1a3310 100644 --- a/gateway/src/parse/snapshot.rs +++ b/gateway/src/parse/snapshot.rs @@ -1,15 +1,12 @@ -use std::{any::TypeId, collections::HashSet, num::NonZeroU32, path::PathBuf, sync::Arc}; +use std::{num::NonZeroU32, path::PathBuf, sync::Arc}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _}; use snafu::Snafu; use crate::parse::{ - cascade::{ - ACCESS_RULES, ConfigOrigin, DEFAULT_TYPE, GZIP, GZIP_COMP_LEVEL, GZIP_MIN_LENGTH, - GZIP_TYPES, GZIP_VARY, InheritedSourceLocation, TYPES, - }, + cascade::{ConfigOrigin, InheritedSourceLocation}, domain::{DirectiveName, ResolvedConfigPath, ResolvedConfigPathError}, - registry::{CascadePolicy, ConfigRegistry, DirectiveShape, TransportPolicy, context}, + registry::ReservedV1SnapshotField, tree::HomeConfigTree, types::{ AccessRulesUri, AccessRulesUriValidationError, BoolConfig, DefaultType, GzipCompLevel, @@ -35,131 +32,6 @@ pub struct RootInheritedConfigV1 { access_log: InheritedValue>>, } -#[derive(Debug)] -pub(crate) struct RootSnapshotRegistryContract; - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RootSnapshotRegistryContractError { - #[snafu(display("root snapshot directive `{directive}` is not registered in PISHOO"))] - MissingDirective { directive: DirectiveName }, - #[snafu(display( - "root snapshot directive `{directive}` has value type `{actual}`, expected `{expected}`" - ))] - ValueType { - directive: DirectiveName, - expected: &'static str, - actual: &'static str, - }, - #[snafu(display("root snapshot directive `{directive}` has an incompatible shape"))] - Shape { directive: DirectiveName }, - #[snafu(display("root snapshot directive `{directive}` has an incompatible cascade policy"))] - Cascade { directive: DirectiveName }, - #[snafu(display( - "root snapshot directive `{directive}` is not registered as WorkerInheritable" - ))] - Transport { directive: DirectiveName }, - #[snafu(display( - "reserved root snapshot directive `{directive}` was registered before its checked domain" - ))] - PrematureReservedDirective { directive: DirectiveName }, -} - -impl RootSnapshotRegistryContract { - pub(crate) fn validate( - registry: &ConfigRegistry, - ) -> Result { - validate_snapshot_directive::( - registry, - ACCESS_RULES.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - GZIP.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - GZIP_VARY.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - GZIP_MIN_LENGTH.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - GZIP_COMP_LEVEL.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - GZIP_TYPES.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - DEFAULT_TYPE.name(), - DirectiveShape::Leaf, - CascadePolicy::NearestWins, - )?; - validate_snapshot_directive::( - registry, - TYPES.name(), - DirectiveShape::RawBlock, - CascadePolicy::ReplaceWhole, - )?; - let access_log = DirectiveName::new("access_log"); - if registry - .directive_contract(context::PISHOO, access_log.as_str()) - .is_some() - { - return Err( - RootSnapshotRegistryContractError::PrematureReservedDirective { - directive: access_log, - }, - ); - } - Ok(Self) - } -} - -fn validate_snapshot_directive( - registry: &ConfigRegistry, - directive: DirectiveName, - shape: DirectiveShape, - cascade: CascadePolicy, -) -> Result<(), RootSnapshotRegistryContractError> { - let contract = registry - .directive_contract(context::PISHOO, directive.as_str()) - .ok_or(RootSnapshotRegistryContractError::MissingDirective { directive })?; - if contract.value_type != Some(TypeId::of::()) { - return Err(RootSnapshotRegistryContractError::ValueType { - directive, - expected: std::any::type_name::(), - actual: contract.value_type_name.unwrap_or(""), - }); - } - if contract.shape != shape { - return Err(RootSnapshotRegistryContractError::Shape { directive }); - } - if contract.cascade != cascade { - return Err(RootSnapshotRegistryContractError::Cascade { directive }); - } - if contract.transport != TransportPolicy::WorkerInheritable { - return Err(RootSnapshotRegistryContractError::Transport { directive }); - } - Ok(()) -} - #[derive(Debug, Clone, PartialEq, Eq)] struct InheritedValue { value: T, @@ -219,26 +91,41 @@ pub enum RootConfigSnapshotError { impl RootConfigSnapshot { pub(crate) fn project(tree: &Arc) -> Result { let pishoo = tree.pishoo(); - let access_rules = project_optional(tree, snapshot_query(pishoo.cascaded(ACCESS_RULES))?)?; - let gzip = project_required(tree, GZIP.name(), snapshot_query(pishoo.cascaded(GZIP))?)?; + let schema = tree.v1_snapshot_schema().schema(); + let access_rules = project_optional( + tree, + snapshot_query(pishoo.cascaded(schema.access_rules.key()))?, + )?; + let gzip = project_required( + tree, + schema.gzip.key().name(), + snapshot_query(pishoo.cascaded(schema.gzip.key()))?, + )?; let gzip_vary = project_required( tree, - GZIP_VARY.name(), - snapshot_query(pishoo.cascaded(GZIP_VARY))?, + schema.gzip_vary.key().name(), + snapshot_query(pishoo.cascaded(schema.gzip_vary.key()))?, )?; let gzip_min_length = project_required( tree, - GZIP_MIN_LENGTH.name(), - snapshot_query(pishoo.cascaded(GZIP_MIN_LENGTH))?, + schema.gzip_min_length.key().name(), + snapshot_query(pishoo.cascaded(schema.gzip_min_length.key()))?, )?; let gzip_comp_level = project_required( tree, - GZIP_COMP_LEVEL.name(), - snapshot_query(pishoo.cascaded(GZIP_COMP_LEVEL))?, + schema.gzip_comp_level.key().name(), + snapshot_query(pishoo.cascaded(schema.gzip_comp_level.key()))?, )?; - let gzip_types = project_optional(tree, snapshot_query(pishoo.cascaded(GZIP_TYPES))?)?; - let default_type = project_optional(tree, snapshot_query(pishoo.cascaded(DEFAULT_TYPE))?)?; - let types = project_optional(tree, snapshot_query(pishoo.cascaded(TYPES))?)?; + let gzip_types = project_optional( + tree, + snapshot_query(pishoo.cascaded(schema.gzip_types.key()))?, + )?; + let default_type = project_optional( + tree, + snapshot_query(pishoo.cascaded(schema.default_type.key()))?, + )?; + let types = project_optional(tree, snapshot_query(pishoo.cascaded(schema.types.key()))?)?; + let access_log = project_reserved(schema.access_log); Ok(Self::V1(RootInheritedConfigV1 { access_rules, gzip, @@ -248,10 +135,7 @@ impl RootConfigSnapshot { gzip_types, default_type, types, - access_log: InheritedValue { - value: None, - origin: InheritedOrigin::Builtin, - }, + access_log, })) } @@ -389,6 +273,13 @@ fn project_optional( }) } +fn project_reserved(_: ReservedV1SnapshotField) -> InheritedValue>> { + InheritedValue { + value: None, + origin: InheritedOrigin::Builtin, + } +} + fn project_origin( tree: &HomeConfigTree, origin: Option<&ConfigOrigin>, @@ -687,19 +578,14 @@ fn encode_mime_types(types: &MimeTypes) -> MimeTypesV1 { } fn decode_mime_types(types: MimeTypesV1) -> Result { - let mut seen = HashSet::new(); - let mut entries = Vec::with_capacity(types.0.len()); - for entry in types.0 { - let extension = String::from(entry.extension); - if !seen.insert(extension.clone()) { - return Err(RootConfigSnapshotError::MimeTypes { - source: MimeTypesValidationError::DuplicateExtension { extension }, - }); - } - entries.push((extension, entry.value.0.into_vec())); - } - MimeTypes::checked_from_bytes(entries) - .map_err(|source| RootConfigSnapshotError::MimeTypes { source }) + MimeTypes::checked_from_bytes( + types + .0 + .into_vec() + .into_iter() + .map(|entry| (String::from(entry.extension), entry.value.0.into_vec())), + ) + .map_err(|source| RootConfigSnapshotError::MimeTypes { source }) } fn encode_access_log( @@ -815,17 +701,7 @@ pub(crate) mod test_support { types, access_log, )); - [ - "access_rules", - "gzip", - "gzip_vary", - "gzip_min_length", - "gzip_comp_level", - "gzip_types", - "default_type", - "types", - "access_log", - ] + crate::parse::registry::v1_snapshot_field_names() } pub(crate) fn checked_wire_round_trip( diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 0b24495e..f9c59397 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -1094,6 +1094,54 @@ fn snapshot_contract_rejects_worker_local_or_wrong_domain_registration() { ); } +#[test] +fn snapshot_contract_rejects_extra_worker_inheritable_directive() { + let fixture = TempConfigDir::new("snapshot_registry_extra"); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "extra_inherited", + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment( + &mut parser, + "pishoo {}", + &fixture.join("root/pishoo.conf"), + None, + ); + + assert!(matches!( + build_global_tree(®istry, root, Vec::new()), + Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { + source: + crate::parse::registry::V1SnapshotSchemaError::ExtraWorkerInheritableDirective { + directive, + }, + }) if directive.as_str() == "extra_inherited" + )); + assert!(matches!( + build_worker_tree( + ®istry, + snapshot_test_support::snapshot_with_builtin_gzip(true), + None, + Vec::new(), + ), + Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { + source: + crate::parse::registry::V1SnapshotSchemaError::ExtraWorkerInheritableDirective { + directive, + }, + }) if directive.as_str() == "extra_inherited" + )); +} + #[test] fn cascade_lineage_is_builtin_root_worker_server_location() { let fixture = TempConfigDir::new("cascade_lineage"); @@ -1542,6 +1590,35 @@ fn snapshot_mime_entries_are_sorted_and_reject_duplicates() { assert!(snapshot_test_support::decode_mime_entries(&[("", b"text/a")]).is_err()); } +#[test] +fn text_and_snapshot_mime_decode_use_the_same_validation_category() { + let text_failure = + crate::parse::parse_config_str_for_test("pishoo { types { text/a a; 'bad\nvalue' a; } }") + .expect_err("text MIME value containing a newline must fail"); + let mut text_error: &(dyn std::error::Error + 'static) = &text_failure.error; + let text_category = loop { + if let Some(category) = + text_error.downcast_ref::() + { + break category; + } + text_error = text_error + .source() + .expect("text MIME failure should retain the domain error"); + }; + assert!(matches!( + text_category, + crate::parse::types::MimeTypesValidationError::HeaderValue { .. } + )); + + assert!(matches!( + snapshot_test_support::decode_mime_entries(&[("a", b"text/a"), ("a", b"bad\nvalue")]), + Err(crate::parse::snapshot::RootConfigSnapshotError::MimeTypes { + source: crate::parse::types::MimeTypesValidationError::HeaderValue { .. }, + }) + )); +} + #[test] fn snapshot_preserves_absence_and_gzip_fallbacks() { let fixture = TempConfigDir::new("snapshot_fallbacks"); diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index e4b7d41e..84f50136 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -11,11 +11,10 @@ use crate::parse::{ }, error::ConfigQueryError, fragment::{ParsedPishooFragment, ParsedServerFragment}, - registry::{CascadePolicy, ConfigRegistry, context}, - snapshot::{ - RootConfigSnapshot, RootConfigSnapshotError, RootSnapshotRegistryContract, - RootSnapshotRegistryContractError, + registry::{ + CascadePolicy, ConfigRegistry, V1SnapshotSchemaError, ValidatedV1SnapshotSchema, context, }, + snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, source::{ConfigDocumentSourceMap, SourceId, SourceMap, SourceSpan}, value::ConfigValue, }; @@ -93,7 +92,7 @@ pub struct HomeConfigTree { servers: Box<[ConfigNodeId]>, inherited_root: Option>, sources: ConfigSourceBundle, - _snapshot_contract: RootSnapshotRegistryContract, + snapshot_schema: ValidatedV1SnapshotSchema, } #[derive(Debug)] @@ -132,9 +131,7 @@ pub enum HomeConfigTreeError { source: Box, }, #[snafu(display("registry is incompatible with the root snapshot schema"))] - SnapshotContract { - source: RootSnapshotRegistryContractError, - }, + SnapshotContract { source: V1SnapshotSchemaError }, } pub fn build_global_tree( @@ -175,7 +172,7 @@ struct HomeConfigTreeBuilder<'registry> { inherited_root: Option>, sources: Vec, document_ids: ConfigDocumentIdAllocator, - snapshot_contract: RootSnapshotRegistryContract, + snapshot_schema: ValidatedV1SnapshotSchema, } impl<'registry> HomeConfigTreeBuilder<'registry> { @@ -184,7 +181,8 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { fragment: ParsedPishooFragment, identity_fragments: impl Iterator, ) -> Result { - let snapshot_contract = RootSnapshotRegistryContract::validate(registry) + let snapshot_schema = registry + .validate_v1_snapshot_schema() .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; let synthetic_span = fragment.node().span; let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); @@ -197,7 +195,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { inherited_root: None, sources: Vec::new(), document_ids: ConfigDocumentIdAllocator::new(), - snapshot_contract, + snapshot_schema, }; let document_id = builder.allocate_document_id()?; builder.root = builder.push_node(None, root_node, ParentLink::Root); @@ -227,7 +225,8 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { worker_fragment: Option, identity_fragments: impl Iterator, ) -> Result { - let snapshot_contract = RootSnapshotRegistryContract::validate(registry) + let snapshot_schema = registry + .validate_v1_snapshot_schema() .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; let synthetic_span = worker_fragment .as_ref() @@ -246,7 +245,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { inherited_root: Some(Arc::new(root_snapshot)), sources: Vec::new(), document_ids: ConfigDocumentIdAllocator::new(), - snapshot_contract, + snapshot_schema, }; builder.root = builder.push_node(None, root_node, ParentLink::Root); let worker_document_id = worker_fragment @@ -410,7 +409,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { sources: ConfigSourceBundle { owners: self.sources.into_boxed_slice(), }, - _snapshot_contract: self.snapshot_contract, + snapshot_schema: self.snapshot_schema, })) } } @@ -439,6 +438,10 @@ impl HomeConfigTree { RootConfigSnapshot::project(self) } + pub(crate) const fn v1_snapshot_schema(&self) -> ValidatedV1SnapshotSchema { + self.snapshot_schema + } + pub fn source_path(&self, span: ConfigSourceSpan) -> Option<&Path> { self.sources.source_path(span) } From 2427e51dece33d59980b0825b71209319821732f Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 04:28:27 +0800 Subject: [PATCH 08/18] fix(config): freeze parser registry contracts --- gateway/src/parse/builtin/access.rs | 45 +++++++-- gateway/src/parse/fragment.rs | 29 +++++- gateway/src/parse/registry.rs | 147 ++++++++++++++++++++++----- gateway/src/parse/tests.rs | 148 ++++++++++++++++++++++++++++ gateway/src/parse/tree.rs | 16 ++- 5 files changed, 345 insertions(+), 40 deletions(-) diff --git a/gateway/src/parse/builtin/access.rs b/gateway/src/parse/builtin/access.rs index 350d1af1..9444eca2 100644 --- a/gateway/src/parse/builtin/access.rs +++ b/gateway/src/parse/builtin/access.rs @@ -1,3 +1,5 @@ +use std::path::{Path, PathBuf}; + use snafu::{ResultExt, Snafu}; use crate::parse::{ @@ -27,6 +29,8 @@ pub enum AccessRulesUriError { span: SourceSpan, source: normalize::NormalizeDirectiveValueError, }, + #[snafu(display("failed to encode the access_rules configuration base path as a URL"))] + BasePathUrl { span: SourceSpan, path: PathBuf }, #[snafu(display("invalid access_rules uri domain"))] Domain { span: SourceSpan, @@ -57,17 +61,28 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for AccessR .context(access_rules_uri_error::UriSnafu { span: arg.span })?; let path = AccessRulesUri::decoded_sqlite_path(&uri) .context(access_rules_uri_error::DomainSnafu { span: arg.span })?; - let normalized_path = normalize::normalize_path(&path, arg.span, input.source_map) - .context(access_rules_uri_error::ResolveRelativePathSnafu { span: arg.span })?; - - let mut normalized = format!("sqlite://{}", normalized_path.display()); - if let Some(query) = uri.query() { - normalized.push('?'); - normalized.push_str(query); + if path.is_absolute() { + return AccessRulesUri::try_from(uri) + .context(access_rules_uri_error::DomainSnafu { span: arg.span }); } - let uri = url::Url::parse(&normalized) + let base_path = normalize::normalize_path(Path::new("."), arg.span, input.source_map) + .context(access_rules_uri_error::ResolveRelativePathSnafu { span: arg.span })?; + let base_url = url::Url::from_directory_path(&base_path).map_err(|()| { + AccessRulesUriError::BasePathUrl { + span: arg.span, + path: base_path, + } + })?; + let encoded_path = uri.path().to_owned(); + let query = uri.query().map(str::to_owned); + let resolved = base_url + .join(&encoded_path) + .context(access_rules_uri_error::UriSnafu { span: arg.span })?; + let mut uri = url::Url::parse("sqlite:///") .context(access_rules_uri_error::UriSnafu { span: arg.span })?; + uri.set_path(resolved.path()); + uri.set_query(query.as_deref()); AccessRulesUri::try_from(uri) .context(access_rules_uri_error::DomainSnafu { span: arg.span }) } @@ -102,9 +117,15 @@ mod tests { std::fs::create_dir_all(dir.join("db")).expect("create db dir"); std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); + #[cfg(unix)] + let access_rules = "sqlite:./db/access%3Fpart%23name%FF.db?mode=ro%23strict"; + #[cfg(not(unix))] + let access_rules = "sqlite:./db/access%3Fpart%23name.db?mode=ro%23strict"; std::fs::write( dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n access_rules sqlite:./db/access.db?mode=ro;\n}\n", + format!( + "server {{\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n access_rules {access_rules};\n}}\n" + ), ) .expect("write config"); @@ -118,13 +139,17 @@ mod tests { .expect("config should load"); let server = parsed.root.children("server").expect("server children")[0].clone(); + #[cfg(unix)] + let expected_suffix = "db/access%3Fpart%23name%FF.db?mode=ro%23strict"; + #[cfg(not(unix))] + let expected_suffix = "db/access%3Fpart%23name.db?mode=ro%23strict"; assert_eq!( server .require::("access_rules") .expect("access_rules should be typed") .0 .as_str(), - format!("sqlite://{}?mode=ro", dir.join("db/access.db").display()) + format!("sqlite://{}/{expected_suffix}", dir.display()) ); } diff --git a/gateway/src/parse/fragment.rs b/gateway/src/parse/fragment.rs index bd3d896a..1e39f4da 100644 --- a/gateway/src/parse/fragment.rs +++ b/gateway/src/parse/fragment.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::parse::{ document::ConfigNode, domain::{ConfigDocumentId, ConfigSourceSpan}, + registry::ConfigRegistryContract, source::{ConfigDocumentSourceMap, SourceMap}, }; @@ -18,6 +19,7 @@ pub struct ParsedPishooFragment { sources: Arc, node: Arc, servers: Box<[ParsedServerFragment]>, + registry_contract: ConfigRegistryContract, } #[derive(Debug)] @@ -25,6 +27,7 @@ pub struct ParsedServerFragment { sources: Arc, node: Arc, locations: Box<[ParsedLocationFragment]>, + registry_contract: ConfigRegistryContract, } #[derive(Debug)] @@ -34,17 +37,24 @@ pub struct ParsedLocationFragment { } impl ParsedPishooFragment { - pub(crate) fn new(sources: Arc, node: Arc) -> Self { + pub(crate) fn new( + sources: Arc, + node: Arc, + registry_contract: ConfigRegistryContract, + ) -> Self { let servers = node .children_optional("server") .iter() .cloned() - .map(|server| ParsedServerFragment::new(Arc::clone(&sources), server)) + .map(|server| { + ParsedServerFragment::new(Arc::clone(&sources), server, registry_contract.clone()) + }) .collect(); Self { sources, node, servers, + registry_contract, } } @@ -69,6 +79,10 @@ impl ParsedPishooFragment { Arc::clone(&self.sources) } + pub(crate) fn registry_contract(&self) -> &ConfigRegistryContract { + &self.registry_contract + } + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn node(&self) -> &Arc { &self.node @@ -76,7 +90,11 @@ impl ParsedPishooFragment { } impl ParsedServerFragment { - pub(crate) fn new(sources: Arc, node: Arc) -> Self { + pub(crate) fn new( + sources: Arc, + node: Arc, + registry_contract: ConfigRegistryContract, + ) -> Self { let locations = node .children_optional("location") .iter() @@ -87,6 +105,7 @@ impl ParsedServerFragment { sources, node, locations, + registry_contract, } } @@ -111,6 +130,10 @@ impl ParsedServerFragment { Arc::clone(&self.sources) } + pub(crate) fn registry_contract(&self) -> &ConfigRegistryContract { + &self.registry_contract + } + #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage pub(crate) fn node(&self) -> &Arc { &self.node diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index f80c7136..9738a8dc 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -42,11 +42,25 @@ pub mod context { pub const STUN_SERVER: ContextKey = ContextKey("gateway.stun_server"); } +#[derive(Debug, Default)] +struct ConfigRegistryContractIdentity; + +#[derive(Debug, Clone, Default)] +pub(crate) struct ConfigRegistryContract(Arc); + +impl ConfigRegistryContract { + fn matches(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + #[derive(Default)] pub struct ConfigRegistry { contexts: HashMap, directives: HashMap<(ContextKey, &'static str), DirectiveSpec>, + cascade_policies: HashMap>, attached_finalizers: HashMap, + contract: ConfigRegistryContract, } pub struct ContextSpec { @@ -273,7 +287,10 @@ impl V1SnapshotDirectiveShape { pub(crate) struct V1SnapshotDirective { key: DirectiveKey, shape: V1SnapshotDirectiveShape, + duplicate: DuplicatePolicy, cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, } impl Clone for V1SnapshotDirective { @@ -288,12 +305,18 @@ impl V1SnapshotDirective { const fn new( key: DirectiveKey, shape: V1SnapshotDirectiveShape, + duplicate: DuplicatePolicy, cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, ) -> Self { Self { key, shape, + duplicate, cascade, + transport, + reload, } } @@ -308,7 +331,10 @@ impl V1SnapshotDirective { ErasedV1SnapshotDirective { name: self.key.name(), shape: self.shape.registry_shape(), + duplicate: self.duplicate, cascade: self.cascade, + transport: self.transport, + reload: self.reload, value_type: TypeId::of::, value_type_name: std::any::type_name::(), } @@ -325,18 +351,18 @@ impl V1SnapshotDirective { V1SnapshotDirectiveShape::Leaf => DirectiveSpec::leaf_value::( name, vec![context::PISHOO], - DuplicatePolicy::Reject, + self.duplicate, self.cascade, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, + self.transport, + self.reload, ), V1SnapshotDirectiveShape::RawBlock => DirectiveSpec::raw_value::( name, vec![context::PISHOO], - DuplicatePolicy::Reject, + self.duplicate, self.cascade, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, + self.transport, + self.reload, ), }; registry.register_directive(context::PISHOO, spec); @@ -347,7 +373,10 @@ impl V1SnapshotDirective { struct ErasedV1SnapshotDirective { name: DirectiveName, shape: DirectiveShape, + duplicate: DuplicatePolicy, cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, value_type: fn() -> TypeId, value_type_name: &'static str, } @@ -373,7 +402,8 @@ macro_rules! define_v1_snapshot_schema { ( $( $field:ident: $value_type:ty = - $key:expr, $shape:ident, $cascade:expr; + $key:expr, $shape:ident, $duplicate:expr, $cascade:expr, + $transport:expr, $reload:expr; )+ @reserved $reserved:ident = $reserved_name:literal; ) => { @@ -404,7 +434,10 @@ macro_rules! define_v1_snapshot_schema { $($field: V1SnapshotDirective::new( $key, V1SnapshotDirectiveShape::$shape, + $duplicate, $cascade, + $transport, + $reload, ),)+ $reserved: ReservedV1SnapshotField::new($reserved_name), }; @@ -412,14 +445,28 @@ macro_rules! define_v1_snapshot_schema { } define_v1_snapshot_schema! { - access_rules: AccessRulesUri = ACCESS_RULES, Leaf, CascadePolicy::NearestWins; - gzip: BoolConfig = GZIP, Leaf, CascadePolicy::NearestWins; - gzip_vary: BoolConfig = GZIP_VARY, Leaf, CascadePolicy::NearestWins; - gzip_min_length: GzipMinLength = GZIP_MIN_LENGTH, Leaf, CascadePolicy::NearestWins; - gzip_comp_level: GzipCompLevel = GZIP_COMP_LEVEL, Leaf, CascadePolicy::NearestWins; - gzip_types: StringList = GZIP_TYPES, Leaf, CascadePolicy::NearestWins; - default_type: DefaultType = DEFAULT_TYPE, Leaf, CascadePolicy::NearestWins; - types: MimeTypes = TYPES, RawBlock, CascadePolicy::ReplaceWhole; + access_rules: AccessRulesUri = ACCESS_RULES, Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState; + gzip: BoolConfig = GZIP, Leaf, DuplicatePolicy::Reject, CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, ReloadImpact::RuntimeState; + gzip_vary: BoolConfig = GZIP_VARY, Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState; + gzip_min_length: GzipMinLength = GZIP_MIN_LENGTH, Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState; + gzip_comp_level: GzipCompLevel = GZIP_COMP_LEVEL, Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState; + gzip_types: StringList = GZIP_TYPES, Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState; + default_type: DefaultType = DEFAULT_TYPE, Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState; + types: MimeTypes = TYPES, RawBlock, DuplicatePolicy::Reject, CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerInheritable, ReloadImpact::RuntimeState; @reserved access_log = "access_log"; } @@ -454,12 +501,16 @@ pub enum V1SnapshotSchemaError { }, #[snafu(display("root snapshot directive `{directive}` has an incompatible shape"))] Shape { directive: DirectiveName }, + #[snafu(display("root snapshot directive `{directive}` has an incompatible duplicate policy"))] + Duplicate { directive: DirectiveName }, #[snafu(display("root snapshot directive `{directive}` has an incompatible cascade policy"))] Cascade { directive: DirectiveName }, #[snafu(display( "root snapshot directive `{directive}` is not registered as WorkerInheritable" ))] Transport { directive: DirectiveName }, + #[snafu(display("root snapshot directive `{directive}` has an incompatible reload impact"))] + Reload { directive: DirectiveName }, #[snafu(display( "worker-inheritable PISHOO directive `{directive}` is not part of the V1 snapshot schema" ))] @@ -525,10 +576,26 @@ impl ConfigRegistry { pub fn register_context(&mut self, spec: ContextSpec) { self.contexts.insert(spec.key, spec); + self.advance_contract(); } pub fn register_directive(&mut self, context: ContextKey, spec: DirectiveSpec) { self.directives.insert((context, spec.name.as_str()), spec); + self.rebuild_cascade_policies(context); + self.advance_contract(); + } + + fn rebuild_cascade_policies(&mut self, context: ContextKey) { + let mut policies: Vec<_> = self + .directives + .iter() + .filter_map(|((registered_context, _), spec)| { + (*registered_context == context).then_some((spec.name, spec.cascade)) + }) + .collect(); + policies.sort_unstable_by_key(|(name, _)| name.as_str()); + self.cascade_policies + .insert(context, Arc::from(policies.into_boxed_slice())); } pub(crate) fn register_attached_finalizer( @@ -537,6 +604,19 @@ impl ConfigRegistry { finalize: AttachedContextFinalizeFn, ) { self.attached_finalizers.insert(context, finalize); + self.advance_contract(); + } + + fn advance_contract(&mut self) { + self.contract = ConfigRegistryContract::default(); + } + + pub(crate) fn contract(&self) -> ConfigRegistryContract { + self.contract.clone() + } + + pub(crate) fn matches_contract(&self, contract: &ConfigRegistryContract) -> bool { + self.contract.matches(contract) } pub(crate) fn finalize_attached( @@ -561,13 +641,11 @@ impl ConfigRegistry { pub(crate) fn cascade_policies( &self, context: ContextKey, - ) -> Box<[(DirectiveName, CascadePolicy)]> { - self.directives - .iter() - .filter_map(|((registered_context, _), spec)| { - (*registered_context == context).then_some((spec.name, spec.cascade)) - }) - .collect() + ) -> Arc<[(DirectiveName, CascadePolicy)]> { + self.cascade_policies + .get(&context) + .cloned() + .unwrap_or_default() } pub(crate) fn register_v1_snapshot_directives(&mut self) { @@ -613,16 +691,26 @@ impl ConfigRegistry { directive: expected.name, }); } + if spec.duplicate != expected.duplicate { + return Err(V1SnapshotSchemaError::Duplicate { + directive: expected.name, + }); + } if spec.cascade != expected.cascade { return Err(V1SnapshotSchemaError::Cascade { directive: expected.name, }); } - if spec.transport != TransportPolicy::WorkerInheritable { + if spec.transport != expected.transport { return Err(V1SnapshotSchemaError::Transport { directive: expected.name, }); } + if spec.reload != expected.reload { + return Err(V1SnapshotSchemaError::Reload { + directive: expected.name, + }); + } } Ok(ValidatedV1SnapshotSchema { @@ -664,6 +752,7 @@ impl ConfigRegistry { options: BuildOptions<'_>, role: ConfigDocumentRoleKind, ) -> Result { + let registry_contract = self.contract(); self.validate_role_registration(&sources, &directives, role)?; validate_document_shape(&sources, &directives, role)?; self.validate_role_directives(&sources, &directives, context::ROOT, role)?; @@ -683,13 +772,13 @@ impl ConfigRegistry { ConfigDocumentRoleKind::HypervisorRoot => { let pishoo = self.required_built_child(&sources, &root, "pishoo", role)?; Ok(ParsedConfigDocument::HypervisorRoot( - ParsedPishooFragment::new(sources, pishoo), + ParsedPishooFragment::new(sources, pishoo, registry_contract), )) } ConfigDocumentRoleKind::WorkerPishoo => { let pishoo = self.required_built_child(&sources, &root, "pishoo", role)?; Ok(ParsedConfigDocument::WorkerPishoo( - ParsedPishooFragment::new(sources, pishoo), + ParsedPishooFragment::new(sources, pishoo, registry_contract), )) } ConfigDocumentRoleKind::IdentityServer => { @@ -697,7 +786,13 @@ impl ConfigRegistry { .children_optional("server") .iter() .cloned() - .map(|server| ParsedServerFragment::new(Arc::clone(&sources), server)) + .map(|server| { + ParsedServerFragment::new( + Arc::clone(&sources), + server, + registry_contract.clone(), + ) + }) .collect(); if servers.is_empty() { return Err(RoleDocumentBuildError::Role( diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index f9c59397..839a3da3 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -1142,6 +1142,133 @@ fn snapshot_contract_rejects_extra_worker_inheritable_directive() { )); } +#[test] +fn snapshot_contract_rejects_last_wins_and_wrong_reload_metadata() { + let fixture = TempConfigDir::new("snapshot_registry_metadata"); + + let mut last_wins_registry = crate::parse::default_registry(); + last_wins_registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "gzip", + vec![context::PISHOO], + DuplicatePolicy::LastWins, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(&last_wins_registry); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip off; gzip on; }", + &fixture.join("last-wins/pishoo.conf"), + None, + ); + assert!(matches!( + build_global_tree(&last_wins_registry, root, Vec::new()), + Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { + source: crate::parse::registry::V1SnapshotSchemaError::Duplicate { directive }, + }) if directive.as_str() == "gzip" + )); + + let mut wrong_reload_registry = crate::parse::default_registry(); + wrong_reload_registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "gzip", + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::ListenerSet, + ), + ); + let mut parser = ConfigDocumentParser::new(&wrong_reload_registry); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip on; }", + &fixture.join("wrong-reload/pishoo.conf"), + None, + ); + assert!(matches!( + build_global_tree(&wrong_reload_registry, root, Vec::new()), + Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { + source: crate::parse::registry::V1SnapshotSchemaError::Reload { directive }, + }) if directive.as_str() == "gzip" + )); +} + +#[test] +fn detached_fragments_require_the_exact_parse_registry_contract() { + let fixture = TempConfigDir::new("fragment_registry_contract"); + let mut parse_registry = crate::parse::default_registry(); + parse_registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "gzip", + vec![context::PISHOO], + DuplicatePolicy::LastWins, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ), + ); + let mut parser = ConfigDocumentParser::new(&parse_registry); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip off; gzip on; }", + &fixture.join("registry-a/pishoo.conf"), + None, + ); + let seal_registry = crate::parse::default_registry(); + + assert!(matches!( + build_global_tree(&seal_registry, root, Vec::new()), + Err(crate::parse::tree::HomeConfigTreeError::RegistryContractMismatch) + )); +} + +#[test] +fn detached_fragments_reject_parse_registry_mutation_before_seal() { + let fixture = TempConfigDir::new("fragment_registry_mutation"); + let mut registry = crate::parse::default_registry(); + let root = { + let mut parser = ConfigDocumentParser::new(®istry); + parse_root_fragment( + &mut parser, + "pishoo { gzip on; }", + &fixture.join("root/pishoo.conf"), + None, + ) + }; + registry.register_directive( + context::PISHOO, + DirectiveSpec::leaf_value::( + "worker_local_after_parse", + vec![context::PISHOO], + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), + ); + + assert!(matches!( + build_global_tree(®istry, root, Vec::new()), + Err(crate::parse::tree::HomeConfigTreeError::RegistryContractMismatch) + )); +} + +#[test] +fn registry_shares_precomputed_cascade_policy_tables() { + let registry = crate::parse::default_registry(); + let first = registry.cascade_policies(context::PISHOO); + let second = registry.cascade_policies(context::PISHOO); + + assert!(Arc::ptr_eq(&first, &second)); +} + #[test] fn cascade_lineage_is_builtin_root_worker_server_location() { let fixture = TempConfigDir::new("cascade_lineage"); @@ -1568,6 +1695,27 @@ fn access_rules_rejects_literal_and_percent_encoded_nul_in_text_and_snapshot() { } } +#[test] +fn access_rules_preserves_percent_encoded_path_and_query_in_text_and_snapshot() { + #[cfg(unix)] + let source = "sqlite:/tmp/rules%3Fpart%23name%FF.db?mode=ro%23strict"; + #[cfg(not(unix))] + let source = "sqlite:/tmp/rules%3Fpart%23name.db?mode=ro%23strict"; + let expected = url::Url::parse(source).expect("fixture URL"); + + let document = + crate::parse::parse_config_str_for_test(&format!("pishoo {{ access_rules {source}; }}")) + .expect("text access_rules URL should parse losslessly"); + let text = first_pishoo(&document) + .require::("access_rules") + .expect("text access_rules should be typed"); + let snapshot = snapshot_test_support::decode_access_rules(source) + .expect("snapshot access_rules URL should decode losslessly"); + + assert_eq!(text.0, expected); + assert_eq!(snapshot.0, expected); +} + #[test] fn snapshot_header_values_revalidate_from_bytes() { assert!(snapshot_test_support::decode_header_value(b"text/plain").is_ok()); diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index 84f50136..0ab42447 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -34,7 +34,7 @@ struct SealedConfigNode { node: Arc, parent: ParentLink, children: Vec, - cascade_policies: Box<[(DirectiveName, CascadePolicy)]>, + cascade_policies: Arc<[(DirectiveName, CascadePolicy)]>, } #[derive(Clone, Copy)] @@ -132,6 +132,8 @@ pub enum HomeConfigTreeError { }, #[snafu(display("registry is incompatible with the root snapshot schema"))] SnapshotContract { source: V1SnapshotSchemaError }, + #[snafu(display("detached configuration fragment was parsed with another registry contract"))] + RegistryContractMismatch, } pub fn build_global_tree( @@ -181,6 +183,9 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { fragment: ParsedPishooFragment, identity_fragments: impl Iterator, ) -> Result { + if !registry.matches_contract(fragment.registry_contract()) { + return Err(HomeConfigTreeError::RegistryContractMismatch); + } let snapshot_schema = registry .validate_v1_snapshot_schema() .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; @@ -225,6 +230,12 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { worker_fragment: Option, identity_fragments: impl Iterator, ) -> Result { + if worker_fragment + .as_ref() + .is_some_and(|fragment| !registry.matches_contract(fragment.registry_contract())) + { + return Err(HomeConfigTreeError::RegistryContractMismatch); + } let snapshot_schema = registry .validate_v1_snapshot_schema() .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; @@ -280,6 +291,9 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { &mut self, fragment: ParsedServerFragment, ) -> Result<(), HomeConfigTreeError> { + if !self.registry.matches_contract(fragment.registry_contract()) { + return Err(HomeConfigTreeError::RegistryContractMismatch); + } if let Some(document_id) = self.document_id(fragment.source_map()) { self.attach_server(&fragment, document_id); } else { From 14aa39d0045222779d555f5bc8e917c87e99a0af Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 04:44:01 +0800 Subject: [PATCH 09/18] fix(config): resolve sqlite paths as OS paths --- gateway/src/parse/builtin/access.rs | 113 +++++++++++++++++----------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/gateway/src/parse/builtin/access.rs b/gateway/src/parse/builtin/access.rs index 9444eca2..20db8db3 100644 --- a/gateway/src/parse/builtin/access.rs +++ b/gateway/src/parse/builtin/access.rs @@ -29,8 +29,8 @@ pub enum AccessRulesUriError { span: SourceSpan, source: normalize::NormalizeDirectiveValueError, }, - #[snafu(display("failed to encode the access_rules configuration base path as a URL"))] - BasePathUrl { span: SourceSpan, path: PathBuf }, + #[snafu(display("failed to encode the access_rules sqlite path as a URL"))] + PathUrl { span: SourceSpan, path: PathBuf }, #[snafu(display("invalid access_rules uri domain"))] Domain { span: SourceSpan, @@ -61,33 +61,43 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for AccessR .context(access_rules_uri_error::UriSnafu { span: arg.span })?; let path = AccessRulesUri::decoded_sqlite_path(&uri) .context(access_rules_uri_error::DomainSnafu { span: arg.span })?; - if path.is_absolute() { - return AccessRulesUri::try_from(uri) - .context(access_rules_uri_error::DomainSnafu { span: arg.span }); - } - - let base_path = normalize::normalize_path(Path::new("."), arg.span, input.source_map) + let use_empty_authority = !path.is_absolute() || uri.as_str().starts_with("sqlite://"); + let path = normalize::normalize_path(&path, arg.span, input.source_map) .context(access_rules_uri_error::ResolveRelativePathSnafu { span: arg.span })?; - let base_url = url::Url::from_directory_path(&base_path).map_err(|()| { - AccessRulesUriError::BasePathUrl { - span: arg.span, - path: base_path, - } - })?; - let encoded_path = uri.path().to_owned(); let query = uri.query().map(str::to_owned); - let resolved = base_url - .join(&encoded_path) - .context(access_rules_uri_error::UriSnafu { span: arg.span })?; - let mut uri = url::Url::parse("sqlite:///") - .context(access_rules_uri_error::UriSnafu { span: arg.span })?; - uri.set_path(resolved.path()); - uri.set_query(query.as_deref()); + let uri = encode_sqlite_path(&path, query.as_deref(), use_empty_authority, arg.span)?; AccessRulesUri::try_from(uri) .context(access_rules_uri_error::DomainSnafu { span: arg.span }) } } +fn encode_sqlite_path( + path: &Path, + query: Option<&str>, + use_empty_authority: bool, + span: SourceSpan, +) -> Result { + let path_url = url::Url::from_file_path(path).map_err(|()| AccessRulesUriError::PathUrl { + span, + path: path.to_owned(), + })?; + if path_url.host_str().is_some() { + return Err(AccessRulesUriError::PathUrl { + span, + path: path.to_owned(), + }); + } + let root = if use_empty_authority { + "sqlite:///" + } else { + "sqlite:/" + }; + let mut uri = url::Url::parse(root).context(access_rules_uri_error::UriSnafu { span })?; + uri.set_path(path_url.path()); + uri.set_query(query); + Ok(uri) +} + #[cfg(test)] mod tests { use crate::parse::tests::assert_error_chain_display_single_line; @@ -105,7 +115,7 @@ mod tests { } #[tokio::test] - async fn parse_access_rules_relative_sqlite_is_resolved_against_source_file() { + async fn parse_access_rules_relative_sqlite_preserves_os_path_bytes() { let dir = std::env::temp_dir().join(format!( "gateway-relative-access-rules-{}-{}", std::process::id(), @@ -117,17 +127,33 @@ mod tests { std::fs::create_dir_all(dir.join("db")).expect("create db dir"); std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); + let mut cases = vec![("sqlite:a:b.db?mode=ro%23strict", dir.join("a:b.db"))]; #[cfg(unix)] - let access_rules = "sqlite:./db/access%3Fpart%23name%FF.db?mode=ro%23strict"; + { + use std::os::unix::ffi::OsStringExt; + + cases.extend([ + (r"sqlite:a\b.db?mode=ro%23strict", dir.join(r"a\b.db")), + ("sqlite:a%5Cb.db?mode=ro%23strict", dir.join(r"a\b.db")), + ( + "sqlite:a%3Fb%23c%FF.db?mode=ro%23strict", + dir.join(std::ffi::OsString::from_vec(b"a?b#c\xff.db".to_vec())), + ), + ]); + } #[cfg(not(unix))] - let access_rules = "sqlite:./db/access%3Fpart%23name.db?mode=ro%23strict"; - std::fs::write( - dir.join("server.conf"), - format!( - "server {{\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n access_rules {access_rules};\n}}\n" - ), - ) - .expect("write config"); + cases.push(("sqlite:a%3Fb%23c.db?mode=ro%23strict", dir.join("a?b#c.db"))); + let config = cases + .iter() + .enumerate() + .map(|(index, (access_rules, _))| { + format!( + "server {{\n listen all {};\n server_name example-{index}.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n access_rules {access_rules};\n}}\n", + 5378 + index, + ) + }) + .collect::(); + std::fs::write(dir.join("server.conf"), config).expect("write config"); let registry = crate::parse::default_registry(); let parsed = crate::parse::load_config_file( @@ -138,19 +164,20 @@ mod tests { .await .expect("config should load"); - let server = parsed.root.children("server").expect("server children")[0].clone(); - #[cfg(unix)] - let expected_suffix = "db/access%3Fpart%23name%FF.db?mode=ro%23strict"; - #[cfg(not(unix))] - let expected_suffix = "db/access%3Fpart%23name.db?mode=ro%23strict"; - assert_eq!( - server + let servers = parsed.root.children("server").expect("server children"); + assert_eq!(servers.len(), cases.len()); + for (server, (_, expected_path)) in servers.iter().zip(cases) { + let uri = &server .require::("access_rules") .expect("access_rules should be typed") - .0 - .as_str(), - format!("sqlite://{}/{expected_suffix}", dir.display()) - ); + .0; + assert_eq!( + crate::parse::types::AccessRulesUri::decoded_sqlite_path(uri) + .expect("access_rules path should decode"), + expected_path, + ); + assert_eq!(uri.query(), Some("mode=ro%23strict")); + } } #[test] From 8f4b464de2448797e5c246e4f2eb9100e75f85d6 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 08:47:56 +0800 Subject: [PATCH 10/18] refactor(config): add typed sealed tree queries --- gateway/src/parse.rs | 101 ++++ gateway/src/parse/builtin/core.rs | 48 +- gateway/src/parse/builtin/location.rs | 263 ++++++---- gateway/src/parse/builtin/pishoo.rs | 79 +-- gateway/src/parse/builtin/server.rs | 294 ++++++----- gateway/src/parse/cascade.rs | 70 +-- gateway/src/parse/error.rs | 1 + gateway/src/parse/normalize.rs | 2 +- gateway/src/parse/registry.rs | 722 +++++++++++++++++++++++--- gateway/src/parse/tests.rs | 251 ++++++++- gateway/src/parse/tree.rs | 77 ++- gateway/src/parse/types.rs | 35 -- gateway/src/reverse/file.rs | 11 +- gateway/src/reverse/router.rs | 16 +- gateway/src/reverse/upstream_tls.rs | 10 +- pishoo/src/config.rs | 6 +- pishoo/src/config/tests.rs | 50 +- pishoo/src/service/source.rs | 16 +- 18 files changed, 1612 insertions(+), 440 deletions(-) diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index 2cbaff8c..ffca5aa2 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -22,6 +22,107 @@ pub mod tree; pub mod types; pub mod value; +pub mod keys { + pub mod pishoo { + use crate::parse::{ + domain::ResolvedConfigPath, registry::LocalDirectiveKey, types::StringList, + }; + + pub const PID: LocalDirectiveKey = + crate::parse::builtin::pishoo::PID_KEY; + pub const WORKERS: LocalDirectiveKey = + crate::parse::builtin::pishoo::WORKERS_KEY; + pub const GROUPS: LocalDirectiveKey = crate::parse::builtin::pishoo::GROUPS_KEY; + } + + pub mod server { + use crate::parse::{ + domain::ResolvedConfigPath, + registry::{LocalDirectiveKey, RepeatedDirectiveKey}, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, + ListenConfig, MimeTypes, ResolverConfig, ServerNames, StringList, + }, + }; + + pub const LISTEN: RepeatedDirectiveKey = + crate::parse::builtin::server::LISTEN_KEY; + pub const SERVER_NAME: LocalDirectiveKey = + crate::parse::builtin::server::SERVER_NAME_KEY; + pub const DNS: LocalDirectiveKey = crate::parse::builtin::server::DNS_KEY; + pub const GZIP: LocalDirectiveKey = crate::parse::builtin::server::GZIP_KEY; + pub const GZIP_VARY: LocalDirectiveKey = + crate::parse::builtin::server::GZIP_VARY_KEY; + pub const GZIP_MIN_LENGTH: LocalDirectiveKey = + crate::parse::builtin::server::GZIP_MIN_LENGTH_KEY; + pub const GZIP_COMP_LEVEL: LocalDirectiveKey = + crate::parse::builtin::server::GZIP_COMP_LEVEL_KEY; + pub const GZIP_TYPES: LocalDirectiveKey = + crate::parse::builtin::server::GZIP_TYPES_KEY; + pub const SSL_CERTIFICATE: LocalDirectiveKey = + crate::parse::builtin::server::SSL_CERTIFICATE_KEY; + pub const SSL_CERTIFICATE_KEY: LocalDirectiveKey = + crate::parse::builtin::server::SSL_CERTIFICATE_KEY_KEY; + pub const DEFAULT_TYPE: LocalDirectiveKey = + crate::parse::builtin::server::DEFAULT_TYPE_KEY; + pub const ACCESS_RULES: LocalDirectiveKey = + crate::parse::builtin::server::ACCESS_RULES_KEY; + pub const RELAY: LocalDirectiveKey = crate::parse::builtin::server::RELAY_KEY; + pub const STUN: LocalDirectiveKey = crate::parse::builtin::server::STUN_KEY; + pub const TYPES: LocalDirectiveKey = crate::parse::builtin::server::TYPES_KEY; + } + + pub mod location { + use crate::parse::{ + domain::ResolvedConfigPath, + pattern::Pattern, + registry::{ContextPayloadKey, LocalDirectiveKey, RepeatedDirectiveKey}, + types::{ + BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRules, MimeTypes, + ProxyPass, SshLoginMethods, SshSslUsers, StringList, + }, + }; + + pub const PATTERN: ContextPayloadKey = + crate::parse::builtin::location::PATTERN_KEY; + pub const ROOT: LocalDirectiveKey = + crate::parse::builtin::location::ROOT_KEY; + pub const ALIAS: LocalDirectiveKey = + crate::parse::builtin::location::ALIAS_KEY; + pub const GZIP: LocalDirectiveKey = crate::parse::builtin::location::GZIP_KEY; + pub const GZIP_VARY: LocalDirectiveKey = + crate::parse::builtin::location::GZIP_VARY_KEY; + pub const GZIP_MIN_LENGTH: LocalDirectiveKey = + crate::parse::builtin::location::GZIP_MIN_LENGTH_KEY; + pub const GZIP_COMP_LEVEL: LocalDirectiveKey = + crate::parse::builtin::location::GZIP_COMP_LEVEL_KEY; + pub const GZIP_TYPES: LocalDirectiveKey = + crate::parse::builtin::location::GZIP_TYPES_KEY; + pub const INDEX: LocalDirectiveKey = crate::parse::builtin::location::INDEX_KEY; + pub const ADD_HEADER: RepeatedDirectiveKey = + crate::parse::builtin::location::ADD_HEADER_KEY; + pub const PROXY_SET_HEADER: RepeatedDirectiveKey = + crate::parse::builtin::location::PROXY_SET_HEADER_KEY; + pub const PROXY_PASS: LocalDirectiveKey = + crate::parse::builtin::location::PROXY_PASS_KEY; + pub const PROXY_SSL_CERTIFICATE: LocalDirectiveKey = + crate::parse::builtin::location::PROXY_SSL_CERTIFICATE_KEY; + pub const PROXY_SSL_CERTIFICATE_KEY: LocalDirectiveKey = + crate::parse::builtin::location::PROXY_SSL_CERTIFICATE_KEY_KEY; + pub const PROXY_SSL_TRUSTED_CERTIFICATE: LocalDirectiveKey = + crate::parse::builtin::location::PROXY_SSL_TRUSTED_CERTIFICATE_KEY; + pub const SSH_LOGIN: LocalDirectiveKey = + crate::parse::builtin::location::SSH_LOGIN_KEY; + pub const SSH_SSL_USER: RepeatedDirectiveKey = + crate::parse::builtin::location::SSH_SSL_USER_KEY; + pub const SSH_DENY: LocalDirectiveKey = + crate::parse::builtin::location::SSH_DENY_KEY; + pub const DEFAULT_TYPE: LocalDirectiveKey = + crate::parse::builtin::location::DEFAULT_TYPE_KEY; + pub const TYPES: LocalDirectiveKey = crate::parse::builtin::location::TYPES_KEY; + } +} + pub struct ConfigDocumentParser<'registry> { registry: &'registry registry::ConfigRegistry, document_ids: domain::ConfigDocumentIdAllocator, diff --git a/gateway/src/parse/builtin/core.rs b/gateway/src/parse/builtin/core.rs index 10c994e9..d73898b6 100644 --- a/gateway/src/parse/builtin/core.rs +++ b/gateway/src/parse/builtin/core.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; -use snafu::{Snafu, ensure}; +use snafu::{ResultExt, Snafu, ensure}; use crate::parse::{ ast::{AstBody, AstDirective, Spanned}, + domain::ResolvedConfigPath, + normalize::NormalizeDirectiveValueError, registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{BoolConfig, GzipTypesValidationError, PathConfig, StringConfig, StringList}, @@ -181,6 +183,50 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for PathCon } } +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ResolvedConfigPathDirectiveError { + #[snafu(display("invalid resolved path directive argument count"))] + InvalidArgumentCount { + span: SourceSpan, + expected: &'static str, + actual: usize, + }, + #[snafu(display("failed to resolve configuration path"))] + Resolve { + span: SourceSpan, + source: NormalizeDirectiveValueError, + }, +} + +impl DirectiveValue for ResolvedConfigPath { + type Error = ResolvedConfigPathDirectiveError; + + fn span(input: &DirectiveInput<'_>) -> SourceSpan { + first_arg_span(input) + } +} + +impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for ResolvedConfigPath { + type Error = ResolvedConfigPathDirectiveError; + + fn try_from(input: &'input DirectiveInput<'directive>) -> Result { + let Some(arg) = only_arg(input.directive) else { + return Err(ResolvedConfigPathDirectiveError::InvalidArgumentCount { + span: input.directive.span, + expected: "1", + actual: input.directive.args.len(), + }); + }; + crate::parse::normalize::resolve_config_path( + std::path::Path::new(&arg.value), + arg.span, + input.source_map, + ) + .context(resolved_config_path_directive_error::ResolveSnafu { span: arg.span }) + } +} + #[cfg(test)] mod tests { use crate::parse::tests::*; diff --git a/gateway/src/parse/builtin/location.rs b/gateway/src/parse/builtin/location.rs index 684e4d8b..93efb435 100644 --- a/gateway/src/parse/builtin/location.rs +++ b/gateway/src/parse/builtin/location.rs @@ -3,19 +3,151 @@ use snafu::{OptionExt, Snafu, ensure}; use crate::parse::{ document::ConfigNode, + domain::ResolvedConfigPath, pattern::{ParsePatternError, Pattern}, registry::{ - BuildOptions, CascadePolicy, ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, - DuplicatePolicy, ReloadImpact, TransportPolicy, context, + BuildOptions, CascadePolicy, ConfigRegistry, ContextPayloadKey, DirectiveInput, + DirectiveValue, DuplicatePolicy, LocalDirectiveKey, PayloadCardinality, ReloadImpact, + RepeatedCardinality, RepeatedDirectiveKey, SingleCardinality, TransportPolicy, + TypedDirectiveDefinition, context, }, source::SourceSpan, types::{ BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, - PathConfig, ProxyPass, SshLoginMethods, SshSslUsers, StringList, + ProxyPass, SshLoginMethods, SshSslUsers, StringList, }, value::TypedValue, }; +macro_rules! single_definition { + ($definition:ident, $key:ident, $value:ty, $name:literal) => { + const $definition: TypedDirectiveDefinition<$value, SingleCardinality> = + TypedDirectiveDefinition::single_leaf( + context::LOCATION, + $name, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); + pub(crate) const $key: LocalDirectiveKey<$value> = $definition.key(); + }; +} + +macro_rules! repeated_definition { + ($definition:ident, $key:ident, $value:ty, $name:literal) => { + const $definition: TypedDirectiveDefinition<$value, RepeatedCardinality> = + TypedDirectiveDefinition::repeated_leaf( + context::LOCATION, + $name, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); + pub(crate) const $key: RepeatedDirectiveKey<$value> = $definition.key(); + }; +} + +const PATTERN_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::payload( + context::SERVER, + context::LOCATION, + "location", + DuplicatePolicy::Append, + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); +pub(crate) const PATTERN_KEY: ContextPayloadKey = PATTERN_DEFINITION.key(); +single_definition!(ROOT_DEFINITION, ROOT_KEY, ResolvedConfigPath, "root"); +single_definition!(ALIAS_DEFINITION, ALIAS_KEY, ResolvedConfigPath, "alias"); +single_definition!(GZIP_DEFINITION, GZIP_KEY, BoolConfig, "gzip"); +single_definition!(GZIP_VARY_DEFINITION, GZIP_VARY_KEY, BoolConfig, "gzip_vary"); +single_definition!( + GZIP_MIN_LENGTH_DEFINITION, + GZIP_MIN_LENGTH_KEY, + GzipMinLength, + "gzip_min_length" +); +single_definition!( + GZIP_COMP_LEVEL_DEFINITION, + GZIP_COMP_LEVEL_KEY, + GzipCompLevel, + "gzip_comp_level" +); +single_definition!( + GZIP_TYPES_DEFINITION, + GZIP_TYPES_KEY, + StringList, + "gzip_types" +); +single_definition!(INDEX_DEFINITION, INDEX_KEY, StringList, "index"); +repeated_definition!( + ADD_HEADER_DEFINITION, + ADD_HEADER_KEY, + HeaderRules, + "add_header" +); +repeated_definition!( + PROXY_SET_HEADER_DEFINITION, + PROXY_SET_HEADER_KEY, + HeaderRules, + "proxy_set_header" +); +single_definition!( + PROXY_PASS_DEFINITION, + PROXY_PASS_KEY, + ProxyPass, + "proxy_pass" +); +single_definition!( + PROXY_SSL_CERTIFICATE_DEFINITION, + PROXY_SSL_CERTIFICATE_KEY, + ResolvedConfigPath, + "proxy_ssl_certificate" +); +single_definition!( + PROXY_SSL_CERTIFICATE_KEY_DEFINITION, + PROXY_SSL_CERTIFICATE_KEY_KEY, + ResolvedConfigPath, + "proxy_ssl_certificate_key" +); +single_definition!( + PROXY_SSL_TRUSTED_CERTIFICATE_DEFINITION, + PROXY_SSL_TRUSTED_CERTIFICATE_KEY, + ResolvedConfigPath, + "proxy_ssl_trusted_certificate" +); +single_definition!( + SSH_LOGIN_DEFINITION, + SSH_LOGIN_KEY, + SshLoginMethods, + "ssh_login" +); +repeated_definition!( + SSH_SSL_USER_DEFINITION, + SSH_SSL_USER_KEY, + SshSslUsers, + "ssh_ssl_user" +); +single_definition!(SSH_DENY_DEFINITION, SSH_DENY_KEY, StringList, "ssh_deny"); +single_definition!( + DEFAULT_TYPE_DEFINITION, + DEFAULT_TYPE_KEY, + DefaultType, + "default_type" +); +const TYPES_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::raw( + context::LOCATION, + "types", + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); +pub(crate) const TYPES_KEY: LocalDirectiveKey = TYPES_DEFINITION.key(); + #[derive(Debug, Snafu)] #[snafu(module)] pub enum FinalizeLocationError { @@ -46,76 +178,26 @@ pub fn register(registry: &mut ConfigRegistry) { key: context::LOCATION, finalize: Some(finalize_location), }); - registry.register_directive( - context::SERVER, - DirectiveSpec::context_payload::( - "location", - vec![context::SERVER], - context::LOCATION, - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); - register_leaf::(registry, "root", DuplicatePolicy::Reject); - register_leaf::(registry, "alias", DuplicatePolicy::Reject); - register_leaf::(registry, "gzip", DuplicatePolicy::Reject); - register_leaf::(registry, "gzip_vary", DuplicatePolicy::Reject); - register_leaf::(registry, "gzip_min_length", DuplicatePolicy::Reject); - register_leaf::(registry, "gzip_comp_level", DuplicatePolicy::Reject); - register_leaf::(registry, "gzip_types", DuplicatePolicy::Reject); - register_leaf::(registry, "index", DuplicatePolicy::Reject); - register_leaf::(registry, "add_header", DuplicatePolicy::Append); - register_leaf::(registry, "proxy_set_header", DuplicatePolicy::Append); - register_leaf::(registry, "proxy_pass", DuplicatePolicy::Reject); - register_leaf::(registry, "proxy_ssl_certificate", DuplicatePolicy::Reject); - register_leaf::( - registry, - "proxy_ssl_certificate_key", - DuplicatePolicy::Reject, - ); - register_leaf::( - registry, - "proxy_ssl_trusted_certificate", - DuplicatePolicy::Reject, - ); - register_leaf::(registry, "ssh_login", DuplicatePolicy::Reject); - register_leaf::(registry, "ssh_ssl_user", DuplicatePolicy::Append); - register_leaf::(registry, "ssh_deny", DuplicatePolicy::Reject); - register_leaf::(registry, "default_type", DuplicatePolicy::Reject); - registry.register_directive( - context::LOCATION, - DirectiveSpec::raw_value::( - "types", - vec![context::LOCATION], - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); -} - -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str, duplicate: DuplicatePolicy) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::LOCATION, - DirectiveSpec::leaf_value::( - name, - vec![context::LOCATION], - duplicate, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); + PATTERN_DEFINITION.register(registry); + ROOT_DEFINITION.register(registry); + ALIAS_DEFINITION.register(registry); + GZIP_DEFINITION.register(registry); + GZIP_VARY_DEFINITION.register(registry); + GZIP_MIN_LENGTH_DEFINITION.register(registry); + GZIP_COMP_LEVEL_DEFINITION.register(registry); + GZIP_TYPES_DEFINITION.register(registry); + INDEX_DEFINITION.register(registry); + ADD_HEADER_DEFINITION.register(registry); + PROXY_SET_HEADER_DEFINITION.register(registry); + PROXY_PASS_DEFINITION.register(registry); + PROXY_SSL_CERTIFICATE_DEFINITION.register(registry); + PROXY_SSL_CERTIFICATE_KEY_DEFINITION.register(registry); + PROXY_SSL_TRUSTED_CERTIFICATE_DEFINITION.register(registry); + SSH_LOGIN_DEFINITION.register(registry); + SSH_SSL_USER_DEFINITION.register(registry); + SSH_DENY_DEFINITION.register(registry); + DEFAULT_TYPE_DEFINITION.register(registry); + TYPES_DEFINITION.register(registry); } fn finalize_location( @@ -126,9 +208,11 @@ fn finalize_location( .payload::()? .context(finalize_location_error::MissingPatternSnafu { span: node.span })?; let proxy_pass = node.get::("proxy_pass")?; - let has_cert = node.get::("proxy_ssl_certificate")?.is_some(); + let has_cert = node + .get::("proxy_ssl_certificate")? + .is_some(); let has_key = node - .get::("proxy_ssl_certificate_key")? + .get::("proxy_ssl_certificate_key")? .is_some(); ensure!( !matches!(pattern.as_ref(), Pattern::Regex(_) | Pattern::CRegex(_)) @@ -155,12 +239,13 @@ mod tests { use std::path::PathBuf; use crate::parse::{ + domain::ResolvedConfigPath, pattern::Pattern, tests::{ build_proxy_conf, build_server_conf, cleanup_temp_files, create_temp_file, first_server, parse_doc, }, - types::{BoolConfig, DefaultType, HeaderRules, PathConfig, ProxyPass, StringList}, + types::{BoolConfig, DefaultType, HeaderRules, ProxyPass, StringList}, }; #[test] @@ -174,9 +259,10 @@ mod tests { assert_eq!( location - .require::("root") + .require::("root") .expect("root should be typed") - .0, + .as_ref() + .as_ref(), PathBuf::from("/var/www/site") ); @@ -228,9 +314,10 @@ mod tests { assert_eq!( location - .require::("alias") + .require::("alias") .expect("alias should be typed") - .0, + .as_ref() + .as_ref(), PathBuf::from("/mnt/assets") ); assert_eq!( @@ -343,7 +430,7 @@ mod tests { ); assert!( location - .require::("proxy_ssl_trusted_certificate") + .require::("proxy_ssl_trusted_certificate") .is_ok() ); @@ -402,12 +489,12 @@ mod tests { assert!( location - .require::("proxy_ssl_certificate") + .require::("proxy_ssl_certificate") .is_ok() ); assert!( location - .require::("proxy_ssl_certificate_key") + .require::("proxy_ssl_certificate_key") .is_ok() ); @@ -555,9 +642,10 @@ mod tests { let location = server.children("location").expect("location children")[0].clone(); assert_eq!( location - .require::("root") + .require::("root") .expect("root should be typed") - .0, + .as_ref() + .as_ref(), dir ); } @@ -596,9 +684,10 @@ mod tests { let location = server.children("location").expect("location children")[0].clone(); assert_eq!( location - .require::("root") + .require::("root") .expect("root should be typed") - .0, + .as_ref() + .as_ref(), dir.join("sites") ); } diff --git a/gateway/src/parse/builtin/pishoo.rs b/gateway/src/parse/builtin/pishoo.rs index 0bd1651f..a5fafc0e 100644 --- a/gateway/src/parse/builtin/pishoo.rs +++ b/gateway/src/parse/builtin/pishoo.rs @@ -1,11 +1,44 @@ use crate::parse::{ + domain::ResolvedConfigPath, registry::{ - CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, ReloadImpact, - TransportPolicy, context, + CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, LocalDirectiveKey, + ReloadImpact, SingleCardinality, TransportPolicy, TypedDirectiveDefinition, context, }, - types::{PathConfig, StringList}, + types::StringList, }; +const PID_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::single_leaf( + context::PISHOO, + "pid", + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::Supervisor, + ); +const WORKERS_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::single_leaf( + context::PISHOO, + "workers", + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::Supervisor, + ); +const GROUPS_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::single_leaf( + context::PISHOO, + "groups", + DuplicatePolicy::Reject, + CascadePolicy::None, + TransportPolicy::HypervisorOnly, + ReloadImpact::Supervisor, + ); + +pub(crate) const PID_KEY: LocalDirectiveKey = PID_DEFINITION.key(); +pub(crate) const WORKERS_KEY: LocalDirectiveKey = WORKERS_DEFINITION.key(); +pub(crate) const GROUPS_KEY: LocalDirectiveKey = GROUPS_DEFINITION.key(); + pub fn register(registry: &mut ConfigRegistry) { registry.register_context(crate::parse::registry::ContextSpec { key: context::PISHOO, @@ -23,40 +56,20 @@ pub fn register(registry: &mut ConfigRegistry) { ReloadImpact::Supervisor, ), ); - register_hypervisor_leaf::(registry, "pid"); - register_hypervisor_leaf::(registry, "workers"); - register_hypervisor_leaf::(registry, "groups"); + PID_DEFINITION.register(registry); + WORKERS_DEFINITION.register(registry); + GROUPS_DEFINITION.register(registry); registry.register_v1_snapshot_directives(); } -fn register_hypervisor_leaf(registry: &mut ConfigRegistry, name: &'static str) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - name, - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::Supervisor, - ), - ); -} - #[cfg(test)] mod tests { use std::path::PathBuf; use crate::parse::{ + domain::ResolvedConfigPath, tests::{first_pishoo, parse_doc}, - types::{AccessRulesUri, MimeTypes, PathConfig, StringList}, + types::{AccessRulesUri, MimeTypes, StringList}, }; #[test] @@ -117,9 +130,10 @@ mod tests { assert_eq!( pishoo - .require::("pid") + .require::("pid") .expect("pid should be typed") - .0, + .as_ref() + .as_ref(), PathBuf::from("/tmp/pishoo-test.pid") ); assert_eq!( @@ -158,9 +172,10 @@ mod tests { let pishoo = crate::parse::tests::first_pishoo(&parsed); assert_eq!( pishoo - .require::("pid") + .require::("pid") .expect("pid should be typed") - .0, + .as_ref() + .as_ref(), dir.join("run/pishoo.pid") ); } diff --git a/gateway/src/parse/builtin/server.rs b/gateway/src/parse/builtin/server.rs index d9536b56..75a4898c 100644 --- a/gateway/src/parse/builtin/server.rs +++ b/gateway/src/parse/builtin/server.rs @@ -2,19 +2,147 @@ use snafu::{OptionExt, Snafu, ensure}; use crate::parse::{ document::ConfigNode, + domain::ResolvedConfigPath, registry::{ BuildOptions, CascadePolicy, ConfigRegistry, ContextKey, DirectiveSpec, DuplicatePolicy, - ReloadImpact, TransportPolicy, context, + LocalDirectiveKey, ReloadImpact, RepeatedCardinality, RepeatedDirectiveKey, + SingleCardinality, TransportPolicy, TypedDirectiveDefinition, context, }, source::SourceSpan, tree::AttachedConfigNode, types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, ListenConfig, - MimeTypes, PathConfig, ResolverConfig, ServerName, ServerNames, StringList, + MimeTypes, ResolverConfig, ServerName, ServerNames, StringList, }, value::TypedValue, }; +macro_rules! single_definition { + ($definition:ident, $key:ident, $value:ty, $name:literal, $reload:expr) => { + const $definition: TypedDirectiveDefinition<$value, SingleCardinality> = + TypedDirectiveDefinition::single_leaf( + context::SERVER, + $name, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + $reload, + ); + pub(crate) const $key: LocalDirectiveKey<$value> = $definition.key(); + }; +} + +const LISTEN_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::repeated_leaf( + context::SERVER, + "listen", + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, + ); +pub(crate) const LISTEN_KEY: RepeatedDirectiveKey = LISTEN_DEFINITION.key(); +single_definition!( + SERVER_NAME_DEFINITION, + SERVER_NAME_KEY, + ServerNames, + "server_name", + ReloadImpact::ListenerSet +); +single_definition!( + DNS_DEFINITION, + DNS_KEY, + ResolverConfig, + "dns", + ReloadImpact::ListenerSet +); +single_definition!( + GZIP_DEFINITION, + GZIP_KEY, + BoolConfig, + "gzip", + ReloadImpact::RuntimeState +); +single_definition!( + GZIP_VARY_DEFINITION, + GZIP_VARY_KEY, + BoolConfig, + "gzip_vary", + ReloadImpact::RuntimeState +); +single_definition!( + GZIP_MIN_LENGTH_DEFINITION, + GZIP_MIN_LENGTH_KEY, + GzipMinLength, + "gzip_min_length", + ReloadImpact::RuntimeState +); +single_definition!( + GZIP_COMP_LEVEL_DEFINITION, + GZIP_COMP_LEVEL_KEY, + GzipCompLevel, + "gzip_comp_level", + ReloadImpact::RuntimeState +); +single_definition!( + GZIP_TYPES_DEFINITION, + GZIP_TYPES_KEY, + StringList, + "gzip_types", + ReloadImpact::RuntimeState +); +single_definition!( + SSL_CERTIFICATE_DEFINITION, + SSL_CERTIFICATE_KEY, + ResolvedConfigPath, + "ssl_certificate", + ReloadImpact::ListenerSet +); +single_definition!( + SSL_CERTIFICATE_KEY_DEFINITION, + SSL_CERTIFICATE_KEY_KEY, + ResolvedConfigPath, + "ssl_certificate_key", + ReloadImpact::ListenerSet +); +single_definition!( + DEFAULT_TYPE_DEFINITION, + DEFAULT_TYPE_KEY, + DefaultType, + "default_type", + ReloadImpact::RuntimeState +); +single_definition!( + ACCESS_RULES_DEFINITION, + ACCESS_RULES_KEY, + AccessRulesUri, + "access_rules", + ReloadImpact::RuntimeState +); +single_definition!( + RELAY_DEFINITION, + RELAY_KEY, + BoolConfig, + "relay", + ReloadImpact::RuntimeState +); +single_definition!( + STUN_DEFINITION, + STUN_KEY, + BoolConfig, + "stun", + ReloadImpact::RuntimeState +); +const TYPES_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::raw( + context::SERVER, + "types", + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); +pub(crate) const TYPES_KEY: LocalDirectiveKey = TYPES_DEFINITION.key(); + #[derive(Debug, Snafu)] #[snafu(module)] pub enum FinalizeServerError { @@ -38,101 +166,21 @@ pub fn register(registry: &mut ConfigRegistry) { registry.register_attached_finalizer(context::SERVER, finalize_attached_server); registry.register_directive(context::ROOT, server_block(context::ROOT)); registry.register_directive(context::PISHOO, server_block(context::PISHOO)); - register_server_leaf::( - registry, - "listen", - DuplicatePolicy::Append, - ReloadImpact::ListenerSet, - ); - register_server_leaf::( - registry, - "server_name", - DuplicatePolicy::Reject, - ReloadImpact::ListenerSet, - ); - register_server_leaf::( - registry, - "dns", - DuplicatePolicy::Reject, - ReloadImpact::ListenerSet, - ); - register_server_leaf::( - registry, - "gzip", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "gzip_vary", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "gzip_min_length", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "gzip_comp_level", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "gzip_types", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "ssl_certificate", - DuplicatePolicy::Reject, - ReloadImpact::ListenerSet, - ); - register_server_leaf::( - registry, - "ssl_certificate_key", - DuplicatePolicy::Reject, - ReloadImpact::ListenerSet, - ); - register_server_leaf::( - registry, - "default_type", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "access_rules", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "relay", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - register_server_leaf::( - registry, - "stun", - DuplicatePolicy::Reject, - ReloadImpact::RuntimeState, - ); - registry.register_directive( - context::SERVER, - DirectiveSpec::raw_value::( - "types", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); + LISTEN_DEFINITION.register(registry); + SERVER_NAME_DEFINITION.register(registry); + DNS_DEFINITION.register(registry); + GZIP_DEFINITION.register(registry); + GZIP_VARY_DEFINITION.register(registry); + GZIP_MIN_LENGTH_DEFINITION.register(registry); + GZIP_COMP_LEVEL_DEFINITION.register(registry); + GZIP_TYPES_DEFINITION.register(registry); + SSL_CERTIFICATE_DEFINITION.register(registry); + SSL_CERTIFICATE_KEY_DEFINITION.register(registry); + DEFAULT_TYPE_DEFINITION.register(registry); + ACCESS_RULES_DEFINITION.register(registry); + RELAY_DEFINITION.register(registry); + STUN_DEFINITION.register(registry); + TYPES_DEFINITION.register(registry); } fn server_block(parent: ContextKey) -> DirectiveSpec { @@ -147,31 +195,6 @@ fn server_block(parent: ContextKey) -> DirectiveSpec { ) } -fn register_server_leaf( - registry: &mut ConfigRegistry, - name: &'static str, - duplicate: DuplicatePolicy, - reload: ReloadImpact, -) where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::SERVER, - DirectiveSpec::leaf_value::( - name, - vec![context::SERVER], - duplicate, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - reload, - ), - ); -} - fn finalize_server( node: &mut ConfigNode, options: &BuildOptions<'_>, @@ -194,18 +217,20 @@ fn finalize_server( ); } - let has_cert = node.get::("ssl_certificate")?.is_some(); - let has_key = node.get::("ssl_certificate_key")?.is_some(); + let has_cert = node.get::("ssl_certificate")?.is_some(); + let has_key = node + .get::("ssl_certificate_key")? + .is_some(); match (has_cert, has_key, options.has_dhttp_home_context()) { (true, true, _) => Ok(()), (false, false, true) => Ok(()), (false, _, _) => { - node.get::("ssl_certificate")? + node.get::("ssl_certificate")? .context(finalize_server_error::MissingCertificateSnafu { span: node.span })?; Ok(()) } (_, false, _) => { - node.get::("ssl_certificate_key")? + node.get::("ssl_certificate_key")? .context(finalize_server_error::MissingCertificateKeySnafu { span: node.span })?; Ok(()) } @@ -236,13 +261,14 @@ fn finalize_attached_server( #[cfg(test)] mod tests { use crate::parse::{ + domain::ResolvedConfigPath, tests::{ assert_error_chain_display_single_line, build_server_conf, cleanup_temp_files, create_temp_file, first_server, parse_doc, }, types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, ListenConfig, MimeTypes, - PathConfig, ResolverConfig, ServerNames, + ResolverConfig, ServerNames, }, }; @@ -294,16 +320,18 @@ mod tests { assert_eq!( server - .require::("ssl_certificate") + .require::("ssl_certificate") .expect("ssl_certificate should be typed") - .0, + .as_ref() + .as_ref(), cert.clone() ); assert_eq!( server - .require::("ssl_certificate_key") + .require::("ssl_certificate_key") .expect("ssl_certificate_key should be typed") - .0, + .as_ref() + .as_ref(), key.clone() ); @@ -348,13 +376,13 @@ mod tests { assert_eq!(names.0[0].name, name); assert!( server - .get::("ssl_certificate") + .get::("ssl_certificate") .unwrap() .is_none() ); assert!( server - .get::("ssl_certificate_key") + .get::("ssl_certificate_key") .unwrap() .is_none() ); diff --git a/gateway/src/parse/cascade.rs b/gateway/src/parse/cascade.rs index afed8d20..68e3cd18 100644 --- a/gateway/src/parse/cascade.rs +++ b/gateway/src/parse/cascade.rs @@ -3,10 +3,6 @@ use std::{marker::PhantomData, num::NonZeroU32, path::Path, sync::Arc}; use crate::parse::{ domain::{ConfigSourceSpan, DirectiveName}, snapshot::RootConfigSnapshot, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, - StringList, - }, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -74,8 +70,9 @@ impl CascadedValue { } } -type BuiltinValue = fn() -> Option>; -type SnapshotValue = fn(&RootConfigSnapshot, DirectiveName) -> Option<(Arc, ConfigOrigin)>; +pub(crate) type BuiltinValue = fn() -> Option>; +pub(crate) type SnapshotValue = + fn(&RootConfigSnapshot, DirectiveName) -> Option<(Arc, ConfigOrigin)>; #[derive(Debug)] pub struct DirectiveKey { @@ -120,50 +117,35 @@ impl DirectiveKey { } } -fn absent() -> Option> { +pub(crate) fn absent() -> Option> { + None +} + +pub(crate) fn no_snapshot( + _snapshot: &RootConfigSnapshot, + _name: DirectiveName, +) -> Option<(Arc, ConfigOrigin)> { None } -fn builtin_false() -> Option> { - Some(Arc::new(BoolConfig(false))) +pub(crate) fn builtin_false() -> Option> { + Some(Arc::new(crate::parse::types::BoolConfig(false))) } -fn builtin_min_length() -> Option> { - Some(Arc::new(GzipMinLength::checked(20))) +pub(crate) fn builtin_min_length() -> Option> { + Some(Arc::new(crate::parse::types::GzipMinLength::checked(20))) } -fn builtin_comp_level() -> Option> { - Some(Arc::new(GzipCompLevel::checked(1))) +pub(crate) fn builtin_comp_level() -> Option> { + Some(Arc::new(crate::parse::types::GzipCompLevel::checked(1))) } -pub const ACCESS_RULES: DirectiveKey = DirectiveKey::new( - "access_rules", - absent, - RootConfigSnapshot::cascade_access_rules, -); -pub const GZIP: DirectiveKey = - DirectiveKey::new("gzip", builtin_false, RootConfigSnapshot::cascade_gzip); -pub const GZIP_VARY: DirectiveKey = DirectiveKey::new( - "gzip_vary", - builtin_false, - RootConfigSnapshot::cascade_gzip_vary, -); -pub const GZIP_MIN_LENGTH: DirectiveKey = DirectiveKey::new( - "gzip_min_length", - builtin_min_length, - RootConfigSnapshot::cascade_gzip_min_length, -); -pub const GZIP_COMP_LEVEL: DirectiveKey = DirectiveKey::new( - "gzip_comp_level", - builtin_comp_level, - RootConfigSnapshot::cascade_gzip_comp_level, -); -pub const GZIP_TYPES: DirectiveKey = - DirectiveKey::new("gzip_types", absent, RootConfigSnapshot::cascade_gzip_types); -pub const DEFAULT_TYPE: DirectiveKey = DirectiveKey::new( - "default_type", - absent, - RootConfigSnapshot::cascade_default_type, -); -pub const TYPES: DirectiveKey = - DirectiveKey::new("types", absent, RootConfigSnapshot::cascade_types); +#[cfg(test)] +pub(crate) const GZIP: DirectiveKey = + crate::parse::registry::v1_gzip_key(); +#[cfg(test)] +pub(crate) const GZIP_TYPES: DirectiveKey = + crate::parse::registry::v1_gzip_types_key(); +#[cfg(test)] +pub(crate) const TYPES: DirectiveKey = + crate::parse::registry::v1_types_key(); diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index cc89ccfb..23f9f39e 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -258,6 +258,7 @@ pub enum ConfigQueryError { directive: String, inherited: CascadePolicy, local: CascadePolicy, + mismatch: Option, }, #[snafu(display("directive `{directive}` has no registered cascade policy"))] diff --git a/gateway/src/parse/normalize.rs b/gateway/src/parse/normalize.rs index c36d6606..0a5b213f 100644 --- a/gateway/src/parse/normalize.rs +++ b/gateway/src/parse/normalize.rs @@ -21,7 +21,7 @@ pub enum NormalizeDirectiveValueError { }, } -fn resolve_config_path( +pub(crate) fn resolve_config_path( path: &Path, span: SourceSpan, source_map: &SourceMap, diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 9738a8dc..0bde0e52 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -4,15 +4,13 @@ use snafu::{OptionExt, ResultExt, Snafu}; use crate::parse::{ ast::{AstBody, AstDirective}, - cascade::{ - ACCESS_RULES, DEFAULT_TYPE, DirectiveKey, GZIP, GZIP_COMP_LEVEL, GZIP_MIN_LENGTH, - GZIP_TYPES, GZIP_VARY, TYPES, - }, + cascade::{BuiltinValue, DirectiveKey, SnapshotValue}, document::{ConfigDocument, ConfigNode}, domain::{ConfigDocumentRoleKind, DirectiveName}, error::{BuildDocumentError, ConfigDocumentRoleError, build_document_error}, fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, normalize, + snapshot::RootConfigSnapshot, source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, tree::AttachedConfigNode, types::{ @@ -126,6 +124,282 @@ pub enum ReloadImpact { RuntimeState, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectiveCardinality { + Single, + Repeated, + ContextPayload, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectiveContractMismatch { + Name { + expected: DirectiveName, + actual: Option, + }, + Context { + expected: ContextKey, + actual: ContextKey, + }, + ValueType { + expected: &'static str, + actual: &'static str, + }, + Cardinality { + expected: DirectiveCardinality, + actual: DirectiveCardinality, + }, + Shape { + expected: DirectiveShape, + actual: DirectiveShape, + }, + Payload { + expected: PayloadMode, + actual: PayloadMode, + }, + Duplicate { + expected: DuplicatePolicy, + actual: DuplicatePolicy, + }, + Cascade { + expected: CascadePolicy, + actual: CascadePolicy, + }, + Transport { + expected: TransportPolicy, + actual: TransportPolicy, + }, + Reload { + expected: ReloadImpact, + actual: ReloadImpact, + }, +} + +#[derive(Debug, Clone, Copy)] +struct DirectiveContractTemplate { + context: ContextKey, + name: DirectiveName, + value_type: fn() -> TypeId, + value_type_name: fn() -> &'static str, + cardinality: DirectiveCardinality, + shape: DirectiveShape, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, +} + +impl DirectiveContractTemplate { + fn freeze(self) -> DirectiveContract { + DirectiveContract { + context: self.context, + name: self.name, + value_type: Some((self.value_type)()), + value_type_name: Some((self.value_type_name)()), + cardinality: self.cardinality, + shape: self.shape, + duplicate: self.duplicate, + cascade: self.cascade, + transport: self.transport, + reload: self.reload, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DirectiveContract { + context: ContextKey, + name: DirectiveName, + value_type: Option, + value_type_name: Option<&'static str>, + cardinality: DirectiveCardinality, + shape: DirectiveShape, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, +} + +fn value_type_name() -> &'static str { + std::any::type_name::() +} + +impl DirectiveContract { + pub(crate) const fn name(self) -> DirectiveName { + self.name + } + + pub(crate) const fn cascade(self) -> CascadePolicy { + self.cascade + } + + pub(crate) fn mismatch(self, actual: Self) -> Option { + if self.name != actual.name { + return Some(DirectiveContractMismatch::Name { + expected: self.name, + actual: Some(actual.name), + }); + } + if self.context != actual.context { + return Some(DirectiveContractMismatch::Context { + expected: self.context, + actual: actual.context, + }); + } + if self.cardinality != actual.cardinality { + return Some(DirectiveContractMismatch::Cardinality { + expected: self.cardinality, + actual: actual.cardinality, + }); + } + if let ( + DirectiveShape::ContextBlock { + payload: expected, .. + }, + DirectiveShape::ContextBlock { + payload: actual, .. + }, + ) = (self.shape, actual.shape) + && expected != actual + { + return Some(DirectiveContractMismatch::Payload { expected, actual }); + } + if self.value_type != actual.value_type { + return Some(DirectiveContractMismatch::ValueType { + expected: self.value_type_name.unwrap_or(""), + actual: actual.value_type_name.unwrap_or(""), + }); + } + if self.shape != actual.shape { + return Some(DirectiveContractMismatch::Shape { + expected: self.shape, + actual: actual.shape, + }); + } + if self.duplicate != actual.duplicate { + return Some(DirectiveContractMismatch::Duplicate { + expected: self.duplicate, + actual: actual.duplicate, + }); + } + if self.cascade != actual.cascade { + return Some(DirectiveContractMismatch::Cascade { + expected: self.cascade, + actual: actual.cascade, + }); + } + if self.transport != actual.transport { + return Some(DirectiveContractMismatch::Transport { + expected: self.transport, + actual: actual.transport, + }); + } + (self.reload != actual.reload).then_some(DirectiveContractMismatch::Reload { + expected: self.reload, + actual: actual.reload, + }) + } +} + +#[derive(Debug)] +pub struct LocalDirectiveKey { + name: DirectiveName, + contract: DirectiveContractTemplate, + value: std::marker::PhantomData T>, +} + +impl Clone for LocalDirectiveKey { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for LocalDirectiveKey {} + +impl LocalDirectiveKey { + const fn new(name: DirectiveName, contract: DirectiveContractTemplate) -> Self { + Self { + name, + contract, + value: std::marker::PhantomData, + } + } + + pub const fn name(self) -> DirectiveName { + self.name + } + + pub(crate) fn contract(self) -> DirectiveContract { + self.contract.freeze() + } +} + +#[derive(Debug)] +pub struct RepeatedDirectiveKey { + name: DirectiveName, + contract: DirectiveContractTemplate, + value: std::marker::PhantomData T>, +} + +impl Clone for RepeatedDirectiveKey { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for RepeatedDirectiveKey {} + +impl RepeatedDirectiveKey { + const fn new(name: DirectiveName, contract: DirectiveContractTemplate) -> Self { + Self { + name, + contract, + value: std::marker::PhantomData, + } + } + + pub const fn name(self) -> DirectiveName { + self.name + } + + pub(crate) fn contract(self) -> DirectiveContract { + self.contract.freeze() + } +} + +#[derive(Debug)] +pub struct ContextPayloadKey { + name: DirectiveName, + contract: DirectiveContractTemplate, + value: std::marker::PhantomData T>, +} + +impl Clone for ContextPayloadKey { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for ContextPayloadKey {} + +impl ContextPayloadKey { + const fn new(name: DirectiveName, contract: DirectiveContractTemplate) -> Self { + Self { + name, + contract, + value: std::marker::PhantomData, + } + } + + pub const fn name(self) -> DirectiveName { + self.name + } + + pub(crate) fn contract(self) -> DirectiveContract { + self.contract.freeze() + } +} + type DirectiveParserFn = fn(&DirectiveInput<'_>) -> Result>; pub type ContextFinalizeFn = @@ -266,106 +540,344 @@ impl DirectiveSpec { value_type_name: Some(std::any::type_name::()), } } + + fn contract(&self, registration_context: ContextKey) -> DirectiveContract { + let (context, cardinality) = match self.shape { + DirectiveShape::Leaf | DirectiveShape::RawBlock => ( + registration_context, + if self.duplicate == DuplicatePolicy::Append { + DirectiveCardinality::Repeated + } else { + DirectiveCardinality::Single + }, + ), + DirectiveShape::ContextBlock { child_context, .. } => { + (child_context, DirectiveCardinality::ContextPayload) + } + }; + DirectiveContract { + context, + name: self.name, + value_type: self.value_type, + value_type_name: self.value_type_name, + cardinality, + shape: self.shape, + duplicate: self.duplicate, + cascade: self.cascade, + transport: self.transport, + reload: self.reload, + } + } } #[derive(Debug, Clone, Copy)] -enum V1SnapshotDirectiveShape { - Leaf, - RawBlock, +pub(crate) struct SingleCardinality; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RepeatedCardinality; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PayloadCardinality; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct CascadedCardinality; + +#[derive(Debug, Clone, Copy)] +struct DirectiveMetadata { + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, +} + +impl DirectiveMetadata { + const fn new( + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self { + duplicate, + cascade, + transport, + reload, + } + } +} + +#[derive(Debug)] +struct DirectiveProjection { + builtin: Option>, + snapshot: Option>, +} + +impl Clone for DirectiveProjection { + fn clone(&self) -> Self { + *self + } } -impl V1SnapshotDirectiveShape { - const fn registry_shape(self) -> DirectiveShape { - match self { - Self::Leaf => DirectiveShape::Leaf, - Self::RawBlock => DirectiveShape::RawBlock, +impl Copy for DirectiveProjection {} + +impl DirectiveProjection { + const fn new(builtin: BuiltinValue, snapshot: SnapshotValue) -> Self { + Self { + builtin: Some(builtin), + snapshot: Some(snapshot), + } + } + + const fn absent() -> Self { + Self { + builtin: None, + snapshot: None, } } } #[derive(Debug)] -pub(crate) struct V1SnapshotDirective { - key: DirectiveKey, - shape: V1SnapshotDirectiveShape, +pub(crate) struct TypedDirectiveDefinition { + registration_context: ContextKey, + contract_context: ContextKey, + name: DirectiveName, + shape: DirectiveShape, + cardinality: DirectiveCardinality, duplicate: DuplicatePolicy, cascade: CascadePolicy, transport: TransportPolicy, reload: ReloadImpact, + builtin: Option>, + snapshot: Option>, + value: std::marker::PhantomData Cardinality>, } -impl Clone for V1SnapshotDirective { +impl Clone for TypedDirectiveDefinition { fn clone(&self) -> Self { *self } } -impl Copy for V1SnapshotDirective {} +impl Copy for TypedDirectiveDefinition {} -impl V1SnapshotDirective { +impl TypedDirectiveDefinition { const fn new( - key: DirectiveKey, - shape: V1SnapshotDirectiveShape, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, + registration_context: ContextKey, + contract_context: ContextKey, + name: &'static str, + shape: DirectiveShape, + cardinality: DirectiveCardinality, + metadata: DirectiveMetadata, + projection: DirectiveProjection, ) -> Self { Self { - key, + registration_context, + contract_context, + name: DirectiveName::new(name), shape, - duplicate, - cascade, - transport, - reload, + cardinality, + duplicate: metadata.duplicate, + cascade: metadata.cascade, + transport: metadata.transport, + reload: metadata.reload, + builtin: projection.builtin, + snapshot: projection.snapshot, + value: std::marker::PhantomData, } } - pub(crate) const fn key(self) -> DirectiveKey { - self.key - } - - fn erased(self) -> ErasedV1SnapshotDirective + const fn contract_template(self) -> DirectiveContractTemplate where T: 'static, { - ErasedV1SnapshotDirective { - name: self.key.name(), - shape: self.shape.registry_shape(), + DirectiveContractTemplate { + context: self.contract_context, + name: self.name, + value_type: TypeId::of::, + value_type_name: value_type_name::, + cardinality: self.cardinality, + shape: self.shape, duplicate: self.duplicate, cascade: self.cascade, transport: self.transport, reload: self.reload, - value_type: TypeId::of::, - value_type_name: std::any::type_name::(), } } - fn register(self, registry: &mut ConfigRegistry) + pub(crate) fn register(self, registry: &mut ConfigRegistry) where T: DirectiveValue, for<'input, 'directive> T: TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, { - let name = self.key.name().as_str(); let spec = match self.shape { - V1SnapshotDirectiveShape::Leaf => DirectiveSpec::leaf_value::( - name, - vec![context::PISHOO], + DirectiveShape::Leaf => DirectiveSpec::leaf_value::( + self.name.as_str(), + vec![self.registration_context], self.duplicate, self.cascade, self.transport, self.reload, ), - V1SnapshotDirectiveShape::RawBlock => DirectiveSpec::raw_value::( - name, - vec![context::PISHOO], + DirectiveShape::RawBlock => DirectiveSpec::raw_value::( + self.name.as_str(), + vec![self.registration_context], self.duplicate, self.cascade, self.transport, self.reload, ), + DirectiveShape::ContextBlock { child_context, .. } => { + DirectiveSpec::context_payload::( + self.name.as_str(), + vec![self.registration_context], + child_context, + self.duplicate, + self.cascade, + self.transport, + self.reload, + ) + } }; - registry.register_directive(context::PISHOO, spec); + registry.register_directive(self.registration_context, spec); + } +} + +impl TypedDirectiveDefinition { + pub(crate) const fn single_leaf( + context: ContextKey, + name: &'static str, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::new( + context, + context, + name, + DirectiveShape::Leaf, + DirectiveCardinality::Single, + DirectiveMetadata::new(duplicate, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + + pub(crate) const fn raw( + context: ContextKey, + name: &'static str, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::new( + context, + context, + name, + DirectiveShape::RawBlock, + DirectiveCardinality::Single, + DirectiveMetadata::new(duplicate, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + + pub(crate) const fn key(self) -> LocalDirectiveKey + where + T: 'static, + { + LocalDirectiveKey::new(self.name, self.contract_template()) + } +} + +impl TypedDirectiveDefinition { + pub(crate) const fn repeated_leaf( + context: ContextKey, + name: &'static str, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::new( + context, + context, + name, + DirectiveShape::Leaf, + DirectiveCardinality::Repeated, + DirectiveMetadata::new(DuplicatePolicy::Append, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + + pub(crate) const fn key(self) -> RepeatedDirectiveKey + where + T: 'static, + { + RepeatedDirectiveKey::new(self.name, self.contract_template()) + } +} + +impl TypedDirectiveDefinition { + pub(crate) const fn payload( + registration_context: ContextKey, + child_context: ContextKey, + name: &'static str, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::new( + registration_context, + child_context, + name, + DirectiveShape::ContextBlock { + child_context, + payload: PayloadMode::Parser, + }, + DirectiveCardinality::ContextPayload, + DirectiveMetadata::new(duplicate, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + + pub(crate) const fn key(self) -> ContextPayloadKey + where + T: 'static, + { + ContextPayloadKey::new(self.name, self.contract_template()) + } +} + +impl TypedDirectiveDefinition { + const fn cascaded( + context: ContextKey, + name: &'static str, + shape: DirectiveShape, + metadata: DirectiveMetadata, + projection: DirectiveProjection, + ) -> Self { + Self::new( + context, + context, + name, + shape, + DirectiveCardinality::Single, + metadata, + projection, + ) + } + + pub(crate) const fn key(self) -> DirectiveKey { + let builtin = match self.builtin { + Some(builtin) => builtin, + None => crate::parse::cascade::absent, + }; + let snapshot = match self.snapshot { + Some(snapshot) => snapshot, + None => crate::parse::cascade::no_snapshot, + }; + DirectiveKey::new(self.name.as_str(), builtin, snapshot) } } @@ -381,6 +893,24 @@ struct ErasedV1SnapshotDirective { value_type_name: &'static str, } +impl ErasedV1SnapshotDirective { + fn from_definition(definition: TypedDirectiveDefinition) -> Self + where + T: 'static, + { + Self { + name: definition.name, + shape: definition.shape, + duplicate: definition.duplicate, + cascade: definition.cascade, + transport: definition.transport, + reload: definition.reload, + value_type: TypeId::of::, + value_type_name: std::any::type_name::(), + } + } +} + #[derive(Debug, Clone, Copy)] pub(crate) struct ReservedV1SnapshotField { name: DirectiveName, @@ -402,14 +932,14 @@ macro_rules! define_v1_snapshot_schema { ( $( $field:ident: $value_type:ty = - $key:expr, $shape:ident, $duplicate:expr, $cascade:expr, - $transport:expr, $reload:expr; + $name:literal, $shape:expr, $duplicate:expr, $cascade:expr, + $transport:expr, $reload:expr, $builtin:expr, $snapshot:expr; )+ @reserved $reserved:ident = $reserved_name:literal; ) => { #[derive(Debug)] pub(crate) struct V1SnapshotSchema { - $(pub(crate) $field: V1SnapshotDirective<$value_type>,)+ + $(pub(crate) $field: TypedDirectiveDefinition<$value_type, CascadedCardinality>,)+ pub(crate) $reserved: ReservedV1SnapshotField, } @@ -417,7 +947,7 @@ macro_rules! define_v1_snapshot_schema { fn active_directives( &self, ) -> impl Iterator + '_ { - [$(self.$field.erased(),)+].into_iter() + [$(ErasedV1SnapshotDirective::from_definition(self.$field),)+].into_iter() } fn register_active_directives(&self, registry: &mut ConfigRegistry) { @@ -426,18 +956,17 @@ macro_rules! define_v1_snapshot_schema { #[cfg(test)] pub(crate) const fn field_names(&self) -> [&'static str; 9] { - [$(self.$field.key().name().as_str(),)+ self.$reserved.name().as_str()] + [$(self.$field.name.as_str(),)+ self.$reserved.name().as_str()] } } static V1_SNAPSHOT_SCHEMA: V1SnapshotSchema = V1SnapshotSchema { - $($field: V1SnapshotDirective::new( - $key, - V1SnapshotDirectiveShape::$shape, - $duplicate, - $cascade, - $transport, - $reload, + $($field: TypedDirectiveDefinition::cascaded( + context::PISHOO, + $name, + $shape, + DirectiveMetadata::new($duplicate, $cascade, $transport, $reload), + DirectiveProjection::new($builtin, $snapshot), ),)+ $reserved: ReservedV1SnapshotField::new($reserved_name), }; @@ -445,28 +974,40 @@ macro_rules! define_v1_snapshot_schema { } define_v1_snapshot_schema! { - access_rules: AccessRulesUri = ACCESS_RULES, Leaf, DuplicatePolicy::Reject, + access_rules: AccessRulesUri = "access_rules", DirectiveShape::Leaf, DuplicatePolicy::Reject, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, crate::parse::cascade::absent, + RootConfigSnapshot::cascade_access_rules; + gzip: BoolConfig = "gzip", DirectiveShape::Leaf, DuplicatePolicy::Reject, CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState; - gzip: BoolConfig = GZIP, Leaf, DuplicatePolicy::Reject, CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, ReloadImpact::RuntimeState; - gzip_vary: BoolConfig = GZIP_VARY, Leaf, DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, crate::parse::cascade::builtin_false, + RootConfigSnapshot::cascade_gzip; + gzip_vary: BoolConfig = "gzip_vary", DirectiveShape::Leaf, DuplicatePolicy::Reject, CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState; - gzip_min_length: GzipMinLength = GZIP_MIN_LENGTH, Leaf, DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, crate::parse::cascade::builtin_false, + RootConfigSnapshot::cascade_gzip_vary; + gzip_min_length: GzipMinLength = "gzip_min_length", DirectiveShape::Leaf, + DuplicatePolicy::Reject, CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState; - gzip_comp_level: GzipCompLevel = GZIP_COMP_LEVEL, Leaf, DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, crate::parse::cascade::builtin_min_length, + RootConfigSnapshot::cascade_gzip_min_length; + gzip_comp_level: GzipCompLevel = "gzip_comp_level", DirectiveShape::Leaf, + DuplicatePolicy::Reject, CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState; - gzip_types: StringList = GZIP_TYPES, Leaf, DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, crate::parse::cascade::builtin_comp_level, + RootConfigSnapshot::cascade_gzip_comp_level; + gzip_types: StringList = "gzip_types", DirectiveShape::Leaf, DuplicatePolicy::Reject, CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState; - default_type: DefaultType = DEFAULT_TYPE, Leaf, DuplicatePolicy::Reject, + ReloadImpact::RuntimeState, crate::parse::cascade::absent, + RootConfigSnapshot::cascade_gzip_types; + default_type: DefaultType = "default_type", DirectiveShape::Leaf, DuplicatePolicy::Reject, CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState; - types: MimeTypes = TYPES, RawBlock, DuplicatePolicy::Reject, CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerInheritable, ReloadImpact::RuntimeState; + ReloadImpact::RuntimeState, crate::parse::cascade::absent, + RootConfigSnapshot::cascade_default_type; + types: MimeTypes = "types", DirectiveShape::RawBlock, DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, crate::parse::cascade::absent, + RootConfigSnapshot::cascade_types; @reserved access_log = "access_log"; } @@ -475,6 +1016,21 @@ pub(crate) const fn v1_snapshot_field_names() -> [&'static str; 9] { V1_SNAPSHOT_SCHEMA.field_names() } +#[cfg(test)] +pub(crate) const fn v1_gzip_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.gzip.key() +} + +#[cfg(test)] +pub(crate) const fn v1_gzip_types_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.gzip_types.key() +} + +#[cfg(test)] +pub(crate) const fn v1_types_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.types.key() +} + #[derive(Debug, Clone, Copy)] pub(crate) struct ValidatedV1SnapshotSchema { schema: &'static V1SnapshotSchema, @@ -648,6 +1204,20 @@ impl ConfigRegistry { .unwrap_or_default() } + pub(crate) fn directive_contracts(&self, context: ContextKey) -> Arc<[DirectiveContract]> { + let mut contracts = self + .directives + .iter() + .filter_map(|((registration_context, _), spec)| { + let contract = spec.contract(*registration_context); + (contract.context == context).then_some(contract) + }) + .collect::>(); + contracts + .sort_unstable_by_key(|contract| (contract.name.as_str(), contract.cardinality as u8)); + Arc::from(contracts.into_boxed_slice()) + } + pub(crate) fn register_v1_snapshot_directives(&mut self) { V1_SNAPSHOT_SCHEMA.register_active_directives(self); } diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 839a3da3..408f2d11 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -59,6 +59,17 @@ fn assert_attached_server_context( Ok(()) } +fn inject_wrong_pid_type( + node: &mut ConfigNode, + _options: &BuildOptions<'_>, +) -> Result<(), Box> { + node.insert_slot( + "pid", + crate::parse::value::TypedValue::new(StringList(vec!["wrong".to_owned()]), node.span), + ); + Ok(()) +} + #[derive(Debug)] struct TempConfigDir { path: PathBuf, @@ -175,6 +186,238 @@ pub(crate) fn assert_error_chain_display_single_line(error: &(dyn std::error::Er } } +#[test] +fn sealed_single_query_preserves_type_mismatch() { + let fixture = TempConfigDir::new("sealed_single_type_mismatch"); + let mut registry = crate::parse::default_registry(); + registry.register_context(ContextSpec { + key: context::PISHOO, + finalize: Some(inject_wrong_pid_type), + }); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment(&mut parser, "pishoo {}", &fixture.join("pishoo.conf"), None); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + + let error = tree + .pishoo() + .local(crate::parse::keys::pishoo::PID) + .expect_err("wrong stored value type must remain visible"); + + assert!(matches!( + error, + crate::parse::error::ConfigQueryError::TypeMismatch { .. } + )); +} + +#[test] +fn sealed_repeated_query_preserves_order_and_cardinality() { + let fixture = TempConfigDir::new("sealed_repeated_order"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 4101; listen all 4102; } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let server = tree.servers().next().expect("server should attach"); + + let listen = server + .node() + .repeated(crate::parse::keys::server::LISTEN) + .expect("listen query should succeed"); + + assert_eq!(listen.len(), 2); + assert_eq!(listen[0].0[0].port, 4101); + assert_eq!(listen[1].0[0].port, 4102); +} + +#[test] +fn sealed_location_payload_query_returns_location_pattern() { + let fixture = TempConfigDir::new("sealed_location_payload"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 4101; location = /ready {} } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let location = tree + .servers() + .next() + .expect("server should attach") + .locations() + .next() + .expect("location should attach"); + + let pattern = location + .payload(crate::parse::keys::location::PATTERN) + .expect("location payload query should succeed"); + + assert!(matches!( + pattern.as_ref(), + crate::parse::pattern::Pattern::Exact(path) if path == "/ready" + )); +} + +#[test] +fn sealed_query_key_rejects_wrong_registry_contract() { + let fixture = TempConfigDir::new("sealed_wrong_registry_contract"); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::SERVER, + DirectiveSpec::leaf_value::( + "listen", + vec![context::SERVER], + DuplicatePolicy::Append, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, + ), + ); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 4101; } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let server = tree.servers().next().expect("server should attach"); + + let error = server + .node() + .repeated(crate::parse::keys::server::LISTEN) + .expect_err("a key must reject different frozen registry metadata"); + + assert!(matches!( + error, + crate::parse::error::ConfigQueryError::CascadePolicyMismatch { + mismatch: Some(crate::parse::registry::DirectiveContractMismatch::Cascade { .. }), + .. + } + )); +} + +#[test] +fn sealed_query_keeps_tree_alive_after_registry_replacement() { + let fixture = TempConfigDir::new("sealed_query_registry_lifetime"); + let source_path = fixture.join("home/pishoo.conf"); + let tree = { + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment( + &mut parser, + "pishoo { pid run/pishoo.pid; }", + &source_path, + None, + ); + build_global_tree(®istry, root, Vec::new()).expect("tree should seal") + }; + + let pid = tree + .pishoo() + .local(crate::parse::keys::pishoo::PID) + .expect("query should not borrow the registry") + .expect("pid should exist"); + + assert_eq!( + pid.as_ref().as_ref(), + source_path.parent().unwrap().join("run/pishoo.pid") + ); +} + +#[test] +fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { + let fixture = TempConfigDir::new("sealed_equivalent_registry_contract"); + let source_path = fixture.join("home/pishoo.conf"); + let mut registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment( + &mut parser, + "pishoo { pid run/pishoo.pid; }", + &source_path, + None, + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + + registry = crate::parse::default_registry(); + assert!(registry.directive_spec(context::PISHOO, "pid").is_some()); + let pid = tree + .pishoo() + .local(crate::parse::keys::pishoo::PID) + .expect("equivalent semantic contract should query") + .expect("pid should exist"); + + assert_eq!( + pid.as_ref().as_ref(), + source_path.parent().unwrap().join("run/pishoo.pid") + ); +} + +#[test] +fn home_arena_path_query_returns_resolved_config_path() { + let fixture = TempConfigDir::new("home_arena_path_query"); + let include_dir = fixture.join("includes"); + let include_path = include_dir.join("paths.conf"); + fs::create_dir_all(&include_dir).expect("create include directory"); + fs::write(&include_path, "pid run/pishoo.pid;").expect("write included path"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let root = parse_root_fragment( + &mut parser, + "pishoo { include includes/paths.conf; }", + &fixture.join("pishoo.conf"), + None, + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + + let pid: Arc = tree + .pishoo() + .local(crate::parse::keys::pishoo::PID) + .expect("pid query should succeed") + .expect("pid should exist"); + + assert_eq!(pid.as_ref().as_ref(), include_dir.join("run/pishoo.pid")); +} + +#[test] +fn home_arena_rejects_relative_path_without_source_base() { + let registry = crate::parse::default_registry(); + let failure = crate::parse::load_config_text( + "pishoo { pid run/pishoo.pid; }", + None, + ®istry, + BuildOptions::default(), + ) + .expect_err("unanchored home path must be rejected"); + let report = snafu::Report::from_error(&failure.error).to_string(); + + assert!(report.contains("relative path requires a configuration file base directory")); +} + +#[test] +fn independent_proxy_context_keeps_legacy_path_config() { + let document = + parse_doc("pishoo { proxy { listen 127.0.0.1:8080; ssl_certificate /tmp/proxy.pem; } }"); + let proxy = first_pishoo(&document) + .children("proxy") + .expect("proxy should exist")[0] + .clone(); + + let certificate: Arc = proxy + .require("ssl_certificate") + .expect("independent proxy path keeps its legacy type"); + + assert_eq!(certificate.0, PathBuf::from("/tmp/proxy.pem")); +} + fn parse_role_document( text: &str, source_path: &Path, @@ -528,14 +771,12 @@ fn resolved_config_path_is_absolute_and_source_anchored() { }; let path = fragment .node() - .require::("pid") + .require::("pid") .expect("pid should be typed") - .0 .clone(); - let resolved = ResolvedConfigPath::try_from(path).expect("path should be resolved"); - assert_eq!(resolved.as_ref(), destination); - assert!(resolved.as_ref().is_absolute()); + assert_eq!(path.as_ref().as_ref(), destination); + assert!(path.as_ref().as_ref().is_absolute()); assert!( !destination.exists(), "parser must not create the destination" diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index 0ab42447..96e932f3 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -9,10 +9,11 @@ use crate::parse::{ ConfigDocumentId, ConfigDocumentIdAllocator, ConfigDocumentIdError, ConfigSourceSpan, DirectiveName, }, - error::ConfigQueryError, + error::{ConfigQueryError, config_query_error}, fragment::{ParsedPishooFragment, ParsedServerFragment}, registry::{ - CascadePolicy, ConfigRegistry, V1SnapshotSchemaError, ValidatedV1SnapshotSchema, context, + CascadePolicy, ConfigRegistry, ContextPayloadKey, DirectiveContract, LocalDirectiveKey, + RepeatedDirectiveKey, V1SnapshotSchemaError, ValidatedV1SnapshotSchema, context, }, snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, source::{ConfigDocumentSourceMap, SourceId, SourceMap, SourceSpan}, @@ -35,6 +36,7 @@ struct SealedConfigNode { parent: ParentLink, children: Vec, cascade_policies: Arc<[(DirectiveName, CascadePolicy)]>, + directive_contracts: Arc<[DirectiveContract]>, } #[derive(Clone, Copy)] @@ -347,12 +349,14 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { ) -> ConfigNodeId { let id = ConfigNodeId(self.nodes.len()); let cascade_policies = self.registry.cascade_policies(node.context); + let directive_contracts = self.registry.directive_contracts(node.context); self.nodes.push(SealedConfigNode { document_id, node, parent, children: Vec::new(), cascade_policies, + directive_contracts, }); id } @@ -553,6 +557,7 @@ impl HomeConfigTree { directive: directive.as_str().to_owned(), inherited, local, + mismatch: None, }); } inherited = Some(local); @@ -561,6 +566,39 @@ impl HomeConfigTree { directive: directive.as_str().to_owned(), }) } + + fn check_contract( + &self, + node: ConfigNodeId, + expected: DirectiveContract, + ) -> Result<(), ConfigQueryError> { + let sealed = self.node(node); + let Some(actual) = sealed + .directive_contracts + .iter() + .copied() + .find(|contract| contract.name() == expected.name()) + else { + return Err(ConfigQueryError::CascadePolicyMismatch { + directive: expected.name().as_str().to_owned(), + inherited: expected.cascade(), + local: expected.cascade(), + mismatch: Some(crate::parse::registry::DirectiveContractMismatch::Name { + expected: expected.name(), + actual: None, + }), + }); + }; + if let Some(mismatch) = expected.mismatch(actual) { + return Err(ConfigQueryError::CascadePolicyMismatch { + directive: expected.name().as_str().to_owned(), + inherited: expected.cascade(), + local: actual.cascade(), + mismatch: Some(mismatch), + }); + } + Ok(()) + } } impl SealedConfigNode { @@ -658,6 +696,22 @@ impl ConfigNodeRef { { self.tree.cascaded(self.node, key) } + + pub fn local(&self, key: LocalDirectiveKey) -> Result>, ConfigQueryError> + where + T: ConfigValue, + { + self.tree.check_contract(self.node, key.contract())?; + self.tree.node(self.node).node.get(key.name().as_str()) + } + + pub fn repeated(&self, key: RepeatedDirectiveKey) -> Result>, ConfigQueryError> + where + T: ConfigValue, + { + self.tree.check_contract(self.node, key.contract())?; + self.tree.node(self.node).node.get_all(key.name().as_str()) + } } impl ServerConfigRef { @@ -688,4 +742,23 @@ impl LocationConfigRef { pub fn tree(&self) -> &Arc { self.0.tree() } + + pub fn payload(&self, key: ContextPayloadKey) -> Result, ConfigQueryError> + where + T: ConfigValue, + { + self.0.tree.check_contract(self.0.node, key.contract())?; + self.0 + .tree + .node(self.0.node) + .node + .payload()? + .ok_or_else(|| { + config_query_error::MissingRequiredSnafu { + directive: key.name().as_str().to_owned(), + span: self.0.tree.node(self.0.node).node.span, + } + .build() + }) + } } diff --git a/gateway/src/parse/types.rs b/gateway/src/parse/types.rs index 16e05bd9..96fedd50 100644 --- a/gateway/src/parse/types.rs +++ b/gateway/src/parse/types.rs @@ -10,41 +10,6 @@ pub struct ServerName { pub name: DhttpName<'static>, } -// TLS identity configuration for clients/servers that need certificates -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ServerIdentity { - pub cert_path: PathBuf, - pub key_path: PathBuf, - pub server_name: DhttpName<'static>, -} - -pub fn server_identity( - node: &crate::parse::document::ConfigNode, - server_name: DhttpName<'static>, -) -> Option { - let cert_path = node.get::("ssl_certificate").ok().flatten()?; - let key_path = node - .get::("ssl_certificate_key") - .ok() - .flatten()?; - Some(ServerIdentity { - cert_path: cert_path.0.clone(), - key_path: key_path.0.clone(), - server_name, - }) -} - -pub fn optional_server_identity( - node: &crate::parse::document::ConfigNode, - server_name_key: &str, -) -> Option { - let server_name = node - .get::(server_name_key) - .ok() - .flatten()?; - server_identity(node, server_name.0.clone()) -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct BoolConfig(pub bool); diff --git a/gateway/src/reverse/file.rs b/gateway/src/reverse/file.rs index b2a14e97..f08f049c 100644 --- a/gateway/src/reverse/file.rs +++ b/gateway/src/reverse/file.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn}; use crate::{ command::{self, IndexError, content_type, index}, - parse::{document::ConfigNode, types::PathConfig}, + parse::{document::ConfigNode, domain::ResolvedConfigPath}, reverse::location::LocationMatch, }; @@ -35,10 +35,11 @@ pub async fn file_handle( ) -> impl IntoResponse { let location = &loc.location; - let file_path = if let Some(alias) = location.get::("alias").ok().flatten() { - static_file_path(&alias.0, &loc.remaining) - } else if let Some(root) = location.get::("root").ok().flatten() { - static_file_path(&root.0, req.uri().path()) + let file_path = if let Some(alias) = location.get::("alias").ok().flatten() + { + static_file_path(alias.as_ref().as_ref(), &loc.remaining) + } else if let Some(root) = location.get::("root").ok().flatten() { + static_file_path(root.as_ref().as_ref(), req.uri().path()) } else { unreachable!("file_handle requires root or alias") }; diff --git a/gateway/src/reverse/router.rs b/gateway/src/reverse/router.rs index 5211db61..0dcf57c3 100644 --- a/gateway/src/reverse/router.rs +++ b/gateway/src/reverse/router.rs @@ -14,9 +14,7 @@ use super::location::match_location; #[cfg(feature = "sshd")] use crate::parse::types::SshLoginMethods; use crate::parse::{ - document::ConfigNode, - pattern::Pattern, - types::{PathConfig, ProxyPass}, + document::ConfigNode, domain::ResolvedConfigPath, pattern::Pattern, types::ProxyPass, }; #[cfg(feature = "sshd")] @@ -104,8 +102,16 @@ impl tower_service::Service> for NginxRouter { .is_some() { Handler::call(super::proxy::proxy_handle, request, state).await - } else if location.get::("root").ok().flatten().is_some() - || location.get::("alias").ok().flatten().is_some() + } else if location + .get::("root") + .ok() + .flatten() + .is_some() + || location + .get::("alias") + .ok() + .flatten() + .is_some() { Handler::call(super::file::file_handle, request, state).await } else { diff --git a/gateway/src/reverse/upstream_tls.rs b/gateway/src/reverse/upstream_tls.rs index d02fff0c..e0a63b42 100644 --- a/gateway/src/reverse/upstream_tls.rs +++ b/gateway/src/reverse/upstream_tls.rs @@ -17,7 +17,7 @@ use tracing::debug; use crate::{ error::Whatever, - parse::{document::ConfigNode, types::PathConfig}, + parse::{document::ConfigNode, domain::ResolvedConfigPath}, }; type Result = std::result::Result; @@ -187,10 +187,10 @@ fn load_private_key(path: &Path) -> Result> { fn path_from_config(location: &ConfigNode, name: &str) -> Option { location - .get::(name) + .get::(name) .ok() .flatten() - .map(|path| path.0.clone()) + .map(|path| path.as_ref().as_ref().to_path_buf()) } #[cfg(test)] @@ -224,7 +224,9 @@ mod tests { let span = SourceSpan::new(SourceId(0), 0, 0); let mut location = ConfigNode::new(context::LOCATION, None, span); for (name, path) in values { - location.insert_slot(name, TypedValue::new(PathConfig(path.clone()), span)); + let path = ResolvedConfigPath::try_from(path.clone()) + .expect("TLS fixture path should already be absolute"); + location.insert_slot(name, TypedValue::new(path, span)); } location } diff --git a/pishoo/src/config.rs b/pishoo/src/config.rs index b20a6ab1..e963152b 100644 --- a/pishoo/src/config.rs +++ b/pishoo/src/config.rs @@ -1,6 +1,6 @@ use std::{path::PathBuf, sync::Arc}; -use gateway::parse::{document::ConfigNode, error::ConfigQueryError, types::PathConfig}; +use gateway::parse::{document::ConfigNode, domain::ResolvedConfigPath, error::ConfigQueryError}; use snafu::{OptionExt, ResultExt, Snafu}; mod discovery; @@ -93,8 +93,8 @@ fn first_pishoo_node(root: &Arc) -> Result, ConfigEr fn parse_pid_file(pishoo: &Arc) -> Result { Ok(pishoo - .get::("pid") + .get::("pid") .context(ConfigQuerySnafu)? - .map(|pid_file| pid_file.0.clone()) + .map(|pid_file| pid_file.as_ref().as_ref().to_path_buf()) .unwrap_or_else(|| PathBuf::from(PID_FILE_DEFAULT))) } diff --git a/pishoo/src/config/tests.rs b/pishoo/src/config/tests.rs index 68f79f83..ee3d233b 100644 --- a/pishoo/src/config/tests.rs +++ b/pishoo/src/config/tests.rs @@ -44,6 +44,51 @@ fn extracts_pid_and_workers() { assert_eq!(root.workers[1].username, "bob"); } +#[test] +fn pishoo_keys_query_pid_workers_and_groups_across_crate() { + let base = std::env::temp_dir().join(format!( + "pishoo-typed-keys-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + let source_path = base.join("pishoo.conf"); + let registry = gateway::parse::default_registry(); + let mut parser = gateway::parse::ConfigDocumentParser::new(®istry); + let gateway::parse::fragment::ParsedConfigDocument::HypervisorRoot(fragment) = parser + .parse_text( + "pishoo { pid run/pishoo.pid; workers alice bob; groups admin web; }", + &source_path, + gateway::parse::domain::ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect("root config should parse") + else { + panic!("expected pishoo fragment"); + }; + let tree = gateway::parse::tree::build_global_tree(®istry, fragment, Vec::new()) + .expect("tree should seal"); + let pishoo = tree.pishoo(); + + let pid = pishoo + .local(gateway::parse::keys::pishoo::PID) + .expect("pid query") + .expect("pid should exist"); + let workers = pishoo + .local(gateway::parse::keys::pishoo::WORKERS) + .expect("workers query") + .expect("workers should exist"); + let groups = pishoo + .local(gateway::parse::keys::pishoo::GROUPS) + .expect("groups query") + .expect("groups should exist"); + + assert_eq!(pid.as_ref().as_ref(), base.join("run/pishoo.pid")); + assert_eq!(workers.0, vec!["alice", "bob"]); + assert_eq!(groups.0, vec!["admin", "web"]); +} + #[test] fn parse_workers_from_root_config() { let conf = "pishoo { workers alice bob; }"; @@ -122,9 +167,10 @@ async fn parse_entry_config_servers_only_from_file_config() { .clone(); assert_eq!( location - .require::("root") + .require::("root") .expect("root should be typed") - .0, + .as_ref() + .as_ref(), dir, ); } diff --git a/pishoo/src/service/source.rs b/pishoo/src/service/source.rs index c999e25a..9a1412a8 100644 --- a/pishoo/src/service/source.rs +++ b/pishoo/src/service/source.rs @@ -8,6 +8,7 @@ use gateway::{ control_plane::ListenRequest, parse::{ document::ConfigNode, + domain::ResolvedConfigPath, types::{AccessRulesUri, ListenConfig, Listens, ResolverConfig, ServerNames}, }, reverse::router::RouterState, @@ -137,11 +138,11 @@ impl PishooConfigServiceSource { })?; let has_cert = server - .get::("ssl_certificate") + .get::("ssl_certificate") .context(build_config_service_sources_error::ConfigQuerySnafu)? .is_some(); let has_key = server - .get::("ssl_certificate_key") + .get::("ssl_certificate_key") .context(build_config_service_sources_error::ConfigQuerySnafu)? .is_some(); if home.is_some() && !has_cert && !has_key && server_names.0.len() > 1 { @@ -252,15 +253,20 @@ async fn load_identity_for_server( server_name: &DhttpName<'static>, ) -> Result { let cert_path = server - .get::("ssl_certificate") + .get::("ssl_certificate") .context(build_config_service_sources_error::ConfigQuerySnafu)?; let key_path = server - .get::("ssl_certificate_key") + .get::("ssl_certificate_key") .context(build_config_service_sources_error::ConfigQuerySnafu)?; match (cert_path, key_path) { (Some(cert_path), Some(key_path)) => { - load_identity_from_paths(server_name, &cert_path.0, &key_path.0).await + load_identity_from_paths( + server_name, + cert_path.as_ref().as_ref(), + key_path.as_ref().as_ref(), + ) + .await } (None, None) => { let home = home.context(build_config_service_sources_error::MissingDirectiveSnafu { From 36901b73c24abc9a357541501abb7153cd619b56 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 09:06:59 +0800 Subject: [PATCH 11/18] fix(config): validate cascaded query contracts --- gateway/src/parse/cascade.rs | 15 +- gateway/src/parse/registry.rs | 28 ++-- gateway/src/parse/tests.rs | 256 +++++++++++++++++++++++++++++++--- gateway/src/parse/tree.rs | 1 + 4 files changed, 257 insertions(+), 43 deletions(-) diff --git a/gateway/src/parse/cascade.rs b/gateway/src/parse/cascade.rs index 68e3cd18..d2c98efa 100644 --- a/gateway/src/parse/cascade.rs +++ b/gateway/src/parse/cascade.rs @@ -2,6 +2,7 @@ use std::{marker::PhantomData, num::NonZeroU32, path::Path, sync::Arc}; use crate::parse::{ domain::{ConfigSourceSpan, DirectiveName}, + registry::{DirectiveContract, DirectiveContractTemplate}, snapshot::RootConfigSnapshot, }; @@ -77,6 +78,7 @@ pub(crate) type SnapshotValue = #[derive(Debug)] pub struct DirectiveKey { name: DirectiveName, + contract: DirectiveContractTemplate, builtin: BuiltinValue, snapshot: SnapshotValue, value: PhantomData T>, @@ -92,12 +94,14 @@ impl Copy for DirectiveKey {} impl DirectiveKey { pub(crate) const fn new( - name: &'static str, + name: DirectiveName, + contract: DirectiveContractTemplate, builtin: BuiltinValue, snapshot: SnapshotValue, ) -> Self { Self { - name: DirectiveName::new(name), + name, + contract, builtin, snapshot, value: PhantomData, @@ -108,6 +112,10 @@ impl DirectiveKey { self.name } + pub(crate) fn contract(self) -> DirectiveContract { + self.contract.freeze() + } + pub(crate) fn builtin(self) -> Option> { (self.builtin)() } @@ -146,6 +154,3 @@ pub(crate) const GZIP: DirectiveKey = #[cfg(test)] pub(crate) const GZIP_TYPES: DirectiveKey = crate::parse::registry::v1_gzip_types_key(); -#[cfg(test)] -pub(crate) const TYPES: DirectiveKey = - crate::parse::registry::v1_types_key(); diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 0bde0e52..c5df2e22 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -176,7 +176,7 @@ pub enum DirectiveContractMismatch { } #[derive(Debug, Clone, Copy)] -struct DirectiveContractTemplate { +pub(crate) struct DirectiveContractTemplate { context: ContextKey, name: DirectiveName, value_type: fn() -> TypeId, @@ -190,7 +190,7 @@ struct DirectiveContractTemplate { } impl DirectiveContractTemplate { - fn freeze(self) -> DirectiveContract { + pub(crate) fn freeze(self) -> DirectiveContract { DirectiveContract { context: self.context, name: self.name, @@ -583,7 +583,7 @@ pub(crate) struct PayloadCardinality; pub(crate) struct CascadedCardinality; #[derive(Debug, Clone, Copy)] -struct DirectiveMetadata { +pub(crate) struct DirectiveMetadata { duplicate: DuplicatePolicy, cascade: CascadePolicy, transport: TransportPolicy, @@ -591,7 +591,7 @@ struct DirectiveMetadata { } impl DirectiveMetadata { - const fn new( + pub(crate) const fn new( duplicate: DuplicatePolicy, cascade: CascadePolicy, transport: TransportPolicy, @@ -607,7 +607,7 @@ impl DirectiveMetadata { } #[derive(Debug)] -struct DirectiveProjection { +pub(crate) struct DirectiveProjection { builtin: Option>, snapshot: Option>, } @@ -621,14 +621,14 @@ impl Clone for DirectiveProjection { impl Copy for DirectiveProjection {} impl DirectiveProjection { - const fn new(builtin: BuiltinValue, snapshot: SnapshotValue) -> Self { + pub(crate) const fn new(builtin: BuiltinValue, snapshot: SnapshotValue) -> Self { Self { builtin: Some(builtin), snapshot: Some(snapshot), } } - const fn absent() -> Self { + pub(crate) const fn absent() -> Self { Self { builtin: None, snapshot: None, @@ -850,7 +850,7 @@ impl TypedDirectiveDefinition { } impl TypedDirectiveDefinition { - const fn cascaded( + pub(crate) const fn cascaded( context: ContextKey, name: &'static str, shape: DirectiveShape, @@ -868,7 +868,10 @@ impl TypedDirectiveDefinition { ) } - pub(crate) const fn key(self) -> DirectiveKey { + pub(crate) const fn key(self) -> DirectiveKey + where + T: 'static, + { let builtin = match self.builtin { Some(builtin) => builtin, None => crate::parse::cascade::absent, @@ -877,7 +880,7 @@ impl TypedDirectiveDefinition { Some(snapshot) => snapshot, None => crate::parse::cascade::no_snapshot, }; - DirectiveKey::new(self.name.as_str(), builtin, snapshot) + DirectiveKey::new(self.name, self.contract_template(), builtin, snapshot) } } @@ -1026,11 +1029,6 @@ pub(crate) const fn v1_gzip_types_key() -> DirectiveKey { V1_SNAPSHOT_SCHEMA.gzip_types.key() } -#[cfg(test)] -pub(crate) const fn v1_types_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.types.key() -} - #[derive(Debug, Clone, Copy)] pub(crate) struct ValidatedV1SnapshotSchema { schema: &'static V1SnapshotSchema, diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 408f2d11..46bda2f8 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -11,19 +11,22 @@ use dhttp::home::DhttpHome; use crate::parse::{ ConfigDocumentParser, - cascade::{GZIP, GZIP_TYPES, TYPES}, + cascade::{DirectiveKey, GZIP, GZIP_TYPES}, document::{ConfigDocument, ConfigNode}, domain::{ConfigDocumentRole, ConfigDocumentRoleKind, ResolvedConfigPath}, error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, registry::{ - BuildOptions, CascadePolicy, ContextSpec, DirectiveSpec, DuplicatePolicy, ReloadImpact, - TransportPolicy, context, + BuildOptions, CascadePolicy, CascadedCardinality, ConfigRegistry, ContextSpec, + DirectiveContractMismatch, DirectiveMetadata, DirectiveProjection, DirectiveShape, + DirectiveSpec, DuplicatePolicy, ReloadImpact, TransportPolicy, TypedDirectiveDefinition, + context, }, snapshot::{RootConfigSnapshot, test_support as snapshot_test_support}, source::SourceId, tree::{AttachedConfigNode, ParentLink, build_global_tree, build_worker_tree}, - types::{BoolConfig, MimeTypes, PathConfig, StringList}, + types::{BoolConfig, ListenConfig, MimeTypes, PathConfig, StringList}, + value::ConfigValue, }; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); @@ -336,29 +339,198 @@ fn sealed_query_keeps_tree_alive_after_registry_replacement() { #[test] fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { let fixture = TempConfigDir::new("sealed_equivalent_registry_contract"); - let source_path = fixture.join("home/pishoo.conf"); - let mut registry = crate::parse::default_registry(); + let registry = crate::parse::default_registry(); let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); let root = parse_root_fragment( &mut parser, - "pishoo { pid run/pishoo.pid; }", - &source_path, - None, + "pishoo { server { listen all 4101; types { text/plain txt; } } }", + &fixture.join("home/pishoo.conf"), + Some(&home), ); let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - registry = crate::parse::default_registry(); - assert!(registry.directive_spec(context::PISHOO, "pid").is_some()); - let pid = tree - .pishoo() - .local(crate::parse::keys::pishoo::PID) + let definition = cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); + let mut replacement = ConfigRegistry::new(); + replacement.register_context(ContextSpec { + key: context::SERVER, + finalize: None, + }); + definition.register(&mut replacement); + assert!( + replacement + .directive_spec(context::SERVER, "types") + .is_some() + ); + + let types = tree + .servers() + .next() + .expect("server should attach") + .node() + .cascaded(definition.key()) .expect("equivalent semantic contract should query") - .expect("pid should exist"); + .expect("types should exist"); - assert_eq!( - pid.as_ref().as_ref(), - source_path.parent().unwrap().join("run/pishoo.pid") + assert!(types.effective().0.contains_key("txt")); +} + +#[test] +fn sealed_cascaded_query_rejects_each_contract_field_mismatch() { + let fixture = TempConfigDir::new("sealed_cascaded_contract_mismatch"); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 4101; types { text/plain txt; } } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let server = tree.servers().next().expect("server should attach"); + + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::ValueType { .. }), ); + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "listen", + DirectiveShape::Leaf, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Cardinality { .. }), + ); + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::Leaf, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Shape { .. }), + ); + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::LastWins, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Duplicate { .. }), + ); + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), + ); + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Transport { .. }), + ); + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Reload { .. }), + ); +} + +fn cascaded_definition( + context: crate::parse::registry::ContextKey, + name: &'static str, + shape: DirectiveShape, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, +) -> TypedDirectiveDefinition { + TypedDirectiveDefinition::cascaded( + context, + name, + shape, + DirectiveMetadata::new(duplicate, cascade, transport, reload), + DirectiveProjection::absent(), + ) +} + +fn assert_cascaded_contract_mismatch( + node: &crate::parse::tree::ConfigNodeRef, + key: DirectiveKey, + expected: fn(DirectiveContractMismatch) -> bool, +) where + T: ConfigValue, +{ + let Err(crate::parse::error::ConfigQueryError::CascadePolicyMismatch { + mismatch: Some(mismatch), + .. + }) = node.cascaded(key) + else { + panic!("cascaded query should reject the semantic contract mismatch"); + }; + assert!(expected(mismatch)); } #[test] @@ -1206,7 +1378,18 @@ fn types_replaces_the_whole_parent_map() { .next() .expect("server should attach") .node() - .cascaded(TYPES) + .cascaded( + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ) + .key(), + ) .expect("types query should succeed") .expect("types should be configured"); assert_eq!(types.effective().0.len(), 1); @@ -1242,9 +1425,19 @@ fn cascade_query_rejects_registry_policy_mismatch() { let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); let server = tree.servers().next().expect("server should attach"); - assert!( - server.node().cascaded(TYPES).is_err(), - "cascade policy mismatches must not use DirectiveKey hardcoding" + assert_cascaded_contract_mismatch( + server.node(), + cascaded_definition::( + context::SERVER, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ) + .key(), + |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), ); } @@ -1541,7 +1734,24 @@ fn cascade_lineage_is_builtin_root_worker_server_location() { .expect("location"); let gzip = location .node() - .cascaded(GZIP) + .cascaded( + TypedDirectiveDefinition::cascaded( + context::LOCATION, + "gzip", + DirectiveShape::Leaf, + DirectiveMetadata::new( + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ), + DirectiveProjection::new( + crate::parse::cascade::builtin_false, + RootConfigSnapshot::cascade_gzip, + ), + ) + .key(), + ) .expect("gzip query") .expect("gzip fallback"); diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index 96e932f3..93464db6 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -487,6 +487,7 @@ impl HomeConfigTree { where T: ConfigValue, { + self.check_contract(node, key.contract())?; let mut chain = Vec::new(); let mut current = node; loop { From e62361b73b88091bc7a6de302b8f37cd68f6367a Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 09:46:49 +0800 Subject: [PATCH 12/18] fix(config): validate inherited query contracts --- gateway/src/parse.rs | 32 +-- gateway/src/parse/builtin/location.rs | 67 +++-- gateway/src/parse/builtin/server.rs | 65 +++-- gateway/src/parse/cascade.rs | 24 +- gateway/src/parse/diagnostic.rs | 1 + gateway/src/parse/error.rs | 31 ++- gateway/src/parse/registry.rs | 189 ++++++++++----- gateway/src/parse/tests.rs | 337 ++++++++++++++++---------- gateway/src/parse/tree.rs | 161 ++++++++---- pishoo/src/config/tests.rs | 92 +++++++ 10 files changed, 712 insertions(+), 287 deletions(-) diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index ffca5aa2..ce33311f 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -37,6 +37,7 @@ pub mod keys { pub mod server { use crate::parse::{ + cascade::DirectiveKey, domain::ResolvedConfigPath, registry::{LocalDirectiveKey, RepeatedDirectiveKey}, types::{ @@ -50,30 +51,31 @@ pub mod keys { pub const SERVER_NAME: LocalDirectiveKey = crate::parse::builtin::server::SERVER_NAME_KEY; pub const DNS: LocalDirectiveKey = crate::parse::builtin::server::DNS_KEY; - pub const GZIP: LocalDirectiveKey = crate::parse::builtin::server::GZIP_KEY; - pub const GZIP_VARY: LocalDirectiveKey = + pub const GZIP: DirectiveKey = crate::parse::builtin::server::GZIP_KEY; + pub const GZIP_VARY: DirectiveKey = crate::parse::builtin::server::GZIP_VARY_KEY; - pub const GZIP_MIN_LENGTH: LocalDirectiveKey = + pub const GZIP_MIN_LENGTH: DirectiveKey = crate::parse::builtin::server::GZIP_MIN_LENGTH_KEY; - pub const GZIP_COMP_LEVEL: LocalDirectiveKey = + pub const GZIP_COMP_LEVEL: DirectiveKey = crate::parse::builtin::server::GZIP_COMP_LEVEL_KEY; - pub const GZIP_TYPES: LocalDirectiveKey = + pub const GZIP_TYPES: DirectiveKey = crate::parse::builtin::server::GZIP_TYPES_KEY; pub const SSL_CERTIFICATE: LocalDirectiveKey = crate::parse::builtin::server::SSL_CERTIFICATE_KEY; pub const SSL_CERTIFICATE_KEY: LocalDirectiveKey = crate::parse::builtin::server::SSL_CERTIFICATE_KEY_KEY; - pub const DEFAULT_TYPE: LocalDirectiveKey = + pub const DEFAULT_TYPE: DirectiveKey = crate::parse::builtin::server::DEFAULT_TYPE_KEY; - pub const ACCESS_RULES: LocalDirectiveKey = + pub const ACCESS_RULES: DirectiveKey = crate::parse::builtin::server::ACCESS_RULES_KEY; pub const RELAY: LocalDirectiveKey = crate::parse::builtin::server::RELAY_KEY; pub const STUN: LocalDirectiveKey = crate::parse::builtin::server::STUN_KEY; - pub const TYPES: LocalDirectiveKey = crate::parse::builtin::server::TYPES_KEY; + pub const TYPES: DirectiveKey = crate::parse::builtin::server::TYPES_KEY; } pub mod location { use crate::parse::{ + cascade::DirectiveKey, domain::ResolvedConfigPath, pattern::Pattern, registry::{ContextPayloadKey, LocalDirectiveKey, RepeatedDirectiveKey}, @@ -89,14 +91,14 @@ pub mod keys { crate::parse::builtin::location::ROOT_KEY; pub const ALIAS: LocalDirectiveKey = crate::parse::builtin::location::ALIAS_KEY; - pub const GZIP: LocalDirectiveKey = crate::parse::builtin::location::GZIP_KEY; - pub const GZIP_VARY: LocalDirectiveKey = + pub const GZIP: DirectiveKey = crate::parse::builtin::location::GZIP_KEY; + pub const GZIP_VARY: DirectiveKey = crate::parse::builtin::location::GZIP_VARY_KEY; - pub const GZIP_MIN_LENGTH: LocalDirectiveKey = + pub const GZIP_MIN_LENGTH: DirectiveKey = crate::parse::builtin::location::GZIP_MIN_LENGTH_KEY; - pub const GZIP_COMP_LEVEL: LocalDirectiveKey = + pub const GZIP_COMP_LEVEL: DirectiveKey = crate::parse::builtin::location::GZIP_COMP_LEVEL_KEY; - pub const GZIP_TYPES: LocalDirectiveKey = + pub const GZIP_TYPES: DirectiveKey = crate::parse::builtin::location::GZIP_TYPES_KEY; pub const INDEX: LocalDirectiveKey = crate::parse::builtin::location::INDEX_KEY; pub const ADD_HEADER: RepeatedDirectiveKey = @@ -117,9 +119,9 @@ pub mod keys { crate::parse::builtin::location::SSH_SSL_USER_KEY; pub const SSH_DENY: LocalDirectiveKey = crate::parse::builtin::location::SSH_DENY_KEY; - pub const DEFAULT_TYPE: LocalDirectiveKey = + pub const DEFAULT_TYPE: DirectiveKey = crate::parse::builtin::location::DEFAULT_TYPE_KEY; - pub const TYPES: LocalDirectiveKey = crate::parse::builtin::location::TYPES_KEY; + pub const TYPES: DirectiveKey = crate::parse::builtin::location::TYPES_KEY; } } diff --git a/gateway/src/parse/builtin/location.rs b/gateway/src/parse/builtin/location.rs index 93efb435..2370cbab 100644 --- a/gateway/src/parse/builtin/location.rs +++ b/gateway/src/parse/builtin/location.rs @@ -2,14 +2,15 @@ use http::{HeaderName, HeaderValue}; use snafu::{OptionExt, Snafu, ensure}; use crate::parse::{ + cascade::DirectiveKey, document::ConfigNode, domain::ResolvedConfigPath, pattern::{ParsePatternError, Pattern}, registry::{ - BuildOptions, CascadePolicy, ConfigRegistry, ContextPayloadKey, DirectiveInput, - DirectiveValue, DuplicatePolicy, LocalDirectiveKey, PayloadCardinality, ReloadImpact, - RepeatedCardinality, RepeatedDirectiveKey, SingleCardinality, TransportPolicy, - TypedDirectiveDefinition, context, + BuildOptions, CascadePolicy, CascadedCardinality, ConfigRegistry, ContextPayloadKey, + DirectiveInput, DirectiveValue, DuplicatePolicy, LocalDirectiveKey, PayloadCardinality, + ReloadImpact, RepeatedCardinality, RepeatedDirectiveKey, SingleCardinality, + TransportPolicy, TypedDirectiveDefinition, context, }, source::SourceSpan, types::{ @@ -34,6 +35,21 @@ macro_rules! single_definition { }; } +macro_rules! cascaded_definition { + ($definition:ident, $key:ident, $value:ty, $name:literal, $parent:expr) => { + const $definition: TypedDirectiveDefinition<$value, CascadedCardinality> = + TypedDirectiveDefinition::cascaded_leaf( + context::LOCATION, + $name, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, + ); + pub(crate) const $key: DirectiveKey<$value> = $definition.key_inheriting($parent); + }; +} + macro_rules! repeated_definition { ($definition:ident, $key:ident, $value:ty, $name:literal) => { const $definition: TypedDirectiveDefinition<$value, RepeatedCardinality> = @@ -61,25 +77,40 @@ const PATTERN_DEFINITION: TypedDirectiveDefinition pub(crate) const PATTERN_KEY: ContextPayloadKey = PATTERN_DEFINITION.key(); single_definition!(ROOT_DEFINITION, ROOT_KEY, ResolvedConfigPath, "root"); single_definition!(ALIAS_DEFINITION, ALIAS_KEY, ResolvedConfigPath, "alias"); -single_definition!(GZIP_DEFINITION, GZIP_KEY, BoolConfig, "gzip"); -single_definition!(GZIP_VARY_DEFINITION, GZIP_VARY_KEY, BoolConfig, "gzip_vary"); -single_definition!( +cascaded_definition!( + GZIP_DEFINITION, + GZIP_KEY, + BoolConfig, + "gzip", + crate::parse::builtin::server::GZIP_KEY +); +cascaded_definition!( + GZIP_VARY_DEFINITION, + GZIP_VARY_KEY, + BoolConfig, + "gzip_vary", + crate::parse::builtin::server::GZIP_VARY_KEY +); +cascaded_definition!( GZIP_MIN_LENGTH_DEFINITION, GZIP_MIN_LENGTH_KEY, GzipMinLength, - "gzip_min_length" + "gzip_min_length", + crate::parse::builtin::server::GZIP_MIN_LENGTH_KEY ); -single_definition!( +cascaded_definition!( GZIP_COMP_LEVEL_DEFINITION, GZIP_COMP_LEVEL_KEY, GzipCompLevel, - "gzip_comp_level" + "gzip_comp_level", + crate::parse::builtin::server::GZIP_COMP_LEVEL_KEY ); -single_definition!( +cascaded_definition!( GZIP_TYPES_DEFINITION, GZIP_TYPES_KEY, StringList, - "gzip_types" + "gzip_types", + crate::parse::builtin::server::GZIP_TYPES_KEY ); single_definition!(INDEX_DEFINITION, INDEX_KEY, StringList, "index"); repeated_definition!( @@ -131,14 +162,15 @@ repeated_definition!( "ssh_ssl_user" ); single_definition!(SSH_DENY_DEFINITION, SSH_DENY_KEY, StringList, "ssh_deny"); -single_definition!( +cascaded_definition!( DEFAULT_TYPE_DEFINITION, DEFAULT_TYPE_KEY, DefaultType, - "default_type" + "default_type", + crate::parse::builtin::server::DEFAULT_TYPE_KEY ); -const TYPES_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::raw( +const TYPES_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::cascaded_raw( context::LOCATION, "types", DuplicatePolicy::Reject, @@ -146,7 +178,8 @@ const TYPES_DEFINITION: TypedDirectiveDefinition = TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, ); -pub(crate) const TYPES_KEY: LocalDirectiveKey = TYPES_DEFINITION.key(); +pub(crate) const TYPES_KEY: DirectiveKey = + TYPES_DEFINITION.key_inheriting(crate::parse::builtin::server::TYPES_KEY); #[derive(Debug, Snafu)] #[snafu(module)] diff --git a/gateway/src/parse/builtin/server.rs b/gateway/src/parse/builtin/server.rs index 75a4898c..0c2f8916 100644 --- a/gateway/src/parse/builtin/server.rs +++ b/gateway/src/parse/builtin/server.rs @@ -1,12 +1,14 @@ use snafu::{OptionExt, Snafu, ensure}; use crate::parse::{ + cascade::DirectiveKey, document::ConfigNode, domain::ResolvedConfigPath, registry::{ - BuildOptions, CascadePolicy, ConfigRegistry, ContextKey, DirectiveSpec, DuplicatePolicy, - LocalDirectiveKey, ReloadImpact, RepeatedCardinality, RepeatedDirectiveKey, - SingleCardinality, TransportPolicy, TypedDirectiveDefinition, context, + BuildOptions, CascadePolicy, CascadedCardinality, ConfigRegistry, ContextKey, + DirectiveSpec, DuplicatePolicy, LocalDirectiveKey, ReloadImpact, RepeatedCardinality, + RepeatedDirectiveKey, SingleCardinality, TransportPolicy, TypedDirectiveDefinition, + context, }, source::SourceSpan, tree::AttachedConfigNode, @@ -32,6 +34,21 @@ macro_rules! single_definition { }; } +macro_rules! cascaded_definition { + ($definition:ident, $key:ident, $value:ty, $name:literal, $reload:expr, $parent:expr) => { + const $definition: TypedDirectiveDefinition<$value, CascadedCardinality> = + TypedDirectiveDefinition::cascaded_leaf( + context::SERVER, + $name, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + $reload, + ); + pub(crate) const $key: DirectiveKey<$value> = $definition.key_inheriting($parent); + }; +} + const LISTEN_DEFINITION: TypedDirectiveDefinition = TypedDirectiveDefinition::repeated_leaf( context::SERVER, @@ -55,40 +72,45 @@ single_definition!( "dns", ReloadImpact::ListenerSet ); -single_definition!( +cascaded_definition!( GZIP_DEFINITION, GZIP_KEY, BoolConfig, "gzip", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_gzip_key() ); -single_definition!( +cascaded_definition!( GZIP_VARY_DEFINITION, GZIP_VARY_KEY, BoolConfig, "gzip_vary", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_gzip_vary_key() ); -single_definition!( +cascaded_definition!( GZIP_MIN_LENGTH_DEFINITION, GZIP_MIN_LENGTH_KEY, GzipMinLength, "gzip_min_length", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_gzip_min_length_key() ); -single_definition!( +cascaded_definition!( GZIP_COMP_LEVEL_DEFINITION, GZIP_COMP_LEVEL_KEY, GzipCompLevel, "gzip_comp_level", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_gzip_comp_level_key() ); -single_definition!( +cascaded_definition!( GZIP_TYPES_DEFINITION, GZIP_TYPES_KEY, StringList, "gzip_types", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_gzip_types_key() ); single_definition!( SSL_CERTIFICATE_DEFINITION, @@ -104,19 +126,21 @@ single_definition!( "ssl_certificate_key", ReloadImpact::ListenerSet ); -single_definition!( +cascaded_definition!( DEFAULT_TYPE_DEFINITION, DEFAULT_TYPE_KEY, DefaultType, "default_type", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_default_type_key() ); -single_definition!( +cascaded_definition!( ACCESS_RULES_DEFINITION, ACCESS_RULES_KEY, AccessRulesUri, "access_rules", - ReloadImpact::RuntimeState + ReloadImpact::RuntimeState, + crate::parse::registry::v1_access_rules_key() ); single_definition!( RELAY_DEFINITION, @@ -132,8 +156,8 @@ single_definition!( "stun", ReloadImpact::RuntimeState ); -const TYPES_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::raw( +const TYPES_DEFINITION: TypedDirectiveDefinition = + TypedDirectiveDefinition::cascaded_raw( context::SERVER, "types", DuplicatePolicy::Reject, @@ -141,7 +165,8 @@ const TYPES_DEFINITION: TypedDirectiveDefinition = TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, ); -pub(crate) const TYPES_KEY: LocalDirectiveKey = TYPES_DEFINITION.key(); +pub(crate) const TYPES_KEY: DirectiveKey = + TYPES_DEFINITION.key_inheriting(crate::parse::registry::v1_types_key()); #[derive(Debug, Snafu)] #[snafu(module)] diff --git a/gateway/src/parse/cascade.rs b/gateway/src/parse/cascade.rs index d2c98efa..5fd72486 100644 --- a/gateway/src/parse/cascade.rs +++ b/gateway/src/parse/cascade.rs @@ -78,7 +78,8 @@ pub(crate) type SnapshotValue = #[derive(Debug)] pub struct DirectiveKey { name: DirectiveName, - contract: DirectiveContractTemplate, + contracts: [Option; 4], + contract_count: usize, builtin: BuiltinValue, snapshot: SnapshotValue, value: PhantomData T>, @@ -101,7 +102,8 @@ impl DirectiveKey { ) -> Self { Self { name, - contract, + contracts: [Some(contract), None, None, None], + contract_count: 1, builtin, snapshot, value: PhantomData, @@ -112,8 +114,22 @@ impl DirectiveKey { self.name } - pub(crate) fn contract(self) -> DirectiveContract { - self.contract.freeze() + pub(crate) const fn inheriting(mut self, contract: DirectiveContractTemplate) -> Self { + assert!( + self.contract_count < self.contracts.len(), + "gateway cascade contract chain is full" + ); + self.contracts[self.contract_count] = Some(contract); + self.contract_count += 1; + self + } + + pub(crate) fn contracts(self) -> impl Iterator { + self.contracts + .into_iter() + .take(self.contract_count) + .flatten() + .map(DirectiveContractTemplate::freeze) } pub(crate) fn builtin(self) -> Option> { diff --git a/gateway/src/parse/diagnostic.rs b/gateway/src/parse/diagnostic.rs index 004d3489..89077b6b 100644 --- a/gateway/src/parse/diagnostic.rs +++ b/gateway/src/parse/diagnostic.rs @@ -157,6 +157,7 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { | crate::parse::error::ConfigQueryError::MultipleValues { span, .. } | crate::parse::error::ConfigQueryError::MissingChild { span, .. } => Some(*span), crate::parse::error::ConfigQueryError::CascadePolicyMismatch { .. } + | crate::parse::error::ConfigQueryError::ContractMismatch { .. } | crate::parse::error::ConfigQueryError::MissingCascadePolicy { .. } | crate::parse::error::ConfigQueryError::UnsupportedCascadePolicy { .. } => None, }; diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index 23f9f39e..4b3c8697 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -252,13 +252,23 @@ pub enum ConfigQueryError { MissingChild { directive: String, span: SourceSpan }, #[snafu(display( - "directive `{directive}` has inconsistent cascade policies ({inherited:?} and {local:?})" + "directive `{directive}` has inconsistent cascade policies: context `{inherited_context}` uses {inherited:?}, context `{local_context}` uses {local:?}" ))] CascadePolicyMismatch { directive: String, + inherited_context: ContextKey, inherited: CascadePolicy, + local_context: ContextKey, local: CascadePolicy, - mismatch: Option, + }, + + #[snafu(display( + "directive `{directive}` has an incompatible contract in context `{context}`: {mismatch}" + ))] + ContractMismatch { + directive: DirectiveName, + context: ContextKey, + mismatch: crate::parse::registry::DirectiveContractMismatch, }, #[snafu(display("directive `{directive}` has no registered cascade policy"))] @@ -317,4 +327,21 @@ mod tests { assert_eq!(failure.to_string(), "failed to load configuration"); assert!(std::error::Error::source(&failure).is_some()); } + + #[test] + fn cascade_policy_mismatch_display_names_both_contexts() { + let error = ConfigQueryError::CascadePolicyMismatch { + directive: "gzip".to_owned(), + inherited_context: crate::parse::registry::context::PISHOO, + inherited: CascadePolicy::NearestWins, + local_context: crate::parse::registry::context::SERVER, + local: CascadePolicy::ReplaceWhole, + }; + let rendered = error.to_string(); + + assert!(rendered.contains(crate::parse::registry::context::PISHOO.0)); + assert!(rendered.contains(crate::parse::registry::context::SERVER.0)); + assert!(rendered.contains("NearestWins")); + assert!(rendered.contains("ReplaceWhole")); + } } diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index c5df2e22..22830cf0 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -56,7 +56,7 @@ impl ConfigRegistryContract { pub struct ConfigRegistry { contexts: HashMap, directives: HashMap<(ContextKey, &'static str), DirectiveSpec>, - cascade_policies: HashMap>, + contract_tables: HashMap>, attached_finalizers: HashMap, contract: ConfigRegistryContract, } @@ -175,6 +175,49 @@ pub enum DirectiveContractMismatch { }, } +impl std::fmt::Display for DirectiveContractMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Name { expected, actual } => write!( + f, + "name expected `{}`, actual {}", + expected.as_str(), + actual.map_or("", |actual| actual.as_str()) + ), + Self::Context { expected, actual } => { + write!(f, "context expected `{expected}`, actual `{actual}`") + } + Self::ValueType { expected, actual } => { + write!(f, "value type expected `{expected}`, actual `{actual}`") + } + Self::Cardinality { expected, actual } => { + write!(f, "cardinality expected {expected:?}, actual {actual:?}") + } + Self::Shape { expected, actual } => { + write!(f, "shape expected {expected:?}, actual {actual:?}") + } + Self::Payload { expected, actual } => { + write!(f, "payload expected {expected:?}, actual {actual:?}") + } + Self::Duplicate { expected, actual } => { + write!( + f, + "duplicate policy expected {expected:?}, actual {actual:?}" + ) + } + Self::Cascade { expected, actual } => { + write!(f, "cascade policy expected {expected:?}, actual {actual:?}") + } + Self::Transport { expected, actual } => { + write!(f, "transport expected {expected:?}, actual {actual:?}") + } + Self::Reload { expected, actual } => { + write!(f, "reload impact expected {expected:?}, actual {actual:?}") + } + } + } +} + #[derive(Debug, Clone, Copy)] pub(crate) struct DirectiveContractTemplate { context: ContextKey, @@ -206,7 +249,7 @@ impl DirectiveContractTemplate { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct DirectiveContract { context: ContextKey, name: DirectiveName, @@ -225,6 +268,10 @@ fn value_type_name() -> &'static str { } impl DirectiveContract { + pub(crate) const fn context(self) -> ContextKey { + self.context + } + pub(crate) const fn name(self) -> DirectiveName { self.name } @@ -763,25 +810,6 @@ impl TypedDirectiveDefinition { ) } - pub(crate) const fn raw( - context: ContextKey, - name: &'static str, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::new( - context, - context, - name, - DirectiveShape::RawBlock, - DirectiveCardinality::Single, - DirectiveMetadata::new(duplicate, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - pub(crate) const fn key(self) -> LocalDirectiveKey where T: 'static, @@ -868,6 +896,40 @@ impl TypedDirectiveDefinition { ) } + pub(crate) const fn cascaded_leaf( + context: ContextKey, + name: &'static str, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::cascaded( + context, + name, + DirectiveShape::Leaf, + DirectiveMetadata::new(duplicate, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + + pub(crate) const fn cascaded_raw( + context: ContextKey, + name: &'static str, + duplicate: DuplicatePolicy, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::cascaded( + context, + name, + DirectiveShape::RawBlock, + DirectiveMetadata::new(duplicate, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + pub(crate) const fn key(self) -> DirectiveKey where T: 'static, @@ -882,6 +944,13 @@ impl TypedDirectiveDefinition { }; DirectiveKey::new(self.name, self.contract_template(), builtin, snapshot) } + + pub(crate) const fn key_inheriting(self, parent: DirectiveKey) -> DirectiveKey + where + T: 'static, + { + parent.inheriting(self.contract_template()) + } } #[derive(Debug, Clone, Copy)] @@ -1019,16 +1088,38 @@ pub(crate) const fn v1_snapshot_field_names() -> [&'static str; 9] { V1_SNAPSHOT_SCHEMA.field_names() } -#[cfg(test)] pub(crate) const fn v1_gzip_key() -> DirectiveKey { V1_SNAPSHOT_SCHEMA.gzip.key() } -#[cfg(test)] pub(crate) const fn v1_gzip_types_key() -> DirectiveKey { V1_SNAPSHOT_SCHEMA.gzip_types.key() } +pub(crate) const fn v1_access_rules_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.access_rules.key() +} + +pub(crate) const fn v1_gzip_vary_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.gzip_vary.key() +} + +pub(crate) const fn v1_gzip_min_length_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.gzip_min_length.key() +} + +pub(crate) const fn v1_gzip_comp_level_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.gzip_comp_level.key() +} + +pub(crate) const fn v1_default_type_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.default_type.key() +} + +pub(crate) const fn v1_types_key() -> DirectiveKey { + V1_SNAPSHOT_SCHEMA.types.key() +} + #[derive(Debug, Clone, Copy)] pub(crate) struct ValidatedV1SnapshotSchema { schema: &'static V1SnapshotSchema, @@ -1135,21 +1226,29 @@ impl ConfigRegistry { pub fn register_directive(&mut self, context: ContextKey, spec: DirectiveSpec) { self.directives.insert((context, spec.name.as_str()), spec); - self.rebuild_cascade_policies(context); + self.rebuild_contract_tables(); self.advance_contract(); } - fn rebuild_cascade_policies(&mut self, context: ContextKey) { - let mut policies: Vec<_> = self - .directives - .iter() - .filter_map(|((registered_context, _), spec)| { - (*registered_context == context).then_some((spec.name, spec.cascade)) + fn rebuild_contract_tables(&mut self) { + let mut contract_tables = HashMap::>::new(); + for ((registration_context, _), spec) in &self.directives { + let contract = spec.contract(*registration_context); + contract_tables + .entry(contract.context()) + .or_default() + .push(contract); + } + self.contract_tables = contract_tables + .into_iter() + .map(|(context, mut contracts)| { + contracts.sort_unstable_by_key(|contract| { + (contract.name.as_str(), contract.cardinality as u8) + }); + contracts.dedup(); + (context, Arc::from(contracts.into_boxed_slice())) }) .collect(); - policies.sort_unstable_by_key(|(name, _)| name.as_str()); - self.cascade_policies - .insert(context, Arc::from(policies.into_boxed_slice())); } pub(crate) fn register_attached_finalizer( @@ -1192,28 +1291,8 @@ impl ConfigRegistry { }) } - pub(crate) fn cascade_policies( - &self, - context: ContextKey, - ) -> Arc<[(DirectiveName, CascadePolicy)]> { - self.cascade_policies - .get(&context) - .cloned() - .unwrap_or_default() - } - - pub(crate) fn directive_contracts(&self, context: ContextKey) -> Arc<[DirectiveContract]> { - let mut contracts = self - .directives - .iter() - .filter_map(|((registration_context, _), spec)| { - let contract = spec.contract(*registration_context); - (contract.context == context).then_some(contract) - }) - .collect::>(); - contracts - .sort_unstable_by_key(|contract| (contract.name.as_str(), contract.cardinality as u8)); - Arc::from(contracts.into_boxed_slice()) + pub(crate) fn frozen_contract_tables(&self) -> HashMap> { + self.contract_tables.clone() } pub(crate) fn register_v1_snapshot_directives(&mut self) { diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 46bda2f8..5053bb0b 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -25,7 +25,7 @@ use crate::parse::{ snapshot::{RootConfigSnapshot, test_support as snapshot_test_support}, source::SourceId, tree::{AttachedConfigNode, ParentLink, build_global_tree, build_worker_tree}, - types::{BoolConfig, ListenConfig, MimeTypes, PathConfig, StringList}, + types::{BoolConfig, MimeTypes, PathConfig, StringList}, value::ConfigValue, }; @@ -301,8 +301,8 @@ fn sealed_query_key_rejects_wrong_registry_contract() { assert!(matches!( error, - crate::parse::error::ConfigQueryError::CascadePolicyMismatch { - mismatch: Some(crate::parse::registry::DirectiveContractMismatch::Cascade { .. }), + crate::parse::error::ConfigQueryError::ContractMismatch { + mismatch: crate::parse::registry::DirectiveContractMismatch::Cascade { .. }, .. } )); @@ -350,7 +350,16 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { ); let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let definition = cascaded_definition::( + let pishoo_definition = cascaded_definition::( + context::PISHOO, + "types", + DirectiveShape::RawBlock, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ); + let server_definition = cascaded_definition::( context::SERVER, "types", DirectiveShape::RawBlock, @@ -360,11 +369,16 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { ReloadImpact::RuntimeState, ); let mut replacement = ConfigRegistry::new(); + replacement.register_context(ContextSpec { + key: context::PISHOO, + finalize: None, + }); replacement.register_context(ContextSpec { key: context::SERVER, finalize: None, }); - definition.register(&mut replacement); + pishoo_definition.register(&mut replacement); + server_definition.register(&mut replacement); assert!( replacement .directive_spec(context::SERVER, "types") @@ -376,7 +390,7 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { .next() .expect("server should attach") .node() - .cascaded(definition.key()) + .cascaded(server_definition.key_inheriting(pishoo_definition.key())) .expect("equivalent semantic contract should query") .expect("types should exist"); @@ -384,120 +398,106 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { } #[test] -fn sealed_cascaded_query_rejects_each_contract_field_mismatch() { - let fixture = TempConfigDir::new("sealed_cascaded_contract_mismatch"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; types { text/plain txt; } } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let server = tree.servers().next().expect("server should attach"); - - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, +fn sealed_cascaded_query_rejects_ancestor_contract_drift_without_value() { + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, + CascadePolicy::NearestWins, TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, - ) - .key(), + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::ValueType { .. }), ); - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "listen", - DirectiveShape::Leaf, - DuplicatePolicy::Reject, + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], + DuplicatePolicy::Append, CascadePolicy::NearestWins, TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ) - .key(), + ReloadImpact::RuntimeState, + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Cardinality { .. }), ); - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::Leaf, + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::raw_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, + CascadePolicy::NearestWins, TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, - ) - .key(), + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Shape { .. }), ); - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::LastWins, - CascadePolicy::ReplaceWhole, + CascadePolicy::NearestWins, TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, - ) - .key(), + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Duplicate { .. }), ); - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::Reject, - CascadePolicy::NearestWins, + CascadePolicy::ReplaceWhole, TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, - ) - .key(), + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), ); - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, + CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, ReloadImpact::RuntimeState, - ) - .key(), + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Transport { .. }), ); - assert_cascaded_contract_mismatch( - server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, + assert_ancestor_gzip_contract_mismatch( + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, + CascadePolicy::NearestWins, TransportPolicy::WorkerLocalOnly, ReloadImpact::ListenerSet, - ) - .key(), + ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Reload { .. }), ); } +#[test] +fn directive_contract_mismatch_display_names_context_and_expected_actual_values() { + let error = ancestor_gzip_contract_error(DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + )); + let rendered = error.to_string(); + + assert!(rendered.contains("`gzip`")); + assert!(rendered.contains(context::SERVER.0)); + assert!(rendered.contains("transport")); + assert!(rendered.contains("WorkerLocalOnly")); + assert!(rendered.contains("WorkerInheritable")); + assert!(!rendered.contains("inconsistent cascade policies")); +} + fn cascaded_definition( context: crate::parse::registry::ContextKey, name: &'static str, @@ -523,16 +523,58 @@ fn assert_cascaded_contract_mismatch( ) where T: ConfigValue, { - let Err(crate::parse::error::ConfigQueryError::CascadePolicyMismatch { - mismatch: Some(mismatch), - .. - }) = node.cascaded(key) + let Err(crate::parse::error::ConfigQueryError::ContractMismatch { mismatch, .. }) = + node.cascaded(key) else { panic!("cascaded query should reject the semantic contract mismatch"); }; assert!(expected(mismatch)); } +fn ancestor_gzip_contract_error(spec: DirectiveSpec) -> crate::parse::error::ConfigQueryError { + let fixture = TempConfigDir::new("sealed_ancestor_contract_mismatch"); + let mut registry = crate::parse::default_registry(); + registry.register_directive(context::SERVER, spec); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { gzip on; server { listen all 4101; location / { gzip off; } } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let location = tree + .servers() + .next() + .expect("server should attach") + .locations() + .next() + .expect("location should attach"); + + location + .node() + .cascaded(crate::parse::keys::location::GZIP) + .expect_err("an unused ancestor definition must still match the key contract") +} + +fn assert_ancestor_gzip_contract_mismatch( + spec: DirectiveSpec, + expected: fn(DirectiveContractMismatch) -> bool, +) { + let crate::parse::error::ConfigQueryError::ContractMismatch { + directive, + context: actual_context, + mismatch, + } = ancestor_gzip_contract_error(spec) + else { + panic!("expected typed directive contract mismatch"); + }; + assert_eq!(directive.as_str(), "gzip"); + assert_eq!(actual_context, context::SERVER); + assert!(expected(mismatch)); +} + #[test] fn home_arena_path_query_returns_resolved_config_path() { let fixture = TempConfigDir::new("home_arena_path_query"); @@ -1378,18 +1420,7 @@ fn types_replaces_the_whole_parent_map() { .next() .expect("server should attach") .node() - .cascaded( - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ) - .key(), - ) + .cascaded(crate::parse::keys::server::TYPES) .expect("types query should succeed") .expect("types should be configured"); assert_eq!(types.effective().0.len(), 1); @@ -1427,18 +1458,71 @@ fn cascade_query_rejects_registry_policy_mismatch() { assert_cascaded_contract_mismatch( server.node(), - cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, + crate::parse::keys::server::TYPES, + |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), + ); +} + +#[test] +fn cascade_query_distinguishes_cross_context_policy_conflict() { + let fixture = TempConfigDir::new("cascade_cross_context_policy_conflict"); + let mut registry = crate::parse::default_registry(); + registry.register_directive( + context::SERVER, + DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], DuplicatePolicy::Reject, CascadePolicy::ReplaceWhole, TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, - ) - .key(), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), + ), + ); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 4101; } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let pishoo_definition = cascaded_definition::( + context::PISHOO, + "gzip", + DirectiveShape::Leaf, + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + ); + let server_definition = cascaded_definition::( + context::SERVER, + "gzip", + DirectiveShape::Leaf, + DuplicatePolicy::Reject, + CascadePolicy::ReplaceWhole, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::RuntimeState, ); + let error = tree + .servers() + .next() + .expect("server should attach") + .node() + .cascaded(server_definition.key_inheriting(pishoo_definition.key())) + .expect_err("matching contracts with conflicting policies must stay a policy error"); + + assert!(matches!( + error, + crate::parse::error::ConfigQueryError::CascadePolicyMismatch { + inherited_context: context::PISHOO, + inherited: CascadePolicy::NearestWins, + local_context: context::SERVER, + local: CascadePolicy::ReplaceWhole, + .. + } + )); } #[test] @@ -1695,12 +1779,24 @@ fn detached_fragments_reject_parse_registry_mutation_before_seal() { } #[test] -fn registry_shares_precomputed_cascade_policy_tables() { +fn sealed_nodes_share_precomputed_context_contract_tables() { + let fixture = TempConfigDir::new("shared_sealed_contract_tables"); let registry = crate::parse::default_registry(); - let first = registry.cascade_policies(context::PISHOO); - let second = registry.cascade_policies(context::PISHOO); + let mut parser = ConfigDocumentParser::new(®istry); + let home = DhttpHome::new(fixture.join("home")); + let root = parse_root_fragment( + &mut parser, + "pishoo { server { listen all 4101; location / {} } server { listen all 4102; location /two {} } }", + &fixture.join("home/pishoo.conf"), + Some(&home), + ); + let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); + let servers = tree.servers().collect::>(); + let first_location = servers[0].locations().next().expect("first location"); + let second_location = servers[1].locations().next().expect("second location"); - assert!(Arc::ptr_eq(&first, &second)); + assert!(tree.contract_tables_shared(servers[0].node().id(), servers[1].node().id())); + assert!(tree.contract_tables_shared(first_location.node().id(), second_location.node().id())); } #[test] @@ -1734,24 +1830,7 @@ fn cascade_lineage_is_builtin_root_worker_server_location() { .expect("location"); let gzip = location .node() - .cascaded( - TypedDirectiveDefinition::cascaded( - context::LOCATION, - "gzip", - DirectiveShape::Leaf, - DirectiveMetadata::new( - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - DirectiveProjection::new( - crate::parse::cascade::builtin_false, - RootConfigSnapshot::cascade_gzip, - ), - ) - .key(), - ) + .cascaded(crate::parse::keys::location::GZIP) .expect("gzip query") .expect("gzip fallback"); diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs index 93464db6..5e08447f 100644 --- a/gateway/src/parse/tree.rs +++ b/gateway/src/parse/tree.rs @@ -1,4 +1,4 @@ -use std::{num::NonZeroU32, path::Path, sync::Arc}; +use std::{collections::HashMap, num::NonZeroU32, path::Path, sync::Arc}; use snafu::Snafu; @@ -35,8 +35,6 @@ struct SealedConfigNode { node: Arc, parent: ParentLink, children: Vec, - cascade_policies: Arc<[(DirectiveName, CascadePolicy)]>, - directive_contracts: Arc<[DirectiveContract]>, } #[derive(Clone, Copy)] @@ -93,6 +91,7 @@ pub struct HomeConfigTree { pishoo: ConfigNodeId, servers: Box<[ConfigNodeId]>, inherited_root: Option>, + contract_tables: HashMap>, sources: ConfigSourceBundle, snapshot_schema: ValidatedV1SnapshotSchema, } @@ -174,6 +173,7 @@ struct HomeConfigTreeBuilder<'registry> { pishoo: ConfigNodeId, servers: Vec, inherited_root: Option>, + contract_tables: HashMap>, sources: Vec, document_ids: ConfigDocumentIdAllocator, snapshot_schema: ValidatedV1SnapshotSchema, @@ -200,6 +200,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { pishoo: ConfigNodeId(0), servers: Vec::new(), inherited_root: None, + contract_tables: registry.frozen_contract_tables(), sources: Vec::new(), document_ids: ConfigDocumentIdAllocator::new(), snapshot_schema, @@ -256,6 +257,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { pishoo: ConfigNodeId(0), servers: Vec::new(), inherited_root: Some(Arc::new(root_snapshot)), + contract_tables: registry.frozen_contract_tables(), sources: Vec::new(), document_ids: ConfigDocumentIdAllocator::new(), snapshot_schema, @@ -348,15 +350,11 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { parent: ParentLink, ) -> ConfigNodeId { let id = ConfigNodeId(self.nodes.len()); - let cascade_policies = self.registry.cascade_policies(node.context); - let directive_contracts = self.registry.directive_contracts(node.context); self.nodes.push(SealedConfigNode { document_id, node, parent, children: Vec::new(), - cascade_policies, - directive_contracts, }); id } @@ -424,6 +422,7 @@ impl<'registry> HomeConfigTreeBuilder<'registry> { pishoo: self.pishoo, servers: self.servers.into_boxed_slice(), inherited_root: self.inherited_root, + contract_tables: self.contract_tables, sources: ConfigSourceBundle { owners: self.sources.into_boxed_slice(), }, @@ -487,7 +486,6 @@ impl HomeConfigTree { where T: ConfigValue, { - self.check_contract(node, key.contract())?; let mut chain = Vec::new(); let mut current = node; loop { @@ -498,6 +496,7 @@ impl HomeConfigTree { } } chain.reverse(); + self.check_cascaded_contracts(&chain, key)?; let policy = self.cascade_policy(&chain, key.name())?; if !matches!( policy, @@ -546,26 +545,33 @@ impl HomeConfigTree { chain: &[ConfigNodeId], directive: DirectiveName, ) -> Result { - let mut inherited = None; + let mut inherited = None::<(crate::parse::registry::ContextKey, CascadePolicy)>; for &node in chain { - let Some(local) = self.node(node).cascade_policy(directive) else { + let context = self.node(node).node.context; + let Some(local) = self + .directive_contract(context, directive) + .map(DirectiveContract::cascade) + else { continue; }; - if let Some(inherited) = inherited - && inherited != local + if let Some((inherited_context, inherited_policy)) = inherited + && inherited_policy != local { return Err(ConfigQueryError::CascadePolicyMismatch { directive: directive.as_str().to_owned(), - inherited, + inherited_context, + inherited: inherited_policy, + local_context: context, local, - mismatch: None, }); } - inherited = Some(local); + inherited = Some((context, local)); } - inherited.ok_or_else(|| ConfigQueryError::MissingCascadePolicy { - directive: directive.as_str().to_owned(), - }) + inherited + .map(|(_, policy)| policy) + .ok_or_else(|| ConfigQueryError::MissingCascadePolicy { + directive: directive.as_str().to_owned(), + }) } fn check_contract( @@ -573,40 +579,105 @@ impl HomeConfigTree { node: ConfigNodeId, expected: DirectiveContract, ) -> Result<(), ConfigQueryError> { - let sealed = self.node(node); - let Some(actual) = sealed - .directive_contracts - .iter() - .copied() - .find(|contract| contract.name() == expected.name()) - else { - return Err(ConfigQueryError::CascadePolicyMismatch { - directive: expected.name().as_str().to_owned(), - inherited: expected.cascade(), - local: expected.cascade(), - mismatch: Some(crate::parse::registry::DirectiveContractMismatch::Name { - expected: expected.name(), - actual: None, - }), - }); + let context = self.node(node).node.context; + let actual = self.directive_contract(context, expected.name()); + Self::ensure_contract(expected, actual, context)?; + Ok(()) + } + + fn check_cascaded_contracts( + &self, + chain: &[ConfigNodeId], + key: DirectiveKey, + ) -> Result<(), ConfigQueryError> { + let expected = key.contracts().collect::>(); + let target = *chain.last().expect("a cascade chain always has a target"); + let target_context = self.node(target).node.context; + let target_expected = *expected + .last() + .expect("a directive key always has a target contract"); + Self::ensure_contract( + target_expected, + self.directive_contract(target_context, key.name()), + target_context, + )?; + + for &expected in expected[..expected.len() - 1].iter().rev() { + let actual = chain + .iter() + .rev() + .map(|&node| self.node(node).node.context) + .find(|&context| context == expected.context()) + .and_then(|context| self.directive_contract(context, key.name())); + Self::ensure_contract(expected, actual, expected.context())?; + } + + for actual in chain[..chain.len() - 1].iter().filter_map(|&node| { + let context = self.node(node).node.context; + self.directive_contract(context, key.name()) + }) { + if !expected + .iter() + .any(|expected| expected.context() == actual.context()) + { + return Err(ConfigQueryError::ContractMismatch { + directive: key.name(), + context: actual.context(), + mismatch: crate::parse::registry::DirectiveContractMismatch::Context { + expected: target_expected.context(), + actual: actual.context(), + }, + }); + } + } + Ok(()) + } + + fn ensure_contract( + expected: DirectiveContract, + actual: Option, + context: crate::parse::registry::ContextKey, + ) -> Result<(), ConfigQueryError> { + let mismatch = match actual { + Some(actual) => expected.mismatch(actual), + None => Some(crate::parse::registry::DirectiveContractMismatch::Name { + expected: expected.name(), + actual: None, + }), }; - if let Some(mismatch) = expected.mismatch(actual) { - return Err(ConfigQueryError::CascadePolicyMismatch { - directive: expected.name().as_str().to_owned(), - inherited: expected.cascade(), - local: actual.cascade(), - mismatch: Some(mismatch), + if let Some(mismatch) = mismatch { + return Err(ConfigQueryError::ContractMismatch { + directive: expected.name(), + context, + mismatch, }); } Ok(()) } -} -impl SealedConfigNode { - fn cascade_policy(&self, directive: DirectiveName) -> Option { - self.cascade_policies + fn directive_contract( + &self, + context: crate::parse::registry::ContextKey, + directive: DirectiveName, + ) -> Option { + self.contract_tables + .get(&context)? .iter() - .find_map(|(name, policy)| (*name == directive).then_some(*policy)) + .find(|contract| contract.name() == directive) + .copied() + } + + #[cfg(test)] + pub(crate) fn contract_tables_shared(&self, first: ConfigNodeId, second: ConfigNodeId) -> bool { + let first_context = self.node(first).node.context; + let second_context = self.node(second).node.context; + match ( + self.contract_tables.get(&first_context), + self.contract_tables.get(&second_context), + ) { + (Some(first), Some(second)) => Arc::ptr_eq(first, second), + _ => false, + } } } diff --git a/pishoo/src/config/tests.rs b/pishoo/src/config/tests.rs index ee3d233b..32e7781d 100644 --- a/pishoo/src/config/tests.rs +++ b/pishoo/src/config/tests.rs @@ -89,6 +89,98 @@ fn pishoo_keys_query_pid_workers_and_groups_across_crate() { assert_eq!(groups.0, vec!["admin", "web"]); } +#[test] +fn public_key_inventory_has_semantic_query_domains() { + fn local(_: gateway::parse::registry::LocalDirectiveKey) {} + fn repeated(_: gateway::parse::registry::RepeatedDirectiveKey) {} + fn payload(_: gateway::parse::registry::ContextPayloadKey) {} + fn cascaded(_: gateway::parse::cascade::DirectiveKey) {} + + local(gateway::parse::keys::pishoo::PID); + local(gateway::parse::keys::pishoo::WORKERS); + local(gateway::parse::keys::pishoo::GROUPS); + + repeated(gateway::parse::keys::server::LISTEN); + local(gateway::parse::keys::server::SERVER_NAME); + local(gateway::parse::keys::server::DNS); + cascaded(gateway::parse::keys::server::GZIP); + cascaded(gateway::parse::keys::server::GZIP_VARY); + cascaded(gateway::parse::keys::server::GZIP_MIN_LENGTH); + cascaded(gateway::parse::keys::server::GZIP_COMP_LEVEL); + cascaded(gateway::parse::keys::server::GZIP_TYPES); + local(gateway::parse::keys::server::SSL_CERTIFICATE); + local(gateway::parse::keys::server::SSL_CERTIFICATE_KEY); + cascaded(gateway::parse::keys::server::DEFAULT_TYPE); + cascaded(gateway::parse::keys::server::ACCESS_RULES); + local(gateway::parse::keys::server::RELAY); + local(gateway::parse::keys::server::STUN); + cascaded(gateway::parse::keys::server::TYPES); + + payload(gateway::parse::keys::location::PATTERN); + local(gateway::parse::keys::location::ROOT); + local(gateway::parse::keys::location::ALIAS); + cascaded(gateway::parse::keys::location::GZIP); + cascaded(gateway::parse::keys::location::GZIP_VARY); + cascaded(gateway::parse::keys::location::GZIP_MIN_LENGTH); + cascaded(gateway::parse::keys::location::GZIP_COMP_LEVEL); + cascaded(gateway::parse::keys::location::GZIP_TYPES); + local(gateway::parse::keys::location::INDEX); + repeated(gateway::parse::keys::location::ADD_HEADER); + repeated(gateway::parse::keys::location::PROXY_SET_HEADER); + local(gateway::parse::keys::location::PROXY_PASS); + local(gateway::parse::keys::location::PROXY_SSL_CERTIFICATE); + local(gateway::parse::keys::location::PROXY_SSL_CERTIFICATE_KEY); + local(gateway::parse::keys::location::PROXY_SSL_TRUSTED_CERTIFICATE); + local(gateway::parse::keys::location::SSH_LOGIN); + repeated(gateway::parse::keys::location::SSH_SSL_USER); + local(gateway::parse::keys::location::SSH_DENY); + cascaded(gateway::parse::keys::location::DEFAULT_TYPE); + cascaded(gateway::parse::keys::location::TYPES); +} + +#[test] +fn public_cascaded_keys_query_server_and_location_across_crate() { + let base = std::env::temp_dir().join(format!( + "pishoo-cascaded-keys-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + let source_path = base.join("pishoo.conf"); + let registry = gateway::parse::default_registry(); + let mut parser = gateway::parse::ConfigDocumentParser::new(®istry); + let gateway::parse::fragment::ParsedConfigDocument::HypervisorRoot(fragment) = parser + .parse_text( + "pishoo { gzip on; server { listen all 4101; ssl_certificate cert.pem; ssl_certificate_key key.pem; gzip off; location / { gzip on; } } }", + &source_path, + gateway::parse::domain::ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .expect("root config should parse") + else { + panic!("expected pishoo fragment"); + }; + let tree = gateway::parse::tree::build_global_tree(®istry, fragment, Vec::new()) + .expect("tree should seal"); + let server = tree.servers().next().expect("server should attach"); + let location = server.locations().next().expect("location should attach"); + + let server_gzip = server + .node() + .cascaded(gateway::parse::keys::server::GZIP) + .expect("server gzip query") + .expect("server gzip should inherit a value"); + let location_gzip = location + .node() + .cascaded(gateway::parse::keys::location::GZIP) + .expect("location gzip query") + .expect("location gzip should inherit a value"); + + assert!(!server_gzip.effective().0); + assert!(location_gzip.effective().0); +} + #[test] fn parse_workers_from_root_config() { let conf = "pishoo { workers alice bob; }"; From 64f0211407d268245e5e4c252f752e207412436e Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 10:02:40 +0800 Subject: [PATCH 13/18] fix(config): align typed query identity --- gateway/src/parse/registry.rs | 42 +------------ gateway/src/parse/tests.rs | 107 +++++++++++++++++++++++----------- pishoo/src/config/tests.rs | 33 ++++++++++- 3 files changed, 106 insertions(+), 76 deletions(-) diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 22830cf0..934242ee 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -165,14 +165,6 @@ pub enum DirectiveContractMismatch { expected: CascadePolicy, actual: CascadePolicy, }, - Transport { - expected: TransportPolicy, - actual: TransportPolicy, - }, - Reload { - expected: ReloadImpact, - actual: ReloadImpact, - }, } impl std::fmt::Display for DirectiveContractMismatch { @@ -208,12 +200,6 @@ impl std::fmt::Display for DirectiveContractMismatch { Self::Cascade { expected, actual } => { write!(f, "cascade policy expected {expected:?}, actual {actual:?}") } - Self::Transport { expected, actual } => { - write!(f, "transport expected {expected:?}, actual {actual:?}") - } - Self::Reload { expected, actual } => { - write!(f, "reload impact expected {expected:?}, actual {actual:?}") - } } } } @@ -228,8 +214,6 @@ pub(crate) struct DirectiveContractTemplate { shape: DirectiveShape, duplicate: DuplicatePolicy, cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, } impl DirectiveContractTemplate { @@ -243,8 +227,6 @@ impl DirectiveContractTemplate { shape: self.shape, duplicate: self.duplicate, cascade: self.cascade, - transport: self.transport, - reload: self.reload, } } } @@ -259,8 +241,6 @@ pub(crate) struct DirectiveContract { shape: DirectiveShape, duplicate: DuplicatePolicy, cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, } fn value_type_name() -> &'static str { @@ -329,21 +309,9 @@ impl DirectiveContract { actual: actual.duplicate, }); } - if self.cascade != actual.cascade { - return Some(DirectiveContractMismatch::Cascade { - expected: self.cascade, - actual: actual.cascade, - }); - } - if self.transport != actual.transport { - return Some(DirectiveContractMismatch::Transport { - expected: self.transport, - actual: actual.transport, - }); - } - (self.reload != actual.reload).then_some(DirectiveContractMismatch::Reload { - expected: self.reload, - actual: actual.reload, + (self.cascade != actual.cascade).then_some(DirectiveContractMismatch::Cascade { + expected: self.cascade, + actual: actual.cascade, }) } } @@ -611,8 +579,6 @@ impl DirectiveSpec { shape: self.shape, duplicate: self.duplicate, cascade: self.cascade, - transport: self.transport, - reload: self.reload, } } } @@ -746,8 +712,6 @@ impl TypedDirectiveDefinition { shape: self.shape, duplicate: self.duplicate, cascade: self.cascade, - transport: self.transport, - reload: self.reload, } } diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 5053bb0b..410270ca 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -356,8 +356,8 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { DirectiveShape::RawBlock, DuplicatePolicy::Reject, CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, + TransportPolicy::HypervisorOnly, + ReloadImpact::ListenerSet, ); let server_definition = cascaded_definition::( context::SERVER, @@ -365,8 +365,8 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { DirectiveShape::RawBlock, DuplicatePolicy::Reject, CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, + TransportPolicy::WorkerInheritable, + ReloadImpact::Supervisor, ); let mut replacement = ConfigRegistry::new(); replacement.register_context(ContextSpec { @@ -398,7 +398,7 @@ fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { } #[test] -fn sealed_cascaded_query_rejects_ancestor_contract_drift_without_value() { +fn sealed_cascaded_query_rejects_empty_ancestor_contract_drift() { assert_ancestor_gzip_contract_mismatch( DirectiveSpec::leaf_value::( "gzip", @@ -454,48 +454,73 @@ fn sealed_cascaded_query_rejects_ancestor_contract_drift_without_value() { ), |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), ); - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Transport { .. }), - ); - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Reload { .. }), - ); + assert_ancestor_gzip_contract_accepted(DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerInheritable, + ReloadImpact::RuntimeState, + )); + assert_ancestor_gzip_contract_accepted(DirectiveSpec::leaf_value::( + "gzip", + vec![context::SERVER], + DuplicatePolicy::Reject, + CascadePolicy::NearestWins, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, + )); } #[test] -fn directive_contract_mismatch_display_names_context_and_expected_actual_values() { - let error = ancestor_gzip_contract_error(DirectiveSpec::leaf_value::( +fn sealed_query_reports_contract_mismatch_diagnostics() { + let error = ancestor_gzip_contract_error(DirectiveSpec::raw_value::( "gzip", vec![context::SERVER], DuplicatePolicy::Reject, CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, + TransportPolicy::WorkerLocalOnly, ReloadImpact::RuntimeState, )); + let crate::parse::error::ConfigQueryError::ContractMismatch { + directive, + context: actual_context, + mismatch, + } = &error + else { + panic!("expected typed directive contract mismatch"); + }; let rendered = error.to_string(); + assert_eq!(directive.as_str(), "gzip"); + assert_eq!(*actual_context, context::SERVER); + assert!(matches!( + mismatch, + DirectiveContractMismatch::Shape { + expected: DirectiveShape::Leaf, + actual: DirectiveShape::RawBlock, + } + )); assert!(rendered.contains("`gzip`")); assert!(rendered.contains(context::SERVER.0)); - assert!(rendered.contains("transport")); - assert!(rendered.contains("WorkerLocalOnly")); - assert!(rendered.contains("WorkerInheritable")); + assert!(rendered.contains("shape")); + assert!(rendered.contains("Leaf")); + assert!(rendered.contains("RawBlock")); assert!(!rendered.contains("inconsistent cascade policies")); + assert_query_contract_mismatch_domain(*mismatch); +} + +fn assert_query_contract_mismatch_domain(mismatch: DirectiveContractMismatch) { + match mismatch { + DirectiveContractMismatch::Name { .. } + | DirectiveContractMismatch::Context { .. } + | DirectiveContractMismatch::ValueType { .. } + | DirectiveContractMismatch::Cardinality { .. } + | DirectiveContractMismatch::Shape { .. } + | DirectiveContractMismatch::Payload { .. } + | DirectiveContractMismatch::Duplicate { .. } + | DirectiveContractMismatch::Cascade { .. } => {} + } } fn cascaded_definition( @@ -531,7 +556,9 @@ fn assert_cascaded_contract_mismatch( assert!(expected(mismatch)); } -fn ancestor_gzip_contract_error(spec: DirectiveSpec) -> crate::parse::error::ConfigQueryError { +fn ancestor_gzip_contract_result( + spec: DirectiveSpec, +) -> Result<(), crate::parse::error::ConfigQueryError> { let fixture = TempConfigDir::new("sealed_ancestor_contract_mismatch"); let mut registry = crate::parse::default_registry(); registry.register_directive(context::SERVER, spec); @@ -555,7 +582,12 @@ fn ancestor_gzip_contract_error(spec: DirectiveSpec) -> crate::parse::error::Con location .node() .cascaded(crate::parse::keys::location::GZIP) - .expect_err("an unused ancestor definition must still match the key contract") + .map(|_| ()) +} + +fn ancestor_gzip_contract_error(spec: DirectiveSpec) -> crate::parse::error::ConfigQueryError { + ancestor_gzip_contract_result(spec) + .expect_err("an unused ancestor definition must still match the query contract") } fn assert_ancestor_gzip_contract_mismatch( @@ -575,6 +607,11 @@ fn assert_ancestor_gzip_contract_mismatch( assert!(expected(mismatch)); } +fn assert_ancestor_gzip_contract_accepted(spec: DirectiveSpec) { + ancestor_gzip_contract_result(spec) + .expect("transport and reload metadata are outside typed query identity"); +} + #[test] fn home_arena_path_query_returns_resolved_config_path() { let fixture = TempConfigDir::new("home_arena_path_query"); diff --git a/pishoo/src/config/tests.rs b/pishoo/src/config/tests.rs index 32e7781d..5c04ce0e 100644 --- a/pishoo/src/config/tests.rs +++ b/pishoo/src/config/tests.rs @@ -139,7 +139,25 @@ fn public_key_inventory_has_semantic_query_domains() { } #[test] -fn public_cascaded_keys_query_server_and_location_across_crate() { +fn pishoo_public_cascaded_keys_query_server_and_location_across_crate() { + fn cascaded(_: gateway::parse::cascade::DirectiveKey) {} + + cascaded(gateway::parse::keys::server::GZIP); + cascaded(gateway::parse::keys::server::GZIP_VARY); + cascaded(gateway::parse::keys::server::GZIP_MIN_LENGTH); + cascaded(gateway::parse::keys::server::GZIP_COMP_LEVEL); + cascaded(gateway::parse::keys::server::GZIP_TYPES); + cascaded(gateway::parse::keys::server::DEFAULT_TYPE); + cascaded(gateway::parse::keys::server::ACCESS_RULES); + cascaded(gateway::parse::keys::server::TYPES); + cascaded(gateway::parse::keys::location::GZIP); + cascaded(gateway::parse::keys::location::GZIP_VARY); + cascaded(gateway::parse::keys::location::GZIP_MIN_LENGTH); + cascaded(gateway::parse::keys::location::GZIP_COMP_LEVEL); + cascaded(gateway::parse::keys::location::GZIP_TYPES); + cascaded(gateway::parse::keys::location::DEFAULT_TYPE); + cascaded(gateway::parse::keys::location::TYPES); + let base = std::env::temp_dir().join(format!( "pishoo-cascaded-keys-{}-{}", std::process::id(), @@ -149,7 +167,18 @@ fn public_cascaded_keys_query_server_and_location_across_crate() { .as_nanos() )); let source_path = base.join("pishoo.conf"); - let registry = gateway::parse::default_registry(); + let mut registry = gateway::parse::default_registry(); + registry.register_directive( + gateway::parse::registry::context::SERVER, + gateway::parse::registry::DirectiveSpec::leaf_value::( + "gzip", + vec![gateway::parse::registry::context::SERVER], + gateway::parse::registry::DuplicatePolicy::Reject, + gateway::parse::registry::CascadePolicy::NearestWins, + gateway::parse::registry::TransportPolicy::WorkerInheritable, + gateway::parse::registry::ReloadImpact::ListenerSet, + ), + ); let mut parser = gateway::parse::ConfigDocumentParser::new(®istry); let gateway::parse::fragment::ParsedConfigDocument::HypervisorRoot(fragment) = parser .parse_text( From d78ae24b4eaf46ec319b8f71bb0a253d86e4772e Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 14:48:31 +0800 Subject: [PATCH 14/18] feat(pishoo): build inherited worker home trees --- Cargo.lock | 20 +- pishoo/src/config.rs | 4 + pishoo/src/config/account.rs | 353 ++++++++++++++++++ pishoo/src/config/home_tree.rs | 634 +++++++++++++++++++++++++++++++++ pishoo/src/config/source.rs | 162 +++++++-- 5 files changed, 1139 insertions(+), 34 deletions(-) create mode 100644 pishoo/src/config/account.rs create mode 100644 pishoo/src/config/home_tree.rs diff --git a/Cargo.lock b/Cargo.lock index a7f0a24a..dcdc029f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,14 +1020,13 @@ dependencies = [ [[package]] name = "dhttp" version = "0.5.0-beta.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dedfca271d296f9c0d9fee052d337495aacb4d1b49eb4b29f09af9cd446757b1" dependencies = [ "bon", "bytes", "dhttp-access", "dhttp-home", "dhttp-identity", + "dhttp-log", "dyns", "futures", "h3x", @@ -1043,8 +1042,6 @@ dependencies = [ [[package]] name = "dhttp-access" version = "0.4.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbcd9d2805ace5bcac3c112e14b9df13d02f38f2a8a77897acb1cec536fe0715" dependencies = [ "chrono", "derive_more", @@ -1065,8 +1062,6 @@ dependencies = [ [[package]] name = "dhttp-home" version = "0.4.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af7727cd3f09f4f81bb928c3f7e77587a1a622796413bcbb8348e9a51b24f49" dependencies = [ "dhttp-identity", "dirs", @@ -1095,6 +1090,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "dhttp-log" +version = "0.1.0" +dependencies = [ + "chrono", + "dhttp-identity", + "http", + "sha2 0.10.9", + "snafu", + "tracing-subscriber", + "x509-parser", +] + [[package]] name = "digest" version = "0.10.7" diff --git a/pishoo/src/config.rs b/pishoo/src/config.rs index e963152b..683cb361 100644 --- a/pishoo/src/config.rs +++ b/pishoo/src/config.rs @@ -3,8 +3,12 @@ use std::{path::PathBuf, sync::Arc}; use gateway::parse::{document::ConfigNode, domain::ResolvedConfigPath, error::ConfigQueryError}; use snafu::{OptionExt, ResultExt, Snafu}; +#[allow(dead_code)] // prepared as one domain before the atomic runtime cutover +mod account; mod discovery; pub mod entry; +#[allow(dead_code)] // prepared as one sealed model before the atomic runtime cutover +mod home_tree; pub mod root; pub mod source; pub mod worker_target; diff --git a/pishoo/src/config/account.rs b/pishoo/src/config/account.rs new file mode 100644 index 00000000..21c2e9a5 --- /dev/null +++ b/pishoo/src/config/account.rs @@ -0,0 +1,353 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use dhttp::home::DhttpHome; +use nix::unistd::{Gid, Uid}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use snafu::{Snafu, ensure}; + +use super::ResolvedWorkerTarget; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum WorkerAccountError { + #[snafu(display("worker account name cannot be empty"))] + EmptyName, + #[snafu(display("worker login home must be absolute: {}", path.display()))] + RelativeLoginHome { path: PathBuf }, + #[snafu(display("worker dhttp home must be absolute: {}", path.display()))] + RelativeDhttpHome { path: PathBuf }, +} + +#[derive(Clone, Debug)] +pub(crate) struct WorkerAccount { + name: String, + uid: Uid, + primary_gid: Gid, + login_home: PathBuf, + dhttp_home: DhttpHome, +} + +impl WorkerAccount { + pub(crate) fn new( + name: String, + uid: Uid, + primary_gid: Gid, + login_home: PathBuf, + dhttp_home: DhttpHome, + ) -> Result { + ensure!(!name.is_empty(), worker_account_error::EmptyNameSnafu); + ensure!( + login_home.is_absolute(), + worker_account_error::RelativeLoginHomeSnafu { path: login_home } + ); + ensure!( + dhttp_home.as_path().is_absolute(), + worker_account_error::RelativeDhttpHomeSnafu { + path: dhttp_home.as_path().to_path_buf() + } + ); + Ok(Self { + name, + uid, + primary_gid, + login_home, + dhttp_home, + }) + } + + pub(crate) fn from_target( + target: &ResolvedWorkerTarget, + dhttp_home: DhttpHome, + ) -> Result { + Self::new( + target.name.clone(), + target.uid, + target.gid, + target.dir.clone(), + dhttp_home, + ) + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(crate) const fn uid(&self) -> Uid { + self.uid + } + + pub(crate) const fn primary_gid(&self) -> Gid { + self.primary_gid + } + + pub(crate) fn login_home(&self) -> &std::path::Path { + &self.login_home + } + + pub(crate) fn dhttp_home(&self) -> &DhttpHome { + &self.dhttp_home + } +} + +impl PartialEq for WorkerAccount { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.uid == other.uid + && self.primary_gid == other.primary_gid + && self.login_home == other.login_home + && self.dhttp_home.as_path() == other.dhttp_home.as_path() + } +} + +impl Eq for WorkerAccount {} + +#[derive(Serialize, Deserialize)] +struct WorkerAccountWire { + name: String, + uid: u32, + primary_gid: u32, + login_home: PathBuf, + dhttp_home: PathBuf, +} + +impl Serialize for WorkerAccount { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + WorkerAccountWire { + name: self.name.clone(), + uid: self.uid.as_raw(), + primary_gid: self.primary_gid.as_raw(), + login_home: self.login_home.clone(), + dhttp_home: self.dhttp_home.as_path().to_path_buf(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for WorkerAccount { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = WorkerAccountWire::deserialize(deserializer)?; + Self::new( + wire.name, + Uid::from_raw(wire.uid), + Gid::from_raw(wire.primary_gid), + wire.login_home, + DhttpHome::new(wire.dhttp_home), + ) + .map_err(D::Error::custom) + } +} + +pub(crate) fn select_worker_dhttp_home(target: &ResolvedWorkerTarget) -> DhttpHome { + DhttpHome::for_user_home_dir(target.dir.clone()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WorkerCredentialSnapshot { + pub(crate) real_uid: Uid, + pub(crate) effective_uid: Uid, + pub(crate) real_gid: Gid, + pub(crate) effective_gid: Gid, +} + +#[derive(Debug, Snafu)] +pub(crate) enum BuildWorkerRosterError { + #[snafu(display("duplicate worker uid {uid}"))] + DuplicateUid { uid: Uid }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkerRoster(BTreeMap); + +impl WorkerRoster { + pub(crate) fn new( + accounts: impl IntoIterator, + ) -> Result { + let mut roster = BTreeMap::new(); + for account in accounts { + let uid = account.uid().as_raw(); + if roster.insert(uid, account).is_some() { + return Err(BuildWorkerRosterError::DuplicateUid { + uid: Uid::from_raw(uid), + }); + } + } + Ok(Self(roster)) + } + + pub(crate) fn get(&self, uid: Uid) -> Option<&WorkerAccount> { + self.0.get(&uid.as_raw()) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use dhttp::home::DhttpHome; + use nix::unistd::{Gid, Uid, User}; + + use super::{ + BuildWorkerRosterError, WorkerAccount, WorkerAccountError, WorkerCredentialSnapshot, + WorkerRoster, select_worker_dhttp_home, + }; + + fn account() -> WorkerAccount { + WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1000), + Gid::from_raw(100), + PathBuf::from("/home/alice"), + DhttpHome::new(PathBuf::from("/srv/dhttp/alice")), + ) + .unwrap() + } + + #[test] + fn worker_account_rejects_empty_name() { + let error = WorkerAccount::new( + String::new(), + Uid::from_raw(1), + Gid::from_raw(2), + PathBuf::from("/home/alice"), + DhttpHome::new(PathBuf::from("/srv/dhttp/alice")), + ) + .unwrap_err(); + assert!(matches!(error, WorkerAccountError::EmptyName)); + } + + #[test] + fn worker_account_rejects_relative_dhttp_home() { + let error = WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1), + Gid::from_raw(2), + PathBuf::from("/home/alice"), + DhttpHome::new(PathBuf::from("relative/.dhttp")), + ) + .unwrap_err(); + assert!(matches!( + error, + WorkerAccountError::RelativeDhttpHome { .. } + )); + } + + #[test] + fn worker_account_rejects_relative_login_home() { + let error = WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1), + Gid::from_raw(2), + PathBuf::from("relative"), + DhttpHome::new(PathBuf::from("/srv/dhttp/alice")), + ) + .unwrap_err(); + assert!(matches!( + error, + WorkerAccountError::RelativeLoginHome { .. } + )); + } + + #[test] + fn worker_account_keeps_login_home_distinct_from_dhttp_home() { + let account = account(); + assert_eq!(account.login_home(), std::path::Path::new("/home/alice")); + assert_eq!( + account.dhttp_home().as_path(), + std::path::Path::new("/srv/dhttp/alice") + ); + } + + #[test] + fn worker_account_full_equality_includes_gid_and_both_homes() { + let original = account(); + let different_gid = WorkerAccount::new( + original.name().to_owned(), + original.uid(), + Gid::from_raw(101), + original.login_home().to_path_buf(), + original.dhttp_home().clone(), + ) + .unwrap(); + let different_login = WorkerAccount::new( + original.name().to_owned(), + original.uid(), + original.primary_gid(), + PathBuf::from("/different/login"), + original.dhttp_home().clone(), + ) + .unwrap(); + let different_dhttp = WorkerAccount::new( + original.name().to_owned(), + original.uid(), + original.primary_gid(), + original.login_home().to_path_buf(), + DhttpHome::new(PathBuf::from("/different/dhttp")), + ) + .unwrap(); + assert_ne!(original, different_gid); + assert_ne!(original, different_login); + assert_ne!(original, different_dhttp); + assert_eq!(original, original.clone()); + } + + #[test] + fn worker_roster_rejects_duplicate_uid() { + let original = account(); + let duplicate = WorkerAccount::new( + "bob".to_owned(), + original.uid(), + Gid::from_raw(999), + PathBuf::from("/home/bob"), + DhttpHome::new(PathBuf::from("/home/bob/.dhttp")), + ) + .unwrap(); + let error = WorkerRoster::new([original, duplicate]).unwrap_err(); + assert!(matches!(error, BuildWorkerRosterError::DuplicateUid { .. })); + } + + #[test] + fn worker_wire_preserves_root_selected_dhttp_home_distinct_from_login_home() { + let original = account(); + let encoded = serde_json::to_vec(&original).unwrap(); + let decoded: WorkerAccount = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded, original); + assert_ne!(decoded.login_home(), decoded.dhttp_home().as_path()); + } + + #[test] + fn worker_credentials_keep_real_and_effective_ids_typed() { + let credentials = WorkerCredentialSnapshot { + real_uid: Uid::from_raw(1), + effective_uid: Uid::from_raw(2), + real_gid: Gid::from_raw(3), + effective_gid: Gid::from_raw(4), + }; + assert_eq!(credentials.real_uid, Uid::from_raw(1)); + assert_eq!(credentials.effective_uid, Uid::from_raw(2)); + assert_eq!(credentials.real_gid, Gid::from_raw(3)); + assert_eq!(credentials.effective_gid, Gid::from_raw(4)); + } + + #[test] + fn default_worker_home_selection_uses_the_login_home_domain_entry_point() { + let target = User { + name: "alice".to_owned(), + passwd: std::ffi::CString::new("x").unwrap(), + uid: Uid::from_raw(1000), + gid: Gid::from_raw(100), + gecos: std::ffi::CString::new("").unwrap(), + dir: PathBuf::from("/home/alice"), + shell: PathBuf::from("/bin/sh"), + }; + assert_eq!( + select_worker_dhttp_home(&target).as_path(), + std::path::Path::new("/home/alice/.dhttp") + ); + } +} diff --git a/pishoo/src/config/home_tree.rs b/pishoo/src/config/home_tree.rs new file mode 100644 index 00000000..3701f7bf --- /dev/null +++ b/pishoo/src/config/home_tree.rs @@ -0,0 +1,634 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use dhttp::{ + home::{ + DhttpHome, + identity::{IdentityProfile, ssl::ListIdentityProfilesStrictError}, + }, + name::DhttpName, +}; +use gateway::parse::{ + ConfigDocumentParser, + domain::{ConfigDocumentRole, ConfigSourceSpan}, + error::{ConfigLoadFailure, ConfigQueryError}, + fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, + snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, + tree::{ConfigNodeId, HomeConfigTree, HomeConfigTreeError, ServerConfigRef}, +}; +use snafu::{IntoError, ResultExt, Snafu}; + +use super::{account::WorkerAccount, source::PishooConfigSource}; + +#[derive(Clone, Debug)] +pub(crate) enum ServiceScope { + Global, + Worker(Arc), +} + +#[derive(Clone, Debug)] +pub(crate) enum ServiceBindingOrigin { + Direct { + node: ConfigNodeId, + source: Option, + }, + Identity { + node: ConfigNodeId, + source: Option, + profile: IdentityProfile, + }, +} + +#[derive(Clone, Debug)] +pub(crate) struct ScopedServerConfig { + server: ServerConfigRef, + identity_profile: Option, + service_names: Box<[DhttpName<'static>]>, +} + +impl ScopedServerConfig { + pub(crate) fn server(&self) -> &ServerConfigRef { + &self.server + } + + pub(crate) fn identity_profile(&self) -> Option<&IdentityProfile> { + self.identity_profile.as_ref() + } + + pub(crate) fn service_names(&self) -> &[DhttpName<'static>] { + &self.service_names + } +} + +#[derive(Debug)] +pub(crate) struct ScopedHomeConfig { + scope: ServiceScope, + tree: Arc, + servers: Box<[ScopedServerConfig]>, +} + +impl ScopedHomeConfig { + pub(crate) fn scope(&self) -> &ServiceScope { + &self.scope + } + + pub(crate) fn tree(&self) -> &Arc { + &self.tree + } + + pub(crate) fn servers(&self) -> &[ScopedServerConfig] { + &self.servers + } + + pub(crate) fn root_snapshot(&self) -> Result { + self.tree.root_snapshot() + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub(crate) enum BuildHomeConfigError { + #[snafu(display("failed to inspect configuration source {}", path.display()))] + SourceMetadata { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to read configuration source {}", path.display()))] + ReadSource { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to parse configuration source {}", path.display()))] + ParseSource { + path: PathBuf, + source: Box, + }, + #[snafu(display("configuration source {} has the wrong document role", path.display()))] + DocumentRole { path: PathBuf }, + #[snafu(display("failed to enumerate identity profiles in {}", home.display()))] + EnumerateProfiles { + home: PathBuf, + source: Box, + }, + #[snafu(display("identity profile {} contains multiple server blocks", profile.path().display()))] + MultipleIdentityServers { profile: IdentityProfile }, + #[snafu(display("worker pishoo configuration cannot declare direct server blocks"))] + WorkerDirectServers, + #[snafu(display("identity server {} does not bind exactly its profile name", profile.path().display()))] + IdentityServerNames { profile: IdentityProfile }, + #[snafu(display("identity server {} overrides its profile TLS paths", profile.path().display()))] + IdentityTlsOverride { profile: IdentityProfile }, + #[snafu(display("direct server {node:?} does not provide a complete TLS pair"))] + DirectTlsPair { node: ConfigNodeId }, + #[snafu(display("failed to query sealed server configuration"))] + QueryServer { source: ConfigQueryError }, + #[snafu(display("failed to seal the home configuration tree"))] + SealTree { source: HomeConfigTreeError }, + #[snafu(display("duplicate service identity {name}"))] + DuplicateServiceIdentity { + name: DhttpName<'static>, + first: Box, + second: Box, + }, +} + +pub(crate) async fn build_global_home_config( + source: &PishooConfigSource, +) -> Result, BuildHomeConfigError> { + let registry = gateway::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let root_text = read_required(source.config_path()).await?; + let root = parse_root( + &mut parser, + &root_text, + source.config_path(), + source.dhttp_home(), + )?; + let direct_count = root.servers().len(); + + let (identity_fragments, identity_profiles) = match source.dhttp_home() { + Some(home) => load_identity_fragments(&mut parser, home).await?, + None => (Vec::new(), Vec::new()), + }; + let tree = gateway::parse::tree::build_global_tree(®istry, root, identity_fragments) + .context(build_home_config_error::SealTreeSnafu)?; + let all_servers = tree.servers().collect::>(); + let (direct, identity) = all_servers.split_at(direct_count); + let mut sealed = Vec::with_capacity(all_servers.len()); + for (server, profile) in identity.iter().cloned().zip(identity_profiles) { + sealed.push(seal_server(server, Some(profile))?); + } + for server in direct.iter().cloned() { + sealed.push(seal_server(server, None)?); + } + reject_duplicate_names(&sealed)?; + + Ok(Arc::new(ScopedHomeConfig { + scope: ServiceScope::Global, + tree, + servers: sealed.into_boxed_slice(), + })) +} + +pub(crate) async fn build_worker_home_config( + account: WorkerAccount, + root: Arc, +) -> Result, BuildHomeConfigError> { + let registry = gateway::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let home = account.dhttp_home(); + let worker_path = home.join(PishooConfigSource::CONFIG_FILE_NAME); + let worker = match read_optional(&worker_path).await? { + Some(text) => { + let worker = parse_worker(&mut parser, &text, &worker_path, home)?; + if !worker.servers().is_empty() { + return build_home_config_error::WorkerDirectServersSnafu.fail(); + } + Some(worker) + } + None => None, + }; + let (identity_fragments, identity_profiles) = + load_identity_fragments(&mut parser, home).await?; + let tree = gateway::parse::tree::build_worker_tree( + ®istry, + root.as_ref().clone(), + worker, + identity_fragments, + ) + .context(build_home_config_error::SealTreeSnafu)?; + let mut sealed = Vec::new(); + for (server, profile) in tree.servers().zip(identity_profiles) { + sealed.push(seal_server(server, Some(profile))?); + } + reject_duplicate_names(&sealed)?; + + Ok(Arc::new(ScopedHomeConfig { + scope: ServiceScope::Worker(Arc::new(account)), + tree, + servers: sealed.into_boxed_slice(), + })) +} + +async fn load_identity_fragments( + parser: &mut ConfigDocumentParser<'_>, + home: &DhttpHome, +) -> Result<(Vec, Vec), BuildHomeConfigError> { + let profiles = home + .list_identity_profiles_strict() + .await + .map_err(|source| BuildHomeConfigError::EnumerateProfiles { + home: home.as_path().to_path_buf(), + source: Box::new(source), + })?; + let mut fragments = Vec::new(); + let mut owners = Vec::new(); + for profile in profiles.into_vec() { + let path = profile.server_conf_path(); + let Some(text) = read_optional(&path).await? else { + continue; + }; + let ParsedConfigDocument::IdentityServers(servers) = parser + .parse_text( + &text, + &path, + ConfigDocumentRole::IdentityServer { + home, + profile: &profile, + }, + ) + .map_err(|source| BuildHomeConfigError::ParseSource { + path: path.clone(), + source: Box::new(source), + })? + else { + return build_home_config_error::DocumentRoleSnafu { path }.fail(); + }; + if servers.len() != 1 { + return build_home_config_error::MultipleIdentityServersSnafu { profile }.fail(); + } + fragments.push( + servers + .into_vec() + .pop() + .expect("one identity server was validated"), + ); + owners.push(profile); + } + Ok((fragments, owners)) +} + +fn parse_root( + parser: &mut ConfigDocumentParser<'_>, + text: &str, + path: &Path, + home: Option<&DhttpHome>, +) -> Result { + let parsed = parser + .parse_text(text, path, ConfigDocumentRole::HypervisorRoot { home }) + .map_err(|source| BuildHomeConfigError::ParseSource { + path: path.to_path_buf(), + source: Box::new(source), + })?; + match parsed { + ParsedConfigDocument::HypervisorRoot(root) => Ok(root), + _ => build_home_config_error::DocumentRoleSnafu { path }.fail(), + } +} + +fn parse_worker( + parser: &mut ConfigDocumentParser<'_>, + text: &str, + path: &Path, + home: &DhttpHome, +) -> Result { + let parsed = parser + .parse_text(text, path, ConfigDocumentRole::WorkerPishoo { home }) + .map_err(|source| BuildHomeConfigError::ParseSource { + path: path.to_path_buf(), + source: Box::new(source), + })?; + match parsed { + ParsedConfigDocument::WorkerPishoo(worker) => Ok(worker), + _ => build_home_config_error::DocumentRoleSnafu { path }.fail(), + } +} + +async fn read_required(path: &Path) -> Result { + tokio::fs::read_to_string(path) + .await + .context(build_home_config_error::ReadSourceSnafu { path }) +} + +async fn read_optional(path: &Path) -> Result, BuildHomeConfigError> { + match tokio::fs::symlink_metadata(path).await { + Ok(_) => tokio::fs::read_to_string(path) + .await + .map(Some) + .context(build_home_config_error::ReadSourceSnafu { path }), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => { + Err(build_home_config_error::SourceMetadataSnafu { path }.into_error(source)) + } + } +} + +fn seal_server( + server: ServerConfigRef, + identity_profile: Option, +) -> Result { + let names = server + .node() + .local(gateway::parse::keys::server::SERVER_NAME) + .context(build_home_config_error::QueryServerSnafu)? + .map(|names| { + names + .0 + .iter() + .map(|name| name.name.clone()) + .collect::>() + }) + .unwrap_or_default(); + let certificate = server + .node() + .local(gateway::parse::keys::server::SSL_CERTIFICATE) + .context(build_home_config_error::QueryServerSnafu)?; + let key = server + .node() + .local(gateway::parse::keys::server::SSL_CERTIFICATE_KEY) + .context(build_home_config_error::QueryServerSnafu)?; + + if let Some(profile) = &identity_profile { + if names.as_slice() != [profile.name().clone()] { + return build_home_config_error::IdentityServerNamesSnafu { + profile: profile.clone(), + } + .fail(); + } + if certificate.is_some() || key.is_some() { + return build_home_config_error::IdentityTlsOverrideSnafu { + profile: profile.clone(), + } + .fail(); + } + } else if certificate.is_none() || key.is_none() { + return build_home_config_error::DirectTlsPairSnafu { + node: server.node().id(), + } + .fail(); + } + + Ok(ScopedServerConfig { + server, + identity_profile, + service_names: names.into_boxed_slice(), + }) +} + +fn reject_duplicate_names(servers: &[ScopedServerConfig]) -> Result<(), BuildHomeConfigError> { + let mut seen = HashMap::, ServiceBindingOrigin>::new(); + for server in servers { + let origin = match &server.identity_profile { + Some(profile) => ServiceBindingOrigin::Identity { + node: server.server.node().id(), + source: server.server.node().source_span(), + profile: profile.clone(), + }, + None => ServiceBindingOrigin::Direct { + node: server.server.node().id(), + source: server.server.node().source_span(), + }, + }; + for name in &server.service_names { + if let Some(first) = seen.insert(name.clone(), origin.clone()) { + return Err(BuildHomeConfigError::DuplicateServiceIdentity { + name: name.clone(), + first: Box::new(first), + second: Box::new(origin), + }); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, sync::Arc, time::SystemTime}; + + use dhttp::home::DhttpHome; + use nix::unistd::{Gid, Uid}; + + use super::{ + BuildHomeConfigError, ServiceScope, build_global_home_config, build_worker_home_config, + }; + use crate::config::{account::WorkerAccount, source::PishooConfigSource}; + + struct TempHome(PathBuf); + + impl TempHome { + fn new(label: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "pishoo-home-tree-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn home(&self) -> DhttpHome { + DhttpHome::new(self.0.clone()) + } + + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn profile(&self, name: &str, server: Option<&str>) { + std::fs::create_dir_all(self.0.join(name).join("ssl")).unwrap(); + if let Some(server) = server { + self.write(&format!("{name}/server.conf"), server); + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + _ = std::fs::remove_dir_all(&self.0); + } + } + + fn global_source(temp: &TempHome, config: &str) -> PishooConfigSource { + temp.write("pishoo.conf", config); + PishooConfigSource::from_global_home_at(temp.home(), std::path::Path::new("/ignored")) + .unwrap() + } + + async fn root_snapshot() -> Arc { + let temp = TempHome::new("root-snapshot"); + let global = build_global_home_config(&global_source(&temp, "pishoo {}")) + .await + .unwrap(); + Arc::new(global.root_snapshot().unwrap()) + } + + fn account(temp: &TempHome) -> WorkerAccount { + WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1000), + Gid::from_raw(100), + PathBuf::from("/home/alice"), + temp.home(), + ) + .unwrap() + } + + #[tokio::test] + async fn missing_worker_pishoo_file_is_an_empty_overlay() { + let worker = TempHome::new("missing-worker-overlay"); + let config = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap(); + assert!(matches!(config.scope(), ServiceScope::Worker(_))); + assert!(config.servers().is_empty()); + } + + #[tokio::test] + async fn bad_worker_pishoo_file_fails_the_whole_home() { + let worker = TempHome::new("bad-worker-overlay"); + worker.write("pishoo.conf", "pishoo {"); + let error = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap_err(); + assert!(matches!(error, BuildHomeConfigError::ParseSource { .. })); + } + + #[tokio::test] + async fn missing_identity_server_conf_is_skipped() { + let worker = TempHome::new("missing-identity-server"); + worker.profile("reimu.pilot", None); + let config = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap(); + assert!(config.servers().is_empty()); + } + + #[tokio::test] + async fn bad_identity_server_fails_the_whole_home() { + let worker = TempHome::new("bad-identity-server"); + worker.profile("reimu.pilot", Some("server {")); + let error = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap_err(); + assert!(matches!(error, BuildHomeConfigError::ParseSource { .. })); + } + + #[tokio::test] + async fn identity_server_without_server_name_uses_captured_profile_name() { + let worker = TempHome::new("identity-default-name"); + worker.profile("reimu.pilot", Some("server { listen all 443; }")); + let config = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap(); + assert_eq!(config.servers().len(), 1); + assert_eq!( + config.servers()[0].service_names()[0].as_full(), + "reimu.pilot.dhttp.net" + ); + assert_eq!( + config.servers()[0].identity_profile().unwrap().path(), + worker.0.join("reimu.pilot") + ); + } + + #[tokio::test] + async fn identity_server_rejects_mismatched_or_multiple_server_names() { + let worker = TempHome::new("identity-mismatch"); + worker.profile( + "reimu.pilot", + Some("server { listen all 443; server_name youmu.pilot; }"), + ); + let error = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap_err(); + assert!(matches!( + error, + BuildHomeConfigError::IdentityServerNames { .. } + )); + } + + #[tokio::test] + async fn identity_server_rejects_multiple_server_blocks() { + let worker = TempHome::new("identity-multiple-servers"); + worker.profile( + "reimu.pilot", + Some("server { listen all 443; } server { listen all 444; }"), + ); + let error = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap_err(); + assert!(matches!( + error, + BuildHomeConfigError::MultipleIdentityServers { .. } + )); + } + + #[tokio::test] + async fn identity_server_rejects_explicit_tls_path_override() { + let worker = TempHome::new("identity-tls-override"); + worker.profile( + "reimu.pilot", + Some( + "server { listen all 443; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; }", + ), + ); + let error = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap_err(); + assert!(matches!( + error, + BuildHomeConfigError::IdentityTlsOverride { .. } + )); + } + + #[tokio::test] + async fn duplicate_service_name_between_canonical_profile_paths_fails_whole_home() { + let worker = TempHome::new("identity-duplicate-canonical"); + worker.profile("reimu.pilot", Some("server { listen all 443; }")); + worker.profile("reimu.pilot.dhttp.net", Some("server { listen all 444; }")); + let error = build_worker_home_config(account(&worker), root_snapshot().await) + .await + .unwrap_err(); + assert!(matches!( + error, + BuildHomeConfigError::DuplicateServiceIdentity { .. } + )); + } + + #[tokio::test] + async fn explicit_config_does_not_enumerate_home_identities() { + let temp = TempHome::new("explicit-no-identities"); + temp.profile("123", None); + temp.write( + "explicit.conf", + "pishoo { server { listen all 443; server_name reimu.pilot; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; } }", + ); + let source = PishooConfigSource::explicit_at( + temp.0.join("explicit.conf"), + std::path::Path::new("/ignored"), + ) + .unwrap(); + let config = build_global_home_config(&source).await.unwrap(); + assert_eq!(config.servers().len(), 1); + assert!(config.servers()[0].identity_profile().is_none()); + } + + #[tokio::test] + async fn global_home_servers_are_identity_total_order_then_direct_source_order() { + let temp = TempHome::new("global-order"); + temp.profile("youmu.pilot", Some("server { listen all 445; }")); + temp.profile("reimu.pilot", Some("server { listen all 444; }")); + let source = global_source( + &temp, + "pishoo { server { listen all 443; server_name sanae.pilot; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; } }", + ); + let config = build_global_home_config(&source).await.unwrap(); + let names = config + .servers() + .iter() + .map(|server| server.service_names()[0].as_partial()) + .collect::>(); + assert_eq!(names, ["reimu.pilot", "youmu.pilot", "sanae.pilot"]); + assert!(matches!(config.scope(), ServiceScope::Global)); + assert_eq!(config.tree().servers().count(), 3); + } +} diff --git a/pishoo/src/config/source.rs b/pishoo/src/config/source.rs index 92cceb97..609f8f99 100644 --- a/pishoo/src/config/source.rs +++ b/pishoo/src/config/source.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use dhttp::home::{DhttpHome, HomeScope}; +use gateway::parse::domain::{ResolvedConfigPath, ResolvedConfigPathError}; use snafu::{ResultExt, Snafu}; #[derive(Debug, Snafu)] @@ -10,55 +11,106 @@ pub enum ResolveConfigSourceError { GlobalHome { source: dhttp::home::LoadDhttpHomeError, }, + #[snafu(display("failed to resolve the current directory for pishoo configuration"))] + CurrentDirectory { source: std::io::Error }, + #[snafu(display("invalid pishoo configuration path {}", path.display()))] + ConfigPath { + path: PathBuf, + source: ResolvedConfigPathError, + }, + #[snafu(display("invalid global dhttp home path {}", path.display()))] + DhttpHome { + path: PathBuf, + source: ResolvedConfigPathError, + }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigSourceKind { + GlobalHome, + ExplicitFile, +} + +/// A source selection whose home and configuration path were anchored together exactly once. +/// +/// The representation is deliberately opaque: callers cannot construct a relative source or pair +/// one global home with an unrelated configuration path. +/// +/// ```compile_fail +/// use pishoo::config::PishooConfigSource; +/// let _ = PishooConfigSource::ExplicitFile { config_path: "relative.conf".into() }; +/// ``` #[derive(Debug, Clone)] -pub enum PishooConfigSource { - GlobalHome { - home: DhttpHome, - config_path: PathBuf, - }, - ExplicitFile { - config_path: PathBuf, - }, +pub struct PishooConfigSource { + kind: ConfigSourceKind, + home: Option, + config_path: ResolvedConfigPath, } impl PishooConfigSource { pub const CONFIG_FILE_NAME: &'static str = "pishoo.conf"; pub fn resolve(config_file: Option) -> Result { + let current_dir = + std::env::current_dir().context(resolve_config_source_error::CurrentDirectorySnafu)?; + Self::resolve_at(config_file, ¤t_dir) + } + + fn resolve_at( + config_file: Option, + current_dir: &Path, + ) -> Result { match config_file { - Some(config_path) => Ok(Self::explicit(config_path)), + Some(config_path) => Self::explicit_at(config_path, current_dir), None => { let home = DhttpHome::load(HomeScope::Global) .context(resolve_config_source_error::GlobalHomeSnafu)?; - Ok(Self::from_global_home(home)) + Self::from_global_home_at(home, current_dir) } } } - pub fn from_global_home(home: DhttpHome) -> Self { - let config_path = home.join(Self::CONFIG_FILE_NAME); - Self::GlobalHome { home, config_path } + pub(crate) fn explicit_at( + config_path: PathBuf, + current_dir: &Path, + ) -> Result { + let config_path = anchor(config_path, current_dir); + let config_path = ResolvedConfigPath::try_from(config_path.clone()) + .context(resolve_config_source_error::ConfigPathSnafu { path: config_path })?; + Ok(Self { + kind: ConfigSourceKind::ExplicitFile, + home: None, + config_path, + }) } - pub fn explicit(config_path: PathBuf) -> Self { - Self::ExplicitFile { config_path } + pub(crate) fn from_global_home_at( + home: DhttpHome, + current_dir: &Path, + ) -> Result { + let home_path = anchor(home.as_path().to_path_buf(), current_dir); + let resolved_home = ResolvedConfigPath::try_from(home_path.clone()).context( + resolve_config_source_error::DhttpHomeSnafu { + path: home_path.clone(), + }, + )?; + let home = DhttpHome::new(resolved_home.as_ref().to_path_buf()); + let config_path = home.join(Self::CONFIG_FILE_NAME); + let config_path = ResolvedConfigPath::try_from(config_path.clone()) + .context(resolve_config_source_error::ConfigPathSnafu { path: config_path })?; + Ok(Self { + kind: ConfigSourceKind::GlobalHome, + home: Some(home), + config_path, + }) } pub fn config_path(&self) -> &Path { - match self { - Self::GlobalHome { config_path, .. } | Self::ExplicitFile { config_path } => { - config_path - } - } + self.config_path.as_ref() } pub fn dhttp_home(&self) -> Option<&DhttpHome> { - match self { - Self::GlobalHome { home, .. } => Some(home), - Self::ExplicitFile { .. } => None, - } + self.home.as_ref() } pub fn build_options(&self) -> gateway::parse::registry::BuildOptions<'_> { @@ -69,11 +121,19 @@ impl PishooConfigSource { } pub fn default_worker_groups_enabled(&self) -> bool { - matches!(self, Self::GlobalHome { .. }) + self.kind == ConfigSourceKind::GlobalHome } pub fn load_identity_services(&self) -> bool { - matches!(self, Self::GlobalHome { .. }) + self.kind == ConfigSourceKind::GlobalHome + } +} + +fn anchor(path: PathBuf, current_dir: &Path) -> PathBuf { + if path.is_absolute() { + path + } else { + current_dir.join(path) } } @@ -87,7 +147,11 @@ mod tests { #[test] fn global_home_source_uses_home_pishoo_conf() { - let source = PishooConfigSource::from_global_home(home("/tmp/dhttp-global")); + let source = PishooConfigSource::from_global_home_at( + home("/tmp/dhttp-global"), + Path::new("/ignored"), + ) + .unwrap(); assert_eq!( source.config_path(), @@ -100,11 +164,53 @@ mod tests { #[test] fn explicit_file_source_has_no_home_context() { - let source = PishooConfigSource::explicit(PathBuf::from("/tmp/custom.conf")); + let source = PishooConfigSource::explicit_at( + PathBuf::from("/tmp/custom.conf"), + Path::new("/ignored"), + ) + .unwrap(); assert_eq!(source.config_path(), Path::new("/tmp/custom.conf")); assert!(source.dhttp_home().is_none()); assert!(!source.default_worker_groups_enabled()); assert!(!source.load_identity_services()); } + + #[test] + fn relative_explicit_source_is_anchored_once_to_absolute_path() { + let source = PishooConfigSource::explicit_at( + PathBuf::from("config/pishoo.conf"), + Path::new("/srv/pishoo"), + ) + .unwrap(); + assert_eq!( + source.config_path(), + Path::new("/srv/pishoo/config/pishoo.conf") + ); + } + + #[test] + fn relative_global_home_is_anchored_once_to_absolute_path() { + let source = + PishooConfigSource::from_global_home_at(home("state/dhttp"), Path::new("/srv/pishoo")) + .unwrap(); + assert_eq!( + source.dhttp_home().unwrap().as_path(), + Path::new("/srv/pishoo/state/dhttp") + ); + assert_eq!( + source.config_path(), + Path::new("/srv/pishoo/state/dhttp/pishoo.conf") + ); + } + + #[test] + fn reload_reuses_resolved_source_after_base_changes() { + let source = + PishooConfigSource::explicit_at(PathBuf::from("pishoo.conf"), Path::new("/first")) + .unwrap(); + let retained = source.clone(); + assert_eq!(retained.config_path(), Path::new("/first/pishoo.conf")); + assert_ne!(retained.config_path(), Path::new("/second/pishoo.conf")); + } } From 8e362b96b862351ae243181e4c30df0ec1d262dd Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 14:52:59 +0800 Subject: [PATCH 15/18] refactor(config): type stun server compounds --- gateway/src/parse.rs | 3 + gateway/src/parse/builtin/stun.rs | 378 ++++++++++++++++-------------- gateway/src/parse/registry.rs | 19 +- gateway/src/parse/types.rs | 9 +- gateway/src/stun/config.rs | 168 +++++++------ pishoo/src/config/tests.rs | 9 + 6 files changed, 336 insertions(+), 250 deletions(-) diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index ce33311f..af9bf5be 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -43,6 +43,7 @@ pub mod keys { types::{ AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, ListenConfig, MimeTypes, ResolverConfig, ServerNames, StringList, + StunServerConfigValue, }, }; @@ -70,6 +71,8 @@ pub mod keys { crate::parse::builtin::server::ACCESS_RULES_KEY; pub const RELAY: LocalDirectiveKey = crate::parse::builtin::server::RELAY_KEY; pub const STUN: LocalDirectiveKey = crate::parse::builtin::server::STUN_KEY; + pub const STUN_SERVERS: RepeatedDirectiveKey = + crate::parse::builtin::stun::STUN_SERVERS_KEY; pub const TYPES: DirectiveKey = crate::parse::builtin::server::TYPES_KEY; } diff --git a/gateway/src/parse/builtin/stun.rs b/gateway/src/parse/builtin/stun.rs index d335b285..23ad926f 100644 --- a/gateway/src/parse/builtin/stun.rs +++ b/gateway/src/parse/builtin/stun.rs @@ -3,13 +3,18 @@ use std::net::SocketAddr; use snafu::{ResultExt, Snafu}; use crate::parse::{ - builtin::core::{first_arg_span, only_arg}, + ast::AstBody, + builtin::{ + core::{first_arg_span, only_arg}, + net::SocketAddrsError, + }, registry::{ - CascadePolicy, ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, - DuplicatePolicy, ReloadImpact, TransportPolicy, context, + CascadePolicy, ConfigRegistry, DirectiveInput, DirectiveValue, ReloadImpact, + RepeatedCardinality, RepeatedDirectiveKey, TransportPolicy, TypedDirectiveDefinition, + context, }, source::SourceSpan, - types::{SocketAddrs, StunBindConfigValue, StunChangePort}, + types::{SocketAddrs, StunBindConfigValue, StunChangePort, StunServerConfigValue}, }; #[derive(Debug, Snafu)] @@ -142,205 +147,226 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StunCha } } -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::STUN_SERVER, - finalize: None, - }); - registry.register_directive( - context::SERVER, - DirectiveSpec::context_empty( - "stun_server", - vec![context::SERVER], - context::STUN_SERVER, - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ), - ); - registry.register_directive( - context::STUN_SERVER, - DirectiveSpec::leaf_value::( - "bind", - vec![context::STUN_SERVER], - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ), - ); - for name in ["outer_addr", "change_addr"] { - registry.register_directive( - context::STUN_SERVER, - DirectiveSpec::leaf_value::( - name, - vec![context::STUN_SERVER], - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ), - ); +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum StunServerConfigValueError { + #[snafu(display("stun_server must use block form"))] + ExpectedBlock { span: SourceSpan }, + #[snafu(display("unknown stun_server child directive `{name}`"))] + UnknownChild { name: String, span: SourceSpan }, + #[snafu(display("stun_server child directive `{name}` must use leaf form"))] + ExpectedLeaf { name: String, span: SourceSpan }, + #[snafu(display("duplicate stun_server fallback directive `{name}`"))] + DuplicateFallback { name: String, span: SourceSpan }, + #[snafu(display("invalid stun_server bind directive"))] + Bind { source: StunBindConfigValueError }, + #[snafu(display("invalid stun_server address fallback"))] + Address { source: SocketAddrsError }, + #[snafu(display("invalid stun_server change_port fallback"))] + ChangePort { source: StunChangePortError }, +} + +impl DirectiveValue for StunServerConfigValue { + type Error = StunServerConfigValueError; +} + +impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StunServerConfigValue { + type Error = StunServerConfigValueError; + + fn try_from(input: &'input DirectiveInput<'directive>) -> Result { + let AstBody::Block { children, .. } = &input.directive.body else { + return stun_server_config_value_error::ExpectedBlockSnafu { + span: input.directive.span, + } + .fail(); + }; + let mut binds = Vec::new(); + let mut outer_addr = None; + let mut change_addr = None; + let mut change_port = None; + for child in children { + if !child.is_leaf() { + return stun_server_config_value_error::ExpectedLeafSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + let child_input = DirectiveInput { + directive: child, + context: input.context, + source_map: input.source_map, + }; + match child.name.value.as_str() { + "bind" => binds.push( + StunBindConfigValue::try_from(&child_input) + .context(stun_server_config_value_error::BindSnafu)?, + ), + "outer_addr" => { + if outer_addr.is_some() { + return stun_server_config_value_error::DuplicateFallbackSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + outer_addr = SocketAddrs::try_from(&child_input) + .context(stun_server_config_value_error::AddressSnafu)? + .0 + .first() + .copied(); + } + "change_addr" => { + if change_addr.is_some() { + return stun_server_config_value_error::DuplicateFallbackSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + change_addr = SocketAddrs::try_from(&child_input) + .context(stun_server_config_value_error::AddressSnafu)? + .0 + .first() + .copied(); + } + "change_port" => { + if change_port.is_some() { + return stun_server_config_value_error::DuplicateFallbackSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + change_port = Some( + StunChangePort::try_from(&child_input) + .context(stun_server_config_value_error::ChangePortSnafu)? + .0, + ); + } + name => { + return stun_server_config_value_error::UnknownChildSnafu { + name: name.to_owned(), + span: child.name.span, + } + .fail(); + } + } + } + for bind in &mut binds { + bind.outer_addr = bind.outer_addr.or(outer_addr); + bind.change_addr = bind.change_addr.or(change_addr); + bind.change_port = bind.change_port.or(change_port); + } + Ok(Self { + binds: binds.into_boxed_slice(), + }) } - registry.register_directive( - context::STUN_SERVER, - DirectiveSpec::leaf_value::( - "change_port", - vec![context::STUN_SERVER], - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ), - ); +} + +const STUN_SERVERS_DEFINITION: TypedDirectiveDefinition< + StunServerConfigValue, + RepeatedCardinality, +> = TypedDirectiveDefinition::repeated_raw( + context::SERVER, + "stun_server", + CascadePolicy::None, + TransportPolicy::WorkerLocalOnly, + ReloadImpact::ListenerSet, +); +pub(crate) const STUN_SERVERS_KEY: RepeatedDirectiveKey = + STUN_SERVERS_DEFINITION.key(); + +pub fn register(registry: &mut ConfigRegistry) { + STUN_SERVERS_DEFINITION.register(registry); } #[cfg(test)] mod tests { use crate::parse::{ - tests::{ - assert_error_chain_display_single_line, cleanup_temp_files, create_temp_file, - first_server, parse_doc, - }, - types::{SocketAddrs, StunBindConfigValue, StunChangePort}, + ConfigDocumentParser, + domain::ConfigDocumentRole, + fragment::ParsedConfigDocument, + keys, + tests::{cleanup_temp_files, create_temp_file}, + tree::build_global_tree, }; - #[test] - fn parse_stun_bind_and_ports() { - let cert = create_temp_file("stun_bind_cert"); - let key = create_temp_file("stun_bind_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 10.0.0.1:20000 outer_addr 10.0.0.2:20001 change_addr 10.0.0.3:20002 change_port 3478; }} }} }}", + fn sealed_server(stun: &str) -> crate::parse::tree::ServerConfigRef { + let cert = create_temp_file("stun_compound_cert"); + let key = create_temp_file("stun_compound_key"); + let text = format!( + "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; {stun} }} }}", cert.display(), key.display() ); - - let server = first_server(&parse_doc(&conf)); - let stun = server - .children("stun_server") - .expect("stun_server should exist")[0] - .clone(); - let bind = stun.require::("bind").unwrap(); - assert_eq!( - bind.bind, - "10.0.0.1:20000".parse::().unwrap() - ); - assert_eq!( - bind.outer_addr.unwrap(), - "10.0.0.2:20001".parse::().unwrap() - ); - assert_eq!( - bind.change_addr.unwrap(), - "10.0.0.3:20002".parse::().unwrap() - ); - assert_eq!(bind.change_port.unwrap(), 3478); - + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let ParsedConfigDocument::HypervisorRoot(root) = parser + .parse_text( + &text, + std::path::Path::new("/tmp/pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .unwrap() + else { + panic!("expected root fragment") + }; + let tree = build_global_tree(®istry, root, []).unwrap(); + let server = tree.servers().next().unwrap(); cleanup_temp_files(&[&cert, &key]); + server } #[test] - fn parse_stun_bind_config_rejects_unknown_option() { - let cert = create_temp_file("stun_bind_unknown_cert"); - let key = create_temp_file("stun_bind_unknown_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 10.0.0.1:20000 bad 10.0.0.1:20001; }} }} }}", - cert.display(), - key.display() + fn stun_compounds_preserve_block_and_bind_order() { + let server = sealed_server( + "stun_server { bind 127.0.0.1:1000; bind 127.0.0.1:1001; } stun_server { bind 127.0.0.1:2000; }", ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("unknown bind option should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid stun_server bind directive option") + let values = server.node().repeated(keys::server::STUN_SERVERS).unwrap(); + let addresses = values + .iter() + .flat_map(|value| value.binds.iter().map(|bind| bind.bind)) + .collect::>(); + assert_eq!( + addresses, + [ + "127.0.0.1:1000".parse().unwrap(), + "127.0.0.1:1001".parse().unwrap(), + "127.0.0.1:2000".parse().unwrap(), + ] ); - - cleanup_temp_files(&[&cert, &key]); } #[test] - fn parse_stun_change_port_accepts_valid_port() { - let cert = create_temp_file("stun_port_valid_cert"); - let key = create_temp_file("stun_port_valid_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 127.0.0.1:20000; change_port 3478; }} }} }}", - cert.display(), - key.display() + fn stun_bind_explicit_values_override_block_fallbacks() { + let server = sealed_server( + "stun_server { outer_addr 127.0.0.1:3000; change_port 4000; bind 127.0.0.1:1000 outer_addr 127.0.0.1:3001 change_port 4001; }", ); - - let server = first_server(&parse_doc(&conf)); - let stun = server - .children("stun_server") - .expect("stun_server should exist")[0] - .clone(); - assert_eq!( - stun.require::("change_port").unwrap().0, - 3478 - ); - - cleanup_temp_files(&[&cert, &key]); + let values = server.node().repeated(keys::server::STUN_SERVERS).unwrap(); + let bind = &values[0].binds[0]; + assert_eq!(bind.outer_addr, Some("127.0.0.1:3001".parse().unwrap())); + assert_eq!(bind.change_port, Some(4001)); } #[test] - fn parse_stun_server_accepts_outer_addr_and_change_addr() { - let cert = create_temp_file("stun_addr_cert"); - let key = create_temp_file("stun_addr_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 127.0.0.1:20000; outer_addr 127.0.0.1:20001; change_addr 127.0.0.1:20002; }} }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let stun = server - .children("stun_server") - .expect("stun_server should exist")[0] - .clone(); - - assert_eq!( - stun.require::("outer_addr") - .expect("outer_addr should be typed") - .0, - vec![ - "127.0.0.1:20001" - .parse::() - .expect("outer address should parse") - ] - ); - assert_eq!( - stun.require::("change_addr") - .expect("change_addr should be typed") - .0, - vec![ - "127.0.0.1:20002" - .parse::() - .expect("change address should parse") - ] - ); - - cleanup_temp_files(&[&cert, &key]); + fn empty_stun_server_block_remains_a_noop_compound() { + let server = sealed_server("stun_server {}"); + let values = server.node().repeated(keys::server::STUN_SERVERS).unwrap(); + assert_eq!(values.len(), 1); + assert!(values[0].binds.is_empty()); } #[test] - fn parse_stun_change_port_rejects_invalid_value() { - let cert = create_temp_file("stun_port_cert"); - let key = create_temp_file("stun_port_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 127.0.0.1:20000; change_port invalid; }} }} }}", - cert.display(), - key.display() + fn stun_server_leaf_form_is_rejected() { + let failure = crate::parse::parse_config_str_for_test( + "pishoo { server { listen all 1; server_name x; ssl_certificate /tmp/c; ssl_certificate_key /tmp/k; stun_server value; } }", + ) + .unwrap_err(); + assert!( + snafu::Report::from_error(&failure.error) + .to_string() + .contains("stun_server must use block form") ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid change_port should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - - assert!(report.contains("failed to parse directive `change_port`")); - assert_error_chain_display_single_line(&failure.error); - - cleanup_temp_files(&[&cert, &key]); } } diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs index 934242ee..ab335a9b 100644 --- a/gateway/src/parse/registry.rs +++ b/gateway/src/parse/registry.rs @@ -37,7 +37,6 @@ pub mod context { pub const SERVER: ContextKey = ContextKey("gateway.server"); pub const LOCATION: ContextKey = ContextKey("gateway.location"); pub const PROXY: ContextKey = ContextKey("gateway.proxy"); - pub const STUN_SERVER: ContextKey = ContextKey("gateway.stun_server"); } #[derive(Debug, Default)] @@ -783,6 +782,24 @@ impl TypedDirectiveDefinition { } impl TypedDirectiveDefinition { + pub(crate) const fn repeated_raw( + context: ContextKey, + name: &'static str, + cascade: CascadePolicy, + transport: TransportPolicy, + reload: ReloadImpact, + ) -> Self { + Self::new( + context, + context, + name, + DirectiveShape::RawBlock, + DirectiveCardinality::Repeated, + DirectiveMetadata::new(DuplicatePolicy::Append, cascade, transport, reload), + DirectiveProjection::absent(), + ) + } + pub(crate) const fn repeated_leaf( context: ContextKey, name: &'static str, diff --git a/gateway/src/parse/types.rs b/gateway/src/parse/types.rs index 96fedd50..018e8595 100644 --- a/gateway/src/parse/types.rs +++ b/gateway/src/parse/types.rs @@ -237,7 +237,7 @@ pub struct SshSslUser { #[derive(Debug, Clone)] pub struct SshSslUsers(pub Vec); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StunBindConfigValue { pub bind: std::net::SocketAddr, pub outer_addr: Option, @@ -245,9 +245,14 @@ pub struct StunBindConfigValue { pub change_port: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StunChangePort(pub u16); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StunServerConfigValue { + pub binds: Box<[StunBindConfigValue]>, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum IpFamilies { V4, diff --git a/gateway/src/stun/config.rs b/gateway/src/stun/config.rs index e64f03ea..6a2a29a4 100644 --- a/gateway/src/stun/config.rs +++ b/gateway/src/stun/config.rs @@ -1,22 +1,10 @@ -use std::{collections::HashSet, net::SocketAddr, sync::Arc}; +use std::{collections::HashSet, net::SocketAddr}; -use crate::parse::{ - document::ConfigNode, - types::{BoolConfig, SocketAddrs, StunBindConfigValue, StunChangePort}, -}; +use snafu::{ResultExt, Snafu}; + +use crate::parse::{error::ConfigQueryError, tree::ServerConfigRef}; /// 本节点的 STUN 运行时配置。 -/// -/// 这不是配置文件 AST 的原样映射,而是把 `server` 块里的以下配置归一化后的结果: -/// - `stun on|off;` -/// - `relay on|off;` -/// - 多个 `stun_server { ... }` -/// -/// 最终行为由内容决定: -/// - 配置了 `stun_server { ... }` → 走 configured 模式,独立绑定主/辅 socket -/// - 只有 `stun on;` → 走 dynamic 模式,等本地 listener 被判定为 `FullCone` 后寄生启动 -/// -/// `relay` 目前只是配置侧保留字段,本文件中的 STUN server 生命周期并不会直接使用它。 #[derive(Debug, Clone)] pub struct StunNodeConfig { /// 是否加入 forward 中转网络(默认 false) @@ -28,83 +16,121 @@ pub struct StunNodeConfig { /// 单个 bind 块的 STUN 配置 #[derive(Debug, Clone)] pub struct StunBindConfig { - /// 绑定地址 pub bind_address: SocketAddr, - /// 对外发布地址 pub outer_address: Option, - /// STUN `ChangedAddress` / `CHANGE_IP` 对应的完整替代地址(包含 IP 和端口) pub change_address: Option, - /// 辅助端口 pub change_port: Option, } +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum StunConfigError { + #[snafu(display("failed to query stun directive"))] + Stun { source: ConfigQueryError }, + #[snafu(display("failed to query relay directive"))] + Relay { source: ConfigQueryError }, + #[snafu(display("failed to query stun_server compounds"))] + Servers { source: ConfigQueryError }, +} + impl StunNodeConfig { - /// 从 `server` 配置节点提取 STUN 相关配置。 - /// - /// 两种启用方式满足其一即可: - /// - `stun on;` - /// - 存在至少一个 `stun_server { ... }` - pub fn from_server_node(server: &Arc) -> Option { + /// Materializes STUN configuration exclusively from the sealed SERVER-local typed slots. + pub fn from_server_ref(server: &ServerConfigRef) -> Result, StunConfigError> { let stun_enabled = server - .get::("stun") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false); - - let binds: Vec = server - .children_optional("stun_server") + .node() + .local(crate::parse::keys::server::STUN) + .context(stun_config_error::StunSnafu)? + .is_some_and(|value| value.0); + let compounds = server + .node() + .repeated(crate::parse::keys::server::STUN_SERVERS) + .context(stun_config_error::ServersSnafu)?; + let binds = compounds .iter() - .flat_map(|node| { - let outer_address = first_addr(node, "outer_addr"); - let change_address = first_addr(node, "change_addr"); - let change_port = node - .get::("change_port") - .ok() - .flatten() - .map(|port| port.0); - - node.get_all::("bind") - .ok() - .into_iter() - .flatten() - .map(move |bind| StunBindConfig { - bind_address: bind.bind, - outer_address: bind.outer_addr.or(outer_address), - change_address: bind.change_addr.or(change_address), - change_port: bind.change_port.or(change_port), - }) + .flat_map(|compound| compound.binds.iter()) + .map(|bind| StunBindConfig { + bind_address: bind.bind, + outer_address: bind.outer_addr, + change_address: bind.change_addr, + change_port: bind.change_port, }) - .collect(); - + .collect::>(); if !stun_enabled && binds.is_empty() { - return None; + return Ok(None); } let relay = server - .get::("relay") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false); - - Some(Self { relay, binds }) + .node() + .local(crate::parse::keys::server::RELAY) + .context(stun_config_error::RelaySnafu)? + .is_some_and(|value| value.0); + Ok(Some(Self { relay, binds })) } - /// 是否配置了 bind 块 pub fn has_configured_binds(&self) -> bool { !self.binds.is_empty() } - /// 收集 configured 模式下需要绑定的所有地址 pub fn configured_addrs(&self) -> HashSet { - self.binds.iter().map(|b| b.bind_address).collect() + self.binds.iter().map(|bind| bind.bind_address).collect() } } -fn first_addr(node: &ConfigNode, name: &str) -> Option { - node.get::(name) - .ok() - .flatten() - .and_then(|addrs| addrs.0.first().copied()) +#[cfg(test)] +mod tests { + use super::StunNodeConfig; + use crate::parse::{ + ConfigDocumentParser, domain::ConfigDocumentRole, fragment::ParsedConfigDocument, + tree::build_global_tree, + }; + + fn server(extra: &str) -> crate::parse::tree::ServerConfigRef { + let text = format!( + "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; {extra} }} }}" + ); + let registry = crate::parse::default_registry(); + let mut parser = ConfigDocumentParser::new(®istry); + let ParsedConfigDocument::HypervisorRoot(root) = parser + .parse_text( + &text, + std::path::Path::new("/tmp/pishoo.conf"), + ConfigDocumentRole::HypervisorRoot { home: None }, + ) + .unwrap() + else { + panic!("expected root fragment") + }; + build_global_tree(®istry, root, []) + .unwrap() + .servers() + .next() + .unwrap() + } + + #[test] + fn stun_on_without_bind_remains_dynamic() { + let config = StunNodeConfig::from_server_ref(&server("stun on;")) + .unwrap() + .unwrap(); + assert!(!config.has_configured_binds()); + } + + #[test] + fn stun_off_with_bind_remains_configured() { + let config = StunNodeConfig::from_server_ref(&server( + "stun off; stun_server { bind 127.0.0.1:1000; }", + )) + .unwrap() + .unwrap(); + assert!(config.has_configured_binds()); + } + + #[test] + fn relay_on_alone_remains_disabled_without_stun_or_bind() { + assert!( + StunNodeConfig::from_server_ref(&server("relay on;")) + .unwrap() + .is_none() + ); + } } diff --git a/pishoo/src/config/tests.rs b/pishoo/src/config/tests.rs index 5c04ce0e..06ade786 100644 --- a/pishoo/src/config/tests.rs +++ b/pishoo/src/config/tests.rs @@ -10,6 +10,15 @@ use crate::config::{ }, }; +#[test] +fn pishoo_public_stun_servers_key_is_repeated() { + fn accepts_repeated(_: gateway::parse::registry::RepeatedDirectiveKey) {} + + accepts_repeated::( + gateway::parse::keys::server::STUN_SERVERS, + ); +} + fn create_temp_tls_files() -> (PathBuf, PathBuf) { let base = std::env::temp_dir().join(format!( "pishoo-config-test-{}", From b2798dfbd051580a61cdaad29230dfee88cd7274 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 00:16:28 +0800 Subject: [PATCH 16/18] feat(pishoo): build static reload service pipeline --- Cargo.lock | 3 +- gateway/Cargo.toml | 4 + gateway/examples/forward.rs | 23 +- gateway/src/command.rs | 23 +- gateway/src/command/file.rs | 29 +- gateway/src/command/header.rs | 62 +- gateway/src/control_plane.rs | 13 - gateway/src/forward.rs | 22 +- gateway/src/parse.rs | 399 +-- gateway/src/parse/build.rs | 833 ++++++ gateway/src/parse/builtin.rs | 15 - gateway/src/parse/builtin/access.rs | 34 +- gateway/src/parse/builtin/core.rs | 2 +- gateway/src/parse/builtin/http.rs | 97 +- gateway/src/parse/builtin/location.rs | 727 ----- gateway/src/parse/builtin/net.rs | 52 +- gateway/src/parse/builtin/pishoo.rs | 182 -- gateway/src/parse/builtin/proxy.rs | 260 -- gateway/src/parse/builtin/server.rs | 830 ------ gateway/src/parse/builtin/ssh.rs | 35 +- gateway/src/parse/builtin/stun.rs | 70 +- gateway/src/parse/cascade.rs | 172 -- gateway/src/parse/config.rs | 603 ++++ gateway/src/parse/decode.rs | 27 + gateway/src/parse/diagnostic.rs | 173 +- gateway/src/parse/document.rs | 255 -- gateway/src/parse/domain.rs | 90 +- gateway/src/parse/error.rs | 271 +- gateway/src/parse/fragment.rs | 165 -- gateway/src/parse/normalize.rs | 15 - gateway/src/parse/registry.rs | 1758 ------------ gateway/src/parse/snapshot.rs | 889 ------ gateway/src/parse/source.rs | 8 + gateway/src/parse/tests.rs | 2517 ++--------------- gateway/src/parse/tree.rs | 836 ------ gateway/src/parse/value.rs | 60 - gateway/src/reverse/access_log.rs | 206 +- gateway/src/reverse/access_log/body.rs | 215 ++ gateway/src/reverse/file.rs | 13 +- gateway/src/reverse/gzip.rs | 46 +- gateway/src/reverse/location.rs | 108 +- gateway/src/reverse/log.rs | 206 +- gateway/src/reverse/proxy.rs | 12 +- gateway/src/reverse/request_uri.rs | 22 +- gateway/src/reverse/router.rs | 216 +- gateway/src/reverse/sshd.rs | 5 +- gateway/src/reverse/upstream_tls.rs | 60 +- gateway/src/stun/config.rs | 97 +- gateway/tests/relative_static_root.rs | 40 +- pishoo/Cargo.toml | 2 +- pishoo/src/bin/pishoo_worker.rs | 82 +- pishoo/src/config.rs | 71 +- pishoo/src/config/account.rs | 92 +- pishoo/src/config/discovery.rs | 23 - pishoo/src/config/entry.rs | 59 - pishoo/src/config/home_tree.rs | 634 ----- pishoo/src/config/plan.rs | 349 +++ pishoo/src/config/root.rs | 35 - pishoo/src/config/source.rs | 7 - pishoo/src/config/tests.rs | 557 ---- pishoo/src/config/worker_target.rs | 258 +- pishoo/src/hypervisor/global_service.rs | 166 +- pishoo/src/hypervisor/in_process_plane.rs | 14 +- pishoo/src/hypervisor/ipc_server.rs | 62 +- pishoo/src/hypervisor/process/batch.rs | 7 +- pishoo/src/hypervisor/process/spawn.rs | 47 +- pishoo/src/hypervisor/reload.rs | 2 +- pishoo/src/hypervisor/reload/orchestrate.rs | 209 +- pishoo/src/hypervisor/reload/snapshot.rs | 57 +- pishoo/src/hypervisor/state.rs | 22 +- .../src/hypervisor/state/listener_registry.rs | 75 - pishoo/src/hypervisor/state/process_ops.rs | 54 +- pishoo/src/hypervisor/state/server_ops.rs | 178 +- pishoo/src/hypervisor/state/tests.rs | 43 +- pishoo/src/ipc.rs | 67 +- pishoo/src/main.rs | 115 +- pishoo/src/naming.rs | 13 +- pishoo/src/service.rs | 2 + pishoo/src/service/accept.rs | 296 +- pishoo/src/service/resource.rs | 58 + pishoo/src/service/runtime.rs | 704 ++--- pishoo/src/service/set.rs | 237 ++ pishoo/src/service/snapshot.rs | 114 +- pishoo/src/service/source.rs | 1320 ++------- pishoo/src/worker.rs | 1 - pishoo/src/worker/config.rs | 136 - pishoo/src/worker/remote_plane.rs | 36 +- pishoo/tests/main.rs | 11 +- pishoo/tests/worker.rs | 36 +- 89 files changed, 4617 insertions(+), 14434 deletions(-) create mode 100644 gateway/src/parse/build.rs delete mode 100644 gateway/src/parse/builtin/location.rs delete mode 100644 gateway/src/parse/builtin/pishoo.rs delete mode 100644 gateway/src/parse/builtin/proxy.rs delete mode 100644 gateway/src/parse/builtin/server.rs delete mode 100644 gateway/src/parse/cascade.rs create mode 100644 gateway/src/parse/config.rs create mode 100644 gateway/src/parse/decode.rs delete mode 100644 gateway/src/parse/document.rs delete mode 100644 gateway/src/parse/fragment.rs delete mode 100644 gateway/src/parse/registry.rs delete mode 100644 gateway/src/parse/snapshot.rs delete mode 100644 gateway/src/parse/tree.rs delete mode 100644 gateway/src/parse/value.rs create mode 100644 gateway/src/reverse/access_log/body.rs delete mode 100644 pishoo/src/config/discovery.rs delete mode 100644 pishoo/src/config/entry.rs delete mode 100644 pishoo/src/config/home_tree.rs create mode 100644 pishoo/src/config/plan.rs delete mode 100644 pishoo/src/config/root.rs delete mode 100644 pishoo/src/config/tests.rs create mode 100644 pishoo/src/service/resource.rs create mode 100644 pishoo/src/service/set.rs delete mode 100644 pishoo/src/worker/config.rs diff --git a/Cargo.lock b/Cargo.lock index dcdc029f..81c5d467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1099,7 +1099,6 @@ dependencies = [ "http", "sha2 0.10.9", "snafu", - "tracing-subscriber", "x509-parser", ] @@ -1537,6 +1536,7 @@ dependencies = [ "glob", "h3x", "http", + "http-body", "http-body-util", "hyper", "hyper-util", @@ -1548,6 +1548,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde", + "serde_json", "snafu", "socket2", "tokio", diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 30f788ec..dc65fe87 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -27,6 +27,7 @@ bytes = { workspace = true } http = { workspace = true } url = { workspace = true } http-body-util = { workspace = true } +http-body = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } axum = { workspace = true } @@ -57,6 +58,9 @@ async-compression = { version = "0.4", features = ["tokio", "gzip"] } chrono = "0.4" tracing-appender = { workspace = true } +[dev-dependencies] +serde_json = { workspace = true } + # --- Platform Specific Dependencies --- [target.'cfg(any(target_os = "android", target_env = "ohos"))'.dependencies] pnet = "0.35.0" diff --git a/gateway/examples/forward.rs b/gateway/examples/forward.rs index 1dbbdefc..9257f1a9 100644 --- a/gateway/examples/forward.rs +++ b/gateway/examples/forward.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use dhttp::endpoint::Endpoint; use gateway::{forward, parse}; -use snafu::{FromString, OptionExt, ResultExt, Whatever, whatever}; +use snafu::{FromString, ResultExt, Whatever, whatever}; use tokio::task::JoinSet; use tracing::Instrument; @@ -27,14 +27,8 @@ async fn main() -> Result<(), Whatever> { std::process::exit(1); }; let config_file = std::path::Path::new(config_file); - let registry = parse::default_registry(); - let config = match parse::load_config_file( - config_file, - ®istry, - parse::registry::BuildOptions::default(), - ) - .await - { + let mut parser = parse::TypedConfigParser::new(); + let config = match parse::load_root_config_file(&mut parser, config_file, None).await { Ok(config) => config, Err(failure) => { tracing::error!( @@ -51,14 +45,7 @@ async fn main() -> Result<(), Whatever> { // TODO 对于绑定到 [::]:0 的监听, 应该进行特殊操作, 每个 server 都单独绑定到 不同端口 上 - let pishoo = config - .root - .children("pishoo") - .ok() - .and_then(|pishoo| pishoo.first()) - .whatever_context("no pishoo block found")?; - - let proxies = pishoo.children_optional("proxy").to_vec(); + let proxies = config.pishoo().forward_proxies(); if proxies.is_empty() { whatever!("no proxy found in pishoo configuration"); } @@ -73,7 +60,7 @@ async fn main() -> Result<(), Whatever> { let mut handler = JoinSet::new(); - for proxy in proxies { + for proxy in proxies.iter().cloned() { let span = tracing::info_span!("forward_example_proxy"); let client = client.clone(); handler.spawn( diff --git a/gateway/src/command.rs b/gateway/src/command.rs index f99365ec..511a9d87 100644 --- a/gateway/src/command.rs +++ b/gateway/src/command.rs @@ -1,7 +1,3 @@ -use std::sync::Arc; - -use crate::parse::{document::ConfigNode, types::StringList}; - pub(crate) mod acl; pub(crate) mod file; pub(crate) mod header; @@ -11,22 +7,9 @@ pub use file::IndexError; pub(crate) use file::index; pub(crate) use header::{add_header, content_type, proxy_set_header}; -pub(crate) fn acl(node: &Arc) -> acl::Acl { - let allow_vec = node - .get::("allow") - .ok() - .flatten() - .map(|allow| allow.0.clone()) - .unwrap_or_default(); - let deny_vec = node - .get::("deny") - .ok() - .flatten() - .map(|deny| deny.0.clone()) - .unwrap_or_default(); - - let allow = acl::parse_host_matches(&allow_vec); - let deny = acl::parse_host_matches(&deny_vec); +pub(crate) fn acl(allow: &[String], deny: &[String]) -> acl::Acl { + let allow = acl::parse_host_matches(allow); + let deny = acl::parse_host_matches(deny); acl::Acl::new(allow, deny) } diff --git a/gateway/src/command/file.rs b/gateway/src/command/file.rs index 6b0390ac..251a0d69 100644 --- a/gateway/src/command/file.rs +++ b/gateway/src/command/file.rs @@ -7,7 +7,7 @@ use std::{ use snafu::{ResultExt, Snafu}; use tokio::fs::File; -use crate::parse::{document::ConfigNode, types::StringList}; +use crate::parse::config::LocationConfig; #[derive(Debug, Snafu)] #[snafu(visibility(pub(crate)))] @@ -87,7 +87,7 @@ pub(crate) fn safe_relative_path(path: &str) -> Result { /// Returns an `io::Error` if the path doesn't exist, if it's a directory without a /// suitable index file, or if file/metadata operations fail. pub(crate) async fn index( - node: &Arc, + node: &Arc, file_path: impl Into, ) -> Result<(File, u64, PathBuf), IndexError> { let file_path = file_path.into(); @@ -116,9 +116,7 @@ pub(crate) async fn index( let base_dir_path = file_path.clone(); let index_files = node - .get::("index") - .ok() - .flatten() + .index() .map(|index| index.0.clone()) .ok_or(IndexError::MissingIndexFiles)?; @@ -153,25 +151,10 @@ mod tests { }; use super::*; - use crate::parse::{ - document::ConfigNode, - registry::context, - source::{SourceId, SourceSpan}, - types::StringList, - value::TypedValue, - }; + use crate::parse::tests::parse_location; - fn node_with_indexes(indexes: Vec<&str>) -> Arc { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = ConfigNode::new(context::LOCATION, None, span); - node.insert_slot( - "index", - TypedValue::new( - StringList(indexes.into_iter().map(str::to_owned).collect()), - span, - ), - ); - Arc::new(node) + fn node_with_indexes(indexes: Vec<&str>) -> Arc { + parse_location(&format!("index {};", indexes.join(" "))).unwrap() } fn temp_dir(name: &str) -> PathBuf { diff --git a/gateway/src/command/header.rs b/gateway/src/command/header.rs index 4a81d6e2..e2e0fdbd 100644 --- a/gateway/src/command/header.rs +++ b/gateway/src/command/header.rs @@ -4,18 +4,16 @@ use http::{HeaderValue, Request, header, response::Parts}; use super::variables; use crate::parse::{ - document::ConfigNode, - types::{DefaultType, HeaderRule, HeaderRules, MimeTypes, ProxyPass}, + config::LocationConfig, + types::{HeaderRule, HeaderRules}, }; -pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Request { +pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Request { let (mut parts, body) = req.into_parts(); // 默认将 Host 变更为 proxy_pass target let proxy_host = node - .get::("proxy_pass") - .ok() - .flatten() + .proxy_pass() .map(|proxy_pass| proxy_pass.proxy_host.clone()); if let Some(host) = proxy_host { parts.headers.insert( @@ -30,7 +28,7 @@ pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Re .headers .insert(header::CONNECTION, HeaderValue::from_static("close")); // 遍历 proxy_set_header 中的记录, 匹配 Header, 设置支持的字段 - let proxy_set_header = header_rules(node, "proxy_set_header"); + let proxy_set_header = header_rules(node.proxy_set_headers()); for HeaderRule { name, @@ -57,8 +55,8 @@ pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Re /// /// * `node` - A config node potentially containing header configurations under the key "add_header". /// * `parts` - A mutable reference to `http::response::Parts` where headers will be added. -pub(crate) fn add_header(node: &Arc, parts: &mut Parts) { - let add_header = header_rules(node, "add_header"); +pub(crate) fn add_header(node: &Arc, parts: &mut Parts) { + let add_header = header_rules(node.add_headers()); for HeaderRule { name, @@ -73,28 +71,20 @@ pub(crate) fn add_header(node: &Arc, parts: &mut Parts) { } /// Determines and sets the "Content-Type" header for a given file path based on configuration. -pub(crate) fn content_type(node: &Arc, parts: &mut Parts, file_path: &Path) { - let mime_types = node.inherited::("types").ok().flatten(); - let default_type = node.inherited::("default_type").ok().flatten(); +pub(crate) fn content_type(node: &Arc, parts: &mut Parts, file_path: &Path) { + let mime_types = node.http().types().effective().as_ref(); + let default_type = node.http().default_type().effective().as_ref(); if let Some(mime_types) = mime_types - && let Some(content_type) = infer_content_type( - file_path, - &mime_types.0, - default_type.as_ref().map(|v| &v.0), - ) + && let Some(content_type) = + infer_content_type(file_path, &mime_types.0, default_type.map(|v| &v.0)) { parts.headers.insert("Content-Type", content_type.clone()); } } -fn header_rules(node: &ConfigNode, name: &str) -> Vec { - node.get_all::(name) - .ok() - .into_iter() - .flatten() - .flat_map(|headers| headers.0.clone()) - .collect() +fn header_rules(rules: &[HeaderRules]) -> Vec { + rules.iter().flat_map(|headers| headers.0.clone()).collect() } /// Infers the `Content-Type` `HeaderValue` for a given file path based on its extension. @@ -116,32 +106,14 @@ fn infer_content_type<'a>( #[cfg(test)] mod tests { use super::*; - use crate::parse::{ - document::ConfigNode, - registry::context, - source::{SourceId, SourceSpan}, - value::TypedValue, - }; + use crate::parse::tests::parse_location; #[test] fn proxy_set_header_defaults_host_to_proxy_host_with_port() { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = ConfigNode::new(context::LOCATION, None, span); - node.insert_slot( - "proxy_pass", - TypedValue::new( - ProxyPass { - raw: "http://backend.example.com:8080/base/".to_string(), - uri: "http://backend.example.com:8080/base/".parse().unwrap(), - proxy_host: "backend.example.com:8080".to_string(), - explicit_path_and_query: Some("/base/".to_string()), - }, - span, - ), - ); + let node = parse_location("proxy_pass http://backend.example.com:8080/base/;").unwrap(); let req = http::Request::builder().uri("/echo").body(()).unwrap(); - let req = proxy_set_header(&Arc::new(node), req); + let req = proxy_set_header(&node, req); assert_eq!( req.headers()[http::header::HOST], "backend.example.com:8080" diff --git a/gateway/src/control_plane.rs b/gateway/src/control_plane.rs index 07a37e79..f1fe58da 100644 --- a/gateway/src/control_plane.rs +++ b/gateway/src/control_plane.rs @@ -50,8 +50,6 @@ pub trait ProvideListener: Send + Sync { type Listener: quic::Listen; /// Error type for [`listener()`](Self::listener) operations. type ListenError: std::error::Error + Send + Sync; - /// Error type for [`rebuild_listener()`](Self::rebuild_listener) operations. - type RebuildError: std::error::Error + Send + Sync; /// Request the control plane to create a QUIC listener for the given /// server configuration. @@ -59,17 +57,6 @@ pub trait ProvideListener: Send + Sync { &self, request: ListenRequest, ) -> impl Future> + Send + '_; - - /// Atomically replace a previously acquired listener with one matching the - /// new request. The previous listener is consumed; implementations are - /// responsible for any necessary teardown of the old listener as part of - /// the rebuild critical section so the server name is never observed - /// vacant. - fn rebuild_listener( - &self, - old: Self::Listener, - request: ListenRequest, - ) -> impl Future> + Send + '_; } // --------------------------------------------------------------------------- diff --git a/gateway/src/forward.rs b/gateway/src/forward.rs index c38e8ddc..08c7222f 100644 --- a/gateway/src/forward.rs +++ b/gateway/src/forward.rs @@ -13,7 +13,7 @@ use http::{Method, StatusCode}; use http_body_util::{BodyExt, Empty, Full, combinators::UnsyncBoxBody}; use hyper::{Request, Response, server::conn::http1, service::service_fn, upgrade::OnUpgrade}; use hyper_util::rt::tokio::TokioIo; -use snafu::{OptionExt, Report, ResultExt, Snafu}; +use snafu::{Report, ResultExt, Snafu}; use tokio::{ io, net::{TcpListener, TcpStream}, @@ -25,7 +25,6 @@ use crate::{ command, error::{BoxError, Result, Whatever}, forward, - parse::{document::ConfigNode, types::SocketAddrs}, }; mod normal; @@ -37,6 +36,13 @@ use task_scope::ForwardTaskScope; #[allow(dead_code)] pub static ALPN: &[u8] = b"h3"; +#[derive(Clone, Debug)] +pub struct ForwardConfig { + pub listen: SocketAddr, + pub allow: Vec, + pub deny: Vec, +} + type BoxResponse = Response>; #[derive(Debug, Snafu)] @@ -76,20 +82,14 @@ fn configure_tcp_keepalive(stream: &TcpStream) { /// # Returns /// * `Result<(SocketAddr, impl Future)>` - The address and server task pub async fn serve( - node: Arc, + config: ForwardConfig, client: Arc, ) -> Result<( SocketAddr, impl Future> + Send + 'static, )> { tracing::info!("starting forward proxy server"); - let listen = node - .require::("listen") - .whatever_context::<_, Whatever>("failed to read forward proxy listen directive")?; - let addr = *listen - .0 - .first() - .whatever_context::<_, Whatever>("missing forward proxy listen address")?; + let addr = config.listen; let (listener, local_addr) = async { let listener = TcpListener::bind(addr).await?; @@ -102,7 +102,7 @@ pub async fn serve( info!(%local_addr, "listening on http endpoint"); // 访问权限控制 - let acl = Arc::new(command::acl(&node)); + let acl = Arc::new(command::acl(&config.allow, &config.deny)); let semaphore = Arc::new(Semaphore::new(1024)); let task_scope = ForwardTaskScope::new(); let task_spawner = task_scope.spawner(); diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index af9bf5be..1bf151c4 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -1,357 +1,114 @@ -use std::{path::Path, sync::Arc}; +use std::path::Path; -use dhttp::home::identity::IdentityProfile; -use snafu::ResultExt; +use dhttp::home::{DhttpHome, identity::IdentityProfile}; pub mod ast; +pub mod build; pub mod builtin; -pub mod cascade; +pub mod config; +pub mod decode; pub mod diagnostic; -pub mod document; pub mod domain; pub mod error; -pub mod fragment; pub mod grammar; pub mod include; pub mod normalize; pub mod pattern; -pub mod registry; -pub mod snapshot; pub mod source; -pub mod tree; pub mod types; -pub mod value; -pub mod keys { - pub mod pishoo { - use crate::parse::{ - domain::ResolvedConfigPath, registry::LocalDirectiveKey, types::StringList, - }; - - pub const PID: LocalDirectiveKey = - crate::parse::builtin::pishoo::PID_KEY; - pub const WORKERS: LocalDirectiveKey = - crate::parse::builtin::pishoo::WORKERS_KEY; - pub const GROUPS: LocalDirectiveKey = crate::parse::builtin::pishoo::GROUPS_KEY; - } - - pub mod server { - use crate::parse::{ - cascade::DirectiveKey, - domain::ResolvedConfigPath, - registry::{LocalDirectiveKey, RepeatedDirectiveKey}, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, - ListenConfig, MimeTypes, ResolverConfig, ServerNames, StringList, - StunServerConfigValue, - }, - }; +#[cfg(test)] +pub(crate) mod tests; - pub const LISTEN: RepeatedDirectiveKey = - crate::parse::builtin::server::LISTEN_KEY; - pub const SERVER_NAME: LocalDirectiveKey = - crate::parse::builtin::server::SERVER_NAME_KEY; - pub const DNS: LocalDirectiveKey = crate::parse::builtin::server::DNS_KEY; - pub const GZIP: DirectiveKey = crate::parse::builtin::server::GZIP_KEY; - pub const GZIP_VARY: DirectiveKey = - crate::parse::builtin::server::GZIP_VARY_KEY; - pub const GZIP_MIN_LENGTH: DirectiveKey = - crate::parse::builtin::server::GZIP_MIN_LENGTH_KEY; - pub const GZIP_COMP_LEVEL: DirectiveKey = - crate::parse::builtin::server::GZIP_COMP_LEVEL_KEY; - pub const GZIP_TYPES: DirectiveKey = - crate::parse::builtin::server::GZIP_TYPES_KEY; - pub const SSL_CERTIFICATE: LocalDirectiveKey = - crate::parse::builtin::server::SSL_CERTIFICATE_KEY; - pub const SSL_CERTIFICATE_KEY: LocalDirectiveKey = - crate::parse::builtin::server::SSL_CERTIFICATE_KEY_KEY; - pub const DEFAULT_TYPE: DirectiveKey = - crate::parse::builtin::server::DEFAULT_TYPE_KEY; - pub const ACCESS_RULES: DirectiveKey = - crate::parse::builtin::server::ACCESS_RULES_KEY; - pub const RELAY: LocalDirectiveKey = crate::parse::builtin::server::RELAY_KEY; - pub const STUN: LocalDirectiveKey = crate::parse::builtin::server::STUN_KEY; - pub const STUN_SERVERS: RepeatedDirectiveKey = - crate::parse::builtin::stun::STUN_SERVERS_KEY; - pub const TYPES: DirectiveKey = crate::parse::builtin::server::TYPES_KEY; - } +pub use build::{ + BuildTypedConfigError, IdentityServerCandidate, ParsedPishooConfig, ServerConfigCandidate, + TypedConfigParser, +}; - pub mod location { - use crate::parse::{ - cascade::DirectiveKey, - domain::ResolvedConfigPath, - pattern::Pattern, - registry::{ContextPayloadKey, LocalDirectiveKey, RepeatedDirectiveKey}, - types::{ - BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRules, MimeTypes, - ProxyPass, SshLoginMethods, SshSslUsers, StringList, - }, - }; +pub(crate) type Result = std::result::Result; - pub const PATTERN: ContextPayloadKey = - crate::parse::builtin::location::PATTERN_KEY; - pub const ROOT: LocalDirectiveKey = - crate::parse::builtin::location::ROOT_KEY; - pub const ALIAS: LocalDirectiveKey = - crate::parse::builtin::location::ALIAS_KEY; - pub const GZIP: DirectiveKey = crate::parse::builtin::location::GZIP_KEY; - pub const GZIP_VARY: DirectiveKey = - crate::parse::builtin::location::GZIP_VARY_KEY; - pub const GZIP_MIN_LENGTH: DirectiveKey = - crate::parse::builtin::location::GZIP_MIN_LENGTH_KEY; - pub const GZIP_COMP_LEVEL: DirectiveKey = - crate::parse::builtin::location::GZIP_COMP_LEVEL_KEY; - pub const GZIP_TYPES: DirectiveKey = - crate::parse::builtin::location::GZIP_TYPES_KEY; - pub const INDEX: LocalDirectiveKey = crate::parse::builtin::location::INDEX_KEY; - pub const ADD_HEADER: RepeatedDirectiveKey = - crate::parse::builtin::location::ADD_HEADER_KEY; - pub const PROXY_SET_HEADER: RepeatedDirectiveKey = - crate::parse::builtin::location::PROXY_SET_HEADER_KEY; - pub const PROXY_PASS: LocalDirectiveKey = - crate::parse::builtin::location::PROXY_PASS_KEY; - pub const PROXY_SSL_CERTIFICATE: LocalDirectiveKey = - crate::parse::builtin::location::PROXY_SSL_CERTIFICATE_KEY; - pub const PROXY_SSL_CERTIFICATE_KEY: LocalDirectiveKey = - crate::parse::builtin::location::PROXY_SSL_CERTIFICATE_KEY_KEY; - pub const PROXY_SSL_TRUSTED_CERTIFICATE: LocalDirectiveKey = - crate::parse::builtin::location::PROXY_SSL_TRUSTED_CERTIFICATE_KEY; - pub const SSH_LOGIN: LocalDirectiveKey = - crate::parse::builtin::location::SSH_LOGIN_KEY; - pub const SSH_SSL_USER: RepeatedDirectiveKey = - crate::parse::builtin::location::SSH_SSL_USER_KEY; - pub const SSH_DENY: LocalDirectiveKey = - crate::parse::builtin::location::SSH_DENY_KEY; - pub const DEFAULT_TYPE: DirectiveKey = - crate::parse::builtin::location::DEFAULT_TYPE_KEY; - pub const TYPES: DirectiveKey = crate::parse::builtin::location::TYPES_KEY; - } +pub async fn load_root_config_file( + parser: &mut TypedConfigParser, + path: &Path, + home: Option<&DhttpHome>, +) -> Result { + let text = read_config(path).await?; + parser.parse_root(&text, path, home) } -pub struct ConfigDocumentParser<'registry> { - registry: &'registry registry::ConfigRegistry, - document_ids: domain::ConfigDocumentIdAllocator, +pub async fn load_worker_config_file( + parser: &mut TypedConfigParser, + path: &Path, + home: &DhttpHome, + root: &config::RootWorkerDefaultsSnapshot, +) -> Result, error::ConfigLoadFailure> { + let Some(text) = read_optional_config(path).await? else { + return Ok(None); + }; + parser.parse_worker(&text, path, home, root).map(Some) } -impl<'registry> ConfigDocumentParser<'registry> { - pub fn new(registry: &'registry registry::ConfigRegistry) -> Self { - Self { - registry, - document_ids: domain::ConfigDocumentIdAllocator::new(), - } - } - - #[cfg(test)] - pub(crate) fn with_next_document_index( - registry: &'registry registry::ConfigRegistry, - next_index: u64, - ) -> Self { - Self { - registry, - document_ids: domain::ConfigDocumentIdAllocator::with_next_index(next_index), - } - } - - pub fn parse_text( - &mut self, - text: &str, - source_path: &Path, - role: domain::ConfigDocumentRole<'_>, - ) -> Result { - let document_id = match self.document_ids.allocate() { - Ok(document_id) => document_id, - Err(source) => { - return Err(error::ConfigLoadFailure { - error: error::LoadConfigError::DocumentId { source }, - source_map: Arc::new(source::SourceMap::default()), - document_id: None, - }); - } - }; - let role_kind = role.kind(); - let options = role.build_options(); - let source_path = source_path.to_path_buf(); - let mut source_map = source::SourceMap::default(); - let source_id = source_map.add_source( - Some(source_path.clone()), - Arc::from(text), - source_path.parent().map(Path::to_path_buf), - None, - ); - - let directives = match grammar::parse_source(text, source_id) - .context(error::load_config_error::ParseFileSnafu { source_id }) - { - Ok(directives) => directives, - Err(error) => { - return Err(error::ConfigLoadFailure { - error, - source_map: Arc::new(source_map), - document_id: Some(document_id), - }); - } - }; +pub async fn load_identity_config_file( + parser: &mut TypedConfigParser, + profile: IdentityProfile, + root: &config::RootWorkerDefaultsSnapshot, +) -> Result, error::ConfigLoadFailure> { + let path = profile.server_conf_path(); + let Some(text) = read_optional_config(&path).await? else { + return Ok(None); + }; + parser.parse_identity(&text, &path, profile, root).map(Some) +} - let directives = - match include::expand_includes(directives, &mut source_map, source_path.parent()) - .context(error::load_config_error::ResolveIncludeSnafu) - { - Ok(directives) => directives, - Err(error) => { - return Err(error::ConfigLoadFailure { - error, - source_map: Arc::new(source_map), - document_id: Some(document_id), - }); - } - }; +async fn read_config(path: &Path) -> Result { + tokio::fs::read_to_string(path) + .await + .map_err(|source| error::ConfigLoadFailure::read(path, source)) +} - let source_map = Arc::new(source_map); - let document_sources = Arc::new(source::ConfigDocumentSourceMap::new( - document_id, - Arc::clone(&source_map), - )); - match self - .registry - .build_for_role(document_sources, directives, options, role_kind) - { - Ok(document) => Ok(document), - Err(registry::RoleDocumentBuildError::Role(source)) => Err(error::ConfigLoadFailure { - error: error::LoadConfigError::DocumentRole { source }, - source_map, - document_id: Some(document_id), - }), - Err(registry::RoleDocumentBuildError::Build(source)) => Err(error::ConfigLoadFailure { - error: error::LoadConfigError::BuildDocument { source }, - source_map, - document_id: Some(document_id), - }), - } +async fn read_optional_config(path: &Path) -> Result, error::ConfigLoadFailure> { + match tokio::fs::read_to_string(path).await { + Ok(text) => Ok(Some(text)), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(error::ConfigLoadFailure::read(path, source)), } } #[cfg(test)] -mod tests; - -pub(crate) type Result = std::result::Result; - -pub fn default_registry() -> registry::ConfigRegistry { - let mut registry = registry::ConfigRegistry::new(); - builtin::register_gateway_directives(&mut registry); - registry +pub(crate) struct TestConfigFailure { + pub(crate) error: TestConfigError, } -pub fn parse_config_str_for_test( - text: &str, -) -> Result { - let registry = default_registry(); - load_config_text(text, None, ®istry, registry::BuildOptions::default()) -} - -pub fn parse_server_config_str_for_test( - text: &str, - identity_profile: &IdentityProfile, -) -> Result { - let registry = default_registry(); - let home_path = identity_profile - .path() - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| identity_profile.path().to_path_buf()); - let home = dhttp::home::DhttpHome::new(home_path); - load_config_text( - text, - Some(identity_profile.path()), - ®istry, - registry::BuildOptions { - dhttp_home: Some(&home), - identity_profile: Some(identity_profile), - }, - ) -} +#[cfg(test)] +#[derive(Debug)] +pub(crate) struct TestConfigError(Box); -pub fn load_config_text( - text: &str, - root: Option<&Path>, - registry: ®istry::ConfigRegistry, - options: registry::BuildOptions<'_>, -) -> Result { - load_config_text_inner(text, None, root, registry, options) +#[cfg(test)] +impl std::fmt::Display for TestConfigError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } } -fn load_config_text_inner( - text: &str, - source_path: Option<&Path>, - root: Option<&Path>, - registry: ®istry::ConfigRegistry, - options: registry::BuildOptions<'_>, -) -> Result { - let mut source_map = source::SourceMap::default(); - let source_id = source_map.add_source( - source_path.map(Path::to_path_buf), - Arc::from(text), - root.map(Path::to_path_buf), - None, - ); - - let directives = match grammar::parse_source(text, source_id) - .context(error::load_config_error::ParseFileSnafu { source_id }) - { - Ok(directives) => directives, - Err(error) => { - return Err(error::ConfigLoadFailure { - error, - source_map: Arc::new(source_map), - document_id: None, - }); - } - }; - - let directives = match include::expand_includes(directives, &mut source_map, root) - .context(error::load_config_error::ResolveIncludeSnafu) - { - Ok(directives) => directives, - Err(error) => { - return Err(error::ConfigLoadFailure { - error, - source_map: Arc::new(source_map), - document_id: None, - }); - } - }; - - let source_map = Arc::new(source_map); - match registry - .build(Arc::clone(&source_map), directives, options) - .context(error::load_config_error::BuildDocumentSnafu) - { - Ok(document) => Ok(document), - Err(error) => Err(error::ConfigLoadFailure { - error, - source_map, - document_id: None, - }), +#[cfg(test)] +impl std::error::Error for TestConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) } } -#[tracing::instrument(level = "info", skip(registry), fields(path = %path.display()))] -pub async fn load_config_file( - path: &Path, - registry: ®istry::ConfigRegistry, - options: registry::BuildOptions<'_>, -) -> Result { - let text = match tokio::fs::read_to_string(path).await { - Ok(text) => text, - Err(source) => { - return Err(error::ConfigLoadFailure { - error: error::LoadConfigError::ReadSource { - path: path.to_path_buf(), - source, - }, - source_map: Arc::new(source::SourceMap::default()), - document_id: None, - }); - } - }; - load_config_text_inner(&text, Some(path), path.parent(), registry, options) +#[cfg(test)] +pub(crate) fn parse_config_str_for_test(text: &str) -> Result<(), TestConfigFailure> { + let parsed = TypedConfigParser::new() + .parse_root(text, Path::new("/tmp/pishoo.conf"), None) + .map_err(|error| TestConfigFailure { + error: TestConfigError(Box::new(error)), + })?; + let (_, candidates) = parsed.into_parts(); + for candidate in candidates { + candidate.into_result().map_err(|error| TestConfigFailure { + error: TestConfigError(Box::new(error)), + })?; + } + Ok(()) } diff --git a/gateway/src/parse/build.rs b/gateway/src/parse/build.rs new file mode 100644 index 00000000..219802d7 --- /dev/null +++ b/gateway/src/parse/build.rs @@ -0,0 +1,833 @@ +use std::{path::Path, sync::Arc}; + +use dhttp::home::{DhttpHome, identity::IdentityProfile}; +use http::{HeaderName, HeaderValue}; +use snafu::{ResultExt, Snafu}; + +use super::{ + ast::{AstBody, AstDirective}, + config::{ + AccessLogDirective, Cascaded, ConfigOrigin, EffectiveHttpConfig, LocationConfig, + OriginScope, PishooConfig, PreparedProxyTlsPaths, RootWorkerDefaultsSnapshot, ServerConfig, + ServerIdentity, + }, + decode::{ConfigContext, DirectiveInput, DirectiveValue}, + domain::{ConfigDocumentIdAllocator, ConfigSourceSpan, ResolvedConfigPath}, + error::{ConfigLoadFailure, LoadConfigError}, + source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, + HeaderRules, MimeTypes, ProxyPass, ServerNames, StringList, + }, +}; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum BuildTypedConfigError { + #[snafu(display("unknown directive `{name}` in {context}"))] + UnknownDirective { + name: String, + context: &'static str, + span: SourceSpan, + }, + #[snafu(display("directive `{name}` has the wrong shape"))] + Shape { name: String, span: SourceSpan }, + #[snafu(display("duplicate directive `{name}`"))] + Duplicate { + name: String, + first: SourceSpan, + duplicate: SourceSpan, + }, + #[snafu(display("failed to parse directive `{name}`"))] + Directive { + name: String, + span: SourceSpan, + source: Box, + }, + #[snafu(display("missing required directive `{name}`"))] + Missing { + name: &'static str, + span: SourceSpan, + }, + #[snafu(display("configuration must contain exactly one pishoo block"))] + PishooCardinality { span: SourceSpan }, + #[snafu(display("worker configuration cannot contain server blocks"))] + WorkerServer { span: SourceSpan }, + #[snafu(display("server certificate and private key must be configured together"))] + ServerTlsPair { span: SourceSpan }, + #[snafu(display("standalone server requires certificate and private key"))] + StandaloneTls { span: SourceSpan }, + #[snafu(display("implicit profile TLS requires exactly one server_name"))] + AmbiguousProfile { span: SourceSpan }, + #[snafu(display("identity server must bind exactly its profile name"))] + IdentityName { span: SourceSpan }, + #[snafu(display("identity server cannot override profile TLS paths"))] + IdentityTls { span: SourceSpan }, + #[snafu(display("proxy certificate and private key must be configured together"))] + ProxyTlsPair { span: SourceSpan }, + #[snafu(display("proxy_pass cannot include a URI part in a regex location"))] + RegexProxyUri { span: SourceSpan }, + #[snafu(display("access_log default is invalid for a direct server"))] + DirectDefaultAccessLog { span: SourceSpan }, + #[snafu(display("access_log expects exactly one argument"))] + AccessLogArgument { span: SourceSpan }, + #[snafu(display("forward proxy requires exactly one listen address"))] + ForwardListen { span: SourceSpan }, +} + +#[derive(Debug)] +pub struct ServerConfigCandidate { + source: ConfigSourceSpan, + result: Result, +} +impl ServerConfigCandidate { + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn result(&self) -> &Result { + &self.result + } + pub fn into_result(self) -> Result { + self.result + } +} + +#[derive(Debug)] +pub struct ParsedPishooConfig { + pishoo: PishooConfig, + servers: Box<[ServerConfigCandidate]>, +} +impl ParsedPishooConfig { + pub fn pishoo(&self) -> &PishooConfig { + &self.pishoo + } + pub fn servers(&self) -> &[ServerConfigCandidate] { + &self.servers + } + pub fn into_parts(self) -> (PishooConfig, Box<[ServerConfigCandidate]>) { + (self.pishoo, self.servers) + } +} + +#[derive(Debug)] +pub struct IdentityServerCandidate { + profile: IdentityProfile, + result: Result, +} +impl IdentityServerCandidate { + pub fn profile(&self) -> &IdentityProfile { + &self.profile + } + pub fn result(&self) -> &Result { + &self.result + } + pub fn into_parts(self) -> (IdentityProfile, Result) { + (self.profile, self.result) + } +} + +#[derive(Default)] +struct LocalHttp { + access_rules: Option>, + gzip: Option>, + gzip_vary: Option>, + gzip_min_length: Option>, + gzip_comp_level: Option>, + gzip_types: Option>, + default_type: Option>, + types: Option>, + access_log: Option>, +} + +struct Assignment { + value: T, + span: SourceSpan, +} + +impl Assignment { + fn new(value: T, span: SourceSpan) -> Self { + Self { value, span } + } + + fn map(self, map: impl FnOnce(T) -> U) -> Assignment { + Assignment::new(map(self.value), self.span) + } +} + +pub struct TypedConfigParser { + ids: ConfigDocumentIdAllocator, +} +impl Default for TypedConfigParser { + fn default() -> Self { + Self::new() + } +} +impl TypedConfigParser { + pub fn new() -> Self { + Self { + ids: ConfigDocumentIdAllocator::new(), + } + } + + pub fn parse_root( + &mut self, + text: &str, + path: &Path, + home: Option<&DhttpHome>, + ) -> Result { + let (sources, directives) = self.syntax(text, path)?; + self.build_pishoo( + &sources, + &directives, + builtin_http(), + OriginScope::RootPishoo, + home, + false, + ) + .map_err(|source| failure(source, sources)) + } + + pub fn parse_worker( + &mut self, + text: &str, + path: &Path, + home: &DhttpHome, + root: &RootWorkerDefaultsSnapshot, + ) -> Result { + let (sources, directives) = self.syntax(text, path)?; + self.build_pishoo( + &sources, + &directives, + root.http().clone(), + OriginScope::WorkerPishoo, + Some(home), + true, + ) + .map_err(|source| failure(source, sources)) + } + + pub fn parse_identity( + &mut self, + text: &str, + path: &Path, + profile: IdentityProfile, + defaults: &RootWorkerDefaultsSnapshot, + ) -> Result { + let (sources, directives) = self.syntax(text, path)?; + let source = directives + .first() + .map_or(SourceSpan::new(super::source::SourceId(0), 0, 0), |d| { + d.span + }); + if directives.len() != 1 || directives[0].name.value != "server" { + return Err(failure( + BuildTypedConfigError::Missing { + name: "server", + span: source, + }, + sources, + )); + } + let result = build_server( + &directives[0], + &sources, + defaults.http(), + Some(&profile), + None, + ); + Ok(IdentityServerCandidate { profile, result }) + } + + fn syntax( + &mut self, + text: &str, + path: &Path, + ) -> Result<(Arc, Vec), ConfigLoadFailure> { + let document_id = self.ids.allocate().map_err(|source| ConfigLoadFailure { + error: LoadConfigError::DocumentId { source }, + source_map: Arc::new(SourceMap::default()), + document_id: None, + })?; + let mut source_map = SourceMap::default(); + let source_id = source_map.add_source( + Some(path.to_path_buf()), + Arc::from(text), + path.parent().map(Path::to_path_buf), + None, + ); + let directives = match super::grammar::parse_source(text, source_id) + .context(super::error::load_config_error::ParseFileSnafu { source_id }) + { + Ok(directives) => directives, + Err(error) => { + return Err(ConfigLoadFailure { + error, + source_map: Arc::new(source_map), + document_id: Some(document_id), + }); + } + }; + let directives = + match super::include::expand_includes(directives, &mut source_map, path.parent()) + .context(super::error::load_config_error::ResolveIncludeSnafu) + { + Ok(directives) => directives, + Err(error) => { + return Err(ConfigLoadFailure { + error, + source_map: Arc::new(source_map), + document_id: Some(document_id), + }); + } + }; + Ok(( + Arc::new(ConfigDocumentSourceMap::new( + document_id, + Arc::new(source_map), + )), + directives, + )) + } + + fn build_pishoo( + &self, + sources: &Arc, + directives: &[AstDirective], + parent: EffectiveHttpConfig, + scope: OriginScope, + home: Option<&DhttpHome>, + worker: bool, + ) -> Result { + if directives.len() != 1 || directives[0].name.value != "pishoo" { + return Err(BuildTypedConfigError::PishooCardinality { + span: directives + .first() + .map_or(SourceSpan::new(super::source::SourceId(0), 0, 0), |d| { + d.span + }), + }); + } + let pishoo = &directives[0]; + let children = block(pishoo)?; + let mut pid = None; + let mut workers = None; + let mut groups = None; + let mut http = LocalHttp::default(); + let mut seen = std::collections::HashMap::new(); + for directive in children { + if directive.name.value == "server" { + if worker { + return Err(BuildTypedConfigError::WorkerServer { + span: directive.span, + }); + } + continue; + } + if directive.name.value == "proxy" { + if worker { + return Err(BuildTypedConfigError::UnknownDirective { + name: "proxy".to_owned(), + context: "worker pishoo", + span: directive.span, + }); + } + continue; + } + reject_duplicate(&mut seen, directive)?; + match directive.name.value.as_str() { + "pid" => pid = Some(parse(directive, ConfigContext::Pishoo, sources)?), + "workers" => workers = Some(parse(directive, ConfigContext::Pishoo, sources)?), + "groups" => groups = Some(parse(directive, ConfigContext::Pishoo, sources)?), + name if parse_http(name, directive, ConfigContext::Pishoo, sources, &mut http)? => { + } + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "pishoo", + span: directive.span, + }); + } + } + } + let effective = overlay_http(&parent, http, scope, sources.source_map()); + // Server candidates are rebuilt after all PISHOO directives are known, so textual order is irrelevant. + let forward_proxies = children + .iter() + .filter(|d| d.name.value == "proxy") + .map(|directive| build_forward_proxy(directive, sources)) + .collect::, _>>()?; + let servers = children + .iter() + .filter(|d| d.name.value == "server") + .map(|directive| { + let source = sources.config_span(directive.span); + ServerConfigCandidate { + source, + result: build_server(directive, sources, &effective, None, home), + } + }) + .collect(); + Ok(ParsedPishooConfig { + pishoo: PishooConfig::new( + sources.config_span(pishoo.span), + pid, + workers, + groups, + effective, + forward_proxies, + ), + servers, + }) + } +} + +fn failure( + source: BuildTypedConfigError, + sources: Arc, +) -> ConfigLoadFailure { + ConfigLoadFailure { + error: LoadConfigError::BuildTyped { source }, + source_map: Arc::clone(sources.source_map_arc()), + document_id: Some(sources.document_id()), + } +} + +fn block(directive: &AstDirective) -> Result<&[AstDirective], BuildTypedConfigError> { + match &directive.body { + AstBody::Block { children, .. } => Ok(children), + _ => Err(BuildTypedConfigError::Shape { + name: directive.name.value.clone(), + span: directive.span, + }), + } +} +fn leaf(directive: &AstDirective) -> Result<(), BuildTypedConfigError> { + if directive.is_leaf() { + Ok(()) + } else { + Err(BuildTypedConfigError::Shape { + name: directive.name.value.clone(), + span: directive.span, + }) + } +} +fn parse( + directive: &AstDirective, + context: ConfigContext, + sources: &ConfigDocumentSourceMap, +) -> Result +where + T: DirectiveValue, + for<'a, 'b> T: TryFrom<&'a DirectiveInput<'b>, Error = ::Error>, +{ + if !matches!(directive.name.value.as_str(), "types" | "stun_server") { + leaf(directive)?; + } + T::try_from(&DirectiveInput { + directive, + context, + source_map: sources.source_map(), + }) + .map_err(|source| BuildTypedConfigError::Directive { + name: directive.name.value.clone(), + span: directive.span, + source: Box::new(source), + }) +} +fn reject_duplicate( + seen: &mut std::collections::HashMap, + directive: &AstDirective, +) -> Result<(), BuildTypedConfigError> { + if matches!( + directive.name.value.as_str(), + "listen" | "location" | "stun_server" | "add_header" | "proxy_set_header" | "ssh_ssl_user" + ) { + return Ok(()); + } + if let Some(first) = seen.insert(directive.name.value.clone(), directive.span) { + return Err(BuildTypedConfigError::Duplicate { + name: directive.name.value.clone(), + first, + duplicate: directive.span, + }); + } + Ok(()) +} + +fn builtin(value: T) -> Cascaded { + Cascaded::new(value, vec![ConfigOrigin::builtin()].into_boxed_slice()) +} +fn builtin_http() -> EffectiveHttpConfig { + EffectiveHttpConfig::new( + builtin(None), + builtin(BoolConfig(false)), + builtin(BoolConfig(false)), + builtin(GzipMinLength(20)), + builtin(GzipCompLevel(1)), + builtin(StringList(Vec::new())), + builtin(None), + builtin(None), + builtin(AccessLogDirective::Off), + ) +} +fn cascade( + parent: &Cascaded, + local: Option>, + scope: OriginScope, + sources: &SourceMap, +) -> Cascaded { + match local { + Some(assignment) => { + let mut lineage = parent.lineage().to_vec(); + lineage.push(ConfigOrigin::source(scope, sources, assignment.span)); + Cascaded::new(assignment.value, lineage.into_boxed_slice()) + } + None => parent.clone(), + } +} +fn overlay_http( + parent: &EffectiveHttpConfig, + local: LocalHttp, + scope: OriginScope, + sources: &SourceMap, +) -> EffectiveHttpConfig { + EffectiveHttpConfig::new( + cascade( + parent.access_rules(), + local.access_rules.map(|value| value.map(Some)), + scope, + sources, + ), + cascade(parent.gzip(), local.gzip, scope, sources), + cascade(parent.gzip_vary(), local.gzip_vary, scope, sources), + cascade( + parent.gzip_min_length(), + local.gzip_min_length, + scope, + sources, + ), + cascade( + parent.gzip_comp_level(), + local.gzip_comp_level, + scope, + sources, + ), + cascade(parent.gzip_types(), local.gzip_types, scope, sources), + cascade( + parent.default_type(), + local.default_type.map(|value| value.map(Some)), + scope, + sources, + ), + cascade( + parent.types(), + local.types.map(|value| value.map(Some)), + scope, + sources, + ), + cascade(parent.access_log(), local.access_log, scope, sources), + ) +} +fn parse_http( + name: &str, + d: &AstDirective, + c: ConfigContext, + s: &ConfigDocumentSourceMap, + h: &mut LocalHttp, +) -> Result { + match name { + "access_rules" => h.access_rules = Some(parse_assignment(d, c, s)?), + "gzip" => h.gzip = Some(parse_assignment(d, c, s)?), + "gzip_vary" => h.gzip_vary = Some(parse_assignment(d, c, s)?), + "gzip_min_length" => h.gzip_min_length = Some(parse_assignment(d, c, s)?), + "gzip_comp_level" => h.gzip_comp_level = Some(parse_assignment(d, c, s)?), + "gzip_types" => h.gzip_types = Some(parse_assignment(d, c, s)?), + "default_type" => h.default_type = Some(parse_assignment(d, c, s)?), + "types" => h.types = Some(parse_assignment(d, c, s)?), + "access_log" => { + h.access_log = Some(Assignment::new(parse_access_log(d, c, s)?, d.span)); + } + _ => return Ok(false), + } + Ok(true) +} + +fn parse_assignment( + directive: &AstDirective, + context: ConfigContext, + sources: &ConfigDocumentSourceMap, +) -> Result, BuildTypedConfigError> +where + T: DirectiveValue, + for<'a, 'b> T: TryFrom<&'a DirectiveInput<'b>, Error = ::Error>, +{ + Ok(Assignment::new( + parse(directive, context, sources)?, + directive.span, + )) +} +fn parse_access_log( + d: &AstDirective, + c: ConfigContext, + s: &ConfigDocumentSourceMap, +) -> Result { + leaf(d)?; + if d.args.len() != 1 { + return Err(BuildTypedConfigError::AccessLogArgument { span: d.span }); + } + Ok(match d.args[0].value.as_str() { + "off" => AccessLogDirective::Off, + "default" => AccessLogDirective::ProfileDefault, + _ => AccessLogDirective::Resolved(parse::(d, c, s)?), + }) +} + +fn build_forward_proxy( + d: &AstDirective, + s: &ConfigDocumentSourceMap, +) -> Result { + let children = block(d)?; + let mut seen = std::collections::HashMap::new(); + let mut listen = None; + let mut allow = Vec::new(); + let mut deny = Vec::new(); + for directive in children { + reject_duplicate(&mut seen, directive)?; + match directive.name.value.as_str() { + "listen" => { + let addresses: super::types::SocketAddrs = + parse(directive, ConfigContext::Pishoo, s)?; + if addresses.0.len() != 1 { + return Err(BuildTypedConfigError::ForwardListen { + span: directive.span, + }); + } + listen = addresses.0.into_iter().next(); + } + "allow" => allow = parse::(directive, ConfigContext::Pishoo, s)?.0, + "deny" => deny = parse::(directive, ConfigContext::Pishoo, s)?.0, + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "proxy", + span: directive.span, + }); + } + } + } + let Some(listen) = listen else { + return Err(BuildTypedConfigError::ForwardListen { span: d.span }); + }; + Ok(crate::forward::ForwardConfig { + listen, + allow, + deny, + }) +} + +fn build_server( + d: &AstDirective, + s: &ConfigDocumentSourceMap, + parent: &EffectiveHttpConfig, + profile: Option<&IdentityProfile>, + home: Option<&DhttpHome>, +) -> Result { + let children = block(d)?; + let mut seen = std::collections::HashMap::new(); + let mut listens = Vec::new(); + let mut names = None; + let mut resolver = None; + let mut cert = None; + let mut key = None; + let mut relay = None; + let mut stun = None; + let mut stun_servers = Vec::new(); + let mut http = LocalHttp::default(); + let mut locations = Vec::new(); + for x in children { + reject_duplicate(&mut seen, x)?; + match x.name.value.as_str() { + "listen" => listens.push(parse(x, ConfigContext::Server, s)?), + "server_name" => names = Some(parse::(x, ConfigContext::Server, s)?), + "dns" => resolver = Some(parse(x, ConfigContext::Server, s)?), + "ssl_certificate" => cert = Some(parse(x, ConfigContext::Server, s)?), + "ssl_certificate_key" => key = Some(parse(x, ConfigContext::Server, s)?), + "relay" => relay = Some(parse(x, ConfigContext::Server, s)?), + "stun" => stun = Some(parse(x, ConfigContext::Server, s)?), + "stun_server" => stun_servers.push(parse(x, ConfigContext::Server, s)?), + "location" => locations.push(x), + name if parse_http(name, x, ConfigContext::Server, s, &mut http)? => {} + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "server", + span: x.span, + }); + } + } + } + if listens.is_empty() { + return Err(BuildTypedConfigError::Missing { + name: "listen", + span: d.span, + }); + } + let names = names + .map(|v| v.0.into_iter().map(|v| v.name).collect::>()) + .unwrap_or_else(|| { + profile + .map(|p| vec![p.name().clone()].into_boxed_slice()) + .unwrap_or_default() + }); + let identity = if let Some(p) = profile { + if names.as_ref() != [p.name().clone()] { + return Err(BuildTypedConfigError::IdentityName { span: d.span }); + } + if cert.is_some() || key.is_some() { + return Err(BuildTypedConfigError::IdentityTls { span: d.span }); + } + ServerIdentity::Profile(p.clone()) + } else { + match (cert, key) { + (Some(c), Some(k)) => ServerIdentity::Direct { + certificate: c, + private_key: k, + }, + (None, None) => { + let Some(home) = home else { + return Err(BuildTypedConfigError::StandaloneTls { span: d.span }); + }; + if names.len() != 1 { + return Err(BuildTypedConfigError::AmbiguousProfile { span: d.span }); + } + ServerIdentity::Profile(home.identity_profile(names[0].clone())) + } + _ => return Err(BuildTypedConfigError::ServerTlsPair { span: d.span }), + } + }; + let parent = if matches!(identity, ServerIdentity::Profile(_)) + && parent.access_log().lineage().len() == 1 + { + parent + .clone() + .with_access_log(builtin(AccessLogDirective::ProfileDefault)) + } else { + parent.clone() + }; + let effective = overlay_http(&parent, http, OriginScope::Server, s.source_map()); + if matches!(identity, ServerIdentity::Direct { .. }) + && matches!( + effective.access_log().effective(), + AccessLogDirective::ProfileDefault + ) + { + return Err(BuildTypedConfigError::DirectDefaultAccessLog { span: d.span }); + } + let locations = locations + .into_iter() + .map(|x| build_location(x, s, &effective)) + .collect::, _>>()?; + Ok(ServerConfig::new( + identity, + s.config_span(d.span), + names, + listens.into_boxed_slice(), + resolver, + effective, + relay, + stun, + stun_servers.into_boxed_slice(), + locations, + )) +} + +fn build_location( + d: &AstDirective, + s: &ConfigDocumentSourceMap, + parent: &EffectiveHttpConfig, +) -> Result { + let matcher = super::pattern::parse_spanned_pattern(&d.args).map_err(|source| { + BuildTypedConfigError::Directive { + name: d.name.value.clone(), + span: d.span, + source: Box::new(source), + } + })?; + let children = block(d)?; + let mut seen = std::collections::HashMap::new(); + let mut root = None; + let mut alias = None; + let mut index = None; + let mut add = Vec::new(); + let mut set = Vec::new(); + let mut pass = None; + let mut cert = None; + let mut key = None; + let mut trusted = None; + let mut login = None; + let mut users = Vec::new(); + let mut deny = None; + let mut http = LocalHttp::default(); + for x in children { + reject_duplicate(&mut seen, x)?; + match x.name.value.as_str() { + "root" => root = Some(parse(x, ConfigContext::Location, s)?), + "alias" => alias = Some(parse(x, ConfigContext::Location, s)?), + "index" => index = Some(parse(x, ConfigContext::Location, s)?), + "add_header" => add.push(parse(x, ConfigContext::Location, s)?), + "proxy_set_header" => set.push(parse(x, ConfigContext::Location, s)?), + "proxy_pass" => pass = Some(parse(x, ConfigContext::Location, s)?), + "proxy_ssl_certificate" => cert = Some(parse(x, ConfigContext::Location, s)?), + "proxy_ssl_certificate_key" => key = Some(parse(x, ConfigContext::Location, s)?), + "proxy_ssl_trusted_certificate" => { + trusted = Some(parse(x, ConfigContext::Location, s)?) + } + "ssh_login" => login = Some(parse(x, ConfigContext::Location, s)?), + "ssh_ssl_user" => users.push(parse(x, ConfigContext::Location, s)?), + "ssh_deny" => deny = Some(parse(x, ConfigContext::Location, s)?), + name if parse_http(name, x, ConfigContext::Location, s, &mut http)? => {} + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "location", + span: x.span, + }); + } + } + } + let proxy_tls = match (cert, key) { + (Some(c), Some(k)) => Some(PreparedProxyTlsPaths::new(Some(c), Some(k), trusted)), + (None, None) => { + trusted.map(|trusted| PreparedProxyTlsPaths::new(None, None, Some(trusted))) + } + _ => return Err(BuildTypedConfigError::ProxyTlsPair { span: d.span }), + }; + if matches!( + matcher, + super::pattern::Pattern::Regex(_) | super::pattern::Pattern::CRegex(_) + ) && pass.as_ref().is_some_and(ProxyPass::has_explicit_uri) + { + return Err(BuildTypedConfigError::RegexProxyUri { span: d.span }); + } + add.push(HeaderRules(vec![HeaderRule { + name: HeaderName::from_static("server"), + value: HeaderValue::from_static("pishoo"), + always: true, + }])); + let effective = overlay_http(parent, http, OriginScope::Location, s.source_map()); + Ok(LocationConfig::new( + s.config_span(d.span), + matcher, + effective, + root, + alias, + index, + add.into_boxed_slice(), + set.into_boxed_slice(), + pass, + proxy_tls, + login, + users.into_boxed_slice(), + deny, + )) +} diff --git a/gateway/src/parse/builtin.rs b/gateway/src/parse/builtin.rs index 688552f3..16d9d9ee 100644 --- a/gateway/src/parse/builtin.rs +++ b/gateway/src/parse/builtin.rs @@ -1,21 +1,6 @@ pub mod access; pub mod core; pub mod http; -pub mod location; pub mod net; -pub mod pishoo; -pub mod proxy; -pub mod server; pub mod ssh; pub mod stun; - -use crate::parse::registry::ConfigRegistry; - -pub fn register_gateway_directives(registry: &mut ConfigRegistry) { - // Each builtin owns its duplicate, cascade, transport, and reload metadata. - pishoo::register(registry); - server::register(registry); - location::register(registry); - proxy::register(registry); - stun::register(registry); -} diff --git a/gateway/src/parse/builtin/access.rs b/gateway/src/parse/builtin/access.rs index 20db8db3..5ecbb8bc 100644 --- a/gateway/src/parse/builtin/access.rs +++ b/gateway/src/parse/builtin/access.rs @@ -4,8 +4,8 @@ use snafu::{ResultExt, Snafu}; use crate::parse::{ builtin::core::{first_arg_span, only_arg}, + decode::{DirectiveInput, DirectiveValue}, normalize, - registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{AccessRulesUri, AccessRulesUriValidationError}, }; @@ -143,7 +143,7 @@ mod tests { } #[cfg(not(unix))] cases.push(("sqlite:a%3Fb%23c.db?mode=ro%23strict", dir.join("a?b#c.db"))); - let config = cases + let servers = cases .iter() .enumerate() .map(|(index, (access_rules, _))| { @@ -153,23 +153,25 @@ mod tests { ) }) .collect::(); + let config = format!("pishoo {{ {servers} }}"); std::fs::write(dir.join("server.conf"), config).expect("write config"); - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("server.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let servers = parsed.root.children("server").expect("server children"); - assert_eq!(servers.len(), cases.len()); - for (server, (_, expected_path)) in servers.iter().zip(cases) { + let text = std::fs::read_to_string(dir.join("server.conf")).unwrap(); + let parsed = crate::parse::TypedConfigParser::new() + .parse_root(&text, &dir.join("server.conf"), None) + .expect("config should load"); + + assert_eq!(parsed.servers().len(), cases.len()); + for (server, (_, expected_path)) in parsed.servers().iter().zip(cases) { let uri = &server - .require::("access_rules") - .expect("access_rules should be typed") + .result() + .as_ref() + .unwrap() + .http() + .access_rules() + .effective() + .as_ref() + .unwrap() .0; assert_eq!( crate::parse::types::AccessRulesUri::decoded_sqlite_path(uri) diff --git a/gateway/src/parse/builtin/core.rs b/gateway/src/parse/builtin/core.rs index d73898b6..98f97596 100644 --- a/gateway/src/parse/builtin/core.rs +++ b/gateway/src/parse/builtin/core.rs @@ -4,9 +4,9 @@ use snafu::{ResultExt, Snafu, ensure}; use crate::parse::{ ast::{AstBody, AstDirective, Spanned}, + decode::{DirectiveInput, DirectiveValue}, domain::ResolvedConfigPath, normalize::NormalizeDirectiveValueError, - registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{BoolConfig, GzipTypesValidationError, PathConfig, StringConfig, StringList}, }; diff --git a/gateway/src/parse/builtin/http.rs b/gateway/src/parse/builtin/http.rs index 558a626d..190ada9f 100644 --- a/gateway/src/parse/builtin/http.rs +++ b/gateway/src/parse/builtin/http.rs @@ -3,7 +3,7 @@ use snafu::{OptionExt, ResultExt, Snafu, ensure}; use crate::parse::{ builtin::core::{block_children, first_arg_span, only_arg}, - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{ DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, @@ -324,22 +324,23 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for MimeTyp #[cfg(test)] mod tests { - use crate::parse::{ - tests::{ - assert_error_chain_display_single_line, build_proxy_conf, build_server_conf, - cleanup_temp_files, create_temp_file, first_pishoo, first_server, parse_doc, - }, - types::{DefaultType, GzipCompLevel, GzipMinLength, ProxyPass}, + use crate::parse::tests::{ + assert_error_chain_display_single_line, build_proxy_conf, build_server_conf, + cleanup_temp_files, create_temp_file, first_location, parse_root, }; #[test] fn parse_default_type_accepts_single_header_value() { let conf = "pishoo { default_type text/html; }"; - let pishoo = first_pishoo(&parse_doc(conf)); - let value = pishoo - .require::("default_type") - .expect("default_type should be typed") + let parsed = parse_root(conf).unwrap(); + let value = parsed + .pishoo() + .http() + .default_type() + .effective() + .as_ref() + .unwrap() .0 .clone(); @@ -365,25 +366,12 @@ mod tests { #[test] fn parse_numeric_and_identity_directives_keep_typed_values() { - let conf = "pishoo { gzip_min_length 1100; gzip_comp_level 6; proxy { listen 127.0.0.1:8080; client_name example.com; } }"; + let conf = "pishoo { gzip_min_length 1100; gzip_comp_level 6; }"; + let parsed = parse_root(conf).unwrap(); + let pishoo = parsed.pishoo(); - let document = parse_doc(conf); - let pishoo = first_pishoo(&document); - - assert_eq!( - pishoo - .require::("gzip_min_length") - .expect("gzip_min_length should be typed") - .0, - 1100 - ); - assert_eq!( - pishoo - .require::("gzip_comp_level") - .expect("gzip_comp_level should be typed") - .0, - 6 - ); + assert_eq!(pishoo.http().gzip_min_length().effective().0, 1100); + assert_eq!(pishoo.http().gzip_comp_level().effective().0, 6); } #[test] @@ -416,13 +404,8 @@ mod tests { "location /api { proxy_pass http://backend.example.com; }", ); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass"); + let location = first_location(&conf).unwrap(); + let proxy_pass = location.proxy_pass().unwrap(); assert_eq!(proxy_pass.proxy_host, "backend.example.com"); assert!(!proxy_pass.has_explicit_uri()); @@ -441,13 +424,8 @@ mod tests { "location /api { proxy_pass http://backend.example.com/; }", ); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass"); + let location = first_location(&conf).unwrap(); + let proxy_pass = location.proxy_pass().unwrap(); assert!(proxy_pass.has_explicit_uri()); assert_eq!(proxy_pass.explicit_path_and_query(), Some("/")); @@ -465,13 +443,8 @@ mod tests { "location /api { proxy_pass https://backend.example.com:8443/base/?v=1; }", ); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass"); + let location = first_location(&conf).unwrap(); + let proxy_pass = location.proxy_pass().unwrap(); assert_eq!(proxy_pass.proxy_host, "backend.example.com:8443"); assert!(proxy_pass.has_explicit_uri()); @@ -491,33 +464,17 @@ mod tests { "location /api { proxy_pass http://backend.example.com; }", ); - let server = first_server(&parse_doc(&conf)); - let location = server.children("location").expect("location exists")[0].clone(); + let location = first_location(&conf).unwrap(); - assert_eq!( - location - .require::("proxy_pass") - .expect("proxy_pass should be parsed") - .scheme_str(), - "http" - ); + assert_eq!(location.proxy_pass().unwrap().scheme_str(), "http"); let conf_https = build_server_conf( &cert, &key, "location /api { proxy_pass https://backend.example.com; }", ); - let location_https = first_server(&parse_doc(&conf_https)) - .children("location") - .expect("location exists")[0] - .clone(); - assert_eq!( - location_https - .require::("proxy_pass") - .expect("proxy_pass should be parsed") - .scheme_str(), - "https" - ); + let location_https = first_location(&conf_https).unwrap(); + assert_eq!(location_https.proxy_pass().unwrap().scheme_str(), "https"); cleanup_temp_files(&[&cert, &key]); } diff --git a/gateway/src/parse/builtin/location.rs b/gateway/src/parse/builtin/location.rs deleted file mode 100644 index 2370cbab..00000000 --- a/gateway/src/parse/builtin/location.rs +++ /dev/null @@ -1,727 +0,0 @@ -use http::{HeaderName, HeaderValue}; -use snafu::{OptionExt, Snafu, ensure}; - -use crate::parse::{ - cascade::DirectiveKey, - document::ConfigNode, - domain::ResolvedConfigPath, - pattern::{ParsePatternError, Pattern}, - registry::{ - BuildOptions, CascadePolicy, CascadedCardinality, ConfigRegistry, ContextPayloadKey, - DirectiveInput, DirectiveValue, DuplicatePolicy, LocalDirectiveKey, PayloadCardinality, - ReloadImpact, RepeatedCardinality, RepeatedDirectiveKey, SingleCardinality, - TransportPolicy, TypedDirectiveDefinition, context, - }, - source::SourceSpan, - types::{ - BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, - ProxyPass, SshLoginMethods, SshSslUsers, StringList, - }, - value::TypedValue, -}; - -macro_rules! single_definition { - ($definition:ident, $key:ident, $value:ty, $name:literal) => { - const $definition: TypedDirectiveDefinition<$value, SingleCardinality> = - TypedDirectiveDefinition::single_leaf( - context::LOCATION, - $name, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); - pub(crate) const $key: LocalDirectiveKey<$value> = $definition.key(); - }; -} - -macro_rules! cascaded_definition { - ($definition:ident, $key:ident, $value:ty, $name:literal, $parent:expr) => { - const $definition: TypedDirectiveDefinition<$value, CascadedCardinality> = - TypedDirectiveDefinition::cascaded_leaf( - context::LOCATION, - $name, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); - pub(crate) const $key: DirectiveKey<$value> = $definition.key_inheriting($parent); - }; -} - -macro_rules! repeated_definition { - ($definition:ident, $key:ident, $value:ty, $name:literal) => { - const $definition: TypedDirectiveDefinition<$value, RepeatedCardinality> = - TypedDirectiveDefinition::repeated_leaf( - context::LOCATION, - $name, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); - pub(crate) const $key: RepeatedDirectiveKey<$value> = $definition.key(); - }; -} - -const PATTERN_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::payload( - context::SERVER, - context::LOCATION, - "location", - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); -pub(crate) const PATTERN_KEY: ContextPayloadKey = PATTERN_DEFINITION.key(); -single_definition!(ROOT_DEFINITION, ROOT_KEY, ResolvedConfigPath, "root"); -single_definition!(ALIAS_DEFINITION, ALIAS_KEY, ResolvedConfigPath, "alias"); -cascaded_definition!( - GZIP_DEFINITION, - GZIP_KEY, - BoolConfig, - "gzip", - crate::parse::builtin::server::GZIP_KEY -); -cascaded_definition!( - GZIP_VARY_DEFINITION, - GZIP_VARY_KEY, - BoolConfig, - "gzip_vary", - crate::parse::builtin::server::GZIP_VARY_KEY -); -cascaded_definition!( - GZIP_MIN_LENGTH_DEFINITION, - GZIP_MIN_LENGTH_KEY, - GzipMinLength, - "gzip_min_length", - crate::parse::builtin::server::GZIP_MIN_LENGTH_KEY -); -cascaded_definition!( - GZIP_COMP_LEVEL_DEFINITION, - GZIP_COMP_LEVEL_KEY, - GzipCompLevel, - "gzip_comp_level", - crate::parse::builtin::server::GZIP_COMP_LEVEL_KEY -); -cascaded_definition!( - GZIP_TYPES_DEFINITION, - GZIP_TYPES_KEY, - StringList, - "gzip_types", - crate::parse::builtin::server::GZIP_TYPES_KEY -); -single_definition!(INDEX_DEFINITION, INDEX_KEY, StringList, "index"); -repeated_definition!( - ADD_HEADER_DEFINITION, - ADD_HEADER_KEY, - HeaderRules, - "add_header" -); -repeated_definition!( - PROXY_SET_HEADER_DEFINITION, - PROXY_SET_HEADER_KEY, - HeaderRules, - "proxy_set_header" -); -single_definition!( - PROXY_PASS_DEFINITION, - PROXY_PASS_KEY, - ProxyPass, - "proxy_pass" -); -single_definition!( - PROXY_SSL_CERTIFICATE_DEFINITION, - PROXY_SSL_CERTIFICATE_KEY, - ResolvedConfigPath, - "proxy_ssl_certificate" -); -single_definition!( - PROXY_SSL_CERTIFICATE_KEY_DEFINITION, - PROXY_SSL_CERTIFICATE_KEY_KEY, - ResolvedConfigPath, - "proxy_ssl_certificate_key" -); -single_definition!( - PROXY_SSL_TRUSTED_CERTIFICATE_DEFINITION, - PROXY_SSL_TRUSTED_CERTIFICATE_KEY, - ResolvedConfigPath, - "proxy_ssl_trusted_certificate" -); -single_definition!( - SSH_LOGIN_DEFINITION, - SSH_LOGIN_KEY, - SshLoginMethods, - "ssh_login" -); -repeated_definition!( - SSH_SSL_USER_DEFINITION, - SSH_SSL_USER_KEY, - SshSslUsers, - "ssh_ssl_user" -); -single_definition!(SSH_DENY_DEFINITION, SSH_DENY_KEY, StringList, "ssh_deny"); -cascaded_definition!( - DEFAULT_TYPE_DEFINITION, - DEFAULT_TYPE_KEY, - DefaultType, - "default_type", - crate::parse::builtin::server::DEFAULT_TYPE_KEY -); -const TYPES_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::cascaded_raw( - context::LOCATION, - "types", - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); -pub(crate) const TYPES_KEY: DirectiveKey = - TYPES_DEFINITION.key_inheriting(crate::parse::builtin::server::TYPES_KEY); - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum FinalizeLocationError { - #[snafu(display("location context is missing its parsed pattern payload"))] - MissingPattern { span: SourceSpan }, - #[snafu(display( - "proxy_ssl_certificate and proxy_ssl_certificate_key must be configured together" - ))] - ProxyTlsPair { span: SourceSpan }, - #[snafu(display("proxy_pass cannot include a uri part inside regex location"))] - ProxyPassUriInRegexLocation { span: SourceSpan }, -} - -impl DirectiveValue for Pattern { - type Error = ParsePatternError; -} - -impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for Pattern { - type Error = ParsePatternError; - - fn try_from(input: &'input DirectiveInput<'directive>) -> Result { - crate::parse::pattern::parse_spanned_pattern(&input.directive.args) - } -} - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::LOCATION, - finalize: Some(finalize_location), - }); - PATTERN_DEFINITION.register(registry); - ROOT_DEFINITION.register(registry); - ALIAS_DEFINITION.register(registry); - GZIP_DEFINITION.register(registry); - GZIP_VARY_DEFINITION.register(registry); - GZIP_MIN_LENGTH_DEFINITION.register(registry); - GZIP_COMP_LEVEL_DEFINITION.register(registry); - GZIP_TYPES_DEFINITION.register(registry); - INDEX_DEFINITION.register(registry); - ADD_HEADER_DEFINITION.register(registry); - PROXY_SET_HEADER_DEFINITION.register(registry); - PROXY_PASS_DEFINITION.register(registry); - PROXY_SSL_CERTIFICATE_DEFINITION.register(registry); - PROXY_SSL_CERTIFICATE_KEY_DEFINITION.register(registry); - PROXY_SSL_TRUSTED_CERTIFICATE_DEFINITION.register(registry); - SSH_LOGIN_DEFINITION.register(registry); - SSH_SSL_USER_DEFINITION.register(registry); - SSH_DENY_DEFINITION.register(registry); - DEFAULT_TYPE_DEFINITION.register(registry); - TYPES_DEFINITION.register(registry); -} - -fn finalize_location( - node: &mut ConfigNode, - _options: &BuildOptions<'_>, -) -> Result<(), Box> { - let pattern = node - .payload::()? - .context(finalize_location_error::MissingPatternSnafu { span: node.span })?; - let proxy_pass = node.get::("proxy_pass")?; - let has_cert = node - .get::("proxy_ssl_certificate")? - .is_some(); - let has_key = node - .get::("proxy_ssl_certificate_key")? - .is_some(); - ensure!( - !matches!(pattern.as_ref(), Pattern::Regex(_) | Pattern::CRegex(_)) - || proxy_pass - .as_ref() - .is_none_or(|proxy_pass| !proxy_pass.has_explicit_uri()), - finalize_location_error::ProxyPassUriInRegexLocationSnafu { span: node.span } - ); - ensure!( - has_cert == has_key, - finalize_location_error::ProxyTlsPairSnafu { span: node.span } - ); - let server_header = HeaderRules(vec![HeaderRule { - name: HeaderName::from_static("server"), - value: HeaderValue::from_static("pishoo"), - always: true, - }]); - node.insert_slot("add_header", TypedValue::new(server_header, node.span)); - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::parse::{ - domain::ResolvedConfigPath, - pattern::Pattern, - tests::{ - build_proxy_conf, build_server_conf, cleanup_temp_files, create_temp_file, - first_server, parse_doc, - }, - types::{BoolConfig, DefaultType, HeaderRules, ProxyPass, StringList}, - }; - - #[test] - fn parse_path_list_directives_keep_path_config() { - let cert = create_temp_file("proxy_path_cert"); - let key = create_temp_file("proxy_path_key"); - let conf = build_proxy_conf(&cert, &key, "root /var/www/site;"); - - let server = first_server(&parse_doc(&conf)); - let location = server.children("location").expect("location should exist")[0].clone(); - - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .as_ref() - .as_ref(), - PathBuf::from("/var/www/site") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_default_type_in_location_context() { - let cert = create_temp_file("loc_default_type_cert"); - let key = create_temp_file("loc_default_type_key"); - let conf = build_server_conf( - &cert, - &key, - "location / { root /tmp; default_type text/plain; }", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - assert_eq!( - location - .require::("default_type") - .expect("default_type should be typed") - .0 - .to_str() - .unwrap(), - "text/plain" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_location_alias_index_and_ssh_deny_and_gzip_types() { - let cert = create_temp_file("loc_alias_index_cert"); - let key = create_temp_file("loc_alias_index_key"); - let conf = build_server_conf( - &cert, - &key, - "location /assets { alias /mnt/assets; index index.html index.txt; ssh_deny ip1 ip2; gzip on; gzip_vary off; gzip_types txt css; }", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - assert_eq!( - location - .require::("alias") - .expect("alias should be typed") - .as_ref() - .as_ref(), - PathBuf::from("/mnt/assets") - ); - assert_eq!( - location - .require::("index") - .expect("index should be typed") - .0, - vec!["index.html", "index.txt"], - ); - assert_eq!( - location - .require::("ssh_deny") - .expect("ssh_deny should be typed") - .0, - vec!["ip1", "ip2"] - ); - assert!(location.require::("gzip").expect("gzip").0); - assert!( - !location - .require::("gzip_vary") - .expect("gzip_vary") - .0 - ); - assert_eq!( - location - .require::("gzip_types") - .expect("gzip_types should be typed") - .0, - vec!["txt", "css"], - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_location_pattern_variants_from_context_payload() { - let cert = create_temp_file("pattern_cert"); - let key = create_temp_file("pattern_key"); - let conf = build_server_conf( - &cert, - &key, - "location / { root /tmp; }\nlocation = /login { root /var; }\nlocation ~ \\.(gif|png)$ { root /var/www; }", - ); - - let server = first_server(&parse_doc(&conf)); - let locations = server.children("location").expect("location should exist"); - - let common = locations[0] - .payload::() - .expect("common payload") - .expect("common location payload"); - let exact = locations[1] - .payload::() - .expect("exact payload") - .expect("exact location payload"); - let regex = locations[2] - .payload::() - .expect("regex payload") - .expect("regex location payload"); - - assert!(matches!(common.as_ref(), Pattern::Common)); - assert!(matches!(exact.as_ref(), Pattern::Exact(_))); - assert!(matches!(regex.as_ref(), Pattern::Regex(_))); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_location_pattern_rejects_invalid_argument_count() { - let cert = create_temp_file("pattern_invalid_cert"); - let key = create_temp_file("pattern_invalid_key"); - let conf = build_server_conf(&cert, &key, "location /foo bar baz { root /tmp; }"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("pattern with invalid arg count should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("number of location args must be 1 or 2") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_https_proxy_with_optional_trusted_certificate() { - let cert = create_temp_file("https_cert"); - let key = create_temp_file("https_key"); - let trusted = create_temp_file("trusted_ca"); - let conf = build_proxy_conf( - &cert, - &key, - &format!( - "proxy_pass https://backend.example.com; proxy_ssl_trusted_certificate {};", - trusted.display() - ), - ); - - let document = parse_doc(&conf); - let location = first_server(&document) - .children("location") - .expect("location exists")[0] - .clone(); - - assert!( - location - .require::("proxy_pass") - .is_ok() - ); - assert!( - location - .require::("proxy_ssl_trusted_certificate") - .is_ok() - ); - - cleanup_temp_files(&[&cert, &key, &trusted]); - } - - #[test] - fn parse_proxy_ssl_certificate_requires_matching_key() { - let cert = create_temp_file("pair_cert"); - let key = create_temp_file("pair_key"); - let client_cert = create_temp_file("client_cert"); - let conf = build_proxy_conf( - &cert, - &key, - &format!( - "proxy_pass https://backend.example.com; proxy_ssl_certificate {};", - client_cert.display() - ), - ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("missing proxy key should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("failed to finalize configuration context") - ); - - cleanup_temp_files(&[&cert, &key, &client_cert]); - } - - #[test] - fn parse_http_proxy_allows_proxy_ssl_directives() { - let cert = create_temp_file("http_cert"); - let key = create_temp_file("http_key"); - let client_cert = create_temp_file("http_client_cert"); - let client_key = create_temp_file("http_client_key"); - let trusted = create_temp_file("http_trusted"); - let conf = build_proxy_conf( - &cert, - &key, - &format!( - "proxy_pass http://backend.example.com; proxy_ssl_certificate {}; proxy_ssl_certificate_key {}; proxy_ssl_trusted_certificate {};", - client_cert.display(), - client_key.display(), - trusted.display() - ), - ); - - let document = parse_doc(&conf); - let location = first_server(&document) - .children("location") - .expect("location exists")[0] - .clone(); - - assert!( - location - .require::("proxy_ssl_certificate") - .is_ok() - ); - assert!( - location - .require::("proxy_ssl_certificate_key") - .is_ok() - ); - - cleanup_temp_files(&[&cert, &key, &client_cert, &client_key, &trusted]); - } - - #[test] - fn parse_regex_location_rejects_proxy_pass_with_explicit_uri() { - let cert = create_temp_file("regex_proxy_uri_cert"); - let key = create_temp_file("regex_proxy_uri_key"); - let conf = build_server_conf( - &cert, - &key, - r"location ~ \.php$ { proxy_pass http://backend.example.com/base/; }", - ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("regex location proxy_pass with uri should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!(report.contains("proxy_pass cannot include a uri part inside regex location")); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_regex_location_allows_proxy_pass_without_explicit_uri() { - let cert = create_temp_file("regex_proxy_no_uri_cert"); - let key = create_temp_file("regex_proxy_no_uri_key"); - let conf = build_server_conf( - &cert, - &key, - r"location ~ \.php$ { proxy_pass http://backend.example.com; }", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass should be typed"); - assert!(!proxy_pass.has_explicit_uri()); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_header_rules_accepts_add_header_and_proxy_set_header() { - let cert = create_temp_file("header_rules_accept_cert"); - let key = create_temp_file("header_rules_accept_key"); - let conf = build_proxy_conf( - &cert, - &key, - "proxy_set_header X-Trace test; add_header X-Custom yes always;", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - let proxy_set = location - .require::("proxy_set_header") - .expect("proxy_set_header should be typed"); - assert_eq!(proxy_set.0.len(), 1); - assert_eq!(proxy_set.0[0].name.as_str(), "x-trace"); - assert_eq!( - proxy_set.0[0] - .value - .to_str() - .expect("header value should be valid"), - "test" - ); - assert!(proxy_set.0[0].always); - - let add_header_rules = location - .get_all::("add_header") - .expect("add_header values should be typed"); - let header_names: Vec<_> = add_header_rules - .iter() - .flat_map(|entry| entry.0.iter()) - .map(|rule| rule.name.to_string()) - .collect(); - assert!( - header_names - .iter() - .any(|name| name.eq_ignore_ascii_case("x-custom")) - ); - assert!( - header_names - .iter() - .any(|name| name.eq_ignore_ascii_case("server")) - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_header_rules_rejects_invalid_always_marker() { - let cert = create_temp_file("header_rules_always_cert"); - let key = create_temp_file("header_rules_always_key"); - let conf = build_proxy_conf(&cert, &key, "add_header X-Trace test sometimes;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid header add_marker should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid add_header always marker") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[tokio::test] - async fn parse_location_root_is_relative_to_source_file() { - let dir = std::env::temp_dir().join(format!( - "gateway-relative-root-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(), - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); - std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); - std::fs::write( - dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n location / { root .; }\n}\n", - ) - .expect("write config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("server.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = parsed.root.children("server").expect("server children")[0].clone(); - let location = server.children("location").expect("location children")[0].clone(); - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .as_ref() - .as_ref(), - dir - ); - } - - #[tokio::test] - async fn parse_included_location_root_is_relative_to_included_file() { - let dir = std::env::temp_dir().join(format!( - "gateway-relative-include-root-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(), - )); - std::fs::create_dir_all(dir.join("sites")).expect("create sites dir"); - std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); - std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); - std::fs::write( - dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n include sites/static.conf;\n}\n", - ) - .expect("write root config"); - std::fs::write(dir.join("sites/static.conf"), "location / { root .; }\n") - .expect("write included config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("server.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = parsed.root.children("server").expect("server children")[0].clone(); - let location = server.children("location").expect("location children")[0].clone(); - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .as_ref() - .as_ref(), - dir.join("sites") - ); - } -} diff --git a/gateway/src/parse/builtin/net.rs b/gateway/src/parse/builtin/net.rs index 623cc577..4e933c2e 100644 --- a/gateway/src/parse/builtin/net.rs +++ b/gateway/src/parse/builtin/net.rs @@ -5,7 +5,7 @@ use snafu::{ResultExt, Snafu}; use crate::parse::{ builtin::core::{first_arg_span, only_arg}, - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{ ClientNameConfig, IfaceRange, IpFamilies, ListenConfig, Listens, ResolverConfig, @@ -318,19 +318,29 @@ fn ip_families_from_value(value: &str, span: SourceSpan) -> Result Result { + let text = format!("listen {value};"); + let directives = crate::parse::grammar::parse_source(&text, SourceId(0)).unwrap(); + let source_map = SourceMap::default(); + SocketAddrs::try_from(&DirectiveInput { + directive: &directives[0], + context: ConfigContext::Pishoo, + source_map: &source_map, + }) + } #[test] fn parse_socket_addrs_keeps_multiple_addresses() { - let conf = - "pishoo { proxy { listen 127.0.0.1:8080,127.0.0.2:8081; allow any; deny none; } }"; - - let document = parse_doc(conf); - let pishoo = document.root.children("pishoo").expect("pishoo children")[0].clone(); - let proxy = pishoo.children("proxy").expect("proxy should exist")[0].clone(); - assert_eq!( - proxy.require::("listen").unwrap().0, + parse_socket_addrs("127.0.0.1:8080,127.0.0.2:8081") + .unwrap() + .0, vec![ "127.0.0.1:8080" .parse() @@ -344,31 +354,13 @@ mod tests { #[test] fn parse_socket_addrs_rejects_invalid_address() { - let conf = "pishoo { proxy { listen not-a-socket; } }"; - - let failure = crate::parse::parse_config_str_for_test(conf) - .expect_err("invalid socket addr should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("failed to parse directive `listen`") - ); + assert!(parse_socket_addrs("not-a-socket").is_err()); } #[test] fn parse_address_directives_keep_socket_addrs_type() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; } }"; - - let document = parse_doc(conf); - let pishoo = document.root.children("pishoo").expect("pishoo children")[0].clone(); - let proxy = pishoo.children("proxy").expect("proxy should exist")[0].clone(); - assert_eq!( - proxy - .require::("listen") - .expect("proxy listen should be typed") - .0, + parse_socket_addrs("127.0.0.1:8080").unwrap().0, vec!["127.0.0.1:8080".parse().expect("address should parse")] ); } diff --git a/gateway/src/parse/builtin/pishoo.rs b/gateway/src/parse/builtin/pishoo.rs deleted file mode 100644 index a5fafc0e..00000000 --- a/gateway/src/parse/builtin/pishoo.rs +++ /dev/null @@ -1,182 +0,0 @@ -use crate::parse::{ - domain::ResolvedConfigPath, - registry::{ - CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, LocalDirectiveKey, - ReloadImpact, SingleCardinality, TransportPolicy, TypedDirectiveDefinition, context, - }, - types::StringList, -}; - -const PID_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::single_leaf( - context::PISHOO, - "pid", - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::Supervisor, - ); -const WORKERS_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::single_leaf( - context::PISHOO, - "workers", - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::Supervisor, - ); -const GROUPS_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::single_leaf( - context::PISHOO, - "groups", - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::Supervisor, - ); - -pub(crate) const PID_KEY: LocalDirectiveKey = PID_DEFINITION.key(); -pub(crate) const WORKERS_KEY: LocalDirectiveKey = WORKERS_DEFINITION.key(); -pub(crate) const GROUPS_KEY: LocalDirectiveKey = GROUPS_DEFINITION.key(); - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::PISHOO, - finalize: None, - }); - registry.register_directive( - context::ROOT, - DirectiveSpec::context_empty( - "pishoo", - vec![context::ROOT], - context::PISHOO, - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::Supervisor, - ), - ); - PID_DEFINITION.register(registry); - WORKERS_DEFINITION.register(registry); - GROUPS_DEFINITION.register(registry); - registry.register_v1_snapshot_directives(); -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::parse::{ - domain::ResolvedConfigPath, - tests::{first_pishoo, parse_doc}, - types::{AccessRulesUri, MimeTypes, StringList}, - }; - - #[test] - fn parse_pishoo_groups_collects_all_values() { - let pishoo = first_pishoo(&parse_doc("pishoo { groups admin viewer api; }")); - - let groups = pishoo - .require::("groups") - .expect("groups should be typed"); - assert_eq!(groups.0, vec!["admin", "viewer", "api"]); - } - - #[test] - fn parse_pishoo_gzip_types_collects_extensions() { - let pishoo = first_pishoo(&parse_doc("pishoo { gzip_types txt css js; }")); - - let gzip_types = pishoo - .require::("gzip_types") - .expect("gzip_types should be typed"); - assert_eq!(gzip_types.0, vec!["txt", "css", "js"]); - } - - #[test] - fn parse_pishoo_types_block_parses_mime_types() { - let pishoo = first_pishoo(&parse_doc( - "pishoo { types { text/plain txt; application/json json; } }", - )); - - let types = pishoo - .require::("types") - .expect("types should be typed") - .0 - .clone(); - - assert_eq!(types.get("txt").unwrap().to_str().unwrap(), "text/plain"); - assert_eq!( - types.get("json").unwrap().to_str().unwrap(), - "application/json" - ); - } - - #[test] - fn parse_string_list_directive_collects_all_values() { - let conf = "pishoo { workers admin viewer api; }"; - let pishoo = first_pishoo(&parse_doc(conf)); - - let workers = pishoo - .require::("workers") - .expect("workers should be typed"); - assert_eq!(workers.0, vec!["admin", "viewer", "api"]); - } - - #[test] - fn parse_pid_and_access_rules_keep_domain_types() { - let pishoo = first_pishoo(&parse_doc( - "pishoo { pid /tmp/pishoo-test.pid; access_rules sqlite:///tmp/rules.db?mode=ro; }", - )); - - assert_eq!( - pishoo - .require::("pid") - .expect("pid should be typed") - .as_ref() - .as_ref(), - PathBuf::from("/tmp/pishoo-test.pid") - ); - assert_eq!( - pishoo - .require::("access_rules") - .expect("access_rules should be typed") - .0 - .as_str(), - "sqlite:///tmp/rules.db?mode=ro" - ); - } - - #[tokio::test] - async fn parse_relative_pid_uses_root_config_dir() { - let dir = std::env::temp_dir().join(format!( - "gateway-relative-pid-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(), - )); - std::fs::create_dir_all(dir.join("run")).expect("create run dir"); - std::fs::write(dir.join("pishoo.conf"), "pishoo { pid ./run/pishoo.pid; }") - .expect("write config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("pishoo.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let pishoo = crate::parse::tests::first_pishoo(&parsed); - assert_eq!( - pishoo - .require::("pid") - .expect("pid should be typed") - .as_ref() - .as_ref(), - dir.join("run/pishoo.pid") - ); - } -} diff --git a/gateway/src/parse/builtin/proxy.rs b/gateway/src/parse/builtin/proxy.rs deleted file mode 100644 index 99c217e3..00000000 --- a/gateway/src/parse/builtin/proxy.rs +++ /dev/null @@ -1,260 +0,0 @@ -use snafu::{Snafu, ensure}; - -use crate::parse::{ - document::ConfigNode, - registry::{ - BuildOptions, CascadePolicy, ConfigRegistry, DirectiveSpec, DuplicatePolicy, ReloadImpact, - TransportPolicy, context, - }, - source::SourceSpan, - types::{ - ClientNameConfig, DefaultType, MimeTypes, PathConfig, ResolverConfig, SocketAddrs, - StringList, - }, -}; - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum FinalizeProxyError { - #[snafu(display("missing listen directive in proxy context"))] - MissingListen { span: SourceSpan }, -} - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::PROXY, - finalize: Some(finalize_proxy), - }); - registry.register_directive( - context::PISHOO, - DirectiveSpec::context_empty( - "proxy", - vec![context::PISHOO], - context::PROXY, - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::ListenerSet, - ), - ); - register_leaf::(registry, "listen"); - register_leaf::(registry, "client_name"); - register_leaf::(registry, "dns"); - register_leaf::(registry, "ssl_certificate"); - register_leaf::(registry, "ssl_certificate_key"); - register_leaf::(registry, "allow"); - register_leaf::(registry, "deny"); - register_leaf::(registry, "default_type"); - registry.register_directive( - context::PROXY, - DirectiveSpec::raw_value::( - "types", - vec![context::PROXY], - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); -} - -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::PROXY, - DirectiveSpec::leaf_value::( - name, - vec![context::PROXY], - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); -} - -fn finalize_proxy( - node: &mut ConfigNode, - _options: &BuildOptions<'_>, -) -> Result<(), Box> { - ensure!( - !node.get_all_untyped("listen").is_empty(), - finalize_proxy_error::MissingListenSnafu { span: node.span } - ); - Ok(()) -} - -#[cfg(test)] -mod tests { - use crate::parse::{ - tests::{cleanup_temp_files, create_temp_file, first_pishoo, parse_doc}, - types::{ClientNameConfig, DefaultType, MimeTypes, ResolverConfig, StringList}, - }; - - #[test] - fn parse_client_name_directive_and_rejects_invalid_name() { - let cert = create_temp_file("client_name_cert"); - let key = create_temp_file("client_name_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} proxy {{ listen 127.0.0.1:8080; client_name good.example.com; }} }}", - cert.display(), - key.display() - ); - - let proxy = first_pishoo(&parse_doc(&conf)) - .children("proxy") - .expect("proxy should exist")[0] - .clone(); - - assert_eq!( - proxy - .require::("client_name") - .expect("client_name should be typed") - .0 - .as_partial(), - "good.example.com" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_client_name_rejects_invalid_value() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; client_name not host@@; } }"; - - let failure = crate::parse::parse_config_str_for_test(conf) - .expect_err("invalid client_name should fail"); - - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!( - report.contains("invalid client_name directive value") - || report.contains("invalid dhttp name") - || report.contains("failed to parse directive `client_name`") - ); - } - - #[test] - fn parse_default_type_in_proxy_context() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; default_type text/plain; } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("default_type") - .expect("default_type should be typed") - .0 - .to_str() - .unwrap(), - "text/plain" - ); - } - - #[test] - fn parse_mime_types_in_proxy_context() { - let conf = - "pishoo { proxy { listen 127.0.0.1:8080; types { text/plain txt; text/css css; } } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - let types = proxy - .require::("types") - .expect("types should be typed") - .0 - .clone(); - - assert_eq!(types.get("txt").unwrap().to_str().unwrap(), "text/plain"); - assert_eq!(types.get("css").unwrap().to_str().unwrap(), "text/css"); - } - - #[test] - fn parse_proxy_allow_and_deny_collects_values() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; allow admin viewer; deny none; } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("allow") - .expect("allow should be typed") - .0, - vec!["admin", "viewer"] - ); - assert_eq!( - proxy - .require::("deny") - .expect("deny should be typed") - .0, - vec!["none"] - ); - } - - #[test] - fn parse_proxy_dns_directive_accepts_h3_uri() { - let conf = - "pishoo { proxy { listen 127.0.0.1:8080; dns h3 https://dns.genmeta.net/dns-query; } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("dns") - .expect("dns should be typed") - .0 - .to_string(), - "https://dns.genmeta.net/dns-query" - ); - } - - #[test] - fn parse_proxy_ssl_certificate_and_key_path_types() { - let client_cert = create_temp_file("proxy_client_ssl_cert"); - let client_key = create_temp_file("proxy_client_ssl_key"); - - let conf = format!( - "pishoo {{ proxy {{ listen 127.0.0.1:8080; ssl_certificate {}; ssl_certificate_key {}; }} }}", - client_cert.display(), - client_key.display() - ); - - let proxy = first_pishoo(&parse_doc(&conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("ssl_certificate") - .expect("proxy ssl certificate should be typed") - .0, - client_cert.clone() - ); - assert_eq!( - proxy - .require::("ssl_certificate_key") - .expect("proxy ssl certificate key should be typed") - .0, - client_key.clone() - ); - - cleanup_temp_files(&[&client_cert, &client_key]); - } -} diff --git a/gateway/src/parse/builtin/server.rs b/gateway/src/parse/builtin/server.rs deleted file mode 100644 index 0c2f8916..00000000 --- a/gateway/src/parse/builtin/server.rs +++ /dev/null @@ -1,830 +0,0 @@ -use snafu::{OptionExt, Snafu, ensure}; - -use crate::parse::{ - cascade::DirectiveKey, - document::ConfigNode, - domain::ResolvedConfigPath, - registry::{ - BuildOptions, CascadePolicy, CascadedCardinality, ConfigRegistry, ContextKey, - DirectiveSpec, DuplicatePolicy, LocalDirectiveKey, ReloadImpact, RepeatedCardinality, - RepeatedDirectiveKey, SingleCardinality, TransportPolicy, TypedDirectiveDefinition, - context, - }, - source::SourceSpan, - tree::AttachedConfigNode, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, ListenConfig, - MimeTypes, ResolverConfig, ServerName, ServerNames, StringList, - }, - value::TypedValue, -}; - -macro_rules! single_definition { - ($definition:ident, $key:ident, $value:ty, $name:literal, $reload:expr) => { - const $definition: TypedDirectiveDefinition<$value, SingleCardinality> = - TypedDirectiveDefinition::single_leaf( - context::SERVER, - $name, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - $reload, - ); - pub(crate) const $key: LocalDirectiveKey<$value> = $definition.key(); - }; -} - -macro_rules! cascaded_definition { - ($definition:ident, $key:ident, $value:ty, $name:literal, $reload:expr, $parent:expr) => { - const $definition: TypedDirectiveDefinition<$value, CascadedCardinality> = - TypedDirectiveDefinition::cascaded_leaf( - context::SERVER, - $name, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - $reload, - ); - pub(crate) const $key: DirectiveKey<$value> = $definition.key_inheriting($parent); - }; -} - -const LISTEN_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::repeated_leaf( - context::SERVER, - "listen", - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ); -pub(crate) const LISTEN_KEY: RepeatedDirectiveKey = LISTEN_DEFINITION.key(); -single_definition!( - SERVER_NAME_DEFINITION, - SERVER_NAME_KEY, - ServerNames, - "server_name", - ReloadImpact::ListenerSet -); -single_definition!( - DNS_DEFINITION, - DNS_KEY, - ResolverConfig, - "dns", - ReloadImpact::ListenerSet -); -cascaded_definition!( - GZIP_DEFINITION, - GZIP_KEY, - BoolConfig, - "gzip", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_gzip_key() -); -cascaded_definition!( - GZIP_VARY_DEFINITION, - GZIP_VARY_KEY, - BoolConfig, - "gzip_vary", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_gzip_vary_key() -); -cascaded_definition!( - GZIP_MIN_LENGTH_DEFINITION, - GZIP_MIN_LENGTH_KEY, - GzipMinLength, - "gzip_min_length", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_gzip_min_length_key() -); -cascaded_definition!( - GZIP_COMP_LEVEL_DEFINITION, - GZIP_COMP_LEVEL_KEY, - GzipCompLevel, - "gzip_comp_level", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_gzip_comp_level_key() -); -cascaded_definition!( - GZIP_TYPES_DEFINITION, - GZIP_TYPES_KEY, - StringList, - "gzip_types", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_gzip_types_key() -); -single_definition!( - SSL_CERTIFICATE_DEFINITION, - SSL_CERTIFICATE_KEY, - ResolvedConfigPath, - "ssl_certificate", - ReloadImpact::ListenerSet -); -single_definition!( - SSL_CERTIFICATE_KEY_DEFINITION, - SSL_CERTIFICATE_KEY_KEY, - ResolvedConfigPath, - "ssl_certificate_key", - ReloadImpact::ListenerSet -); -cascaded_definition!( - DEFAULT_TYPE_DEFINITION, - DEFAULT_TYPE_KEY, - DefaultType, - "default_type", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_default_type_key() -); -cascaded_definition!( - ACCESS_RULES_DEFINITION, - ACCESS_RULES_KEY, - AccessRulesUri, - "access_rules", - ReloadImpact::RuntimeState, - crate::parse::registry::v1_access_rules_key() -); -single_definition!( - RELAY_DEFINITION, - RELAY_KEY, - BoolConfig, - "relay", - ReloadImpact::RuntimeState -); -single_definition!( - STUN_DEFINITION, - STUN_KEY, - BoolConfig, - "stun", - ReloadImpact::RuntimeState -); -const TYPES_DEFINITION: TypedDirectiveDefinition = - TypedDirectiveDefinition::cascaded_raw( - context::SERVER, - "types", - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); -pub(crate) const TYPES_KEY: DirectiveKey = - TYPES_DEFINITION.key_inheriting(crate::parse::registry::v1_types_key()); - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum FinalizeServerError { - #[snafu(display("missing listen directive in server context"))] - MissingListen { span: SourceSpan }, - #[snafu(display("missing ssl_certificate directive in server context"))] - MissingCertificate { span: SourceSpan }, - #[snafu(display("missing ssl_certificate_key directive in server context"))] - MissingCertificateKey { span: SourceSpan }, - #[snafu(display("server context is not attached to the home PISHOO context"))] - InvalidAttachedParent { span: SourceSpan }, - #[snafu(display("attached location context is missing its parsed pattern"))] - MissingAttachedLocationPattern { span: SourceSpan }, -} - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::SERVER, - finalize: Some(finalize_server), - }); - registry.register_attached_finalizer(context::SERVER, finalize_attached_server); - registry.register_directive(context::ROOT, server_block(context::ROOT)); - registry.register_directive(context::PISHOO, server_block(context::PISHOO)); - LISTEN_DEFINITION.register(registry); - SERVER_NAME_DEFINITION.register(registry); - DNS_DEFINITION.register(registry); - GZIP_DEFINITION.register(registry); - GZIP_VARY_DEFINITION.register(registry); - GZIP_MIN_LENGTH_DEFINITION.register(registry); - GZIP_COMP_LEVEL_DEFINITION.register(registry); - GZIP_TYPES_DEFINITION.register(registry); - SSL_CERTIFICATE_DEFINITION.register(registry); - SSL_CERTIFICATE_KEY_DEFINITION.register(registry); - DEFAULT_TYPE_DEFINITION.register(registry); - ACCESS_RULES_DEFINITION.register(registry); - RELAY_DEFINITION.register(registry); - STUN_DEFINITION.register(registry); - TYPES_DEFINITION.register(registry); -} - -fn server_block(parent: ContextKey) -> DirectiveSpec { - DirectiveSpec::context_empty( - "server", - vec![parent], - context::SERVER, - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::ListenerSet, - ) -} - -fn finalize_server( - node: &mut ConfigNode, - options: &BuildOptions<'_>, -) -> Result<(), Box> { - ensure!( - !node.get_all::("listen")?.is_empty(), - finalize_server_error::MissingListenSnafu { span: node.span } - ); - if let Some(identity_profile) = options.identity_profile - && node.get::("server_name")?.is_none() - { - node.insert_slot( - "server_name", - TypedValue::new( - ServerNames(vec![ServerName { - name: identity_profile.name().to_owned(), - }]), - node.span, - ), - ); - } - - let has_cert = node.get::("ssl_certificate")?.is_some(); - let has_key = node - .get::("ssl_certificate_key")? - .is_some(); - match (has_cert, has_key, options.has_dhttp_home_context()) { - (true, true, _) => Ok(()), - (false, false, true) => Ok(()), - (false, _, _) => { - node.get::("ssl_certificate")? - .context(finalize_server_error::MissingCertificateSnafu { span: node.span })?; - Ok(()) - } - (_, false, _) => { - node.get::("ssl_certificate_key")? - .context(finalize_server_error::MissingCertificateKeySnafu { span: node.span })?; - Ok(()) - } - } -} - -fn finalize_attached_server( - node: AttachedConfigNode<'_>, -) -> Result<(), Box> { - ensure!( - node.parent() - .is_some_and(|parent| parent.context() == context::PISHOO), - finalize_server_error::InvalidAttachedParentSnafu { - span: node.config().span, - } - ); - for location in node.children() { - location - .config() - .payload::()? - .context(finalize_server_error::MissingAttachedLocationPatternSnafu { - span: location.config().span, - })?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use crate::parse::{ - domain::ResolvedConfigPath, - tests::{ - assert_error_chain_display_single_line, build_server_conf, cleanup_temp_files, - create_temp_file, first_server, parse_doc, - }, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, ListenConfig, MimeTypes, - ResolverConfig, ServerNames, - }, - }; - - #[test] - fn parse_server_rejects_legacy_server_id_directive() { - let cert = create_temp_file("legacy_server_id_cert"); - let key = create_temp_file("legacy_server_id_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; server_id 1; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let error = crate::parse::parse_config_str_for_test(&conf) - .expect_err("legacy server_id directive must be rejected"); - - let report = snafu::Report::from_error(&error).to_string(); - assert!( - report.contains("server_id"), - "error report should name the rejected directive: {report}" - ); - assert_error_chain_display_single_line(&error); - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_bool_directives_accept_on_off_values() { - let cert = create_temp_file("bool_directive_cert"); - let key = create_temp_file("bool_directive_key"); - let conf = build_server_conf(&cert, &key, "gzip on; gzip_vary off; relay on; stun off;"); - - let server = first_server(&parse_doc(&conf)); - - assert!(server.require::("gzip").unwrap().0); - assert!(!server.require::("gzip_vary").unwrap().0); - assert!(server.require::("relay").unwrap().0); - assert!(!server.require::("stun").unwrap().0); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_path_directives_preserve_path_values() { - let cert = create_temp_file("server_path_cert"); - let key = create_temp_file("server_path_key"); - let conf = build_server_conf(&cert, &key, ""); - - let server = first_server(&parse_doc(&conf)); - - assert_eq!( - server - .require::("ssl_certificate") - .expect("ssl_certificate should be typed") - .as_ref() - .as_ref(), - cert.clone() - ); - assert_eq!( - server - .require::("ssl_certificate_key") - .expect("ssl_certificate_key should be typed") - .as_ref() - .as_ref(), - key.clone() - ); - - cleanup_temp_files(&[&cert, &key]); - } - - fn unique_test_dir(label: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("pishoo-{label}-{nanos}")) - } - - fn first_root_server( - document: &crate::parse::document::ConfigDocument, - ) -> std::sync::Arc { - document.root.children("server").expect("server children")[0].clone() - } - - #[test] - fn identity_profile_server_conf_can_omit_name_and_tls_with_home_context() { - let home_path = unique_test_dir("identity-profile-server-conf"); - let home = dhttp::home::DhttpHome::new(home_path); - let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()).unwrap(); - let profile = home.identity_profile(name.clone()); - std::fs::create_dir_all(profile.ssl_dir()).expect("create ssl dir"); - - let document = crate::parse::load_config_text( - "server { listen all 443; location / { root .; } }", - Some(profile.path()), - &crate::parse::default_registry(), - crate::parse::registry::BuildOptions { - dhttp_home: Some(&home), - identity_profile: Some(&profile), - }, - ) - .expect("identity service config should parse"); - - let server = first_root_server(&document); - let names = server.require::("server_name").unwrap(); - assert_eq!(names.0[0].name, name); - assert!( - server - .get::("ssl_certificate") - .unwrap() - .is_none() - ); - assert!( - server - .get::("ssl_certificate_key") - .unwrap() - .is_none() - ); - } - - #[test] - fn explicit_config_still_requires_tls_paths() { - let failure = crate::parse::parse_config_str_for_test( - "pishoo { server { listen all 443; server_name alice.dhttp.net; } }", - ) - .expect_err("standalone config must reject missing tls paths"); - - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!(report.contains("missing ssl_certificate directive in server context")); - } - - #[test] - fn parse_server_accepts_default_type_and_access_rules() { - let cert = create_temp_file("server_ctx_access_cert"); - let key = create_temp_file("server_ctx_access_key"); - let conf = build_server_conf( - &cert, - &key, - "default_type application/octet-stream; access_rules sqlite:///tmp/access.db;", - ); - - let server = first_server(&parse_doc(&conf)); - - assert_eq!( - server - .require::("default_type") - .expect("default_type should be typed") - .0 - .to_str() - .unwrap(), - "application/octet-stream" - ); - assert_eq!( - server - .require::("access_rules") - .expect("access_rules should be typed") - .0 - .as_str(), - "sqlite:///tmp/access.db" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_accepts_gzip_comp_level() { - let cert = create_temp_file("server_gzip_comp_level_cert"); - let key = create_temp_file("server_gzip_comp_level_key"); - let conf = build_server_conf(&cert, &key, "gzip_comp_level 6;"); - - let server = first_server(&parse_doc(&conf)); - - assert_eq!( - server - .require::("gzip_comp_level") - .expect("gzip_comp_level should be typed") - .0, - 6 - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_resolver_config_rejects_deprecated_kind() { - let cert = create_temp_file("resolver_deprecated_cert"); - let key = create_temp_file("resolver_deprecated_key"); - let conf = build_server_conf(&cert, &key, "dns udp https://dns.example.com/dns-query;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("deprecated dns resolver kind should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("deprecated resolver kind `udp`") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_resolver_config_rejects_unsupported_kind() { - let cert = create_temp_file("resolver_unsupported_cert"); - let key = create_temp_file("resolver_unsupported_key"); - let conf = build_server_conf(&cert, &key, "dns tcp https://dns.example.com/dns-query;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("unsupported dns resolver kind should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("unsupported resolver kind `tcp`") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_resolver_config_rejects_invalid_uri() { - let cert = create_temp_file("resolver_invalid_uri_cert"); - let key = create_temp_file("resolver_invalid_uri_key"); - let conf = build_server_conf(&cert, &key, "dns h3 http://[::1;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("resolver URI should parse fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid dns resolver uri directive value") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_dns_resolver_and_publisher() { - let cert = create_temp_file("dns_cert"); - let key = create_temp_file("dns_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; dns h3 https://dns.example.com/dns-query; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let document = parse_doc(&conf); - let server = first_server(&document); - let resolver = server - .require::("dns") - .expect("dns should exist"); - - assert_eq!(resolver.0.to_string(), "https://dns.example.com/dns-query"); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_names_with_multiple_values() { - let cert = create_temp_file("server_names_cert"); - let key = create_temp_file("server_names_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name main.example.com backup.example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let document = parse_doc(&conf); - let server = first_server(&document); - let names = server - .require::("server_name") - .expect("server_name should be typed") - .0 - .clone(); - - assert_eq!(names.len(), 2); - assert_eq!(names[0].name.as_partial(), "main.example.com"); - assert_eq!(names[1].name.as_partial(), "backup.example.com"); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_names_rejects_invalid_value() { - let cert = create_temp_file("server_names_invalid_cert"); - let key = create_temp_file("server_names_invalid_key"); - let conf = build_server_conf(&cert, &key, "server_name invalid_host@@; "); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid server_name should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid server_name directive value") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_only() { - let cert = create_temp_file("listen_interface_only_cert"); - let key = create_temp_file("listen_interface_only_key"); - let conf = format!( - "pishoo {{ server {{ listen all; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - assert_eq!(listen.port, 0); - assert_eq!(listen.range, crate::parse::types::IfaceRange::All); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_address_and_families() { - let cert = create_temp_file("listen_address_cert"); - let key = create_temp_file("listen_address_key"); - let conf = format!( - "pishoo {{ server {{ listen 127.0.0.1:443,127.0.0.2:8443; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed"); - - assert_eq!(listen.0.len(), 1); - let only = &listen.0[0]; - assert_eq!(only.port, 0); - assert!(only.specific_addrs.is_some()); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_and_port() { - let cert = create_temp_file("listen_iface_port_cert"); - let key = create_temp_file("listen_iface_port_key"); - let conf = format!( - "pishoo {{ server {{ listen all 8443; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - assert_eq!(listen.port, 8443); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_rejects_invalid_family() { - let cert = create_temp_file("listen_bad_family_cert"); - let key = create_temp_file("listen_bad_family_key"); - let conf = build_server_conf(&cert, &key, "listen all ipv9 8443;"); - - let failure = - crate::parse::parse_config_str_for_test(&conf).expect_err("invalid family should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid listen ip family") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_and_family() { - let cert = create_temp_file("listen_iface_family_cert"); - let key = create_temp_file("listen_iface_family_key"); - let conf = format!( - "pishoo {{ server {{ listen eth0 v4only; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - - assert_eq!(listen.port, 0); - assert_eq!(listen.families, crate::parse::types::IpFamilies::V4); - assert_eq!( - listen.range, - crate::parse::types::IfaceRange::Exact("eth0".to_owned()) - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_family_and_port() { - let cert = create_temp_file("listen_iface_family_port_cert"); - let key = create_temp_file("listen_iface_family_port_key"); - let conf = format!( - "pishoo {{ server {{ listen eth0 v6only 9443; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - - assert_eq!(listen.port, 9443); - assert_eq!(listen.families, crate::parse::types::IpFamilies::V6); - assert_eq!( - listen.range, - crate::parse::types::IfaceRange::Exact("eth0".to_owned()) - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_rejects_too_many_arguments() { - let cert = create_temp_file("listen_too_many_args_cert"); - let key = create_temp_file("listen_too_many_args_key"); - let conf = build_server_conf(&cert, &key, "listen all v4only 443 extra;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("too many listen args should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid listen directive argument count") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_gzip_comp_level_rejects_invalid_value() { - let conf = "pishoo { server { listen all 5378; server_name example.com; ssl_certificate /tmp/server.pem; ssl_certificate_key /tmp/key.pem; gzip_comp_level bad; } }"; - - let failure = crate::parse::parse_config_str_for_test(conf) - .expect_err("invalid gzip_comp_level should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("failed to parse directive `gzip_comp_level`") - ); - } - - #[test] - fn parse_mime_types_directive_parses_block() { - let cert = create_temp_file("mime_types_cert"); - let key = create_temp_file("mime_types_key"); - let conf = build_server_conf( - &cert, - &key, - "types { text/plain txt; application/json json; }", - ); - - let server = first_server(&parse_doc(&conf)); - let types = server - .require::("types") - .expect("types should be typed") - .0 - .clone(); - - assert_eq!(types.get("txt").unwrap().to_str().unwrap(), "text/plain"); - assert_eq!( - types.get("json").unwrap().to_str().unwrap(), - "application/json" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_mime_types_rejects_non_block_form() { - let cert = create_temp_file("mime_types_invalid_cert"); - let key = create_temp_file("mime_types_invalid_key"); - let conf = build_server_conf(&cert, &key, "types text/plain txt;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("mime type map requires block form"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("types directive must be a block") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_gzip_numeric_directives_reject_invalid_values() { - let cert = create_temp_file("gzip_numeric_cert"); - let key = create_temp_file("gzip_numeric_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; gzip_min_length invalid; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid gzip number should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - - assert!(report.contains("failed to parse directive `gzip_min_length`")); - assert_error_chain_display_single_line(&failure.error); - - cleanup_temp_files(&[&cert, &key]); - } -} diff --git a/gateway/src/parse/builtin/ssh.rs b/gateway/src/parse/builtin/ssh.rs index bf8f95a0..0491a21f 100644 --- a/gateway/src/parse/builtin/ssh.rs +++ b/gateway/src/parse/builtin/ssh.rs @@ -1,7 +1,7 @@ use snafu::Snafu; use crate::parse::{ - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{SshLoginMethods, SshSslUser, SshSslUsers}, }; @@ -78,9 +78,8 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for SshSslU #[cfg(test)] mod tests { - use crate::parse::{ - tests::{build_proxy_conf, cleanup_temp_files, create_temp_file, first_server, parse_doc}, - types::{SshLoginMethods, SshSslUsers}, + use crate::parse::tests::{ + build_proxy_conf, cleanup_temp_files, create_temp_file, first_location, }; #[test] @@ -89,16 +88,8 @@ mod tests { let key = create_temp_file("ssh_ssl_user_key"); let conf = build_proxy_conf(&cert, &key, "ssh_ssl_user alice aliceroot;"); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - let users = location - .require::("ssh_ssl_user") - .expect("ssh_ssl_user should be typed") - .0 - .clone(); + let location = first_location(&conf).unwrap(); + let users = &location.ssh_users()[0].0; assert_eq!(users[0].name, "alice"); assert_eq!(users[0].user, "aliceroot"); @@ -155,19 +146,9 @@ mod tests { let key = create_temp_file("ssh_login_key"); let conf = build_proxy_conf(&cert, &key, "ssh_login ssl;"); - let document = parse_doc(&conf); - let location = first_server(&document) - .children("location") - .expect("location exists")[0] - .clone(); - - assert_eq!( - location - .require::("ssh_login") - .expect("ssh_login should be typed") - .0, - vec!["ssl".to_owned()] - ); + let location = first_location(&conf).unwrap(); + + assert_eq!(location.ssh_login().unwrap().0, vec!["ssl".to_owned()]); cleanup_temp_files(&[&cert, &key]); } diff --git a/gateway/src/parse/builtin/stun.rs b/gateway/src/parse/builtin/stun.rs index 23ad926f..2b1a6b22 100644 --- a/gateway/src/parse/builtin/stun.rs +++ b/gateway/src/parse/builtin/stun.rs @@ -8,11 +8,7 @@ use crate::parse::{ core::{first_arg_span, only_arg}, net::SocketAddrsError, }, - registry::{ - CascadePolicy, ConfigRegistry, DirectiveInput, DirectiveValue, ReloadImpact, - RepeatedCardinality, RepeatedDirectiveKey, TransportPolicy, TypedDirectiveDefinition, - context, - }, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{SocketAddrs, StunBindConfigValue, StunChangePort, StunServerConfigValue}, }; @@ -264,67 +260,28 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StunSer } } -const STUN_SERVERS_DEFINITION: TypedDirectiveDefinition< - StunServerConfigValue, - RepeatedCardinality, -> = TypedDirectiveDefinition::repeated_raw( - context::SERVER, - "stun_server", - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, -); -pub(crate) const STUN_SERVERS_KEY: RepeatedDirectiveKey = - STUN_SERVERS_DEFINITION.key(); - -pub fn register(registry: &mut ConfigRegistry) { - STUN_SERVERS_DEFINITION.register(registry); -} - #[cfg(test)] mod tests { - use crate::parse::{ - ConfigDocumentParser, - domain::ConfigDocumentRole, - fragment::ParsedConfigDocument, - keys, - tests::{cleanup_temp_files, create_temp_file}, - tree::build_global_tree, + use crate::parse::tests::{ + build_server_conf, cleanup_temp_files, create_temp_file, first_server, }; - fn sealed_server(stun: &str) -> crate::parse::tree::ServerConfigRef { + fn parsed_server(stun: &str) -> crate::parse::config::ServerConfig { let cert = create_temp_file("stun_compound_cert"); let key = create_temp_file("stun_compound_key"); - let text = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; {stun} }} }}", - cert.display(), - key.display() - ); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let ParsedConfigDocument::HypervisorRoot(root) = parser - .parse_text( - &text, - std::path::Path::new("/tmp/pishoo.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .unwrap() - else { - panic!("expected root fragment") - }; - let tree = build_global_tree(®istry, root, []).unwrap(); - let server = tree.servers().next().unwrap(); + let text = build_server_conf(&cert, &key, stun); + let server = first_server(&text).unwrap(); cleanup_temp_files(&[&cert, &key]); server } #[test] fn stun_compounds_preserve_block_and_bind_order() { - let server = sealed_server( + let server = parsed_server( "stun_server { bind 127.0.0.1:1000; bind 127.0.0.1:1001; } stun_server { bind 127.0.0.1:2000; }", ); - let values = server.node().repeated(keys::server::STUN_SERVERS).unwrap(); - let addresses = values + let addresses = server + .stun_servers() .iter() .flat_map(|value| value.binds.iter().map(|bind| bind.bind)) .collect::>(); @@ -340,19 +297,18 @@ mod tests { #[test] fn stun_bind_explicit_values_override_block_fallbacks() { - let server = sealed_server( + let server = parsed_server( "stun_server { outer_addr 127.0.0.1:3000; change_port 4000; bind 127.0.0.1:1000 outer_addr 127.0.0.1:3001 change_port 4001; }", ); - let values = server.node().repeated(keys::server::STUN_SERVERS).unwrap(); - let bind = &values[0].binds[0]; + let bind = &server.stun_servers()[0].binds[0]; assert_eq!(bind.outer_addr, Some("127.0.0.1:3001".parse().unwrap())); assert_eq!(bind.change_port, Some(4001)); } #[test] fn empty_stun_server_block_remains_a_noop_compound() { - let server = sealed_server("stun_server {}"); - let values = server.node().repeated(keys::server::STUN_SERVERS).unwrap(); + let server = parsed_server("stun_server {}"); + let values = server.stun_servers(); assert_eq!(values.len(), 1); assert!(values[0].binds.is_empty()); } diff --git a/gateway/src/parse/cascade.rs b/gateway/src/parse/cascade.rs deleted file mode 100644 index 5fd72486..00000000 --- a/gateway/src/parse/cascade.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::{marker::PhantomData, num::NonZeroU32, path::Path, sync::Arc}; - -use crate::parse::{ - domain::{ConfigSourceSpan, DirectiveName}, - registry::{DirectiveContract, DirectiveContractTemplate}, - snapshot::RootConfigSnapshot, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ConfigOrigin { - Source(ConfigSourceSpan), - RootInherited { - directive: DirectiveName, - source: Option, - }, - Builtin { - directive: DirectiveName, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InheritedSourceLocation { - document: Option, - line: NonZeroU32, - column: NonZeroU32, -} - -impl InheritedSourceLocation { - pub(crate) fn new( - document: Option, - line: NonZeroU32, - column: NonZeroU32, - ) -> Self { - Self { - document, - line, - column, - } - } - - pub fn document(&self) -> Option<&Path> { - self.document.as_deref() - } - - pub const fn line(&self) -> NonZeroU32 { - self.line - } - - pub const fn column(&self) -> NonZeroU32 { - self.column - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CascadedValue { - effective: T, - lineage: Box<[ConfigOrigin]>, -} - -impl CascadedValue { - pub(crate) fn new(effective: T, lineage: Box<[ConfigOrigin]>) -> Self { - Self { effective, lineage } - } - - pub fn effective(&self) -> &T { - &self.effective - } - - pub fn lineage(&self) -> &[ConfigOrigin] { - &self.lineage - } -} - -pub(crate) type BuiltinValue = fn() -> Option>; -pub(crate) type SnapshotValue = - fn(&RootConfigSnapshot, DirectiveName) -> Option<(Arc, ConfigOrigin)>; - -#[derive(Debug)] -pub struct DirectiveKey { - name: DirectiveName, - contracts: [Option; 4], - contract_count: usize, - builtin: BuiltinValue, - snapshot: SnapshotValue, - value: PhantomData T>, -} - -impl Clone for DirectiveKey { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for DirectiveKey {} - -impl DirectiveKey { - pub(crate) const fn new( - name: DirectiveName, - contract: DirectiveContractTemplate, - builtin: BuiltinValue, - snapshot: SnapshotValue, - ) -> Self { - Self { - name, - contracts: [Some(contract), None, None, None], - contract_count: 1, - builtin, - snapshot, - value: PhantomData, - } - } - - pub const fn name(self) -> DirectiveName { - self.name - } - - pub(crate) const fn inheriting(mut self, contract: DirectiveContractTemplate) -> Self { - assert!( - self.contract_count < self.contracts.len(), - "gateway cascade contract chain is full" - ); - self.contracts[self.contract_count] = Some(contract); - self.contract_count += 1; - self - } - - pub(crate) fn contracts(self) -> impl Iterator { - self.contracts - .into_iter() - .take(self.contract_count) - .flatten() - .map(DirectiveContractTemplate::freeze) - } - - pub(crate) fn builtin(self) -> Option> { - (self.builtin)() - } - - pub(crate) fn snapshot(self, snapshot: &RootConfigSnapshot) -> Option<(Arc, ConfigOrigin)> { - (self.snapshot)(snapshot, self.name) - } -} - -pub(crate) fn absent() -> Option> { - None -} - -pub(crate) fn no_snapshot( - _snapshot: &RootConfigSnapshot, - _name: DirectiveName, -) -> Option<(Arc, ConfigOrigin)> { - None -} - -pub(crate) fn builtin_false() -> Option> { - Some(Arc::new(crate::parse::types::BoolConfig(false))) -} - -pub(crate) fn builtin_min_length() -> Option> { - Some(Arc::new(crate::parse::types::GzipMinLength::checked(20))) -} - -pub(crate) fn builtin_comp_level() -> Option> { - Some(Arc::new(crate::parse::types::GzipCompLevel::checked(1))) -} - -#[cfg(test)] -pub(crate) const GZIP: DirectiveKey = - crate::parse::registry::v1_gzip_key(); -#[cfg(test)] -pub(crate) const GZIP_TYPES: DirectiveKey = - crate::parse::registry::v1_gzip_types_key(); diff --git a/gateway/src/parse/config.rs b/gateway/src/parse/config.rs new file mode 100644 index 00000000..57f4411a --- /dev/null +++ b/gateway/src/parse/config.rs @@ -0,0 +1,603 @@ +use std::path::{Path, PathBuf}; + +use dhttp::{home::identity::IdentityProfile, name::DhttpName}; +use snafu::Snafu; + +use super::{ + domain::{ConfigSourceSpan, ResolvedConfigPath}, + pattern::Pattern, + source::{SourceMap, SourceSpan}, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRules, + ListenConfig, MimeTypes, ProxyPass, ResolverConfig, SshLoginMethods, SshSslUsers, + StringList, StunServerConfigValue, + }, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum OriginScope { + Builtin, + RootPishoo, + WorkerPishoo, + Server, + Location, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ConfigOrigin { + scope: OriginScope, + path: Option, + line: Option, + column: Option, +} + +impl ConfigOrigin { + pub(crate) fn builtin() -> Self { + Self::new(OriginScope::Builtin, None) + } + pub(crate) fn source(scope: OriginScope, sources: &SourceMap, span: SourceSpan) -> Self { + let location = sources.line_column(span); + Self { + scope, + path: sources.path_for_span(span).map(Path::to_path_buf), + line: location.and_then(|location| u32::try_from(location.line).ok()), + column: location.and_then(|location| u32::try_from(location.column).ok()), + } + } + fn new(scope: OriginScope, path: Option) -> Self { + Self { + scope, + path, + line: None, + column: None, + } + } + pub const fn scope(&self) -> OriginScope { + self.scope + } + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + pub const fn line(&self) -> Option { + self.line + } + pub const fn column(&self) -> Option { + self.column + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Cascaded { + effective: T, + lineage: Box<[ConfigOrigin]>, +} + +impl Cascaded { + pub(crate) fn new(effective: T, lineage: Box<[ConfigOrigin]>) -> Self { + Self { effective, lineage } + } + pub fn effective(&self) -> &T { + &self.effective + } + pub fn lineage(&self) -> &[ConfigOrigin] { + &self.lineage + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AccessLogDirective { + Off, + ProfileDefault, + Resolved(ResolvedConfigPath), +} + +impl AccessLogDirective { + pub fn resolved_path(&self) -> Option<&Path> { + match self { + Self::Resolved(path) => Some(path.as_ref()), + _ => None, + } + } + + pub fn materialize( + &self, + identity: &ServerIdentity, + ) -> Result { + match (self, identity) { + (Self::Off, _) => Ok(ResolvedAccessLogConfig::Disabled), + (Self::Resolved(path), _) => Ok(ResolvedAccessLogConfig::Enabled(path.clone())), + (Self::ProfileDefault, ServerIdentity::Profile(profile)) => { + let path = ResolvedConfigPath::try_from(profile.access_log_path()) + .map_err(|source| MaterializeAccessLogError::InvalidProfilePath { source })?; + Ok(ResolvedAccessLogConfig::Enabled(path)) + } + (Self::ProfileDefault, ServerIdentity::Direct { .. }) => { + Err(MaterializeAccessLogError::DirectProfileDefault) + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResolvedAccessLogConfig { + Disabled, + Enabled(ResolvedConfigPath), +} + +#[derive(Debug, Snafu)] +pub enum MaterializeAccessLogError { + #[snafu(display("direct server cannot use the identity-profile access log default"))] + DirectProfileDefault, + #[snafu(display("identity-profile access log path is invalid"))] + InvalidProfilePath { + source: super::domain::ResolvedConfigPathError, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectiveHttpConfig { + access_rules: Cascaded>, + gzip: Cascaded, + gzip_vary: Cascaded, + gzip_min_length: Cascaded, + gzip_comp_level: Cascaded, + gzip_types: Cascaded, + default_type: Cascaded>, + types: Cascaded>, + access_log: Cascaded, +} + +impl EffectiveHttpConfig { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + access_rules: Cascaded>, + gzip: Cascaded, + gzip_vary: Cascaded, + gzip_min_length: Cascaded, + gzip_comp_level: Cascaded, + gzip_types: Cascaded, + default_type: Cascaded>, + types: Cascaded>, + access_log: Cascaded, + ) -> Self { + Self { + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log, + } + } + pub fn access_rules(&self) -> &Cascaded> { + &self.access_rules + } + pub fn gzip(&self) -> &Cascaded { + &self.gzip + } + pub fn gzip_vary(&self) -> &Cascaded { + &self.gzip_vary + } + pub fn gzip_min_length(&self) -> &Cascaded { + &self.gzip_min_length + } + pub fn gzip_comp_level(&self) -> &Cascaded { + &self.gzip_comp_level + } + pub fn gzip_types(&self) -> &Cascaded { + &self.gzip_types + } + pub fn default_type(&self) -> &Cascaded> { + &self.default_type + } + pub fn types(&self) -> &Cascaded> { + &self.types + } + pub fn access_log(&self) -> &Cascaded { + &self.access_log + } + pub(crate) fn with_access_log(mut self, access_log: Cascaded) -> Self { + self.access_log = access_log; + self + } +} + +#[derive(Clone, Debug)] +pub struct PishooConfig { + source: ConfigSourceSpan, + pid: Option, + workers: Option, + groups: Option, + http: EffectiveHttpConfig, + forward_proxies: Box<[crate::forward::ForwardConfig]>, +} + +impl PishooConfig { + pub(crate) fn new( + source: ConfigSourceSpan, + pid: Option, + workers: Option, + groups: Option, + http: EffectiveHttpConfig, + forward_proxies: Box<[crate::forward::ForwardConfig]>, + ) -> Self { + Self { + source, + pid, + workers, + groups, + http, + forward_proxies, + } + } + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn pid(&self) -> Option<&ResolvedConfigPath> { + self.pid.as_ref() + } + pub fn workers(&self) -> Option<&StringList> { + self.workers.as_ref() + } + pub fn groups(&self) -> Option<&StringList> { + self.groups.as_ref() + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } + pub fn forward_proxies(&self) -> &[crate::forward::ForwardConfig] { + &self.forward_proxies + } + pub fn worker_defaults(&self) -> RootWorkerDefaultsSnapshot { + RootWorkerDefaultsSnapshot::from_http(self.http.clone()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RootWorkerDefaultsSnapshot { + http: EffectiveHttpConfig, +} +impl RootWorkerDefaultsSnapshot { + pub(crate) fn from_http(http: EffectiveHttpConfig) -> Self { + Self { http } + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CascadedWire { + effective: T, + lineage: Box<[ConfigOrigin]>, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct RootWorkerDefaultsWire { + access_rules: CascadedWire>, + gzip: CascadedWire, + gzip_vary: CascadedWire, + gzip_min_length: CascadedWire, + gzip_comp_level: CascadedWire, + gzip_types: CascadedWire>, + default_type: CascadedWire>>, + types: CascadedWire>, + access_log: CascadedWire, +} +type MimeTypesWire = Vec<(String, Vec)>; +#[derive(serde::Serialize, serde::Deserialize)] +enum AccessLogWire { + Off, + ProfileDefault, + Resolved(PathBuf), +} +fn encode(value: &Cascaded, f: impl FnOnce(&T) -> W) -> CascadedWire { + CascadedWire { + effective: f(value.effective()), + lineage: value.lineage().into(), + } +} +fn decode( + value: CascadedWire, + f: impl FnOnce(W) -> Result, +) -> Result, String> { + Ok(Cascaded::new(f(value.effective)?, value.lineage)) +} +impl serde::Serialize for RootWorkerDefaultsSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let h = &self.http; + RootWorkerDefaultsWire { + access_rules: encode(h.access_rules(), |v| { + v.as_ref().map(|v| v.0.as_str().to_owned()) + }), + gzip: encode(h.gzip(), |v| v.0), + gzip_vary: encode(h.gzip_vary(), |v| v.0), + gzip_min_length: encode(h.gzip_min_length(), |v| v.0), + gzip_comp_level: encode(h.gzip_comp_level(), |v| v.0), + gzip_types: encode(h.gzip_types(), |v| v.0.clone()), + default_type: encode(h.default_type(), |v| { + v.as_ref().map(|v| v.0.as_bytes().to_vec()) + }), + types: encode(h.types(), |v| { + v.as_ref().map(|v| { + v.0.iter() + .map(|(k, v)| (k.clone(), v.as_bytes().to_vec())) + .collect() + }) + }), + access_log: encode(h.access_log(), |v| match v { + AccessLogDirective::Off => AccessLogWire::Off, + AccessLogDirective::ProfileDefault => AccessLogWire::ProfileDefault, + AccessLogDirective::Resolved(p) => { + AccessLogWire::Resolved(p.as_ref().to_path_buf()) + } + }), + } + .serialize(serializer) + } +} +impl<'de> serde::Deserialize<'de> for RootWorkerDefaultsSnapshot { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + let w = RootWorkerDefaultsWire::deserialize(deserializer)?; + let access_rules = decode(w.access_rules, |v| { + v.map(|v| { + url::Url::parse(&v) + .map_err(|e| e.to_string()) + .and_then(|u| AccessRulesUri::try_from(u).map_err(|e| e.to_string())) + }) + .transpose() + }) + .map_err(D::Error::custom)?; + let gzip = decode(w.gzip, |v| Ok(BoolConfig(v))).map_err(D::Error::custom)?; + let gzip_vary = decode(w.gzip_vary, |v| Ok(BoolConfig(v))).map_err(D::Error::custom)?; + let gzip_min_length = decode(w.gzip_min_length, |v| Ok(GzipMinLength::checked(v))) + .map_err(D::Error::custom)?; + let gzip_comp_level = decode(w.gzip_comp_level, |v| Ok(GzipCompLevel::checked(v))) + .map_err(D::Error::custom)?; + let gzip_types = decode(w.gzip_types, |v| { + StringList::checked_gzip_types(v).map_err(|e| e.to_string()) + }) + .map_err(D::Error::custom)?; + let default_type = decode(w.default_type, |v| { + v.map(|v| DefaultType::checked_from_bytes(&v).map_err(|e| e.to_string())) + .transpose() + }) + .map_err(D::Error::custom)?; + let types = decode(w.types, |v| { + v.map(|v| MimeTypes::checked_from_bytes(v).map_err(|e| e.to_string())) + .transpose() + }) + .map_err(D::Error::custom)?; + let access_log = decode(w.access_log, |v| { + Ok(match v { + AccessLogWire::Off => AccessLogDirective::Off, + AccessLogWire::ProfileDefault => AccessLogDirective::ProfileDefault, + AccessLogWire::Resolved(p) => AccessLogDirective::Resolved( + ResolvedConfigPath::try_from(p).map_err(|e| e.to_string())?, + ), + }) + }) + .map_err(D::Error::custom)?; + Ok(Self::from_http(EffectiveHttpConfig::new( + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log, + ))) + } +} + +#[derive(Clone, Debug)] +pub enum ServerIdentity { + Direct { + certificate: ResolvedConfigPath, + private_key: ResolvedConfigPath, + }, + Profile(IdentityProfile), +} + +#[derive(Clone, Debug)] +pub struct ServerConfig { + identity: ServerIdentity, + source: ConfigSourceSpan, + names: Box<[DhttpName<'static>]>, + listens: Box<[ListenConfig]>, + resolver: Option, + http: EffectiveHttpConfig, + relay: Option, + stun: Option, + stun_servers: Box<[StunServerConfigValue]>, + locations: Box<[LocationConfig]>, +} + +impl ServerConfig { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + identity: ServerIdentity, + source: ConfigSourceSpan, + names: Box<[DhttpName<'static>]>, + listens: Box<[ListenConfig]>, + resolver: Option, + http: EffectiveHttpConfig, + relay: Option, + stun: Option, + stun_servers: Box<[StunServerConfigValue]>, + locations: Box<[LocationConfig]>, + ) -> Self { + Self { + identity, + source, + names, + listens, + resolver, + http, + relay, + stun, + stun_servers, + locations, + } + } + pub fn identity(&self) -> &ServerIdentity { + &self.identity + } + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn names(&self) -> &[DhttpName<'static>] { + &self.names + } + pub fn listens(&self) -> &[ListenConfig] { + &self.listens + } + pub fn resolver(&self) -> Option<&ResolverConfig> { + self.resolver.as_ref() + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } + pub fn relay(&self) -> Option<&BoolConfig> { + self.relay.as_ref() + } + pub fn stun(&self) -> Option<&BoolConfig> { + self.stun.as_ref() + } + pub fn stun_servers(&self) -> &[StunServerConfigValue] { + &self.stun_servers + } + pub fn locations(&self) -> &[LocationConfig] { + &self.locations + } +} + +#[derive(Clone, Debug)] +pub struct PreparedProxyTlsPaths { + certificate: Option, + private_key: Option, + trusted_certificate: Option, +} +impl PreparedProxyTlsPaths { + pub(crate) fn new( + certificate: Option, + private_key: Option, + trusted_certificate: Option, + ) -> Self { + Self { + certificate, + private_key, + trusted_certificate, + } + } + pub fn certificate(&self) -> Option<&ResolvedConfigPath> { + self.certificate.as_ref() + } + pub fn private_key(&self) -> Option<&ResolvedConfigPath> { + self.private_key.as_ref() + } + pub fn trusted_certificate(&self) -> Option<&ResolvedConfigPath> { + self.trusted_certificate.as_ref() + } +} + +#[derive(Clone, Debug)] +pub struct LocationConfig { + source: ConfigSourceSpan, + matcher: Pattern, + http: EffectiveHttpConfig, + root: Option, + alias: Option, + index: Option, + add_headers: Box<[HeaderRules]>, + proxy_set_headers: Box<[HeaderRules]>, + proxy_pass: Option, + proxy_tls: Option, + ssh_login: Option, + ssh_users: Box<[SshSslUsers]>, + ssh_deny: Option, +} +impl LocationConfig { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + source: ConfigSourceSpan, + matcher: Pattern, + http: EffectiveHttpConfig, + root: Option, + alias: Option, + index: Option, + add_headers: Box<[HeaderRules]>, + proxy_set_headers: Box<[HeaderRules]>, + proxy_pass: Option, + proxy_tls: Option, + ssh_login: Option, + ssh_users: Box<[SshSslUsers]>, + ssh_deny: Option, + ) -> Self { + Self { + source, + matcher, + http, + root, + alias, + index, + add_headers, + proxy_set_headers, + proxy_pass, + proxy_tls, + ssh_login, + ssh_users, + ssh_deny, + } + } + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn matcher(&self) -> &Pattern { + &self.matcher + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } + pub fn root(&self) -> Option<&ResolvedConfigPath> { + self.root.as_ref() + } + pub fn alias(&self) -> Option<&ResolvedConfigPath> { + self.alias.as_ref() + } + pub fn index(&self) -> Option<&StringList> { + self.index.as_ref() + } + pub fn add_headers(&self) -> &[HeaderRules] { + &self.add_headers + } + pub fn proxy_set_headers(&self) -> &[HeaderRules] { + &self.proxy_set_headers + } + pub fn proxy_pass(&self) -> Option<&ProxyPass> { + self.proxy_pass.as_ref() + } + pub fn proxy_tls(&self) -> Option<&PreparedProxyTlsPaths> { + self.proxy_tls.as_ref() + } + pub fn ssh_login(&self) -> Option<&SshLoginMethods> { + self.ssh_login.as_ref() + } + pub fn ssh_users(&self) -> &[SshSslUsers] { + &self.ssh_users + } + pub fn ssh_deny(&self) -> Option<&StringList> { + self.ssh_deny.as_ref() + } +} diff --git a/gateway/src/parse/decode.rs b/gateway/src/parse/decode.rs new file mode 100644 index 00000000..af21d49d --- /dev/null +++ b/gateway/src/parse/decode.rs @@ -0,0 +1,27 @@ +use std::error::Error; + +use super::{ + ast::AstDirective, + source::{SourceMap, SourceSpan}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigContext { + Pishoo, + Server, + Location, +} + +pub struct DirectiveInput<'a> { + pub directive: &'a AstDirective, + pub context: ConfigContext, + pub source_map: &'a SourceMap, +} + +pub trait DirectiveValue: Sized { + type Error: Error + Send + Sync + 'static; + + fn span(input: &DirectiveInput<'_>) -> SourceSpan { + input.directive.span + } +} diff --git a/gateway/src/parse/diagnostic.rs b/gateway/src/parse/diagnostic.rs index 89077b6b..aed421cf 100644 --- a/gateway/src/parse/diagnostic.rs +++ b/gateway/src/parse/diagnostic.rs @@ -99,44 +99,27 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { crate::parse::grammar::ParseSyntaxError::Syntax { span, .. } => Some(*span), }; } - if let Some(error) = error.downcast_ref::() { - let span = match error { - crate::parse::error::ConfigDocumentRoleError::DirectiveNotAllowed { span, .. } - | crate::parse::error::ConfigDocumentRoleError::ExpectedSinglePishoo { span, .. } - | crate::parse::error::ConfigDocumentRoleError::MissingIdentityServer { - span, .. - } - | crate::parse::error::ConfigDocumentRoleError::InvalidDirectiveRegistration { - span, - .. - } - | crate::parse::error::ConfigDocumentRoleError::MissingBuiltDirective { - span, .. - } => span, - }; - return Some(span.source_span()); - } - if let Some(error) = error.downcast_ref::() { - return match error { - crate::parse::error::BuildDocumentError::UnknownDirective { span, .. } - | crate::parse::error::BuildDocumentError::InvalidContext { span, .. } - | crate::parse::error::BuildDocumentError::InvalidDirectiveShape { span, .. } - | crate::parse::error::BuildDocumentError::DirectiveParse { span, .. } - | crate::parse::error::BuildDocumentError::NormalizeDirectiveValue { span, .. } => { - Some(*span) - } - crate::parse::error::BuildDocumentError::DuplicateDirective { duplicate, .. } => { - Some(*duplicate) - } - crate::parse::error::BuildDocumentError::MissingRequiredDirective { - context_span, - .. - } => Some(*context_span), - crate::parse::error::BuildDocumentError::FinalizeContext { span, .. } - | crate::parse::error::BuildDocumentError::ParentAlreadyAssigned { span } => { - Some(*span) - } - }; + if let Some(error) = error.downcast_ref::() { + use crate::parse::build::BuildTypedConfigError::*; + return Some(match error { + UnknownDirective { span, .. } + | Shape { span, .. } + | Directive { span, .. } + | Missing { span, .. } + | PishooCardinality { span } + | WorkerServer { span } + | ServerTlsPair { span } + | StandaloneTls { span } + | AmbiguousProfile { span } + | IdentityName { span } + | IdentityTls { span } + | ProxyTlsPair { span } + | RegexProxyUri { span } + | DirectDefaultAccessLog { span } + | AccessLogArgument { span } + | ForwardListen { span } => *span, + Duplicate { duplicate, .. } => *duplicate, + }); } if let Some(error) = error.downcast_ref::() { return match error { @@ -150,119 +133,5 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { } }; } - if let Some(error) = error.downcast_ref::() { - return match error { - crate::parse::error::ConfigQueryError::MissingRequired { span, .. } - | crate::parse::error::ConfigQueryError::TypeMismatch { span, .. } - | crate::parse::error::ConfigQueryError::MultipleValues { span, .. } - | crate::parse::error::ConfigQueryError::MissingChild { span, .. } => Some(*span), - crate::parse::error::ConfigQueryError::CascadePolicyMismatch { .. } - | crate::parse::error::ConfigQueryError::ContractMismatch { .. } - | crate::parse::error::ConfigQueryError::MissingCascadePolicy { .. } - | crate::parse::error::ConfigQueryError::UnsupportedCascadePolicy { .. } => None, - }; - } error.source().and_then(find_span) } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use crate::parse::{ - error::BuildDocumentError, - source::{SourceMap, SourceSpan}, - }; - - #[test] - fn diagnostic_renders_source_line() { - let mut sources = SourceMap::default(); - let id = sources.add_source(None, Arc::from("pishoo {\n bad;\n}\n"), None, None); - let error = BuildDocumentError::UnknownDirective { - directive: "bad".to_owned(), - span: SourceSpan::new(id, 11, 14), - }; - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - assert!(diagnostic.contains("bad;")); - assert!(diagnostic.contains('^')); - assert!(!error.to_string().contains('\n')); - } - - #[test] - fn diagnostic_finds_span_through_load_error_chain() { - let mut sources = SourceMap::default(); - let id = sources.add_source(None, Arc::from("include extra.conf;\n"), None, None); - let error = crate::parse::error::LoadConfigError::ResolveInclude { - source: crate::parse::error::ResolveIncludeError::InvalidArgumentCount { - span: SourceSpan::new(id, 0, 7), - count: 0, - }, - }; - - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - - assert!(diagnostic.contains("include extra.conf;")); - assert!(diagnostic.contains('^')); - } - - #[test] - fn diagnostic_renders_syntax_error_location() { - let mut sources = SourceMap::default(); - let id = sources.add_source(None, Arc::from("pishoo {\n"), None, None); - let syntax = - crate::parse::grammar::parse_source("pishoo {\n", id).expect_err("syntax should fail"); - let error = crate::parse::error::LoadConfigError::ParseFile { - source_id: id, - source: syntax, - }; - - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - - assert!(diagnostic.contains("pishoo {")); - assert!(diagnostic.contains('^')); - } - - #[test] - fn diagnostic_renders_included_source_syntax_error_location() { - let mut sources = SourceMap::default(); - let root = sources.add_source(None, Arc::from("include child.conf;\n"), None, None); - let child = sources.add_source( - None, - Arc::from("server {\n"), - None, - Some(crate::parse::source::IncludeTrace { - parent: root, - directive_span: SourceSpan::new(root, 0, 19), - }), - ); - let syntax = crate::parse::grammar::parse_source("server {\n", child) - .expect_err("syntax should fail"); - let error = crate::parse::error::LoadConfigError::ResolveInclude { - source: crate::parse::error::ResolveIncludeError::ParseSource { - source_id: child, - source: syntax, - }, - }; - - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - - assert!(diagnostic.contains("server {")); - assert!(diagnostic.contains("included from")); - assert!(diagnostic.contains('^')); - } - - #[test] - fn diagnostic_contains_source_snippet_but_display_is_single_line() { - let conf = "pishoo { server { listen all 5378; server_name example.com; ssl_certificate /missing/cert.pem; ssl_certificate_key /missing/key.pem; location /api { proxy_pass ftp://backend.example.com; } } }"; - let failure = - crate::parse::parse_config_str_for_test(conf).expect_err("config should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - let diagnostic = failure.diagnostic().to_string(); - - assert!(report.contains("unsupported proxy_pass uri scheme")); - assert!(!failure.error.to_string().contains('\n')); - assert!(diagnostic.contains("proxy_pass ftp://backend.example.com")); - assert!(diagnostic.contains("^")); - } -} diff --git a/gateway/src/parse/document.rs b/gateway/src/parse/document.rs deleted file mode 100644 index 086610f1..00000000 --- a/gateway/src/parse/document.rs +++ /dev/null @@ -1,255 +0,0 @@ -use std::{ - collections::HashMap, - sync::{Arc, Weak}, -}; - -use tokio::sync::OnceCell; - -use crate::parse::{ - ast::Spanned, - domain::ConfigDocumentId, - error::{ConfigQueryError, config_query_error}, - registry::ContextKey, - source::{SourceMap, SourceSpan}, - value::{ConfigValue, TypedValue}, -}; - -#[derive(Debug)] -pub struct ConfigDocument { - pub source_map: Arc, - pub root: Arc, -} - -#[derive(Debug)] -pub struct ConfigNode { - pub context: ContextKey, - pub name: Option>, - pub span: SourceSpan, - pub payload: Option, - slots: HashMap<&'static str, ConfigSlot>, - children: HashMap<&'static str, Vec>>, - parent: OnceCell>>, -} - -#[derive(Debug, Clone)] -pub struct ConfigSlot { - pub values: Vec, -} - -impl ConfigDocument { - pub(crate) fn new(source_map: Arc, root: Arc) -> Self { - Self { source_map, root } - } - - pub fn document_id(&self) -> Option { - None - } -} - -impl ConfigNode { - pub(crate) fn new( - context: ContextKey, - name: Option>, - span: SourceSpan, - ) -> Self { - Self { - context, - name, - span, - payload: None, - slots: HashMap::new(), - children: HashMap::new(), - parent: OnceCell::new(), - } - } - - pub(crate) fn insert_slot(&mut self, name: &'static str, value: TypedValue) { - self.slots - .entry(name) - .or_insert_with(|| ConfigSlot { values: Vec::new() }) - .values - .push(value); - } - - pub(crate) fn replace_slot(&mut self, name: &'static str, value: TypedValue) { - self.slots.insert( - name, - ConfigSlot { - values: vec![value], - }, - ); - } - - pub(crate) fn get_all_untyped(&self, name: &str) -> &[TypedValue] { - self.slots - .get(name) - .map(|slot| slot.values.as_slice()) - .unwrap_or(&[]) - } - - pub(crate) fn insert_child(&mut self, name: &'static str, child: Arc) { - self.children.entry(name).or_default().push(child); - } - - pub(crate) fn child_groups(&self) -> impl Iterator]> { - self.children.values().map(Vec::as_slice) - } - - pub(crate) fn set_payload(&mut self, payload: TypedValue) { - self.payload = Some(payload); - } - - pub(crate) fn set_parent(&self, parent: Option>) -> Result<(), SourceSpan> { - self.parent.set(parent).map_err(|_| self.span) - } - - pub fn parent(&self) -> Option> { - self.parent - .get() - .and_then(|parent| parent.as_ref()) - .and_then(Weak::upgrade) - } - - pub fn get(&self, name: &str) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(slot) = self.slots.get(name) else { - return Ok(None); - }; - if slot.values.len() > 1 { - return config_query_error::MultipleValuesSnafu { - directive: name.to_owned(), - span: slot.values[1].span(), - } - .fail(); - } - let value = &slot.values[0]; - value.downcast::().map(Some).ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: name.to_owned(), - expected: std::any::type_name::(), - actual: value.type_name(), - span: value.span(), - } - .build() - }) - } - - pub(crate) fn get_with_span( - &self, - name: &str, - ) -> Result, SourceSpan)>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(slot) = self.slots.get(name) else { - return Ok(None); - }; - if slot.values.len() > 1 { - return config_query_error::MultipleValuesSnafu { - directive: name.to_owned(), - span: slot.values[1].span(), - } - .fail(); - } - let value = &slot.values[0]; - value - .downcast::() - .map(|value| Some((value, slot.values[0].span()))) - .ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: name.to_owned(), - expected: std::any::type_name::(), - actual: value.type_name(), - span: value.span(), - } - .build() - }) - } - - pub fn require(&self, name: &str) -> Result, ConfigQueryError> - where - T: ConfigValue, - { - self.get(name)?.ok_or_else(|| { - config_query_error::MissingRequiredSnafu { - directive: name.to_owned(), - span: self.span, - } - .build() - }) - } - - pub fn get_all(&self, name: &str) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(slot) = self.slots.get(name) else { - return Ok(Vec::new()); - }; - slot.values - .iter() - .map(|value| { - value.downcast::().ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: name.to_owned(), - expected: std::any::type_name::(), - actual: value.type_name(), - span: value.span(), - } - .build() - }) - }) - .collect() - } - - pub fn inherited(&self, name: &str) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - if let Some(value) = self.get(name)? { - return Ok(Some(value)); - } - let Some(parent) = self.parent() else { - return Ok(None); - }; - parent.inherited(name) - } - - pub fn children(&self, name: &str) -> Result<&[Arc], ConfigQueryError> { - self.children.get(name).map(Vec::as_slice).ok_or_else(|| { - config_query_error::MissingChildSnafu { - directive: name.to_owned(), - span: self.span, - } - .build() - }) - } - - pub fn children_optional(&self, name: &str) -> &[Arc] { - self.children.get(name).map(Vec::as_slice).unwrap_or(&[]) - } - - pub fn payload(&self) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(payload) = &self.payload else { - return Ok(None); - }; - payload.downcast::().map(Some).ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: self - .name - .as_ref() - .map(|name| name.value.clone()) - .unwrap_or_else(|| "".to_owned()), - expected: std::any::type_name::(), - actual: payload.type_name(), - span: payload.span(), - } - .build() - }) - } -} diff --git a/gateway/src/parse/domain.rs b/gateway/src/parse/domain.rs index f555fc54..beab806c 100644 --- a/gateway/src/parse/domain.rs +++ b/gateway/src/parse/domain.rs @@ -3,10 +3,9 @@ use std::{ path::{Path, PathBuf}, }; -use dhttp::home::{DhttpHome, identity::IdentityProfile}; use snafu::{ResultExt, Snafu}; -use crate::parse::{registry::BuildOptions, source::SourceSpan}; +use crate::parse::source::SourceSpan; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ConfigDocumentId(u32); @@ -89,93 +88,6 @@ impl ConfigSourceSpan { pub fn is_empty(&self) -> bool { self.span.is_empty() } - - pub(crate) fn source_span(&self) -> SourceSpan { - self.span - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct DirectiveName(&'static str); - -impl DirectiveName { - pub(crate) const fn new(name: &'static str) -> Self { - Self(name) - } - - pub const fn as_str(self) -> &'static str { - self.0 - } -} - -impl fmt::Display for DirectiveName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.0) - } -} - -#[derive(Debug)] -pub enum ConfigDocumentRole<'a> { - HypervisorRoot { - home: Option<&'a DhttpHome>, - }, - WorkerPishoo { - home: &'a DhttpHome, - }, - IdentityServer { - home: &'a DhttpHome, - profile: &'a IdentityProfile, - }, -} - -impl ConfigDocumentRole<'_> { - pub(crate) fn kind(&self) -> ConfigDocumentRoleKind { - match self { - Self::HypervisorRoot { .. } => ConfigDocumentRoleKind::HypervisorRoot, - Self::WorkerPishoo { .. } => ConfigDocumentRoleKind::WorkerPishoo, - Self::IdentityServer { .. } => ConfigDocumentRoleKind::IdentityServer, - } - } - - pub(crate) fn build_options(&self) -> BuildOptions<'_> { - match self { - Self::HypervisorRoot { home } => BuildOptions { - dhttp_home: *home, - identity_profile: None, - }, - Self::WorkerPishoo { home } => BuildOptions { - dhttp_home: Some(home), - identity_profile: None, - }, - Self::IdentityServer { home, profile } => BuildOptions { - dhttp_home: Some(home), - identity_profile: Some(profile), - }, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigDocumentRoleKind { - HypervisorRoot, - WorkerPishoo, - IdentityServer, -} - -impl ConfigDocumentRoleKind { - pub const fn as_str(self) -> &'static str { - match self { - Self::HypervisorRoot => "hypervisor root", - Self::WorkerPishoo => "worker pishoo", - Self::IdentityServer => "identity server", - } - } -} - -impl fmt::Display for ConfigDocumentRoleKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index 4b3c8697..a677bff9 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -1,14 +1,13 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use snafu::Snafu; use crate::parse::{ - domain::{ - ConfigDocumentId, ConfigDocumentIdError, ConfigDocumentRoleKind, ConfigSourceSpan, - DirectiveName, - }, + domain::{ConfigDocumentId, ConfigDocumentIdError}, grammar::ParseSyntaxError, - registry::{CascadePolicy, ContextKey}, source::{SourceId, SourceMap, SourceSpan}, }; @@ -17,129 +16,24 @@ use crate::parse::{ pub enum LoadConfigError { #[snafu(display("failed to allocate configuration document identity"))] DocumentId { source: ConfigDocumentIdError }, - #[snafu(display("failed to read configuration source"))] ReadSource { path: PathBuf, source: std::io::Error, }, - #[snafu(display("failed to parse configuration file"))] ParseFile { source_id: SourceId, source: ParseSyntaxError, }, - #[snafu(display("failed to resolve configuration includes"))] ResolveInclude { source: ResolveIncludeError }, - - #[snafu(display("failed to build configuration document"))] - BuildDocument { source: BuildDocumentError }, - - #[snafu(display("configuration document is invalid for its role"))] - DocumentRole { source: ConfigDocumentRoleError }, -} - -#[derive(Debug, Snafu)] -#[snafu(module, visibility(pub(crate)))] -pub enum ConfigDocumentRoleError { - #[snafu(display("directive `{directive}` is not allowed in a {role} configuration document"))] - DirectiveNotAllowed { - directive: DirectiveName, - role: ConfigDocumentRoleKind, - span: ConfigSourceSpan, - }, - - #[snafu(display( - "{role} configuration document must contain exactly one top-level pishoo block, found {found}" - ))] - ExpectedSinglePishoo { - role: ConfigDocumentRoleKind, - found: usize, - span: ConfigSourceSpan, - }, - - #[snafu(display( - "identity server configuration document must contain at least one server block" - ))] - MissingIdentityServer { - role: ConfigDocumentRoleKind, - span: ConfigSourceSpan, - }, - - #[snafu(display( - "directive `{directive}` has an invalid registry contract for a {role} configuration document; expected a top-level `{expected_child_context}` context block" - ))] - InvalidDirectiveRegistration { - directive: DirectiveName, - role: ConfigDocumentRoleKind, - expected_child_context: ContextKey, - span: ConfigSourceSpan, - }, - - #[snafu(display("directive `{directive}` was not built for a {role} configuration document"))] - MissingBuiltDirective { - directive: DirectiveName, - role: ConfigDocumentRoleKind, - span: ConfigSourceSpan, + #[snafu(display("failed to build typed configuration"))] + BuildTyped { + source: crate::parse::build::BuildTypedConfigError, }, } -impl ConfigDocumentRoleError { - pub(crate) fn directive_not_allowed( - directive: DirectiveName, - role: ConfigDocumentRoleKind, - span: ConfigSourceSpan, - ) -> Self { - Self::DirectiveNotAllowed { - directive, - role, - span, - } - } - - pub(crate) fn expected_single_pishoo( - role: ConfigDocumentRoleKind, - found: usize, - span: ConfigSourceSpan, - ) -> Self { - Self::ExpectedSinglePishoo { role, found, span } - } - - pub(crate) fn missing_identity_server( - role: ConfigDocumentRoleKind, - span: ConfigSourceSpan, - ) -> Self { - Self::MissingIdentityServer { role, span } - } - - pub(crate) fn invalid_directive_registration( - directive: DirectiveName, - role: ConfigDocumentRoleKind, - expected_child_context: ContextKey, - span: ConfigSourceSpan, - ) -> Self { - Self::InvalidDirectiveRegistration { - directive, - role, - expected_child_context, - span, - } - } - - pub(crate) fn missing_built_directive( - directive: DirectiveName, - role: ConfigDocumentRoleKind, - span: ConfigSourceSpan, - ) -> Self { - Self::MissingBuiltDirective { - directive, - role, - span, - } - } -} - #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum ResolveIncludeError { @@ -177,171 +71,34 @@ pub enum ResolveIncludeError { }, } -#[derive(Debug, Snafu)] -#[snafu(module, visibility(pub(crate)))] -pub enum BuildDocumentError { - #[snafu(display("unknown directive `{directive}`"))] - UnknownDirective { directive: String, span: SourceSpan }, - - #[snafu(display("directive `{directive}` is not valid in this context"))] - InvalidContext { - directive: String, - context: &'static str, - span: SourceSpan, - }, - - #[snafu(display("invalid directive shape for `{directive}`"))] - InvalidDirectiveShape { directive: String, span: SourceSpan }, - - #[snafu(display("failed to parse directive `{directive}`"))] - DirectiveParse { - directive: String, - span: SourceSpan, - source: Box, - }, - - #[snafu(display("duplicate directive `{directive}`"))] - DuplicateDirective { - directive: String, - first: SourceSpan, - duplicate: SourceSpan, - }, - - #[snafu(display("failed to normalize directive `{directive}`"))] - NormalizeDirectiveValue { - directive: String, - span: SourceSpan, - source: crate::parse::normalize::NormalizeDirectiveValueError, - }, - - #[snafu(display("missing required directive `{directive}`"))] - MissingRequiredDirective { - directive: &'static str, - context_span: SourceSpan, - }, - - #[snafu(display("failed to finalize configuration context"))] - FinalizeContext { - context: &'static str, - span: SourceSpan, - source: Box, - }, - - #[snafu(display("configuration node parent was assigned more than once"))] - ParentAlreadyAssigned { span: SourceSpan }, -} - -#[derive(Debug, Snafu)] -#[snafu(module, visibility(pub(crate)))] -pub enum ConfigQueryError { - #[snafu(display("missing required directive `{directive}`"))] - MissingRequired { directive: String, span: SourceSpan }, - - #[snafu(display("directive `{directive}` has unexpected value type"))] - TypeMismatch { - directive: String, - expected: &'static str, - actual: &'static str, - span: SourceSpan, - }, - - #[snafu(display("directive `{directive}` has multiple values"))] - MultipleValues { directive: String, span: SourceSpan }, - - #[snafu(display("missing child directive `{directive}`"))] - MissingChild { directive: String, span: SourceSpan }, - - #[snafu(display( - "directive `{directive}` has inconsistent cascade policies: context `{inherited_context}` uses {inherited:?}, context `{local_context}` uses {local:?}" - ))] - CascadePolicyMismatch { - directive: String, - inherited_context: ContextKey, - inherited: CascadePolicy, - local_context: ContextKey, - local: CascadePolicy, - }, - - #[snafu(display( - "directive `{directive}` has an incompatible contract in context `{context}`: {mismatch}" - ))] - ContractMismatch { - directive: DirectiveName, - context: ContextKey, - mismatch: crate::parse::registry::DirectiveContractMismatch, - }, - - #[snafu(display("directive `{directive}` has no registered cascade policy"))] - MissingCascadePolicy { directive: String }, - - #[snafu(display("directive `{directive}` does not support typed cascading with {policy:?}"))] - UnsupportedCascadePolicy { - directive: String, - policy: CascadePolicy, - }, -} - #[derive(Debug)] pub struct ConfigLoadFailure { pub error: LoadConfigError, pub source_map: Arc, pub(crate) document_id: Option, } - impl std::fmt::Display for ConfigLoadFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "failed to load configuration") } } - impl std::error::Error for ConfigLoadFailure { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.error) } } - impl ConfigLoadFailure { pub fn document_id(&self) -> Option { self.document_id } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::parse::source::{SourceId, SourceMap, SourceSpan}; - - #[test] - fn config_load_failure_display_describes_only_wrapper() { - let failure = ConfigLoadFailure { - error: LoadConfigError::ResolveInclude { - source: ResolveIncludeError::InvalidArgumentCount { - span: SourceSpan::new(SourceId(0), 0, 7), - count: 0, - }, + pub(crate) fn read(path: &Path, source: std::io::Error) -> Self { + Self { + error: LoadConfigError::ReadSource { + path: path.to_path_buf(), + source, }, source_map: Arc::new(SourceMap::default()), document_id: None, - }; - - assert_eq!(failure.to_string(), "failed to load configuration"); - assert!(std::error::Error::source(&failure).is_some()); - } - - #[test] - fn cascade_policy_mismatch_display_names_both_contexts() { - let error = ConfigQueryError::CascadePolicyMismatch { - directive: "gzip".to_owned(), - inherited_context: crate::parse::registry::context::PISHOO, - inherited: CascadePolicy::NearestWins, - local_context: crate::parse::registry::context::SERVER, - local: CascadePolicy::ReplaceWhole, - }; - let rendered = error.to_string(); - - assert!(rendered.contains(crate::parse::registry::context::PISHOO.0)); - assert!(rendered.contains(crate::parse::registry::context::SERVER.0)); - assert!(rendered.contains("NearestWins")); - assert!(rendered.contains("ReplaceWhole")); + } } } diff --git a/gateway/src/parse/fragment.rs b/gateway/src/parse/fragment.rs deleted file mode 100644 index 1e39f4da..00000000 --- a/gateway/src/parse/fragment.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::sync::Arc; - -use crate::parse::{ - document::ConfigNode, - domain::{ConfigDocumentId, ConfigSourceSpan}, - registry::ConfigRegistryContract, - source::{ConfigDocumentSourceMap, SourceMap}, -}; - -#[derive(Debug)] -pub enum ParsedConfigDocument { - HypervisorRoot(ParsedPishooFragment), - WorkerPishoo(ParsedPishooFragment), - IdentityServers(Box<[ParsedServerFragment]>), -} - -#[derive(Debug)] -pub struct ParsedPishooFragment { - sources: Arc, - node: Arc, - servers: Box<[ParsedServerFragment]>, - registry_contract: ConfigRegistryContract, -} - -#[derive(Debug)] -pub struct ParsedServerFragment { - sources: Arc, - node: Arc, - locations: Box<[ParsedLocationFragment]>, - registry_contract: ConfigRegistryContract, -} - -#[derive(Debug)] -pub struct ParsedLocationFragment { - sources: Arc, - node: Arc, -} - -impl ParsedPishooFragment { - pub(crate) fn new( - sources: Arc, - node: Arc, - registry_contract: ConfigRegistryContract, - ) -> Self { - let servers = node - .children_optional("server") - .iter() - .cloned() - .map(|server| { - ParsedServerFragment::new(Arc::clone(&sources), server, registry_contract.clone()) - }) - .collect(); - Self { - sources, - node, - servers, - registry_contract, - } - } - - pub fn document_id(&self) -> ConfigDocumentId { - self.sources.document_id() - } - - pub fn span(&self) -> ConfigSourceSpan { - self.sources.config_span(self.node.span) - } - - pub fn servers(&self) -> &[ParsedServerFragment] { - &self.servers - } - - #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage - pub(crate) fn source_map(&self) -> &SourceMap { - self.sources.source_map() - } - - pub(crate) fn source_owner(&self) -> Arc { - Arc::clone(&self.sources) - } - - pub(crate) fn registry_contract(&self) -> &ConfigRegistryContract { - &self.registry_contract - } - - #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage - pub(crate) fn node(&self) -> &Arc { - &self.node - } -} - -impl ParsedServerFragment { - pub(crate) fn new( - sources: Arc, - node: Arc, - registry_contract: ConfigRegistryContract, - ) -> Self { - let locations = node - .children_optional("location") - .iter() - .cloned() - .map(|location| ParsedLocationFragment::new(Arc::clone(&sources), location)) - .collect(); - Self { - sources, - node, - locations, - registry_contract, - } - } - - pub fn document_id(&self) -> ConfigDocumentId { - self.sources.document_id() - } - - pub fn span(&self) -> ConfigSourceSpan { - self.sources.config_span(self.node.span) - } - - pub fn locations(&self) -> &[ParsedLocationFragment] { - &self.locations - } - - #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage - pub(crate) fn source_map(&self) -> &SourceMap { - self.sources.source_map() - } - - pub(crate) fn source_owner(&self) -> Arc { - Arc::clone(&self.sources) - } - - pub(crate) fn registry_contract(&self) -> &ConfigRegistryContract { - &self.registry_contract - } - - #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage - pub(crate) fn node(&self) -> &Arc { - &self.node - } -} - -impl ParsedLocationFragment { - fn new(sources: Arc, node: Arc) -> Self { - Self { sources, node } - } - - pub fn document_id(&self) -> ConfigDocumentId { - self.sources.document_id() - } - - pub fn span(&self) -> ConfigSourceSpan { - self.sources.config_span(self.node.span) - } - - #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage - pub(crate) fn source_map(&self) -> &SourceMap { - self.sources.source_map() - } - - #[allow(dead_code)] // consumed by the detached-to-sealed tree builder in the next parser stage - pub(crate) fn node(&self) -> &Arc { - &self.node - } -} diff --git a/gateway/src/parse/normalize.rs b/gateway/src/parse/normalize.rs index 0a5b213f..9c678830 100644 --- a/gateway/src/parse/normalize.rs +++ b/gateway/src/parse/normalize.rs @@ -5,8 +5,6 @@ use snafu::{OptionExt, ResultExt, Snafu}; use crate::parse::{ domain::{ResolvedConfigPath, ResolvedConfigPathError}, source::{SourceMap, SourceSpan}, - types::PathConfig, - value::TypedValue, }; #[derive(Debug, Snafu)] @@ -45,16 +43,3 @@ pub(crate) fn normalize_path( ) -> Result { resolve_config_path(path, span, source_map).map(Into::into) } - -pub(crate) fn normalize_slot_value( - value: TypedValue, - source_map: &SourceMap, -) -> Result { - let span = value.span(); - if let Some(path) = value.downcast::() { - let normalized = normalize_path(&path.0, span, source_map)?; - return Ok(TypedValue::new(PathConfig(normalized), span)); - } - - Ok(value) -} diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs deleted file mode 100644 index ab335a9b..00000000 --- a/gateway/src/parse/registry.rs +++ /dev/null @@ -1,1758 +0,0 @@ -use std::{any::TypeId, collections::HashMap, error::Error, sync::Arc}; - -use snafu::{OptionExt, ResultExt, Snafu}; - -use crate::parse::{ - ast::{AstBody, AstDirective}, - cascade::{BuiltinValue, DirectiveKey, SnapshotValue}, - document::{ConfigDocument, ConfigNode}, - domain::{ConfigDocumentRoleKind, DirectiveName}, - error::{BuildDocumentError, ConfigDocumentRoleError, build_document_error}, - fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, - normalize, - snapshot::RootConfigSnapshot, - source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, - tree::AttachedConfigNode, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, - StringList, - }, - value::{ConfigValue, TypedValue}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ContextKey(pub &'static str); - -impl std::fmt::Display for ContextKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.0) - } -} - -pub mod context { - use super::ContextKey; - - pub const ROOT: ContextKey = ContextKey("gateway.root"); - pub const PISHOO: ContextKey = ContextKey("gateway.pishoo"); - pub const SERVER: ContextKey = ContextKey("gateway.server"); - pub const LOCATION: ContextKey = ContextKey("gateway.location"); - pub const PROXY: ContextKey = ContextKey("gateway.proxy"); -} - -#[derive(Debug, Default)] -struct ConfigRegistryContractIdentity; - -#[derive(Debug, Clone, Default)] -pub(crate) struct ConfigRegistryContract(Arc); - -impl ConfigRegistryContract { - fn matches(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.0, &other.0) - } -} - -#[derive(Default)] -pub struct ConfigRegistry { - contexts: HashMap, - directives: HashMap<(ContextKey, &'static str), DirectiveSpec>, - contract_tables: HashMap>, - attached_finalizers: HashMap, - contract: ConfigRegistryContract, -} - -pub struct ContextSpec { - pub key: ContextKey, - pub finalize: Option, -} - -pub struct DirectiveSpec { - pub name: DirectiveName, - pub allowed_in: Vec, - pub shape: DirectiveShape, - parser: DirectiveParserFn, - pub duplicate: DuplicatePolicy, - pub cascade: CascadePolicy, - pub transport: TransportPolicy, - pub reload: ReloadImpact, - value_type: Option, - value_type_name: Option<&'static str>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DirectiveShape { - Leaf, - ContextBlock { - child_context: ContextKey, - payload: PayloadMode, - }, - RawBlock, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PayloadMode { - None, - Parser, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DuplicatePolicy { - Reject, - LastWins, - Append, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CascadePolicy { - None, - NearestWins, - ReplaceWhole, - MergeByKey, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TransportPolicy { - HypervisorOnly, - WorkerInheritable, - WorkerLocalOnly, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReloadImpact { - Supervisor, - ListenerSet, - RuntimeState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DirectiveCardinality { - Single, - Repeated, - ContextPayload, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DirectiveContractMismatch { - Name { - expected: DirectiveName, - actual: Option, - }, - Context { - expected: ContextKey, - actual: ContextKey, - }, - ValueType { - expected: &'static str, - actual: &'static str, - }, - Cardinality { - expected: DirectiveCardinality, - actual: DirectiveCardinality, - }, - Shape { - expected: DirectiveShape, - actual: DirectiveShape, - }, - Payload { - expected: PayloadMode, - actual: PayloadMode, - }, - Duplicate { - expected: DuplicatePolicy, - actual: DuplicatePolicy, - }, - Cascade { - expected: CascadePolicy, - actual: CascadePolicy, - }, -} - -impl std::fmt::Display for DirectiveContractMismatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Name { expected, actual } => write!( - f, - "name expected `{}`, actual {}", - expected.as_str(), - actual.map_or("", |actual| actual.as_str()) - ), - Self::Context { expected, actual } => { - write!(f, "context expected `{expected}`, actual `{actual}`") - } - Self::ValueType { expected, actual } => { - write!(f, "value type expected `{expected}`, actual `{actual}`") - } - Self::Cardinality { expected, actual } => { - write!(f, "cardinality expected {expected:?}, actual {actual:?}") - } - Self::Shape { expected, actual } => { - write!(f, "shape expected {expected:?}, actual {actual:?}") - } - Self::Payload { expected, actual } => { - write!(f, "payload expected {expected:?}, actual {actual:?}") - } - Self::Duplicate { expected, actual } => { - write!( - f, - "duplicate policy expected {expected:?}, actual {actual:?}" - ) - } - Self::Cascade { expected, actual } => { - write!(f, "cascade policy expected {expected:?}, actual {actual:?}") - } - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct DirectiveContractTemplate { - context: ContextKey, - name: DirectiveName, - value_type: fn() -> TypeId, - value_type_name: fn() -> &'static str, - cardinality: DirectiveCardinality, - shape: DirectiveShape, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, -} - -impl DirectiveContractTemplate { - pub(crate) fn freeze(self) -> DirectiveContract { - DirectiveContract { - context: self.context, - name: self.name, - value_type: Some((self.value_type)()), - value_type_name: Some((self.value_type_name)()), - cardinality: self.cardinality, - shape: self.shape, - duplicate: self.duplicate, - cascade: self.cascade, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct DirectiveContract { - context: ContextKey, - name: DirectiveName, - value_type: Option, - value_type_name: Option<&'static str>, - cardinality: DirectiveCardinality, - shape: DirectiveShape, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, -} - -fn value_type_name() -> &'static str { - std::any::type_name::() -} - -impl DirectiveContract { - pub(crate) const fn context(self) -> ContextKey { - self.context - } - - pub(crate) const fn name(self) -> DirectiveName { - self.name - } - - pub(crate) const fn cascade(self) -> CascadePolicy { - self.cascade - } - - pub(crate) fn mismatch(self, actual: Self) -> Option { - if self.name != actual.name { - return Some(DirectiveContractMismatch::Name { - expected: self.name, - actual: Some(actual.name), - }); - } - if self.context != actual.context { - return Some(DirectiveContractMismatch::Context { - expected: self.context, - actual: actual.context, - }); - } - if self.cardinality != actual.cardinality { - return Some(DirectiveContractMismatch::Cardinality { - expected: self.cardinality, - actual: actual.cardinality, - }); - } - if let ( - DirectiveShape::ContextBlock { - payload: expected, .. - }, - DirectiveShape::ContextBlock { - payload: actual, .. - }, - ) = (self.shape, actual.shape) - && expected != actual - { - return Some(DirectiveContractMismatch::Payload { expected, actual }); - } - if self.value_type != actual.value_type { - return Some(DirectiveContractMismatch::ValueType { - expected: self.value_type_name.unwrap_or(""), - actual: actual.value_type_name.unwrap_or(""), - }); - } - if self.shape != actual.shape { - return Some(DirectiveContractMismatch::Shape { - expected: self.shape, - actual: actual.shape, - }); - } - if self.duplicate != actual.duplicate { - return Some(DirectiveContractMismatch::Duplicate { - expected: self.duplicate, - actual: actual.duplicate, - }); - } - (self.cascade != actual.cascade).then_some(DirectiveContractMismatch::Cascade { - expected: self.cascade, - actual: actual.cascade, - }) - } -} - -#[derive(Debug)] -pub struct LocalDirectiveKey { - name: DirectiveName, - contract: DirectiveContractTemplate, - value: std::marker::PhantomData T>, -} - -impl Clone for LocalDirectiveKey { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for LocalDirectiveKey {} - -impl LocalDirectiveKey { - const fn new(name: DirectiveName, contract: DirectiveContractTemplate) -> Self { - Self { - name, - contract, - value: std::marker::PhantomData, - } - } - - pub const fn name(self) -> DirectiveName { - self.name - } - - pub(crate) fn contract(self) -> DirectiveContract { - self.contract.freeze() - } -} - -#[derive(Debug)] -pub struct RepeatedDirectiveKey { - name: DirectiveName, - contract: DirectiveContractTemplate, - value: std::marker::PhantomData T>, -} - -impl Clone for RepeatedDirectiveKey { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for RepeatedDirectiveKey {} - -impl RepeatedDirectiveKey { - const fn new(name: DirectiveName, contract: DirectiveContractTemplate) -> Self { - Self { - name, - contract, - value: std::marker::PhantomData, - } - } - - pub const fn name(self) -> DirectiveName { - self.name - } - - pub(crate) fn contract(self) -> DirectiveContract { - self.contract.freeze() - } -} - -#[derive(Debug)] -pub struct ContextPayloadKey { - name: DirectiveName, - contract: DirectiveContractTemplate, - value: std::marker::PhantomData T>, -} - -impl Clone for ContextPayloadKey { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for ContextPayloadKey {} - -impl ContextPayloadKey { - const fn new(name: DirectiveName, contract: DirectiveContractTemplate) -> Self { - Self { - name, - contract, - value: std::marker::PhantomData, - } - } - - pub const fn name(self) -> DirectiveName { - self.name - } - - pub(crate) fn contract(self) -> DirectiveContract { - self.contract.freeze() - } -} - -type DirectiveParserFn = - fn(&DirectiveInput<'_>) -> Result>; -pub type ContextFinalizeFn = - fn(&mut ConfigNode, &BuildOptions<'_>) -> Result<(), Box>; -type AttachedContextFinalizeFn = - for<'tree> fn( - AttachedConfigNode<'tree>, - ) -> Result<(), Box>; - -pub trait DirectiveValue: ConfigValue + Sized { - type Error: Error + Send + Sync + 'static; - - fn span(input: &DirectiveInput<'_>) -> SourceSpan { - input.directive.span - } -} - -pub struct DirectiveInput<'a> { - pub directive: &'a AstDirective, - pub context: ContextKey, - pub source_map: &'a SourceMap, -} - -pub enum ParsedDirective { - Slot(TypedValue), - Payload(TypedValue), - Empty, -} - -impl DirectiveSpec { - pub fn leaf_value( - name: &'static str, - allowed_in: Vec, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - Self { - name: DirectiveName::new(name), - allowed_in, - shape: DirectiveShape::Leaf, - parser: slot_value_parser::, - duplicate, - cascade, - transport, - reload, - value_type: Some(TypeId::of::()), - value_type_name: Some(std::any::type_name::()), - } - } - - pub fn raw_value( - name: &'static str, - allowed_in: Vec, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - Self { - name: DirectiveName::new(name), - allowed_in, - shape: DirectiveShape::RawBlock, - parser: slot_value_parser::, - duplicate, - cascade, - transport, - reload, - value_type: Some(TypeId::of::()), - value_type_name: Some(std::any::type_name::()), - } - } - - pub fn context_empty( - name: &'static str, - allowed_in: Vec, - child_context: ContextKey, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self { - name: DirectiveName::new(name), - allowed_in, - shape: DirectiveShape::ContextBlock { - child_context, - payload: PayloadMode::None, - }, - parser: empty_parser, - duplicate, - cascade, - transport, - reload, - value_type: None, - value_type_name: None, - } - } - - pub fn context_payload( - name: &'static str, - allowed_in: Vec, - child_context: ContextKey, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - Self { - name: DirectiveName::new(name), - allowed_in, - shape: DirectiveShape::ContextBlock { - child_context, - payload: PayloadMode::Parser, - }, - parser: payload_value_parser::, - duplicate, - cascade, - transport, - reload, - value_type: Some(TypeId::of::()), - value_type_name: Some(std::any::type_name::()), - } - } - - fn contract(&self, registration_context: ContextKey) -> DirectiveContract { - let (context, cardinality) = match self.shape { - DirectiveShape::Leaf | DirectiveShape::RawBlock => ( - registration_context, - if self.duplicate == DuplicatePolicy::Append { - DirectiveCardinality::Repeated - } else { - DirectiveCardinality::Single - }, - ), - DirectiveShape::ContextBlock { child_context, .. } => { - (child_context, DirectiveCardinality::ContextPayload) - } - }; - DirectiveContract { - context, - name: self.name, - value_type: self.value_type, - value_type_name: self.value_type_name, - cardinality, - shape: self.shape, - duplicate: self.duplicate, - cascade: self.cascade, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct SingleCardinality; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct RepeatedCardinality; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct PayloadCardinality; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct CascadedCardinality; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct DirectiveMetadata { - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, -} - -impl DirectiveMetadata { - pub(crate) const fn new( - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self { - duplicate, - cascade, - transport, - reload, - } - } -} - -#[derive(Debug)] -pub(crate) struct DirectiveProjection { - builtin: Option>, - snapshot: Option>, -} - -impl Clone for DirectiveProjection { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for DirectiveProjection {} - -impl DirectiveProjection { - pub(crate) const fn new(builtin: BuiltinValue, snapshot: SnapshotValue) -> Self { - Self { - builtin: Some(builtin), - snapshot: Some(snapshot), - } - } - - pub(crate) const fn absent() -> Self { - Self { - builtin: None, - snapshot: None, - } - } -} - -#[derive(Debug)] -pub(crate) struct TypedDirectiveDefinition { - registration_context: ContextKey, - contract_context: ContextKey, - name: DirectiveName, - shape: DirectiveShape, - cardinality: DirectiveCardinality, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - builtin: Option>, - snapshot: Option>, - value: std::marker::PhantomData Cardinality>, -} - -impl Clone for TypedDirectiveDefinition { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for TypedDirectiveDefinition {} - -impl TypedDirectiveDefinition { - const fn new( - registration_context: ContextKey, - contract_context: ContextKey, - name: &'static str, - shape: DirectiveShape, - cardinality: DirectiveCardinality, - metadata: DirectiveMetadata, - projection: DirectiveProjection, - ) -> Self { - Self { - registration_context, - contract_context, - name: DirectiveName::new(name), - shape, - cardinality, - duplicate: metadata.duplicate, - cascade: metadata.cascade, - transport: metadata.transport, - reload: metadata.reload, - builtin: projection.builtin, - snapshot: projection.snapshot, - value: std::marker::PhantomData, - } - } - - const fn contract_template(self) -> DirectiveContractTemplate - where - T: 'static, - { - DirectiveContractTemplate { - context: self.contract_context, - name: self.name, - value_type: TypeId::of::, - value_type_name: value_type_name::, - cardinality: self.cardinality, - shape: self.shape, - duplicate: self.duplicate, - cascade: self.cascade, - } - } - - pub(crate) fn register(self, registry: &mut ConfigRegistry) - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - let spec = match self.shape { - DirectiveShape::Leaf => DirectiveSpec::leaf_value::( - self.name.as_str(), - vec![self.registration_context], - self.duplicate, - self.cascade, - self.transport, - self.reload, - ), - DirectiveShape::RawBlock => DirectiveSpec::raw_value::( - self.name.as_str(), - vec![self.registration_context], - self.duplicate, - self.cascade, - self.transport, - self.reload, - ), - DirectiveShape::ContextBlock { child_context, .. } => { - DirectiveSpec::context_payload::( - self.name.as_str(), - vec![self.registration_context], - child_context, - self.duplicate, - self.cascade, - self.transport, - self.reload, - ) - } - }; - registry.register_directive(self.registration_context, spec); - } -} - -impl TypedDirectiveDefinition { - pub(crate) const fn single_leaf( - context: ContextKey, - name: &'static str, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::new( - context, - context, - name, - DirectiveShape::Leaf, - DirectiveCardinality::Single, - DirectiveMetadata::new(duplicate, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - - pub(crate) const fn key(self) -> LocalDirectiveKey - where - T: 'static, - { - LocalDirectiveKey::new(self.name, self.contract_template()) - } -} - -impl TypedDirectiveDefinition { - pub(crate) const fn repeated_raw( - context: ContextKey, - name: &'static str, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::new( - context, - context, - name, - DirectiveShape::RawBlock, - DirectiveCardinality::Repeated, - DirectiveMetadata::new(DuplicatePolicy::Append, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - - pub(crate) const fn repeated_leaf( - context: ContextKey, - name: &'static str, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::new( - context, - context, - name, - DirectiveShape::Leaf, - DirectiveCardinality::Repeated, - DirectiveMetadata::new(DuplicatePolicy::Append, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - - pub(crate) const fn key(self) -> RepeatedDirectiveKey - where - T: 'static, - { - RepeatedDirectiveKey::new(self.name, self.contract_template()) - } -} - -impl TypedDirectiveDefinition { - pub(crate) const fn payload( - registration_context: ContextKey, - child_context: ContextKey, - name: &'static str, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::new( - registration_context, - child_context, - name, - DirectiveShape::ContextBlock { - child_context, - payload: PayloadMode::Parser, - }, - DirectiveCardinality::ContextPayload, - DirectiveMetadata::new(duplicate, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - - pub(crate) const fn key(self) -> ContextPayloadKey - where - T: 'static, - { - ContextPayloadKey::new(self.name, self.contract_template()) - } -} - -impl TypedDirectiveDefinition { - pub(crate) const fn cascaded( - context: ContextKey, - name: &'static str, - shape: DirectiveShape, - metadata: DirectiveMetadata, - projection: DirectiveProjection, - ) -> Self { - Self::new( - context, - context, - name, - shape, - DirectiveCardinality::Single, - metadata, - projection, - ) - } - - pub(crate) const fn cascaded_leaf( - context: ContextKey, - name: &'static str, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::cascaded( - context, - name, - DirectiveShape::Leaf, - DirectiveMetadata::new(duplicate, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - - pub(crate) const fn cascaded_raw( - context: ContextKey, - name: &'static str, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - ) -> Self { - Self::cascaded( - context, - name, - DirectiveShape::RawBlock, - DirectiveMetadata::new(duplicate, cascade, transport, reload), - DirectiveProjection::absent(), - ) - } - - pub(crate) const fn key(self) -> DirectiveKey - where - T: 'static, - { - let builtin = match self.builtin { - Some(builtin) => builtin, - None => crate::parse::cascade::absent, - }; - let snapshot = match self.snapshot { - Some(snapshot) => snapshot, - None => crate::parse::cascade::no_snapshot, - }; - DirectiveKey::new(self.name, self.contract_template(), builtin, snapshot) - } - - pub(crate) const fn key_inheriting(self, parent: DirectiveKey) -> DirectiveKey - where - T: 'static, - { - parent.inheriting(self.contract_template()) - } -} - -#[derive(Debug, Clone, Copy)] -struct ErasedV1SnapshotDirective { - name: DirectiveName, - shape: DirectiveShape, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, - value_type: fn() -> TypeId, - value_type_name: &'static str, -} - -impl ErasedV1SnapshotDirective { - fn from_definition(definition: TypedDirectiveDefinition) -> Self - where - T: 'static, - { - Self { - name: definition.name, - shape: definition.shape, - duplicate: definition.duplicate, - cascade: definition.cascade, - transport: definition.transport, - reload: definition.reload, - value_type: TypeId::of::, - value_type_name: std::any::type_name::(), - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct ReservedV1SnapshotField { - name: DirectiveName, -} - -impl ReservedV1SnapshotField { - const fn new(name: &'static str) -> Self { - Self { - name: DirectiveName::new(name), - } - } - - pub(crate) const fn name(self) -> DirectiveName { - self.name - } -} - -macro_rules! define_v1_snapshot_schema { - ( - $( - $field:ident: $value_type:ty = - $name:literal, $shape:expr, $duplicate:expr, $cascade:expr, - $transport:expr, $reload:expr, $builtin:expr, $snapshot:expr; - )+ - @reserved $reserved:ident = $reserved_name:literal; - ) => { - #[derive(Debug)] - pub(crate) struct V1SnapshotSchema { - $(pub(crate) $field: TypedDirectiveDefinition<$value_type, CascadedCardinality>,)+ - pub(crate) $reserved: ReservedV1SnapshotField, - } - - impl V1SnapshotSchema { - fn active_directives( - &self, - ) -> impl Iterator + '_ { - [$(ErasedV1SnapshotDirective::from_definition(self.$field),)+].into_iter() - } - - fn register_active_directives(&self, registry: &mut ConfigRegistry) { - $(self.$field.register(registry);)+ - } - - #[cfg(test)] - pub(crate) const fn field_names(&self) -> [&'static str; 9] { - [$(self.$field.name.as_str(),)+ self.$reserved.name().as_str()] - } - } - - static V1_SNAPSHOT_SCHEMA: V1SnapshotSchema = V1SnapshotSchema { - $($field: TypedDirectiveDefinition::cascaded( - context::PISHOO, - $name, - $shape, - DirectiveMetadata::new($duplicate, $cascade, $transport, $reload), - DirectiveProjection::new($builtin, $snapshot), - ),)+ - $reserved: ReservedV1SnapshotField::new($reserved_name), - }; - }; -} - -define_v1_snapshot_schema! { - access_rules: AccessRulesUri = "access_rules", DirectiveShape::Leaf, DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::absent, - RootConfigSnapshot::cascade_access_rules; - gzip: BoolConfig = "gzip", DirectiveShape::Leaf, DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::builtin_false, - RootConfigSnapshot::cascade_gzip; - gzip_vary: BoolConfig = "gzip_vary", DirectiveShape::Leaf, DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::builtin_false, - RootConfigSnapshot::cascade_gzip_vary; - gzip_min_length: GzipMinLength = "gzip_min_length", DirectiveShape::Leaf, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::builtin_min_length, - RootConfigSnapshot::cascade_gzip_min_length; - gzip_comp_level: GzipCompLevel = "gzip_comp_level", DirectiveShape::Leaf, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::builtin_comp_level, - RootConfigSnapshot::cascade_gzip_comp_level; - gzip_types: StringList = "gzip_types", DirectiveShape::Leaf, DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::absent, - RootConfigSnapshot::cascade_gzip_types; - default_type: DefaultType = "default_type", DirectiveShape::Leaf, DuplicatePolicy::Reject, - CascadePolicy::NearestWins, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::absent, - RootConfigSnapshot::cascade_default_type; - types: MimeTypes = "types", DirectiveShape::RawBlock, DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, crate::parse::cascade::absent, - RootConfigSnapshot::cascade_types; - @reserved access_log = "access_log"; -} - -#[cfg(test)] -pub(crate) const fn v1_snapshot_field_names() -> [&'static str; 9] { - V1_SNAPSHOT_SCHEMA.field_names() -} - -pub(crate) const fn v1_gzip_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.gzip.key() -} - -pub(crate) const fn v1_gzip_types_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.gzip_types.key() -} - -pub(crate) const fn v1_access_rules_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.access_rules.key() -} - -pub(crate) const fn v1_gzip_vary_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.gzip_vary.key() -} - -pub(crate) const fn v1_gzip_min_length_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.gzip_min_length.key() -} - -pub(crate) const fn v1_gzip_comp_level_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.gzip_comp_level.key() -} - -pub(crate) const fn v1_default_type_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.default_type.key() -} - -pub(crate) const fn v1_types_key() -> DirectiveKey { - V1_SNAPSHOT_SCHEMA.types.key() -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct ValidatedV1SnapshotSchema { - schema: &'static V1SnapshotSchema, -} - -impl ValidatedV1SnapshotSchema { - pub(crate) const fn schema(self) -> &'static V1SnapshotSchema { - self.schema - } -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum V1SnapshotSchemaError { - #[snafu(display("root snapshot directive `{directive}` is not registered in PISHOO"))] - MissingDirective { directive: DirectiveName }, - #[snafu(display( - "root snapshot directive `{directive}` has value type `{actual}`, expected `{expected}`" - ))] - ValueType { - directive: DirectiveName, - expected: &'static str, - actual: &'static str, - }, - #[snafu(display("root snapshot directive `{directive}` has an incompatible shape"))] - Shape { directive: DirectiveName }, - #[snafu(display("root snapshot directive `{directive}` has an incompatible duplicate policy"))] - Duplicate { directive: DirectiveName }, - #[snafu(display("root snapshot directive `{directive}` has an incompatible cascade policy"))] - Cascade { directive: DirectiveName }, - #[snafu(display( - "root snapshot directive `{directive}` is not registered as WorkerInheritable" - ))] - Transport { directive: DirectiveName }, - #[snafu(display("root snapshot directive `{directive}` has an incompatible reload impact"))] - Reload { directive: DirectiveName }, - #[snafu(display( - "worker-inheritable PISHOO directive `{directive}` is not part of the V1 snapshot schema" - ))] - ExtraWorkerInheritableDirective { directive: DirectiveName }, - #[snafu(display( - "reserved root snapshot directive `{directive}` was registered before its checked domain" - ))] - PrematureReservedDirective { directive: DirectiveName }, -} - -fn slot_value_parser( - input: &DirectiveInput<'_>, -) -> Result> -where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, -{ - let span = T::span(input); - match T::try_from(input) { - Ok(value) => Ok(ParsedDirective::Slot(TypedValue::new(value, span))), - Err(source) => Err(Box::new(source)), - } -} - -fn payload_value_parser( - input: &DirectiveInput<'_>, -) -> Result> -where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, -{ - let span = T::span(input); - match T::try_from(input) { - Ok(value) => Ok(ParsedDirective::Payload(TypedValue::new(value, span))), - Err(source) => Err(Box::new(source)), - } -} - -fn empty_parser( - _input: &DirectiveInput<'_>, -) -> Result> { - Ok(ParsedDirective::Empty) -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct BuildOptions<'a> { - pub dhttp_home: Option<&'a dhttp::home::DhttpHome>, - pub identity_profile: Option<&'a dhttp::home::identity::IdentityProfile>, -} - -impl BuildOptions<'_> { - pub fn has_dhttp_home_context(&self) -> bool { - self.dhttp_home.is_some() || self.identity_profile.is_some() - } -} - -impl ConfigRegistry { - pub fn new() -> Self { - Self::default() - } - - pub fn register_context(&mut self, spec: ContextSpec) { - self.contexts.insert(spec.key, spec); - self.advance_contract(); - } - - pub fn register_directive(&mut self, context: ContextKey, spec: DirectiveSpec) { - self.directives.insert((context, spec.name.as_str()), spec); - self.rebuild_contract_tables(); - self.advance_contract(); - } - - fn rebuild_contract_tables(&mut self) { - let mut contract_tables = HashMap::>::new(); - for ((registration_context, _), spec) in &self.directives { - let contract = spec.contract(*registration_context); - contract_tables - .entry(contract.context()) - .or_default() - .push(contract); - } - self.contract_tables = contract_tables - .into_iter() - .map(|(context, mut contracts)| { - contracts.sort_unstable_by_key(|contract| { - (contract.name.as_str(), contract.cardinality as u8) - }); - contracts.dedup(); - (context, Arc::from(contracts.into_boxed_slice())) - }) - .collect(); - } - - pub(crate) fn register_attached_finalizer( - &mut self, - context: ContextKey, - finalize: AttachedContextFinalizeFn, - ) { - self.attached_finalizers.insert(context, finalize); - self.advance_contract(); - } - - fn advance_contract(&mut self) { - self.contract = ConfigRegistryContract::default(); - } - - pub(crate) fn contract(&self) -> ConfigRegistryContract { - self.contract.clone() - } - - pub(crate) fn matches_contract(&self, contract: &ConfigRegistryContract) -> bool { - self.contract.matches(contract) - } - - pub(crate) fn finalize_attached( - &self, - node: AttachedConfigNode<'_>, - ) -> Result<(), Box> { - if let Some(finalize) = self.attached_finalizers.get(&node.context()) { - finalize(node)?; - } - Ok(()) - } - - #[cfg(test)] - pub(crate) fn directive_spec(&self, context: ContextKey, name: &str) -> Option<&DirectiveSpec> { - self.directives - .iter() - .find_map(|((registered_context, registered_name), spec)| { - (*registered_context == context && *registered_name == name).then_some(spec) - }) - } - - pub(crate) fn frozen_contract_tables(&self) -> HashMap> { - self.contract_tables.clone() - } - - pub(crate) fn register_v1_snapshot_directives(&mut self) { - V1_SNAPSHOT_SCHEMA.register_active_directives(self); - } - - pub(crate) fn validate_v1_snapshot_schema( - &self, - ) -> Result { - for ((registered_context, registered_name), spec) in &self.directives { - if *registered_context != context::PISHOO { - continue; - } - let directive = DirectiveName::new(registered_name); - if *registered_name == V1_SNAPSHOT_SCHEMA.access_log.name().as_str() { - return Err(V1SnapshotSchemaError::PrematureReservedDirective { directive }); - } - if spec.transport == TransportPolicy::WorkerInheritable - && !V1_SNAPSHOT_SCHEMA - .active_directives() - .any(|expected| expected.name.as_str() == *registered_name) - { - return Err(V1SnapshotSchemaError::ExtraWorkerInheritableDirective { directive }); - } - } - - for expected in V1_SNAPSHOT_SCHEMA.active_directives() { - let spec = self - .directives - .get(&(context::PISHOO, expected.name.as_str())) - .ok_or(V1SnapshotSchemaError::MissingDirective { - directive: expected.name, - })?; - if spec.value_type != Some((expected.value_type)()) { - return Err(V1SnapshotSchemaError::ValueType { - directive: expected.name, - expected: expected.value_type_name, - actual: spec.value_type_name.unwrap_or(""), - }); - } - if spec.shape != expected.shape { - return Err(V1SnapshotSchemaError::Shape { - directive: expected.name, - }); - } - if spec.duplicate != expected.duplicate { - return Err(V1SnapshotSchemaError::Duplicate { - directive: expected.name, - }); - } - if spec.cascade != expected.cascade { - return Err(V1SnapshotSchemaError::Cascade { - directive: expected.name, - }); - } - if spec.transport != expected.transport { - return Err(V1SnapshotSchemaError::Transport { - directive: expected.name, - }); - } - if spec.reload != expected.reload { - return Err(V1SnapshotSchemaError::Reload { - directive: expected.name, - }); - } - } - - Ok(ValidatedV1SnapshotSchema { - schema: &V1_SNAPSHOT_SCHEMA, - }) - } - - pub fn build( - &self, - source_map: Arc, - directives: Vec, - options: BuildOptions<'_>, - ) -> Result { - let span = directives - .first() - .map(|directive| directive.span) - .unwrap_or(SourceSpan { - source_id: crate::parse::source::SourceId(0), - start: 0, - end: 0, - }); - let mut root = ConfigNode::new(context::ROOT, None, span); - self.build_into( - source_map.as_ref(), - &mut root, - context::ROOT, - directives, - &options, - )?; - let root = Arc::new(root); - put_parent_recursively(&root, None)?; - Ok(ConfigDocument::new(source_map, root)) - } - - pub(crate) fn build_for_role( - &self, - sources: Arc, - directives: Vec, - options: BuildOptions<'_>, - role: ConfigDocumentRoleKind, - ) -> Result { - let registry_contract = self.contract(); - self.validate_role_registration(&sources, &directives, role)?; - validate_document_shape(&sources, &directives, role)?; - self.validate_role_directives(&sources, &directives, context::ROOT, role)?; - - let span = document_span(&directives); - let mut root = ConfigNode::new(context::ROOT, None, span); - self.build_into( - sources.source_map(), - &mut root, - context::ROOT, - directives, - &options, - ) - .map_err(RoleDocumentBuildError::Build)?; - - match role { - ConfigDocumentRoleKind::HypervisorRoot => { - let pishoo = self.required_built_child(&sources, &root, "pishoo", role)?; - Ok(ParsedConfigDocument::HypervisorRoot( - ParsedPishooFragment::new(sources, pishoo, registry_contract), - )) - } - ConfigDocumentRoleKind::WorkerPishoo => { - let pishoo = self.required_built_child(&sources, &root, "pishoo", role)?; - Ok(ParsedConfigDocument::WorkerPishoo( - ParsedPishooFragment::new(sources, pishoo, registry_contract), - )) - } - ConfigDocumentRoleKind::IdentityServer => { - let servers: Box<[_]> = root - .children_optional("server") - .iter() - .cloned() - .map(|server| { - ParsedServerFragment::new( - Arc::clone(&sources), - server, - registry_contract.clone(), - ) - }) - .collect(); - if servers.is_empty() { - return Err(RoleDocumentBuildError::Role( - ConfigDocumentRoleError::missing_built_directive( - DirectiveName::new("server"), - role, - sources.config_span(root.span), - ), - )); - } - Ok(ParsedConfigDocument::IdentityServers(servers)) - } - } - } - - fn validate_role_registration( - &self, - sources: &ConfigDocumentSourceMap, - directives: &[AstDirective], - role: ConfigDocumentRoleKind, - ) -> Result<(), RoleDocumentBuildError> { - let (directive, expected_child_context) = match role { - ConfigDocumentRoleKind::HypervisorRoot | ConfigDocumentRoleKind::WorkerPishoo => { - (DirectiveName::new("pishoo"), context::PISHOO) - } - ConfigDocumentRoleKind::IdentityServer => { - (DirectiveName::new("server"), context::SERVER) - } - }; - let spec = self.directives.get(&(context::ROOT, directive.as_str())); - let valid = spec.is_some_and(|spec| { - spec.allowed_in.contains(&context::ROOT) - && matches!( - spec.shape, - DirectiveShape::ContextBlock { - child_context, - payload: PayloadMode::None, - } if child_context == expected_child_context - ) - && self.contexts.contains_key(&expected_child_context) - }); - if valid { - return Ok(()); - } - let span = directives - .iter() - .find(|candidate| candidate.name.value == directive.as_str()) - .map_or_else( - || document_span(directives), - |candidate| candidate.name.span, - ); - Err(RoleDocumentBuildError::Role( - ConfigDocumentRoleError::invalid_directive_registration( - directive, - role, - expected_child_context, - sources.config_span(span), - ), - )) - } - - fn required_built_child( - &self, - sources: &ConfigDocumentSourceMap, - root: &ConfigNode, - directive: &'static str, - role: ConfigDocumentRoleKind, - ) -> Result, RoleDocumentBuildError> { - root.children_optional(directive) - .first() - .cloned() - .ok_or_else(|| { - RoleDocumentBuildError::Role(ConfigDocumentRoleError::missing_built_directive( - DirectiveName::new(directive), - role, - sources.config_span(root.span), - )) - }) - } - - fn validate_role_directives( - &self, - sources: &ConfigDocumentSourceMap, - directives: &[AstDirective], - context: ContextKey, - role: ConfigDocumentRoleKind, - ) -> Result<(), RoleDocumentBuildError> { - for directive in directives { - let Some(spec) = self - .directives - .get(&(context, directive.name.value.as_str())) - else { - continue; - }; - if !directive_allowed_for_role(spec, context, role) { - return Err(RoleDocumentBuildError::Role( - ConfigDocumentRoleError::directive_not_allowed( - spec.name, - role, - sources.config_span(directive.name.span), - ), - )); - } - if let ( - DirectiveShape::ContextBlock { child_context, .. }, - AstBody::Block { children, .. }, - ) = (spec.shape, &directive.body) - { - self.validate_role_directives(sources, children, child_context, role)?; - } - } - Ok(()) - } - - fn build_into( - &self, - source_map: &SourceMap, - node: &mut ConfigNode, - context: ContextKey, - directives: Vec, - options: &BuildOptions<'_>, - ) -> Result<(), BuildDocumentError> { - for directive in directives { - let directive_name = directive.name.value.clone(); - let spec = self - .directives - .get(&(context, directive_name.as_str())) - .with_context(|| build_document_error::UnknownDirectiveSnafu { - directive: directive_name.clone(), - span: directive.name.span, - })?; - apply_shape(&directive, spec)?; - let parsed = (spec.parser)(&DirectiveInput { - directive: &directive, - context, - source_map, - }) - .context(build_document_error::DirectiveParseSnafu { - directive: directive_name.clone(), - span: directive.span, - })?; - match spec.shape { - DirectiveShape::Leaf | DirectiveShape::RawBlock => { - if let ParsedDirective::Slot(value) = parsed { - insert_slot(node, spec, value, source_map)?; - } - } - DirectiveShape::ContextBlock { - child_context, - payload, - } => { - let mut child = ConfigNode::new( - child_context, - Some(directive.name.clone()), - directive.span, - ); - if matches!(payload, PayloadMode::Parser) - && let ParsedDirective::Payload(value) = parsed - { - child.set_payload(value); - } - let children = match directive.body { - AstBody::Block { children, .. } => children, - AstBody::Leaf { .. } => { - return build_document_error::InvalidDirectiveShapeSnafu { - directive: directive_name, - span: directive.span, - } - .fail(); - } - }; - self.build_into(source_map, &mut child, child_context, children, options)?; - node.insert_child(spec.name.as_str(), Arc::new(child)); - } - } - } - self.finalize_local(node, context, options)?; - Ok(()) - } - - fn finalize_local( - &self, - node: &mut ConfigNode, - context: ContextKey, - options: &BuildOptions<'_>, - ) -> Result<(), BuildDocumentError> { - if let Some(spec) = self.contexts.get(&context) - && let Some(finalize) = spec.finalize - { - finalize(node, options).context(build_document_error::FinalizeContextSnafu { - context: context.0, - span: node.span, - })?; - } - Ok(()) - } -} - -fn apply_shape(directive: &AstDirective, spec: &DirectiveSpec) -> Result<(), BuildDocumentError> { - match (&directive.body, spec.shape) { - (AstBody::Leaf { .. }, DirectiveShape::Leaf | DirectiveShape::RawBlock) => Ok(()), - (AstBody::Block { .. }, DirectiveShape::ContextBlock { .. } | DirectiveShape::RawBlock) => { - Ok(()) - } - _ => build_document_error::InvalidDirectiveShapeSnafu { - directive: directive.name.value.clone(), - span: directive.span, - } - .fail(), - } -} - -fn insert_slot( - node: &mut ConfigNode, - spec: &DirectiveSpec, - value: TypedValue, - source_map: &SourceMap, -) -> Result<(), BuildDocumentError> { - let span = value.span(); - let value = normalize::normalize_slot_value(value, source_map).context( - build_document_error::NormalizeDirectiveValueSnafu { - directive: spec.name.as_str().to_owned(), - span, - }, - )?; - - match spec.duplicate { - DuplicatePolicy::Reject if !node.get_all_untyped(spec.name.as_str()).is_empty() => { - let first = node.get_all_untyped(spec.name.as_str())[0].span(); - build_document_error::DuplicateDirectiveSnafu { - directive: spec.name.as_str().to_owned(), - first, - duplicate: value.span(), - } - .fail() - } - DuplicatePolicy::LastWins => { - node.replace_slot(spec.name.as_str(), value); - Ok(()) - } - _ => { - node.insert_slot(spec.name.as_str(), value); - Ok(()) - } - } -} - -fn document_span(directives: &[AstDirective]) -> SourceSpan { - directives - .first() - .map(|directive| directive.span) - .unwrap_or(SourceSpan { - source_id: crate::parse::source::SourceId(0), - start: 0, - end: 0, - }) -} - -fn validate_document_shape( - sources: &ConfigDocumentSourceMap, - directives: &[AstDirective], - role: ConfigDocumentRoleKind, -) -> Result<(), RoleDocumentBuildError> { - match role { - ConfigDocumentRoleKind::HypervisorRoot | ConfigDocumentRoleKind::WorkerPishoo => { - let pishoo = directives - .iter() - .filter(|directive| directive.name.value == "pishoo") - .collect::>(); - if pishoo.len() != 1 { - let span = pishoo - .get(1) - .copied() - .or_else(|| directives.first()) - .map_or_else(|| document_span(directives), |directive| directive.span); - return Err(RoleDocumentBuildError::Role( - ConfigDocumentRoleError::expected_single_pishoo( - role, - pishoo.len(), - sources.config_span(span), - ), - )); - } - } - ConfigDocumentRoleKind::IdentityServer => { - if !directives - .iter() - .any(|directive| directive.name.value == "server") - { - return Err(RoleDocumentBuildError::Role( - ConfigDocumentRoleError::missing_identity_server( - role, - sources.config_span(document_span(directives)), - ), - )); - } - } - } - Ok(()) -} - -fn directive_allowed_for_role( - spec: &DirectiveSpec, - context: ContextKey, - role: ConfigDocumentRoleKind, -) -> bool { - match role { - ConfigDocumentRoleKind::HypervisorRoot => { - context != context::ROOT || spec.name.as_str() == "pishoo" - } - ConfigDocumentRoleKind::WorkerPishoo => spec.transport != TransportPolicy::HypervisorOnly, - ConfigDocumentRoleKind::IdentityServer => { - context != context::ROOT || spec.name.as_str() == "server" - } - } -} - -pub(crate) enum RoleDocumentBuildError { - Role(ConfigDocumentRoleError), - Build(BuildDocumentError), -} - -fn put_parent_recursively( - node: &Arc, - parent: Option<&Arc>, -) -> Result<(), BuildDocumentError> { - node.set_parent(parent.map(Arc::downgrade)) - .map_err(|span| BuildDocumentError::ParentAlreadyAssigned { span })?; - for children in node.child_groups() { - for child in children { - put_parent_recursively(child, Some(node))?; - } - } - Ok(()) -} diff --git a/gateway/src/parse/snapshot.rs b/gateway/src/parse/snapshot.rs deleted file mode 100644 index af1a3310..00000000 --- a/gateway/src/parse/snapshot.rs +++ /dev/null @@ -1,889 +0,0 @@ -use std::{num::NonZeroU32, path::PathBuf, sync::Arc}; - -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _}; -use snafu::Snafu; - -use crate::parse::{ - cascade::{ConfigOrigin, InheritedSourceLocation}, - domain::{DirectiveName, ResolvedConfigPath, ResolvedConfigPathError}, - registry::ReservedV1SnapshotField, - tree::HomeConfigTree, - types::{ - AccessRulesUri, AccessRulesUriValidationError, BoolConfig, DefaultType, GzipCompLevel, - GzipMinLength, GzipTypesValidationError, MimeTypes, MimeTypesValidationError, StringList, - }, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RootConfigSnapshot { - V1(RootInheritedConfigV1), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RootInheritedConfigV1 { - access_rules: InheritedValue>>, - gzip: InheritedValue>, - gzip_vary: InheritedValue>, - gzip_min_length: InheritedValue>, - gzip_comp_level: InheritedValue>, - gzip_types: InheritedValue>>, - default_type: InheritedValue>>, - types: InheritedValue>>, - access_log: InheritedValue>>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct InheritedValue { - value: T, - origin: InheritedOrigin, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum InheritedOrigin { - Builtin, - Source(InheritedSourceLocation), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SnapshotAccessLogDirective { - Off, - ProfileDefault, - Resolved(ResolvedConfigPath), -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RootConfigSnapshotError { - #[snafu(display("configuration source path must be absolute for root snapshot transport"))] - SourcePath { path: PathBuf }, - #[snafu(display("configuration source location exceeds the root snapshot range"))] - SourceLocation { line: usize, column: usize }, - #[snafu(display("root configuration value has no source location"))] - MissingSourceLocation, - #[snafu(display("required root snapshot fallback is missing for `{directive}`"))] - MissingFallback { directive: DirectiveName }, - #[snafu(display("failed to query root configuration value"))] - Query { - source: crate::parse::error::ConfigQueryError, - }, - #[snafu(display("invalid root snapshot access_rules value"))] - AccessRules { - source: AccessRulesUriValidationError, - }, - #[snafu(display("failed to parse root snapshot access_rules URL"))] - AccessRulesUrl { source: url::ParseError }, - #[snafu(display("invalid root snapshot header value"))] - HeaderValue { - source: http::header::InvalidHeaderValue, - }, - #[snafu(display("invalid root snapshot gzip_types value"))] - GzipTypes { source: GzipTypesValidationError }, - #[snafu(display("invalid root snapshot MIME types value"))] - MimeTypes { source: MimeTypesValidationError }, - #[snafu(display("invalid root snapshot absolute path"))] - AbsolutePath { path: PathBuf }, - #[snafu(display("root snapshot path transport requires a Unix host"))] - UnsupportedPathPlatform, - #[snafu(display("invalid resolved root snapshot path"))] - ResolvedPath { source: ResolvedConfigPathError }, -} - -impl RootConfigSnapshot { - pub(crate) fn project(tree: &Arc) -> Result { - let pishoo = tree.pishoo(); - let schema = tree.v1_snapshot_schema().schema(); - let access_rules = project_optional( - tree, - snapshot_query(pishoo.cascaded(schema.access_rules.key()))?, - )?; - let gzip = project_required( - tree, - schema.gzip.key().name(), - snapshot_query(pishoo.cascaded(schema.gzip.key()))?, - )?; - let gzip_vary = project_required( - tree, - schema.gzip_vary.key().name(), - snapshot_query(pishoo.cascaded(schema.gzip_vary.key()))?, - )?; - let gzip_min_length = project_required( - tree, - schema.gzip_min_length.key().name(), - snapshot_query(pishoo.cascaded(schema.gzip_min_length.key()))?, - )?; - let gzip_comp_level = project_required( - tree, - schema.gzip_comp_level.key().name(), - snapshot_query(pishoo.cascaded(schema.gzip_comp_level.key()))?, - )?; - let gzip_types = project_optional( - tree, - snapshot_query(pishoo.cascaded(schema.gzip_types.key()))?, - )?; - let default_type = project_optional( - tree, - snapshot_query(pishoo.cascaded(schema.default_type.key()))?, - )?; - let types = project_optional(tree, snapshot_query(pishoo.cascaded(schema.types.key()))?)?; - let access_log = project_reserved(schema.access_log); - Ok(Self::V1(RootInheritedConfigV1 { - access_rules, - gzip, - gzip_vary, - gzip_min_length, - gzip_comp_level, - gzip_types, - default_type, - types, - access_log, - })) - } - - pub fn access_rules(&self) -> Option<&AccessRulesUri> { - self.v1().access_rules.value.as_deref() - } - - pub fn gzip(&self) -> &BoolConfig { - &self.v1().gzip.value - } - - pub fn gzip_vary(&self) -> &BoolConfig { - &self.v1().gzip_vary.value - } - - pub fn gzip_min_length(&self) -> &GzipMinLength { - &self.v1().gzip_min_length.value - } - - pub fn gzip_comp_level(&self) -> &GzipCompLevel { - &self.v1().gzip_comp_level.value - } - - pub fn gzip_types(&self) -> Option<&StringList> { - self.v1().gzip_types.value.as_deref() - } - - pub fn default_type(&self) -> Option<&DefaultType> { - self.v1().default_type.value.as_deref() - } - - pub fn types(&self) -> Option<&MimeTypes> { - self.v1().types.value.as_deref() - } - - pub(crate) fn cascade_access_rules( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_optional(&self.v1().access_rules, directive) - } - - pub(crate) fn cascade_gzip( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_required(&self.v1().gzip, directive) - } - - pub(crate) fn cascade_gzip_vary( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_required(&self.v1().gzip_vary, directive) - } - - pub(crate) fn cascade_gzip_min_length( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_required(&self.v1().gzip_min_length, directive) - } - - pub(crate) fn cascade_gzip_comp_level( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_required(&self.v1().gzip_comp_level, directive) - } - - pub(crate) fn cascade_gzip_types( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_optional(&self.v1().gzip_types, directive) - } - - pub(crate) fn cascade_default_type( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_optional(&self.v1().default_type, directive) - } - - pub(crate) fn cascade_types( - &self, - directive: DirectiveName, - ) -> Option<(Arc, ConfigOrigin)> { - cascade_optional(&self.v1().types, directive) - } - - fn v1(&self) -> &RootInheritedConfigV1 { - match self { - Self::V1(value) => value, - } - } -} - -fn snapshot_query( - result: Result< - Option>>, - crate::parse::error::ConfigQueryError, - >, -) -> Result>>, RootConfigSnapshotError> { - result.map_err(|source| RootConfigSnapshotError::Query { source }) -} - -fn project_required( - tree: &HomeConfigTree, - directive: DirectiveName, - value: Option>>, -) -> Result>, RootConfigSnapshotError> { - let value = value.ok_or(RootConfigSnapshotError::MissingFallback { directive })?; - let origin = project_origin(tree, value.lineage().last())?; - Ok(InheritedValue { - value: Arc::clone(value.effective()), - origin, - }) -} - -fn project_optional( - tree: &HomeConfigTree, - value: Option>>, -) -> Result>>, RootConfigSnapshotError> { - let Some(value) = value else { - return Ok(InheritedValue { - value: None, - origin: InheritedOrigin::Builtin, - }); - }; - let origin = project_origin(tree, value.lineage().last())?; - Ok(InheritedValue { - value: Some(Arc::clone(value.effective())), - origin, - }) -} - -fn project_reserved(_: ReservedV1SnapshotField) -> InheritedValue>> { - InheritedValue { - value: None, - origin: InheritedOrigin::Builtin, - } -} - -fn project_origin( - tree: &HomeConfigTree, - origin: Option<&ConfigOrigin>, -) -> Result { - match origin { - None | Some(ConfigOrigin::Builtin { .. }) => Ok(InheritedOrigin::Builtin), - Some(ConfigOrigin::Source(span)) => tree - .inherited_source_location(*span) - .map(InheritedOrigin::Source), - Some(ConfigOrigin::RootInherited { source, .. }) => Ok(source - .clone() - .map_or(InheritedOrigin::Builtin, InheritedOrigin::Source)), - } -} - -fn cascade_required( - value: &InheritedValue>, - directive: DirectiveName, -) -> Option<(Arc, ConfigOrigin)> { - Some(( - Arc::clone(&value.value), - inherited_origin(&value.origin, directive), - )) -} - -fn cascade_optional( - value: &InheritedValue>>, - directive: DirectiveName, -) -> Option<(Arc, ConfigOrigin)> { - let origin = &value.origin; - value - .value - .as_ref() - .map(|value| (Arc::clone(value), inherited_origin(origin, directive))) -} - -fn inherited_origin(origin: &InheritedOrigin, directive: DirectiveName) -> ConfigOrigin { - match origin { - InheritedOrigin::Builtin => ConfigOrigin::Builtin { directive }, - InheritedOrigin::Source(source) => ConfigOrigin::RootInherited { - directive, - source: Some(source.clone()), - }, - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -enum RootConfigSnapshotWire { - V1(RootInheritedConfigV1Wire), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct RootInheritedConfigV1Wire { - access_rules: InheritedValueV1>, - gzip: InheritedValueV1, - gzip_vary: InheritedValueV1, - gzip_min_length: InheritedValueV1, - gzip_comp_level: InheritedValueV1, - gzip_types: InheritedValueV1]>>>, - default_type: InheritedValueV1>, - types: InheritedValueV1>, - access_log: InheritedValueV1>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct InheritedValueV1 { - value: T, - origin: InheritedOriginV1, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -enum InheritedOriginV1 { - Builtin, - Source(InheritedSourceLocationV1), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct InheritedSourceLocationV1 { - document: Option, - line: NonZeroU32, - column: NonZeroU32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct HeaderValueV1(Box<[u8]>); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MimeTypeEntryV1 { - extension: Box, - value: HeaderValueV1, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MimeTypesV1(Box<[MimeTypeEntryV1]>); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct AccessRulesSourceV1(Box); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct AbsolutePathV1(Box<[u8]>); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct ResolvedConfigPathV1(AbsolutePathV1); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -enum AccessLogDirectiveV1 { - Off, - ProfileDefault, - Resolved(ResolvedConfigPathV1), -} - -impl Serialize for RootConfigSnapshot { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let wire = RootConfigSnapshotWire::try_from(self).map_err(S::Error::custom)?; - wire.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for RootConfigSnapshot { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let wire = RootConfigSnapshotWire::deserialize(deserializer)?; - Self::try_from(wire).map_err(D::Error::custom) - } -} - -impl TryFrom<&RootConfigSnapshot> for RootConfigSnapshotWire { - type Error = RootConfigSnapshotError; - - fn try_from(snapshot: &RootConfigSnapshot) -> Result { - let value = snapshot.v1(); - let access_log = InheritedValueV1 { - value: value - .access_log - .value - .as_ref() - .map(|value| encode_access_log(value)) - .transpose()?, - origin: encode_origin(&value.access_log.origin)?, - }; - Ok(Self::V1(RootInheritedConfigV1Wire { - access_rules: encode_inherited(&value.access_rules, |value| { - value - .as_ref() - .map(|value| AccessRulesSourceV1(value.0.as_str().into())) - })?, - gzip: encode_inherited(&value.gzip, |value| value.0)?, - gzip_vary: encode_inherited(&value.gzip_vary, |value| value.0)?, - gzip_min_length: encode_inherited(&value.gzip_min_length, |value| value.0)?, - gzip_comp_level: encode_inherited(&value.gzip_comp_level, |value| value.0)?, - gzip_types: encode_inherited(&value.gzip_types, |value| { - value - .as_ref() - .map(|value| value.0.iter().map(|value| value.as_str().into()).collect()) - })?, - default_type: encode_inherited(&value.default_type, |value| { - value - .as_ref() - .map(|value| HeaderValueV1(value.0.as_bytes().into())) - })?, - types: encode_inherited(&value.types, |value| { - value.as_ref().map(|value| encode_mime_types(value)) - })?, - access_log, - })) - } -} - -impl TryFrom for RootConfigSnapshot { - type Error = RootConfigSnapshotError; - - fn try_from(wire: RootConfigSnapshotWire) -> Result { - let RootConfigSnapshotWire::V1(value) = wire; - Ok(Self::V1(RootInheritedConfigV1 { - access_rules: decode_inherited(value.access_rules, |value| { - value - .map(|value| { - let uri = url::Url::parse(&value.0) - .map_err(|source| RootConfigSnapshotError::AccessRulesUrl { source })?; - AccessRulesUri::try_from(uri) - .map(Arc::new) - .map_err(|source| RootConfigSnapshotError::AccessRules { source }) - }) - .transpose() - })?, - gzip: decode_inherited(value.gzip, |value| Ok(Arc::new(BoolConfig(value))))?, - gzip_vary: decode_inherited(value.gzip_vary, |value| Ok(Arc::new(BoolConfig(value))))?, - gzip_min_length: decode_inherited(value.gzip_min_length, |value| { - Ok(Arc::new(GzipMinLength::checked(value))) - })?, - gzip_comp_level: decode_inherited(value.gzip_comp_level, |value| { - Ok(Arc::new(GzipCompLevel::checked(value))) - })?, - gzip_types: decode_inherited(value.gzip_types, |value| { - value - .map(|values| { - StringList::checked_gzip_types( - values.into_vec().into_iter().map(String::from).collect(), - ) - .map(Arc::new) - .map_err(|source| RootConfigSnapshotError::GzipTypes { source }) - }) - .transpose() - })?, - default_type: decode_inherited(value.default_type, |value| { - value - .map(|value| { - DefaultType::checked_from_bytes(&value.0) - .map(Arc::new) - .map_err(|source| RootConfigSnapshotError::HeaderValue { source }) - }) - .transpose() - })?, - types: decode_inherited(value.types, |value| { - value - .map(decode_mime_types) - .transpose() - .map(|value| value.map(Arc::new)) - })?, - access_log: decode_inherited(value.access_log, |value| { - value - .map(decode_access_log) - .transpose() - .map(|value| value.map(Arc::new)) - })?, - })) - } -} - -fn encode_inherited( - value: &InheritedValue, - encode: impl FnOnce(&T) -> W, -) -> Result, RootConfigSnapshotError> { - Ok(InheritedValueV1 { - value: encode(&value.value), - origin: encode_origin(&value.origin)?, - }) -} - -fn decode_inherited( - value: InheritedValueV1, - decode: impl FnOnce(W) -> Result, -) -> Result, RootConfigSnapshotError> { - Ok(InheritedValue { - value: decode(value.value)?, - origin: decode_origin(value.origin)?, - }) -} - -fn encode_origin(origin: &InheritedOrigin) -> Result { - match origin { - InheritedOrigin::Builtin => Ok(InheritedOriginV1::Builtin), - InheritedOrigin::Source(source) => { - Ok(InheritedOriginV1::Source(InheritedSourceLocationV1 { - document: source - .document() - .map(AbsolutePathV1::try_from) - .transpose()?, - line: source.line(), - column: source.column(), - })) - } - } -} - -fn decode_origin(origin: InheritedOriginV1) -> Result { - match origin { - InheritedOriginV1::Builtin => Ok(InheritedOrigin::Builtin), - InheritedOriginV1::Source(source) => { - Ok(InheritedOrigin::Source(InheritedSourceLocation::new( - source.document.map(PathBuf::try_from).transpose()?, - source.line, - source.column, - ))) - } - } -} - -fn encode_mime_types(types: &MimeTypes) -> MimeTypesV1 { - let mut entries = types - .0 - .iter() - .map(|(extension, value)| MimeTypeEntryV1 { - extension: extension.as_str().into(), - value: HeaderValueV1(value.as_bytes().into()), - }) - .collect::>(); - entries - .sort_unstable_by(|left, right| left.extension.as_bytes().cmp(right.extension.as_bytes())); - MimeTypesV1(entries.into_boxed_slice()) -} - -fn decode_mime_types(types: MimeTypesV1) -> Result { - MimeTypes::checked_from_bytes( - types - .0 - .into_vec() - .into_iter() - .map(|entry| (String::from(entry.extension), entry.value.0.into_vec())), - ) - .map_err(|source| RootConfigSnapshotError::MimeTypes { source }) -} - -fn encode_access_log( - value: &SnapshotAccessLogDirective, -) -> Result { - Ok(match value { - SnapshotAccessLogDirective::Off => AccessLogDirectiveV1::Off, - SnapshotAccessLogDirective::ProfileDefault => AccessLogDirectiveV1::ProfileDefault, - SnapshotAccessLogDirective::Resolved(path) => AccessLogDirectiveV1::Resolved( - ResolvedConfigPathV1(AbsolutePathV1::try_from(path.as_ref())?), - ), - }) -} - -fn decode_access_log( - value: AccessLogDirectiveV1, -) -> Result { - Ok(match value { - AccessLogDirectiveV1::Off => SnapshotAccessLogDirective::Off, - AccessLogDirectiveV1::ProfileDefault => SnapshotAccessLogDirective::ProfileDefault, - AccessLogDirectiveV1::Resolved(path) => SnapshotAccessLogDirective::Resolved( - ResolvedConfigPath::try_from(PathBuf::try_from(path.0)?) - .map_err(|source| RootConfigSnapshotError::ResolvedPath { source })?, - ), - }) -} - -impl TryFrom<&std::path::Path> for AbsolutePathV1 { - type Error = RootConfigSnapshotError; - - fn try_from(path: &std::path::Path) -> Result { - if !path.is_absolute() || path.as_os_str().as_encoded_bytes().contains(&0) { - return Err(RootConfigSnapshotError::AbsolutePath { - path: path.to_path_buf(), - }); - } - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - Ok(Self(path.as_os_str().as_bytes().into())) - } - #[cfg(not(unix))] - { - let _ = path; - Err(RootConfigSnapshotError::UnsupportedPathPlatform) - } - } -} - -impl TryFrom for PathBuf { - type Error = RootConfigSnapshotError; - - fn try_from(path: AbsolutePathV1) -> Result { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStringExt; - let path = PathBuf::from(std::ffi::OsString::from_vec(path.0.into_vec())); - if !path.is_absolute() - || path.as_os_str().as_encoded_bytes().is_empty() - || path.as_os_str().as_encoded_bytes().contains(&0) - { - return Err(RootConfigSnapshotError::AbsolutePath { path }); - } - Ok(path) - } - #[cfg(not(unix))] - { - let _ = path; - Err(RootConfigSnapshotError::UnsupportedPathPlatform) - } - } -} - -#[cfg(test)] -pub(crate) mod test_support { - use remoc::codec::Codec; - - use super::*; - - #[derive(Debug, PartialEq, Eq)] - pub(crate) struct SnapshotValues { - access_rules: Option, - gzip: BoolConfig, - gzip_vary: BoolConfig, - gzip_min_length: GzipMinLength, - gzip_comp_level: GzipCompLevel, - gzip_types: Option, - default_type: Option, - types: Option, - access_log: Option, - } - - pub(crate) fn wire_field_names(snapshot: &RootConfigSnapshot) -> [&'static str; 9] { - let RootConfigSnapshotWire::V1(RootInheritedConfigV1Wire { - access_rules, - gzip, - gzip_vary, - gzip_min_length, - gzip_comp_level, - gzip_types, - default_type, - types, - access_log, - }) = RootConfigSnapshotWire::try_from(snapshot).expect("snapshot should encode"); - drop(( - access_rules, - gzip, - gzip_vary, - gzip_min_length, - gzip_comp_level, - gzip_types, - default_type, - types, - access_log, - )); - crate::parse::registry::v1_snapshot_field_names() - } - - pub(crate) fn checked_wire_round_trip( - snapshot: &RootConfigSnapshot, - ) -> Result { - RootConfigSnapshot::try_from(RootConfigSnapshotWire::try_from(snapshot)?) - } - - pub(crate) fn codec_round_trip( - snapshot: &RootConfigSnapshot, - ) -> Result { - let mut bytes = Vec::new(); - ::serialize(&mut bytes, snapshot) - .map_err(|error| error.to_string())?; - ::deserialize(bytes.as_slice()) - .map_err(|error| error.to_string()) - } - - pub(crate) fn codec_rejects_unknown_schema(snapshot: &RootConfigSnapshot) -> bool { - let mut bytes = Vec::new(); - if ::serialize(&mut bytes, snapshot).is_err() - || bytes.len() < 4 - { - return false; - } - bytes[..4].copy_from_slice(&1_u32.to_le_bytes()); - ::deserialize::<_, RootConfigSnapshot>(bytes.as_slice()) - .is_err() - } - - pub(crate) fn codec_rejects_malformed_access_rules(snapshot: &RootConfigSnapshot) -> bool { - let Ok(mut wire) = RootConfigSnapshotWire::try_from(snapshot) else { - return false; - }; - let RootConfigSnapshotWire::V1(value) = &mut wire; - value.access_rules.value = Some(AccessRulesSourceV1("not a URL".into())); - let mut bytes = Vec::new(); - if ::serialize(&mut bytes, &wire).is_err() { - return false; - } - ::deserialize::<_, RootConfigSnapshot>(bytes.as_slice()) - .is_err() - } - - pub(crate) fn snapshot_with_builtin_gzip(gzip: bool) -> RootConfigSnapshot { - let builtin = || InheritedOrigin::Builtin; - RootConfigSnapshot::V1(RootInheritedConfigV1 { - access_rules: InheritedValue { - value: None, - origin: builtin(), - }, - gzip: InheritedValue { - value: Arc::new(BoolConfig(gzip)), - origin: builtin(), - }, - gzip_vary: InheritedValue { - value: Arc::new(BoolConfig(false)), - origin: builtin(), - }, - gzip_min_length: InheritedValue { - value: Arc::new(GzipMinLength::checked(20)), - origin: builtin(), - }, - gzip_comp_level: InheritedValue { - value: Arc::new(GzipCompLevel::checked(1)), - origin: builtin(), - }, - gzip_types: InheritedValue { - value: None, - origin: builtin(), - }, - default_type: InheritedValue { - value: None, - origin: builtin(), - }, - types: InheritedValue { - value: None, - origin: builtin(), - }, - access_log: InheritedValue { - value: None, - origin: builtin(), - }, - }) - } - - pub(crate) fn gzip_types_arc(snapshot: &RootConfigSnapshot) -> Option> { - snapshot.v1().gzip_types.value.clone() - } - - pub(crate) fn values_without_origins(snapshot: &RootConfigSnapshot) -> SnapshotValues { - let value = snapshot.v1(); - SnapshotValues { - access_rules: value - .access_rules - .value - .as_ref() - .map(|value| value.as_ref().clone()), - gzip: value.gzip.value.as_ref().clone(), - gzip_vary: value.gzip_vary.value.as_ref().clone(), - gzip_min_length: value.gzip_min_length.value.as_ref().clone(), - gzip_comp_level: value.gzip_comp_level.value.as_ref().clone(), - gzip_types: value - .gzip_types - .value - .as_ref() - .map(|value| value.as_ref().clone()), - default_type: value - .default_type - .value - .as_ref() - .map(|value| value.as_ref().clone()), - types: value - .types - .value - .as_ref() - .map(|value| value.as_ref().clone()), - access_log: value - .access_log - .value - .as_ref() - .map(|value| value.as_ref().clone()), - } - } - - pub(crate) fn round_trip_resolved_path( - path: ResolvedConfigPath, - ) -> Result { - let wire = ResolvedConfigPathV1(AbsolutePathV1::try_from(path.as_ref())?); - ResolvedConfigPath::try_from(PathBuf::try_from(wire.0)?) - .map_err(|source| RootConfigSnapshotError::ResolvedPath { source }) - } - - pub(crate) fn decode_absolute_path(bytes: &[u8]) -> Result { - PathBuf::try_from(AbsolutePathV1(bytes.into())) - } - - pub(crate) fn decode_schema(schema: u32) -> Result<(), serde::de::value::Error> { - let variant = format!("V{schema}"); - let deserializer = serde::de::value::StringDeserializer::new(variant); - RootConfigSnapshot::deserialize(deserializer).map(drop) - } - - pub(crate) fn decode_access_rules( - value: &str, - ) -> Result { - let uri = url::Url::parse(value) - .map_err(|source| RootConfigSnapshotError::AccessRulesUrl { source })?; - AccessRulesUri::try_from(uri) - .map_err(|source| RootConfigSnapshotError::AccessRules { source }) - } - - pub(crate) fn decode_header_value( - value: &[u8], - ) -> Result { - DefaultType::checked_from_bytes(value) - .map_err(|source| RootConfigSnapshotError::HeaderValue { source }) - } - - pub(crate) fn mime_wire_extensions(types: &MimeTypes) -> Vec { - let wire = encode_mime_types(types); - wire.0 - .iter() - .map(|entry| entry.extension.to_string()) - .collect() - } - - pub(crate) fn decode_mime_entries( - entries: &[(&str, &[u8])], - ) -> Result { - decode_mime_types(MimeTypesV1( - entries - .iter() - .map(|(extension, value)| MimeTypeEntryV1 { - extension: (*extension).into(), - value: HeaderValueV1((*value).into()), - }) - .collect(), - )) - } - - pub(crate) fn has_access_log(snapshot: &RootConfigSnapshot) -> bool { - snapshot.v1().access_log.value.is_some() - } -} diff --git a/gateway/src/parse/source.rs b/gateway/src/parse/source.rs index b1f34a44..b0aca1ec 100644 --- a/gateway/src/parse/source.rs +++ b/gateway/src/parse/source.rs @@ -118,6 +118,10 @@ impl SourceMap { .unwrap_or_else(|| source.text.len()); Some(source.text[start..end].trim_end_matches(['\r', '\n'])) } + + pub fn path_for_span(&self, span: SourceSpan) -> Option<&Path> { + self.get(span.source_id)?.path.as_deref() + } } impl ConfigDocumentSourceMap { @@ -139,6 +143,10 @@ impl ConfigDocumentSourceMap { pub(crate) fn source_map(&self) -> &SourceMap { &self.source_map } + + pub(crate) fn source_map_arc(&self) -> &Arc { + &self.source_map + } } impl fmt::Display for SourceId { diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 410270ca..b779ef58 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -1,120 +1,101 @@ use std::{ - fs, + error::Error, path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicU64, AtomicUsize, Ordering}, - }, + sync::Arc, }; -use dhttp::home::DhttpHome; - -use crate::parse::{ - ConfigDocumentParser, - cascade::{DirectiveKey, GZIP, GZIP_TYPES}, - document::{ConfigDocument, ConfigNode}, - domain::{ConfigDocumentRole, ConfigDocumentRoleKind, ResolvedConfigPath}, - error::{ConfigDocumentRoleError, ConfigLoadFailure, LoadConfigError}, - fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, - registry::{ - BuildOptions, CascadePolicy, CascadedCardinality, ConfigRegistry, ContextSpec, - DirectiveContractMismatch, DirectiveMetadata, DirectiveProjection, DirectiveShape, - DirectiveSpec, DuplicatePolicy, ReloadImpact, TransportPolicy, TypedDirectiveDefinition, - context, +use super::{ + ParsedPishooConfig, TypedConfigParser, + config::{ + AccessLogDirective, LocationConfig, OriginScope, ResolvedAccessLogConfig, ServerConfig, }, - snapshot::{RootConfigSnapshot, test_support as snapshot_test_support}, - source::SourceId, - tree::{AttachedConfigNode, ParentLink, build_global_tree, build_worker_tree}, - types::{BoolConfig, MimeTypes, PathConfig, StringList}, - value::ConfigValue, + error::ConfigLoadFailure, }; -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); -static LOCAL_FINALIZER_CALLS: AtomicUsize = AtomicUsize::new(0); -static ATTACHED_FINALIZER_CALLS: AtomicUsize = AtomicUsize::new(0); - -fn count_local_server_finalizer( - _node: &mut ConfigNode, - _options: &BuildOptions<'_>, -) -> Result<(), Box> { - LOCAL_FINALIZER_CALLS.fetch_add(1, Ordering::Relaxed); - Ok(()) +pub(crate) fn parse_root(text: &str) -> Result { + TypedConfigParser::new().parse_root(text, Path::new("/tmp/pishoo.conf"), None) } -fn assert_attached_server_context( - node: AttachedConfigNode<'_>, -) -> Result<(), Box> { - let parent = node - .parent() - .ok_or_else(|| std::io::Error::other("server must be attached before finalization"))?; - if parent.context() != context::PISHOO { - return Err(std::io::Error::other("server parent must be PISHOO").into()); - } - if parent - .children() - .filter(|child| child.context() == context::SERVER) - .count() - != 2 +pub(crate) fn parse_direct_server(extra: &str) -> Result { + let text = format!( + "pishoo {{ server {{ listen all 5378; server_name example.com; \ + ssl_certificate /tmp/server.crt; ssl_certificate_key /tmp/server.key; {extra} }} }}" + ); + let parsed = + parse_root(&text).map_err(|error| snafu::Report::from_error(&error).to_string())?; + match parsed + .servers() + .first() + .expect("fixture has one server") + .result() { - return Err(std::io::Error::other("finalizer must observe every attached sibling").into()); + Ok(server) => Ok(server.clone()), + Err(error) => Err(snafu::Report::from_error(error).to_string()), } - ATTACHED_FINALIZER_CALLS.fetch_add(1, Ordering::Relaxed); - Ok(()) } -fn inject_wrong_pid_type( - node: &mut ConfigNode, - _options: &BuildOptions<'_>, -) -> Result<(), Box> { - node.insert_slot( - "pid", - crate::parse::value::TypedValue::new(StringList(vec!["wrong".to_owned()]), node.span), - ); - Ok(()) +pub(crate) fn first_server(text: &str) -> Result { + let parsed = parse_root(text).map_err(|error| snafu::Report::from_error(&error).to_string())?; + parsed + .into_parts() + .1 + .into_vec() + .into_iter() + .next() + .expect("fixture has one server") + .into_result() + .map_err(|error| snafu::Report::from_error(&error).to_string()) } -#[derive(Debug)] -struct TempConfigDir { - path: PathBuf, +pub(crate) fn first_location(text: &str) -> Result { + first_server(text)? + .locations() + .first() + .cloned() + .ok_or_else(|| "fixture has no location".to_owned()) } -impl TempConfigDir { - fn new(prefix: &str) -> Self { - let path = std::env::temp_dir().join(format!( - "gateway_parse_{prefix}_{}_{}", - std::process::id(), - NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir_all(&path).expect("create temporary config fixture directory"); - Self { path } - } - - fn join(&self, path: impl AsRef) -> PathBuf { - self.path.join(path) - } +pub(crate) fn parse_location(body: &str) -> Result, String> { + parse_location_pattern("/", body) +} - fn worker_home(&self) -> DhttpHome { - DhttpHome::new(self.join(".dhttp")) - } +pub(crate) fn parse_location_pattern( + pattern: &str, + body: &str, +) -> Result, String> { + let server = parse_direct_server(&format!("location {pattern} {{ {body} }}"))?; + Ok(Arc::new( + server + .locations() + .first() + .expect("fixture has one location") + .clone(), + )) +} - fn worker_source_path(&self) -> PathBuf { - self.join(".dhttp/pishoo.conf") - } +pub(crate) fn build_server_conf(cert: &Path, key: &Path, body: &str) -> String { + format!( + "pishoo {{ server {{ listen all 5378; server_name example.com; \ + ssl_certificate {}; ssl_certificate_key {}; {body} }} }}", + cert.display(), + key.display(), + ) } -impl Drop for TempConfigDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } +pub(crate) fn build_proxy_conf(cert: &Path, key: &Path, body: &str) -> String { + build_server_conf(cert, key, &format!("location / {{ {body} }}")) } -pub(crate) fn create_temp_file(prefix: &str) -> PathBuf { +pub(crate) fn create_temp_file(name: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( - "gateway_{prefix}_{}_{}.pem", + "gateway-{name}-{}-{}", std::process::id(), - NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(), )); - std::fs::write(&path, "dummy").expect("write temp config fixture"); + std::fs::write(&path, b"fixture").expect("create temporary fixture"); path } @@ -124,2276 +105,208 @@ pub(crate) fn cleanup_temp_files(paths: &[&Path]) { } } -pub(crate) fn parse_doc(conf: &str) -> ConfigDocument { - crate::parse::parse_config_str_for_test(conf).expect("config should parse") -} - -pub(crate) fn first_pishoo(document: &ConfigDocument) -> Arc { - document.root.children("pishoo").expect("pishoo children")[0].clone() -} - -pub(crate) fn first_server(document: &ConfigDocument) -> Arc { - first_pishoo(document) - .children("server") - .expect("server children")[0] - .clone() -} - -pub(crate) fn build_server_conf( - server_cert: &Path, - server_key: &Path, - server_body: &str, -) -> String { - format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; {} }} }}", - server_cert.display(), - server_key.display(), - server_body - ) -} - -pub(crate) fn build_proxy_conf( - server_cert: &Path, - server_key: &Path, - location_body: &str, -) -> String { - format!( - r#" -pishoo {{ - server {{ - listen all 5378; - server_name example.com; - ssl_certificate {}; - ssl_certificate_key {}; - location /api {{ - {} - }} - }} -}} -"#, - server_cert.display(), - server_key.display(), - location_body - ) -} - -pub(crate) fn assert_error_chain_display_single_line(error: &(dyn std::error::Error + 'static)) { +pub(crate) fn assert_error_chain_display_single_line(error: &(dyn Error + 'static)) { let mut current = Some(error); while let Some(error) = current { - assert!( - !error.to_string().contains('\n'), - "error display should be single-line: {}", - error - ); + assert!(!error.to_string().contains('\n')); current = error.source(); } } #[test] -fn sealed_single_query_preserves_type_mismatch() { - let fixture = TempConfigDir::new("sealed_single_type_mismatch"); - let mut registry = crate::parse::default_registry(); - registry.register_context(ContextSpec { - key: context::PISHOO, - finalize: Some(inject_wrong_pid_type), - }); - let mut parser = ConfigDocumentParser::new(®istry); - let root = parse_root_fragment(&mut parser, "pishoo {}", &fixture.join("pishoo.conf"), None); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - - let error = tree - .pishoo() - .local(crate::parse::keys::pishoo::PID) - .expect_err("wrong stored value type must remain visible"); - - assert!(matches!( - error, - crate::parse::error::ConfigQueryError::TypeMismatch { .. } - )); -} - -#[test] -fn sealed_repeated_query_preserves_order_and_cardinality() { - let fixture = TempConfigDir::new("sealed_repeated_order"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; listen all 4102; } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let server = tree.servers().next().expect("server should attach"); - - let listen = server - .node() - .repeated(crate::parse::keys::server::LISTEN) - .expect("listen query should succeed"); - - assert_eq!(listen.len(), 2); - assert_eq!(listen[0].0[0].port, 4101); - assert_eq!(listen[1].0[0].port, 4102); -} - -#[test] -fn sealed_location_payload_query_returns_location_pattern() { - let fixture = TempConfigDir::new("sealed_location_payload"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; location = /ready {} } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let location = tree - .servers() - .next() - .expect("server should attach") - .locations() - .next() - .expect("location should attach"); - - let pattern = location - .payload(crate::parse::keys::location::PATTERN) - .expect("location payload query should succeed"); - - assert!(matches!( - pattern.as_ref(), - crate::parse::pattern::Pattern::Exact(path) if path == "/ready" - )); -} - -#[test] -fn sealed_query_key_rejects_wrong_registry_contract() { - let fixture = TempConfigDir::new("sealed_wrong_registry_contract"); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::SERVER, - DirectiveSpec::leaf_value::( - "listen", - vec![context::SERVER], - DuplicatePolicy::Append, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - ), - ); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let server = tree.servers().next().expect("server should attach"); - - let error = server - .node() - .repeated(crate::parse::keys::server::LISTEN) - .expect_err("a key must reject different frozen registry metadata"); - - assert!(matches!( - error, - crate::parse::error::ConfigQueryError::ContractMismatch { - mismatch: crate::parse::registry::DirectiveContractMismatch::Cascade { .. }, - .. - } - )); -} - -#[test] -fn sealed_query_keeps_tree_alive_after_registry_replacement() { - let fixture = TempConfigDir::new("sealed_query_registry_lifetime"); - let source_path = fixture.join("home/pishoo.conf"); - let tree = { - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let root = parse_root_fragment( - &mut parser, - "pishoo { pid run/pishoo.pid; }", - &source_path, - None, - ); - build_global_tree(®istry, root, Vec::new()).expect("tree should seal") - }; - - let pid = tree - .pishoo() - .local(crate::parse::keys::pishoo::PID) - .expect("query should not borrow the registry") - .expect("pid should exist"); - - assert_eq!( - pid.as_ref().as_ref(), - source_path.parent().unwrap().join("run/pishoo.pid") - ); -} - -#[test] -fn sealed_query_accepts_equivalent_frozen_contract_from_new_registry() { - let fixture = TempConfigDir::new("sealed_equivalent_registry_contract"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; types { text/plain txt; } } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - - let pishoo_definition = cascaded_definition::( - context::PISHOO, - "types", - DirectiveShape::RawBlock, - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::HypervisorOnly, - ReloadImpact::ListenerSet, - ); - let server_definition = cascaded_definition::( - context::SERVER, - "types", - DirectiveShape::RawBlock, - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerInheritable, - ReloadImpact::Supervisor, - ); - let mut replacement = ConfigRegistry::new(); - replacement.register_context(ContextSpec { - key: context::PISHOO, - finalize: None, - }); - replacement.register_context(ContextSpec { - key: context::SERVER, - finalize: None, - }); - pishoo_definition.register(&mut replacement); - server_definition.register(&mut replacement); - assert!( - replacement - .directive_spec(context::SERVER, "types") - .is_some() - ); - - let types = tree - .servers() - .next() - .expect("server should attach") - .node() - .cascaded(server_definition.key_inheriting(pishoo_definition.key())) - .expect("equivalent semantic contract should query") - .expect("types should exist"); - - assert!(types.effective().0.contains_key("txt")); -} - -#[test] -fn sealed_cascaded_query_rejects_empty_ancestor_contract_drift() { - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::ValueType { .. }), - ); - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Append, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Cardinality { .. }), - ); - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::raw_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Shape { .. }), - ); - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::LastWins, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Duplicate { .. }), - ); - assert_ancestor_gzip_contract_mismatch( - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), - ); - assert_ancestor_gzip_contract_accepted(DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - )); - assert_ancestor_gzip_contract_accepted(DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::ListenerSet, - )); -} - -#[test] -fn sealed_query_reports_contract_mismatch_diagnostics() { - let error = ancestor_gzip_contract_error(DirectiveSpec::raw_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - )); - let crate::parse::error::ConfigQueryError::ContractMismatch { - directive, - context: actual_context, - mismatch, - } = &error - else { - panic!("expected typed directive contract mismatch"); - }; - let rendered = error.to_string(); - - assert_eq!(directive.as_str(), "gzip"); - assert_eq!(*actual_context, context::SERVER); - assert!(matches!( - mismatch, - DirectiveContractMismatch::Shape { - expected: DirectiveShape::Leaf, - actual: DirectiveShape::RawBlock, - } - )); - assert!(rendered.contains("`gzip`")); - assert!(rendered.contains(context::SERVER.0)); - assert!(rendered.contains("shape")); - assert!(rendered.contains("Leaf")); - assert!(rendered.contains("RawBlock")); - assert!(!rendered.contains("inconsistent cascade policies")); - assert_query_contract_mismatch_domain(*mismatch); -} - -fn assert_query_contract_mismatch_domain(mismatch: DirectiveContractMismatch) { - match mismatch { - DirectiveContractMismatch::Name { .. } - | DirectiveContractMismatch::Context { .. } - | DirectiveContractMismatch::ValueType { .. } - | DirectiveContractMismatch::Cardinality { .. } - | DirectiveContractMismatch::Shape { .. } - | DirectiveContractMismatch::Payload { .. } - | DirectiveContractMismatch::Duplicate { .. } - | DirectiveContractMismatch::Cascade { .. } => {} - } -} - -fn cascaded_definition( - context: crate::parse::registry::ContextKey, - name: &'static str, - shape: DirectiveShape, - duplicate: DuplicatePolicy, - cascade: CascadePolicy, - transport: TransportPolicy, - reload: ReloadImpact, -) -> TypedDirectiveDefinition { - TypedDirectiveDefinition::cascaded( - context, - name, - shape, - DirectiveMetadata::new(duplicate, cascade, transport, reload), - DirectiveProjection::absent(), - ) -} - -fn assert_cascaded_contract_mismatch( - node: &crate::parse::tree::ConfigNodeRef, - key: DirectiveKey, - expected: fn(DirectiveContractMismatch) -> bool, -) where - T: ConfigValue, -{ - let Err(crate::parse::error::ConfigQueryError::ContractMismatch { mismatch, .. }) = - node.cascaded(key) - else { - panic!("cascaded query should reject the semantic contract mismatch"); - }; - assert!(expected(mismatch)); -} - -fn ancestor_gzip_contract_result( - spec: DirectiveSpec, -) -> Result<(), crate::parse::error::ConfigQueryError> { - let fixture = TempConfigDir::new("sealed_ancestor_contract_mismatch"); - let mut registry = crate::parse::default_registry(); - registry.register_directive(context::SERVER, spec); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip on; server { listen all 4101; location / { gzip off; } } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let location = tree - .servers() - .next() - .expect("server should attach") - .locations() - .next() - .expect("location should attach"); - - location - .node() - .cascaded(crate::parse::keys::location::GZIP) - .map(|_| ()) -} - -fn ancestor_gzip_contract_error(spec: DirectiveSpec) -> crate::parse::error::ConfigQueryError { - ancestor_gzip_contract_result(spec) - .expect_err("an unused ancestor definition must still match the query contract") -} - -fn assert_ancestor_gzip_contract_mismatch( - spec: DirectiveSpec, - expected: fn(DirectiveContractMismatch) -> bool, -) { - let crate::parse::error::ConfigQueryError::ContractMismatch { - directive, - context: actual_context, - mismatch, - } = ancestor_gzip_contract_error(spec) - else { - panic!("expected typed directive contract mismatch"); - }; - assert_eq!(directive.as_str(), "gzip"); - assert_eq!(actual_context, context::SERVER); - assert!(expected(mismatch)); -} - -fn assert_ancestor_gzip_contract_accepted(spec: DirectiveSpec) { - ancestor_gzip_contract_result(spec) - .expect("transport and reload metadata are outside typed query identity"); -} - -#[test] -fn home_arena_path_query_returns_resolved_config_path() { - let fixture = TempConfigDir::new("home_arena_path_query"); - let include_dir = fixture.join("includes"); - let include_path = include_dir.join("paths.conf"); - fs::create_dir_all(&include_dir).expect("create include directory"); - fs::write(&include_path, "pid run/pishoo.pid;").expect("write included path"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let root = parse_root_fragment( - &mut parser, - "pishoo { include includes/paths.conf; }", - &fixture.join("pishoo.conf"), - None, - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - - let pid: Arc = tree - .pishoo() - .local(crate::parse::keys::pishoo::PID) - .expect("pid query should succeed") - .expect("pid should exist"); - - assert_eq!(pid.as_ref().as_ref(), include_dir.join("run/pishoo.pid")); -} - -#[test] -fn home_arena_rejects_relative_path_without_source_base() { - let registry = crate::parse::default_registry(); - let failure = crate::parse::load_config_text( - "pishoo { pid run/pishoo.pid; }", - None, - ®istry, - BuildOptions::default(), - ) - .expect_err("unanchored home path must be rejected"); - let report = snafu::Report::from_error(&failure.error).to_string(); - - assert!(report.contains("relative path requires a configuration file base directory")); -} - -#[test] -fn independent_proxy_context_keeps_legacy_path_config() { - let document = - parse_doc("pishoo { proxy { listen 127.0.0.1:8080; ssl_certificate /tmp/proxy.pem; } }"); - let proxy = first_pishoo(&document) - .children("proxy") - .expect("proxy should exist")[0] - .clone(); - - let certificate: Arc = proxy - .require("ssl_certificate") - .expect("independent proxy path keeps its legacy type"); - - assert_eq!(certificate.0, PathBuf::from("/tmp/proxy.pem")); -} - -fn parse_role_document( - text: &str, - source_path: &Path, - role: ConfigDocumentRole<'_>, -) -> Result { - let registry = crate::parse::default_registry(); - ConfigDocumentParser::new(®istry).parse_text(text, source_path, role) -} - -fn parse_root_fragment( - parser: &mut ConfigDocumentParser<'_>, - text: &str, - source_path: &Path, - home: Option<&DhttpHome>, -) -> ParsedPishooFragment { - let ParsedConfigDocument::HypervisorRoot(fragment) = parser - .parse_text( - text, - source_path, - ConfigDocumentRole::HypervisorRoot { home }, +fn concrete_tree_computes_full_lineage_during_construction() { + let root = TypedConfigParser::new() + .parse_root("pishoo { gzip off; }", Path::new("/tmp/root.conf"), None) + .unwrap(); + let root_defaults = root.pishoo().worker_defaults(); + let home = dhttp::home::DhttpHome::for_user_home_dir(PathBuf::from("/tmp/home")); + let worker = TypedConfigParser::new() + .parse_worker( + "pishoo { gzip on; }", + Path::new("/tmp/home/.config/dhttp/pishoo.conf"), + &home, + &root_defaults, ) - .expect("root document should parse") - else { - panic!("expected root pishoo fragment"); - }; - fragment -} - -fn parse_worker_fragment( - parser: &mut ConfigDocumentParser<'_>, - text: &str, - source_path: &Path, - home: &DhttpHome, -) -> ParsedPishooFragment { - let ParsedConfigDocument::WorkerPishoo(fragment) = parser - .parse_text(text, source_path, ConfigDocumentRole::WorkerPishoo { home }) - .expect("worker document should parse") - else { - panic!("expected worker pishoo fragment"); - }; - fragment -} - -fn parse_identity_fragments( - parser: &mut ConfigDocumentParser<'_>, - text: &str, - source_path: &Path, - home: &DhttpHome, - identity: &str, -) -> Vec { - let name = dhttp::name::DhttpName::try_from(identity.to_owned()) - .expect("identity fixture name should be valid"); - let profile = home.identity_profile(name); - let ParsedConfigDocument::IdentityServers(servers) = parser - .parse_text( - text, - source_path, - ConfigDocumentRole::IdentityServer { - home, - profile: &profile, - }, + .unwrap(); + let worker_defaults = worker.pishoo().worker_defaults(); + let profile = home.identity_profile("example.com".parse().unwrap()); + let identity = TypedConfigParser::new() + .parse_identity( + "server { listen all 443; location / { gzip off; } }", + Path::new("/tmp/home/.config/dhttp/identity/example.com/server.conf"), + profile, + &worker_defaults, ) - .expect("identity document should parse") - else { - panic!("expected identity server fragments"); - }; - servers.into_vec() -} - -fn root_snapshot_fixture( - registry: &crate::parse::registry::ConfigRegistry, - parser: &mut ConfigDocumentParser<'_>, - fixture: &TempConfigDir, -) -> RootConfigSnapshot { - let root = parse_root_fragment( - parser, - "pishoo { gzip on; gzip_vary on; gzip_min_length 42; gzip_comp_level 4; types { text/root root; } }", - &fixture.join("root/pishoo.conf"), - None, - ); - let snapshot = build_global_tree(registry, root, Vec::new()) - .expect("root tree should seal") - .root_snapshot() - .expect("root snapshot should project"); - snapshot_test_support::checked_wire_round_trip(&snapshot) - .expect("root snapshot wire round trip should remain checked") -} - -fn expect_role_error(failure: &ConfigLoadFailure) -> &ConfigDocumentRoleError { - let LoadConfigError::DocumentRole { source } = &failure.error else { - panic!("expected document role error, got {:#?}", failure.error); - }; - source -} - -#[test] -fn worker_role_rejects_hypervisor_only_directives() { - let fixture = TempConfigDir::new("worker_hypervisor_only"); - let home = fixture.worker_home(); - let source_path = fixture.worker_source_path(); - let failure = parse_role_document( - "pishoo { pid /run/pishoo.pid; }", - &source_path, - ConfigDocumentRole::WorkerPishoo { home: &home }, - ) - .expect_err("worker config must reject supervisor directives"); + .unwrap(); + let server = identity.result().as_ref().unwrap(); + let gzip = server.locations()[0].http().gzip(); - let ConfigDocumentRoleError::DirectiveNotAllowed { - directive, - role, - span, - } = expect_role_error(&failure) - else { - panic!("expected role-rejected directive"); - }; - assert_eq!(directive.as_str(), "pid"); - assert_eq!(Some(span.document_id()), failure.document_id()); - assert_eq!(*role, ConfigDocumentRoleKind::WorkerPishoo); - assert!(!span.is_empty()); - let cause = std::error::Error::source(&failure.error).expect("role error should be the cause"); - let role_cause = cause - .downcast_ref::() - .expect("load error should expose the genuine role error cause"); - assert!(std::error::Error::source(role_cause).is_none()); - assert!( - failure - .diagnostic() - .to_string() - .contains(&source_path.display().to_string()) + assert!(!gzip.effective().0); + assert_eq!( + gzip.lineage() + .iter() + .map(|origin| origin.scope()) + .collect::>(), + [ + OriginScope::Builtin, + OriginScope::RootPishoo, + OriginScope::WorkerPishoo, + OriginScope::Location, + ] ); } #[test] -fn worker_role_rejects_direct_server_children() { - let fixture = TempConfigDir::new("worker_server_child"); - let home = fixture.worker_home(); - let source_path = fixture.worker_source_path(); - let failure = parse_role_document( - "pishoo { server { listen all 443; } }", - &source_path, - ConfigDocumentRole::WorkerPishoo { home: &home }, - ) - .expect_err("worker config must reject direct server declarations"); - - let ConfigDocumentRoleError::DirectiveNotAllowed { - directive, - role, - span, - } = expect_role_error(&failure) - else { - panic!("expected role-rejected directive"); - }; - assert_eq!(directive.as_str(), "server"); - assert_eq!(Some(span.document_id()), failure.document_id()); - assert_eq!(*role, ConfigDocumentRoleKind::WorkerPishoo); - assert!(!span.is_empty()); -} - -#[test] -fn hypervisor_root_requires_exactly_one_pishoo() { - let fixture = TempConfigDir::new("root_pishoo_cardinality"); - let source = fixture.join("pishoo.conf"); - for (text, expected) in [("", 0), ("pishoo {} pishoo {}", 2)] { - let failure = parse_role_document( - text, - &source, - ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .expect_err("hypervisor root must contain exactly one pishoo block"); - - let ConfigDocumentRoleError::ExpectedSinglePishoo { - role, found, span, .. - } = expect_role_error(&failure) - else { - panic!("expected pishoo cardinality error"); - }; - assert_eq!(*found, expected); - assert_eq!(Some(span.document_id()), failure.document_id()); - assert_eq!(*role, ConfigDocumentRoleKind::HypervisorRoot); - } -} - -#[test] -fn existing_worker_document_requires_exactly_one_pishoo() { - let fixture = TempConfigDir::new("worker_pishoo_cardinality"); - let home = fixture.worker_home(); - let source_path = fixture.worker_source_path(); - for (text, expected) in [("", 0), ("pishoo {} pishoo {}", 2)] { - let failure = parse_role_document( - text, - &source_path, - ConfigDocumentRole::WorkerPishoo { home: &home }, +fn lineage_points_to_the_explicit_directive() { + let parsed = TypedConfigParser::new() + .parse_root( + "pishoo {\n gzip on;\n}", + Path::new("/etc/pishoo/pishoo.conf"), + None, ) - .expect_err("an existing worker document must contain exactly one pishoo block"); - - let ConfigDocumentRoleError::ExpectedSinglePishoo { - role, found, span, .. - } = expect_role_error(&failure) - else { - panic!("expected pishoo cardinality error"); - }; - assert_eq!(*found, expected); - assert_eq!(Some(span.document_id()), failure.document_id()); - assert_eq!(*role, ConfigDocumentRoleKind::WorkerPishoo); - } -} - -#[test] -fn worker_unknown_directive_is_a_configuration_error() { - let fixture = TempConfigDir::new("worker_unknown"); - let home = fixture.worker_home(); - let source_path = fixture.worker_source_path(); - let failure = parse_role_document( - "pishoo { unknown_setting on; }", - &source_path, - ConfigDocumentRole::WorkerPishoo { home: &home }, - ) - .expect_err("worker config must reject unknown directives"); + .unwrap(); + let origin = parsed.pishoo().http().gzip().lineage().last().unwrap(); - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!(report.contains("unknown directive `unknown_setting`")); - assert!(failure.document_id().is_some()); - assert!( - failure - .diagnostic() - .to_string() - .contains(&source_path.display().to_string()) - ); + assert_eq!(origin.path(), Some(Path::new("/etc/pishoo/pishoo.conf"))); + assert_eq!(origin.line(), Some(2)); + assert_eq!(origin.column(), Some(5)); } #[test] -fn default_root_uses_global_home_source_context() { - let fixture = TempConfigDir::new("default_root_source"); - let home = DhttpHome::new(fixture.path.clone()); - let source_path = fixture.join("pishoo.conf"); - let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( - "pishoo { server { listen all 443; } }", - &source_path, - ConfigDocumentRole::HypervisorRoot { home: Some(&home) }, +fn direct_server_semantic_failure_is_isolated_as_candidate() { + let parsed = parse_root( + "pishoo { server { listen all 443; server_name bad.example; } \ + server { listen all 444; server_name good.example; ssl_certificate /tmp/c; ssl_certificate_key /tmp/k; } }", ) - .expect("default root should parse") else { - panic!("expected hypervisor root fragment"); - }; + .unwrap(); - let source = fragment - .source_map() - .get(SourceId(0)) - .expect("root source should exist"); - assert_eq!(source.path.as_deref(), Some(source_path.as_path())); - assert_eq!(source.base_dir.as_deref(), Some(fixture.path.as_path())); + assert!(parsed.servers()[0].result().is_err()); + assert!(parsed.servers()[1].result().is_ok()); } #[test] -fn explicit_root_uses_explicit_file_source_context() { - let fixture = TempConfigDir::new("explicit_root_source"); - let source_path = fixture.join("custom/pishoo.conf"); - let failure = parse_role_document( - "pishoo { server { listen all 443; } }", - &source_path, - ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .expect_err("an explicit root without home context must provide TLS paths"); - - assert!(matches!( - failure.error, - LoadConfigError::BuildDocument { .. } - )); +fn duplicate_scalar_directive_is_rejected() { + let failure = parse_root("pishoo { gzip on; gzip off; }").unwrap_err(); assert!( - failure - .diagnostic() + snafu::Report::from_error(&failure) .to_string() - .contains(&source_path.display().to_string()) + .contains("duplicate directive `gzip`") ); } #[test] -fn identity_document_returns_detached_server_fragments() { - let fixture = TempConfigDir::new("identity_fragments"); - let home = fixture.worker_home(); - let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) - .expect("identity name should be valid"); - let profile = home.identity_profile(name); - let ParsedConfigDocument::IdentityServers(servers) = parse_role_document( - "server { listen all 443; } server { listen all 444; }", - &fixture.join(".dhttp/identities/alice/server.conf"), - ConfigDocumentRole::IdentityServer { - home: &home, - profile: &profile, - }, - ) - .expect("identity server document should parse") else { - panic!("expected identity server fragments"); - }; +fn server_accepts_multiple_location_blocks() { + let server = parse_direct_server("location / {} location /files {}").unwrap(); - assert_eq!(servers.len(), 2); - assert!( - servers - .iter() - .all(|server| server.node().parent().is_none()) - ); + assert_eq!(server.locations().len(), 2); } #[test] -fn identity_document_requires_at_least_one_server() { - let fixture = TempConfigDir::new("identity_cardinality"); - let home = fixture.worker_home(); - let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) - .expect("identity name should be valid"); - let profile = home.identity_profile(name); - let failure = parse_role_document( - "", - &fixture.join(".dhttp/identities/alice/server.conf"), - ConfigDocumentRole::IdentityServer { - home: &home, - profile: &profile, - }, +fn root_snapshot_checked_serde_round_trip_preserves_every_field() { + let parsed = parse_root( + "pishoo { gzip on; gzip_vary on; gzip_min_length 91; gzip_comp_level 4; \ + gzip_types text/plain application/json; default_type application/octet-stream; \ + access_log /tmp/access.log; }", ) - .expect_err("identity server document must not be empty"); + .unwrap(); + let expected = parsed.pishoo().worker_defaults(); + let bytes = serde_json::to_vec(&expected).unwrap(); + let actual = serde_json::from_slice(&bytes).unwrap(); - let ConfigDocumentRoleError::MissingIdentityServer { role, span } = expect_role_error(&failure) - else { - panic!("expected missing identity server error"); - }; - assert_eq!(*role, ConfigDocumentRoleKind::IdentityServer); - assert_eq!(Some(span.document_id()), failure.document_id()); + assert_eq!(expected, actual); } #[test] -fn resolved_config_path_is_absolute_and_source_anchored() { - let fixture = TempConfigDir::new("resolved_config_path"); - let include_dir = fixture.join("includes"); - let include_name = format!( - "paths-{}-{}.conf", - std::process::id(), - NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) - ); - let include_path = include_dir.join(&include_name); - let destination = include_dir.join("run/pishoo.pid"); - std::fs::create_dir_all(&include_dir).expect("create include fixture directory"); - let _ = std::fs::remove_file(&destination); - std::fs::write(&include_path, "pid run/pishoo.pid;").expect("write include fixture"); - - let text = format!("pishoo {{ include includes/{include_name}; }}"); - let ParsedConfigDocument::HypervisorRoot(fragment) = parse_role_document( - &text, - &fixture.join("pishoo.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .expect("included path should parse") else { - panic!("expected hypervisor root fragment"); - }; - let path = fragment - .node() - .require::("pid") - .expect("pid should be typed") - .clone(); - - assert_eq!(path.as_ref().as_ref(), destination); - assert!(path.as_ref().as_ref().is_absolute()); - assert!( - !destination.exists(), - "parser must not create the destination" +fn access_log_defaults_follow_server_identity_ownership() { + let direct = parse_direct_server("").unwrap(); + assert_eq!( + direct.http().access_log().effective(), + &AccessLogDirective::Off ); - assert!(ResolvedConfigPath::try_from(PathBuf::from("relative/path")).is_err()); - #[cfg(unix)] - { - use std::os::unix::ffi::OsStringExt; - let nul_path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/nul\0path".to_vec())); - assert!(ResolvedConfigPath::try_from(nul_path).is_err()); - } -} - -#[test] -fn document_ids_keep_equal_source_spans_distinct() { - let root_fixture = TempConfigDir::new("root_document_id"); - let worker_fixture = TempConfigDir::new("worker_document_id"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let ParsedConfigDocument::HypervisorRoot(root) = parser - .parse_text( + let home = dhttp::home::DhttpHome::for_user_home_dir(PathBuf::from("/tmp/alice")); + let profile = home.identity_profile("alice.dhttp.net".parse().unwrap()); + let defaults = TypedConfigParser::new() + .parse_root( "pishoo {}", - &root_fixture.join("pishoo.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, + Path::new("/etc/pishoo/pishoo.conf"), + Some(&home), ) - .expect("root document should parse") - else { - panic!("expected hypervisor root fragment"); - }; - let home = worker_fixture.worker_home(); - let ParsedConfigDocument::WorkerPishoo(worker) = parser - .parse_text( - "pishoo {}", - &worker_fixture.worker_source_path(), - ConfigDocumentRole::WorkerPishoo { home: &home }, - ) - .expect("worker document should parse") - else { - panic!("expected worker pishoo fragment"); - }; - - assert_eq!(root.span().start(), worker.span().start()); - assert_eq!(root.span().end(), worker.span().end()); - assert_ne!(root.span(), worker.span()); - assert_ne!(root.document_id(), worker.document_id()); -} - -#[test] -fn role_parser_rejects_leaf_pishoo_registration_without_panicking() { - let fixture = TempConfigDir::new("invalid_pishoo_registration"); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::ROOT, - DirectiveSpec::leaf_value::( - "pishoo", - vec![context::ROOT], - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::Supervisor, - ), - ); - let text = format!("pishoo {};", fixture.join("value").display()); - let failure = ConfigDocumentParser::new(®istry) - .parse_text( - &text, - &fixture.join("pishoo.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, + .unwrap() + .pishoo() + .worker_defaults(); + let identity = TypedConfigParser::new() + .parse_identity( + "server { listen all 443; location / {} }", + &profile.server_conf_path(), + profile.clone(), + &defaults, ) - .expect_err("a role-required pishoo registration must be a pishoo context block"); - - let ConfigDocumentRoleError::InvalidDirectiveRegistration { - directive, - role, - expected_child_context, - span, - } = expect_role_error(&failure) - else { - panic!("expected invalid role directive registration"); - }; - assert_eq!(directive.as_str(), "pishoo"); - assert_eq!(*role, ConfigDocumentRoleKind::HypervisorRoot); - assert_eq!(*expected_child_context, context::PISHOO); - assert_eq!(Some(span.document_id()), failure.document_id()); -} + .unwrap(); + let server = identity.result().as_ref().unwrap(); -#[test] -fn payload_free_location_registration_returns_typed_error_without_panicking() { - let fixture = TempConfigDir::new("payload_free_location"); - let home = fixture.worker_home(); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::SERVER, - DirectiveSpec::context_empty( - "location", - vec![context::SERVER], - context::LOCATION, - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), + assert_eq!( + server.http().access_log().effective(), + &AccessLogDirective::ProfileDefault ); - let mut parser = ConfigDocumentParser::new(®istry); - let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - parser.parse_text( - "server { listen all 443; location { } }", - &fixture.join("identity/server.conf"), - ConfigDocumentRole::IdentityServer { - home: &home, - profile: &home.identity_profile( - dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) - .expect("identity name"), - ), - }, + assert_eq!( + server + .http() + .access_log() + .effective() + .materialize(server.identity()) + .unwrap(), + ResolvedAccessLogConfig::Enabled( + super::domain::ResolvedConfigPath::try_from(profile.access_log_path()).unwrap() ) - })); - - assert!( - outcome.is_ok(), - "invalid public registry contracts must not panic" - ); - assert!( - outcome.unwrap().is_err(), - "invalid location shape must fail" ); } #[test] -fn identity_role_rejects_leaf_server_registration_without_bypassing_cardinality() { - let fixture = TempConfigDir::new("invalid_server_registration"); - let home = fixture.worker_home(); - let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()) - .expect("identity name should be valid"); - let profile = home.identity_profile(name); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::ROOT, - DirectiveSpec::leaf_value::( - "server", - vec![context::ROOT], - DuplicatePolicy::Append, - CascadePolicy::None, - TransportPolicy::HypervisorOnly, - ReloadImpact::ListenerSet, - ), - ); - let text = format!("server {};", fixture.join("value").display()); - let failure = ConfigDocumentParser::new(®istry) - .parse_text( - &text, - &fixture.join(".dhttp/identities/alice/server.conf"), - ConfigDocumentRole::IdentityServer { - home: &home, - profile: &profile, - }, - ) - .expect_err("a role-required server registration must be a server context block"); +fn location_access_log_override_keeps_location_lineage() { + let server = first_server( + "pishoo { access_log /tmp/root.log; server { listen all 443; \ + server_name example.com; ssl_certificate /tmp/c; ssl_certificate_key /tmp/k; \ + location /private { access_log off; } } }", + ) + .unwrap(); + let access_log = server.locations()[0].http().access_log(); - let ConfigDocumentRoleError::InvalidDirectiveRegistration { - directive, - role, - expected_child_context, - span, - } = expect_role_error(&failure) - else { - panic!("expected invalid role directive registration"); - }; - assert_eq!(directive.as_str(), "server"); - assert_eq!(*role, ConfigDocumentRoleKind::IdentityServer); - assert_eq!(*expected_child_context, context::SERVER); - assert_eq!(Some(span.document_id()), failure.document_id()); + assert_eq!(access_log.effective(), &AccessLogDirective::Off); + assert_eq!( + access_log.lineage().last().unwrap().scope(), + OriginScope::Location + ); } #[test] -fn document_id_exhaustion_has_no_document_namespace_or_source_span() { - let fixture = TempConfigDir::new("document_id_exhaustion"); - let registry = crate::parse::default_registry(); - let maximum_index = u64::from(u32::MAX); - let mut parser = ConfigDocumentParser::with_next_document_index(®istry, maximum_index); - let ParsedConfigDocument::HypervisorRoot(maximum) = parser - .parse_text( - "pishoo {}", - &fixture.join("maximum.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, +fn root_access_log_path_survives_worker_snapshot_without_rebasing() { + let home = dhttp::home::DhttpHome::for_user_home_dir(PathBuf::from("/home/alice")); + let root = TypedConfigParser::new() + .parse_root( + "pishoo { access_log logs/access.log; }", + Path::new("/etc/pishoo/pishoo.conf"), + Some(&home), ) - .expect("the maximum document id should allocate") - else { - panic!("expected hypervisor root fragment"); - }; - assert_eq!( - maximum.document_id(), - crate::parse::domain::ConfigDocumentId::try_from_index(maximum_index) - .expect("maximum index should be supported") - ); - - let failure = parser - .parse_text( + .unwrap() + .pishoo() + .worker_defaults(); + let worker = TypedConfigParser::new() + .parse_worker( "pishoo {}", - &fixture.join("exhausted.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, + Path::new("/home/alice/.config/dhttp/pishoo.conf"), + &home, + &root, ) - .expect_err("the document id allocator should be exhausted"); - - assert!(failure.document_id().is_none()); - assert!(failure.source_map.get(SourceId(0)).is_none()); - assert!(matches!( - failure.error, - LoadConfigError::DocumentId { - source: crate::parse::domain::ConfigDocumentIdError::IndexOverflow { index, .. }, - } if index == maximum_index + 1 - )); -} - -#[test] -fn registry_v1_metadata_is_orthogonal() { - let registry = crate::parse::default_registry(); - for name in ["pid", "workers", "groups"] { - let spec = registry - .directive_spec(context::PISHOO, name) - .expect("supervisor directive should be registered"); - assert_eq!(spec.duplicate, DuplicatePolicy::Reject); - assert_eq!(spec.cascade, CascadePolicy::None); - assert_eq!(spec.transport, TransportPolicy::HypervisorOnly); - assert_eq!(spec.reload, ReloadImpact::Supervisor); - } - for name in [ - "access_rules", - "gzip", - "gzip_vary", - "gzip_min_length", - "gzip_comp_level", - "gzip_types", - "default_type", - ] { - let spec = registry - .directive_spec(context::PISHOO, name) - .expect("runtime default should be registered"); - assert_eq!(spec.duplicate, DuplicatePolicy::Reject); - assert_eq!(spec.cascade, CascadePolicy::NearestWins); - assert_eq!(spec.transport, TransportPolicy::WorkerInheritable); - assert_eq!(spec.reload, ReloadImpact::RuntimeState); - } - let types = registry - .directive_spec(context::PISHOO, "types") - .expect("types should be registered"); - assert_eq!(types.duplicate, DuplicatePolicy::Reject); - assert_eq!(types.cascade, CascadePolicy::ReplaceWhole); - assert_eq!(types.transport, TransportPolicy::WorkerInheritable); - assert_eq!(types.reload, ReloadImpact::RuntimeState); - - let server = registry - .directive_spec(context::PISHOO, "server") - .expect("root server declaration should be registered"); - assert_eq!(server.duplicate, DuplicatePolicy::Append); - assert_eq!(server.cascade, CascadePolicy::None); - assert_eq!(server.transport, TransportPolicy::HypervisorOnly); - assert_eq!(server.reload, ReloadImpact::ListenerSet); -} - -#[test] -fn document_id_index_conversion_rejects_overflow() { - assert!( - crate::parse::domain::ConfigDocumentId::try_from_index(u64::from(u32::MAX) + 1).is_err() - ); -} - -#[test] -fn loose_documents_do_not_expose_comparable_fake_namespaces() { - let first = parse_doc("pishoo {}"); - let second = parse_doc("pishoo {}"); + .unwrap(); - assert!(first.document_id().is_none()); - assert!(second.document_id().is_none()); -} - -#[test] -fn missing_worker_fragment_still_builds_root_and_pishoo() { - let fixture = TempConfigDir::new("missing_worker_fragment"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let servers = parse_identity_fragments( - &mut parser, - "server { listen all 443; }", - &fixture.join("worker/identity/server.conf"), - &home, - "alice.dhttp.net", - ); - - let tree = - build_worker_tree(®istry, snapshot, None, servers).expect("worker tree should seal"); - - assert_eq!( - tree.pishoo().parent_link(), - ParentLink::Node(tree.root().id()) - ); - let server = tree - .servers() - .next() - .expect("identity server should attach"); - assert_eq!( - server.node().parent_link(), - ParentLink::Node(tree.pishoo().id()) - ); -} - -#[test] -fn worker_scalar_override_wins_over_root_snapshot() { - let fixture = TempConfigDir::new("worker_override"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let worker = parse_worker_fragment( - &mut parser, - "pishoo { gzip off; }", - &fixture.worker_source_path(), - &home, - ); - let tree = - build_worker_tree(®istry, snapshot, Some(worker), Vec::new()).expect("tree should seal"); - - let gzip = tree - .pishoo() - .cascaded(GZIP) - .expect("gzip query should succeed") - .expect("gzip has a builtin fallback"); - assert!(!gzip.effective().0); -} - -#[test] -fn worker_uses_required_snapshot_value_with_builtin_origin() { - let registry = crate::parse::default_registry(); - let snapshot = snapshot_test_support::snapshot_with_builtin_gzip(true); - - let tree = build_worker_tree(®istry, snapshot, None, Vec::new()).expect("tree should seal"); - let gzip = tree - .pishoo() - .cascaded(GZIP) - .expect("gzip query should succeed") - .expect("complete snapshot supplies gzip"); - - assert!(gzip.effective().0); - assert_eq!( - gzip.lineage(), - [crate::parse::cascade::ConfigOrigin::Builtin { - directive: GZIP.name(), - }] - ); -} - -#[test] -fn snapshot_container_queries_share_arc_and_local_override_uses_local_arc() { - let fixture = TempConfigDir::new("snapshot_arc_sharing"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip_types text/plain application/json; }", - &fixture.join("root/pishoo.conf"), - None, - ); - let snapshot = build_global_tree(®istry, root, Vec::new()) - .expect("root tree") - .root_snapshot() - .expect("snapshot"); - let transported = snapshot_test_support::gzip_types_arc(&snapshot).expect("gzip_types"); - let snapshot_clone = snapshot.clone(); - assert!(Arc::ptr_eq( - &transported, - &snapshot_test_support::gzip_types_arc(&snapshot_clone).expect("cloned gzip_types") - )); - - let inherited_tree = - build_worker_tree(®istry, snapshot_clone, None, Vec::new()).expect("worker tree"); - let first = inherited_tree - .pishoo() - .cascaded(GZIP_TYPES) - .expect("query") - .expect("gzip_types"); - let second = inherited_tree - .pishoo() - .cascaded(GZIP_TYPES) - .expect("query") - .expect("gzip_types"); - assert!(Arc::ptr_eq(first.effective(), second.effective())); - assert!(Arc::ptr_eq(first.effective(), &transported)); - - let home = fixture.worker_home(); - let worker = parse_worker_fragment( - &mut parser, - "pishoo { gzip_types image/png; }", - &fixture.worker_source_path(), - &home, - ); - let local_tree = build_worker_tree(®istry, snapshot, Some(worker), Vec::new()) - .expect("local worker tree"); - let local = local_tree - .pishoo() - .cascaded(GZIP_TYPES) - .expect("query") - .expect("local gzip_types"); - assert!(!Arc::ptr_eq(local.effective(), &transported)); -} - -#[test] -fn types_replaces_the_whole_parent_map() { - let fixture = TempConfigDir::new("types_replace"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let servers = parse_identity_fragments( - &mut parser, - "server { listen all 443; types { text/server server; } }", - &fixture.join("worker/identity/server.conf"), - &home, - "alice.dhttp.net", - ); - let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); - - let types = tree - .servers() - .next() - .expect("server should attach") - .node() - .cascaded(crate::parse::keys::server::TYPES) - .expect("types query should succeed") - .expect("types should be configured"); - assert_eq!(types.effective().0.len(), 1); - assert!(types.effective().0.contains_key("server")); - assert!(!types.effective().0.contains_key("root")); -} - -#[test] -fn cascade_query_rejects_registry_policy_mismatch() { - let fixture = TempConfigDir::new("cascade_policy_mismatch"); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::SERVER, - DirectiveSpec::raw_value::( - "types", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let servers = parse_identity_fragments( - &mut parser, - "server { listen all 443; types { text/server server; } }", - &fixture.join("worker/identity/server.conf"), - &home, - "alice.dhttp.net", - ); - let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); - let server = tree.servers().next().expect("server should attach"); - - assert_cascaded_contract_mismatch( - server.node(), - crate::parse::keys::server::TYPES, - |mismatch| matches!(mismatch, DirectiveContractMismatch::Cascade { .. }), - ); -} - -#[test] -fn cascade_query_distinguishes_cross_context_policy_conflict() { - let fixture = TempConfigDir::new("cascade_cross_context_policy_conflict"); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::SERVER, - DirectiveSpec::leaf_value::( - "gzip", - vec![context::SERVER], - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let pishoo_definition = cascaded_definition::( - context::PISHOO, - "gzip", - DirectiveShape::Leaf, - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ); - let server_definition = cascaded_definition::( - context::SERVER, - "gzip", - DirectiveShape::Leaf, - DuplicatePolicy::Reject, - CascadePolicy::ReplaceWhole, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ); - let error = tree - .servers() - .next() - .expect("server should attach") - .node() - .cascaded(server_definition.key_inheriting(pishoo_definition.key())) - .expect_err("matching contracts with conflicting policies must stay a policy error"); - - assert!(matches!( - error, - crate::parse::error::ConfigQueryError::CascadePolicyMismatch { - inherited_context: context::PISHOO, - inherited: CascadePolicy::NearestWins, - local_context: context::SERVER, - local: CascadePolicy::ReplaceWhole, - .. - } - )); -} - -#[test] -fn snapshot_contract_rejects_worker_local_or_wrong_domain_registration() { - let fixture = TempConfigDir::new("snapshot_registry_contract"); - - let mut worker_local_registry = crate::parse::default_registry(); - worker_local_registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "gzip", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(&worker_local_registry); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip on; }", - &fixture.join("worker-local/pishoo.conf"), - None, - ); - assert!( - build_global_tree(&worker_local_registry, root, Vec::new()).is_err(), - "WorkerLocalOnly values must not enter the V1 snapshot contract" - ); - assert!( - build_worker_tree( - &worker_local_registry, - snapshot_test_support::snapshot_with_builtin_gzip(true), - None, - Vec::new(), - ) - .is_err(), - "worker overlays must validate the same registry contract before applying a snapshot" - ); - - let mut wrong_domain_registry = crate::parse::default_registry(); - wrong_domain_registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "gzip", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(&wrong_domain_registry); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip on; }", - &fixture.join("wrong-domain/pishoo.conf"), - None, - ); - assert!( - build_global_tree(&wrong_domain_registry, root, Vec::new()).is_err(), - "the V1 gzip field must remain bound to BoolConfig" - ); - - let mut premature_access_log_registry = crate::parse::default_registry(); - premature_access_log_registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "access_log", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(&premature_access_log_registry); - let root = parse_root_fragment( - &mut parser, - "pishoo {}", - &fixture.join("reserved-access-log/pishoo.conf"), - None, - ); - assert!( - build_global_tree(&premature_access_log_registry, root, Vec::new()).is_err(), - "the reserved V1 access_log slot must remain absent until its checked domain is registered" - ); -} - -#[test] -fn snapshot_contract_rejects_extra_worker_inheritable_directive() { - let fixture = TempConfigDir::new("snapshot_registry_extra"); - let mut registry = crate::parse::default_registry(); - registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "extra_inherited", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(®istry); - let root = parse_root_fragment( - &mut parser, - "pishoo {}", - &fixture.join("root/pishoo.conf"), - None, - ); - - assert!(matches!( - build_global_tree(®istry, root, Vec::new()), - Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { - source: - crate::parse::registry::V1SnapshotSchemaError::ExtraWorkerInheritableDirective { - directive, - }, - }) if directive.as_str() == "extra_inherited" - )); - assert!(matches!( - build_worker_tree( - ®istry, - snapshot_test_support::snapshot_with_builtin_gzip(true), - None, - Vec::new(), - ), - Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { - source: - crate::parse::registry::V1SnapshotSchemaError::ExtraWorkerInheritableDirective { - directive, - }, - }) if directive.as_str() == "extra_inherited" - )); -} - -#[test] -fn snapshot_contract_rejects_last_wins_and_wrong_reload_metadata() { - let fixture = TempConfigDir::new("snapshot_registry_metadata"); - - let mut last_wins_registry = crate::parse::default_registry(); - last_wins_registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "gzip", - vec![context::PISHOO], - DuplicatePolicy::LastWins, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(&last_wins_registry); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip off; gzip on; }", - &fixture.join("last-wins/pishoo.conf"), - None, - ); - assert!(matches!( - build_global_tree(&last_wins_registry, root, Vec::new()), - Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { - source: crate::parse::registry::V1SnapshotSchemaError::Duplicate { directive }, - }) if directive.as_str() == "gzip" - )); - - let mut wrong_reload_registry = crate::parse::default_registry(); - wrong_reload_registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "gzip", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::ListenerSet, - ), - ); - let mut parser = ConfigDocumentParser::new(&wrong_reload_registry); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip on; }", - &fixture.join("wrong-reload/pishoo.conf"), - None, - ); - assert!(matches!( - build_global_tree(&wrong_reload_registry, root, Vec::new()), - Err(crate::parse::tree::HomeConfigTreeError::SnapshotContract { - source: crate::parse::registry::V1SnapshotSchemaError::Reload { directive }, - }) if directive.as_str() == "gzip" - )); -} - -#[test] -fn detached_fragments_require_the_exact_parse_registry_contract() { - let fixture = TempConfigDir::new("fragment_registry_contract"); - let mut parse_registry = crate::parse::default_registry(); - parse_registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "gzip", - vec![context::PISHOO], - DuplicatePolicy::LastWins, - CascadePolicy::NearestWins, - TransportPolicy::WorkerInheritable, - ReloadImpact::RuntimeState, - ), - ); - let mut parser = ConfigDocumentParser::new(&parse_registry); - let root = parse_root_fragment( - &mut parser, - "pishoo { gzip off; gzip on; }", - &fixture.join("registry-a/pishoo.conf"), - None, - ); - let seal_registry = crate::parse::default_registry(); - - assert!(matches!( - build_global_tree(&seal_registry, root, Vec::new()), - Err(crate::parse::tree::HomeConfigTreeError::RegistryContractMismatch) - )); -} - -#[test] -fn detached_fragments_reject_parse_registry_mutation_before_seal() { - let fixture = TempConfigDir::new("fragment_registry_mutation"); - let mut registry = crate::parse::default_registry(); - let root = { - let mut parser = ConfigDocumentParser::new(®istry); - parse_root_fragment( - &mut parser, - "pishoo { gzip on; }", - &fixture.join("root/pishoo.conf"), - None, - ) - }; - registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::( - "worker_local_after_parse", - vec![context::PISHOO], - DuplicatePolicy::Reject, - CascadePolicy::None, - TransportPolicy::WorkerLocalOnly, - ReloadImpact::RuntimeState, - ), - ); - - assert!(matches!( - build_global_tree(®istry, root, Vec::new()), - Err(crate::parse::tree::HomeConfigTreeError::RegistryContractMismatch) - )); -} - -#[test] -fn sealed_nodes_share_precomputed_context_contract_tables() { - let fixture = TempConfigDir::new("shared_sealed_contract_tables"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 4101; location / {} } server { listen all 4102; location /two {} } }", - &fixture.join("home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let servers = tree.servers().collect::>(); - let first_location = servers[0].locations().next().expect("first location"); - let second_location = servers[1].locations().next().expect("second location"); - - assert!(tree.contract_tables_shared(servers[0].node().id(), servers[1].node().id())); - assert!(tree.contract_tables_shared(first_location.node().id(), second_location.node().id())); -} - -#[test] -fn cascade_lineage_is_builtin_root_worker_server_location() { - let fixture = TempConfigDir::new("cascade_lineage"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let worker = parse_worker_fragment( - &mut parser, - "pishoo { gzip off; }", - &fixture.worker_source_path(), - &home, - ); - let servers = parse_identity_fragments( - &mut parser, - "server { listen all 443; gzip on; location / { gzip off; } }", - &fixture.join("worker/identity/server.conf"), - &home, - "alice.dhttp.net", - ); - let tree = - build_worker_tree(®istry, snapshot, Some(worker), servers).expect("tree should seal"); - let location = tree - .servers() - .next() - .expect("server") - .locations() - .next() - .expect("location"); - let gzip = location - .node() - .cascaded(crate::parse::keys::location::GZIP) - .expect("gzip query") - .expect("gzip fallback"); - - assert!(!gzip.effective().0); - assert_eq!(gzip.lineage().len(), 5); - assert!(matches!( - gzip.lineage()[0], - crate::parse::cascade::ConfigOrigin::Builtin { .. } - )); - assert!(matches!( - gzip.lineage()[1], - crate::parse::cascade::ConfigOrigin::RootInherited { .. } - )); - assert!( - gzip.lineage()[2..] - .iter() - .all(|origin| matches!(origin, crate::parse::cascade::ConfigOrigin::Source(_))) - ); -} - -#[test] -fn identity_server_parent_is_home_pishoo() { - let fixture = TempConfigDir::new("identity_parent"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let servers = parse_identity_fragments( - &mut parser, - "server { listen all 443; }", - &fixture.join("identity/server.conf"), - &home, - "alice.dhttp.net", - ); - let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); - let server = tree.servers().next().expect("server"); - - assert_eq!( - server.node().parent_link(), - ParentLink::Node(tree.pishoo().id()) - ); -} - -#[test] -fn global_direct_server_parent_is_global_pishoo() { - let fixture = TempConfigDir::new("global_server_parent"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 443; } }", - &fixture.join("global-home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("global tree should seal"); - let server = tree.servers().next().expect("server"); - - assert_eq!( - server.node().parent_link(), - ParentLink::Node(tree.pishoo().id()) - ); -} - -#[test] -fn global_identity_server_parent_is_global_pishoo() { - let fixture = TempConfigDir::new("global_identity_parent"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let root = parse_root_fragment( - &mut parser, - "pishoo {}", - &fixture.join("global-home/pishoo.conf"), - Some(&home), - ); - let identities = parse_identity_fragments( - &mut parser, - "server { listen all 443; }", - &fixture.join("global-home/identities/alice/server.conf"), - &home, - "alice.dhttp.net", - ); - - let tree = build_global_tree(®istry, root, identities).expect("global tree should seal"); - let server = tree.servers().next().expect("global identity server"); - - assert_eq!( - server.node().parent_link(), - ParentLink::Node(tree.pishoo().id()) - ); -} - -#[test] -fn attached_registry_finalizer_observes_parent_and_complete_siblings() { - let fixture = TempConfigDir::new("attached_finalizer"); - let mut registry = crate::parse::default_registry(); - registry.register_context(ContextSpec { - key: context::SERVER, - finalize: Some(count_local_server_finalizer), - }); - registry.register_attached_finalizer(context::SERVER, assert_attached_server_context); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - LOCAL_FINALIZER_CALLS.store(0, Ordering::Relaxed); - ATTACHED_FINALIZER_CALLS.store(0, Ordering::Relaxed); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 443; } server { listen all 444; } }", - &fixture.join("global-home/pishoo.conf"), - Some(&home), - ); - - assert_eq!(LOCAL_FINALIZER_CALLS.load(Ordering::Relaxed), 2); - assert_eq!(ATTACHED_FINALIZER_CALLS.load(Ordering::Relaxed), 0); - let tree = build_global_tree(®istry, root, Vec::new()).expect("global tree should seal"); - - assert_eq!(tree.servers().count(), 2); - assert_eq!(LOCAL_FINALIZER_CALLS.load(Ordering::Relaxed), 2); - assert_eq!(ATTACHED_FINALIZER_CALLS.load(Ordering::Relaxed), 2); -} - -#[test] -fn location_parent_is_its_server() { - let fixture = TempConfigDir::new("location_parent"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 443; location / { root /tmp; } } }", - &fixture.join("global-home/pishoo.conf"), - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("global tree should seal"); - let server = tree.servers().next().expect("server"); - let location = server.locations().next().expect("location"); - - assert_eq!( - location.node().parent_link(), - ParentLink::Node(server.node().id()) - ); -} - -#[test] -fn tree_ref_keeps_source_and_parent_alive() { - let fixture = TempConfigDir::new("ref_lifetime"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let source_path = fixture.join("global-home/pishoo.conf"); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 443; } }", - &source_path, - Some(&home), - ); - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - let server = tree.servers().next().expect("server"); - let span = server.node().source_span().expect("source span"); - drop(tree); - - assert_eq!( - server.node().parent_link(), - ParentLink::Node(server.tree().pishoo().id()) - ); - assert_eq!(server.tree().source_path(span), Some(source_path.as_path())); -} - -#[test] -fn sealed_source_bundle_retains_only_one_source_map_owner() { - let fixture = TempConfigDir::new("source_owner_weight"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { server { listen all 443; location / {} } }", - &fixture.join("global-home/pishoo.conf"), - Some(&home), - ); - let source_owner = root.source_owner(); - - let tree = build_global_tree(®istry, root, Vec::new()).expect("tree should seal"); - - assert_eq!(tree.servers().count(), 1); - assert_eq!(Arc::strong_count(&source_owner), 2); -} - -#[test] -fn cross_document_diagnostics_keep_the_correct_source_bundle() { - let fixture = TempConfigDir::new("cross_document_sources"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let snapshot = root_snapshot_fixture(®istry, &mut parser, &fixture); - let home = fixture.worker_home(); - let alice_path = fixture.join("alice/server.conf"); - let bob_path = fixture.join("bob/server.conf"); - let mut alice_parser = ConfigDocumentParser::new(®istry); - let mut servers = parse_identity_fragments( - &mut alice_parser, - "server { listen all 443; }", - &alice_path, - &home, - "alice.dhttp.net", - ); - let mut bob_parser = ConfigDocumentParser::new(®istry); - servers.extend(parse_identity_fragments( - &mut bob_parser, - "server { listen all 444; }", - &bob_path, - &home, - "bob.dhttp.net", - )); - let tree = build_worker_tree(®istry, snapshot, None, servers).expect("tree should seal"); - let paths = tree - .servers() - .map(|server| { - let span = server.node().source_span().expect("source span"); - tree.source_path(span).expect("source path").to_path_buf() - }) - .collect::>(); - - assert_eq!(paths, vec![alice_path, bob_path]); -} - -#[test] -fn root_snapshot_contains_only_worker_inheritable_values() { - let fixture = TempConfigDir::new("snapshot_allowlist"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let root = parse_root_fragment( - &mut parser, - "pishoo { pid /run/pishoo.pid; workers alice; groups www; gzip on; server { listen all 443; } }", - &fixture.join("global-home/pishoo.conf"), - Some(&home), - ); - let snapshot = build_global_tree(®istry, root, Vec::new()) - .expect("root tree") - .root_snapshot() - .expect("snapshot"); - - assert_eq!( - snapshot_test_support::wire_field_names(&snapshot), - [ - "access_rules", - "gzip", - "gzip_vary", - "gzip_min_length", - "gzip_comp_level", - "gzip_types", - "default_type", - "types", - "access_log", - ] - ); - assert!(snapshot.gzip().0); -} - -#[test] -fn root_direct_servers_are_not_serialized() { - let fixture = TempConfigDir::new("snapshot_no_servers"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = DhttpHome::new(fixture.join("global-home")); - let without_server = parse_root_fragment( - &mut parser, - "pishoo { gzip on; }", - &fixture.join("without/pishoo.conf"), - Some(&home), - ); - let with_server = parse_root_fragment( - &mut parser, - "pishoo { gzip on; server { listen all 443; } }", - &fixture.join("with/pishoo.conf"), - Some(&home), - ); - let first = build_global_tree(®istry, without_server, Vec::new()) - .unwrap() - .root_snapshot() - .unwrap(); - let second = build_global_tree(®istry, with_server, Vec::new()) - .unwrap() - .root_snapshot() - .unwrap(); - - assert_ne!(first, second, "source origin remains part of equality"); - assert_eq!( - snapshot_test_support::values_without_origins(&first), - snapshot_test_support::values_without_origins(&second) - ); -} - -#[test] -fn resolved_root_path_round_trips_without_worker_rebase() { - let root = ResolvedConfigPath::try_from(PathBuf::from("/srv/root/logs/access.log")).unwrap(); - let decoded = snapshot_test_support::round_trip_resolved_path(root.clone()).unwrap(); - - assert_eq!(decoded, root); -} - -#[cfg(unix)] -#[test] -fn resolved_root_non_utf8_path_round_trips_on_unix() { - use std::os::unix::ffi::OsStringExt; - - let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec())); - let resolved = ResolvedConfigPath::try_from(path).unwrap(); - let decoded = snapshot_test_support::round_trip_resolved_path(resolved.clone()).unwrap(); - - assert_eq!(decoded, resolved); -} - -#[test] -fn snapshot_absolute_path_rejects_relative_or_nul_bytes() { - assert!(snapshot_test_support::decode_absolute_path(b"relative/path").is_err()); - assert!(snapshot_test_support::decode_absolute_path(b"/tmp/nul\0path").is_err()); -} - -#[test] -fn root_path_without_source_base_is_a_configuration_error() { - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let failure = parser - .parse_text( - "pishoo { access_rules sqlite:relative/rules.db; }", - Path::new("pishoo.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .expect_err("unanchored root path must be rejected"); - let error = snafu::Report::from_error(&failure.error).to_string(); - - assert!(error.contains("resolve relative access_rules sqlite path")); -} - -#[test] -fn unknown_snapshot_schema_is_rejected() { - assert!(snapshot_test_support::decode_schema(2).is_err()); -} - -#[test] -fn snapshot_real_codec_round_trips_and_rejects_unknown_or_malformed_wire() { - let snapshot = snapshot_test_support::snapshot_with_builtin_gzip(true); - - assert_eq!( - snapshot_test_support::codec_round_trip(&snapshot).expect("real codec round trip"), - snapshot - ); - assert!(snapshot_test_support::codec_rejects_unknown_schema( - &snapshot - )); - assert!(snapshot_test_support::codec_rejects_malformed_access_rules( - &snapshot - )); -} - -#[test] -fn snapshot_access_rules_revalidates_url_domain() { - assert!(matches!( - snapshot_test_support::decode_access_rules("not a URL"), - Err( - crate::parse::snapshot::RootConfigSnapshotError::AccessRulesUrl { - source: url::ParseError::RelativeUrlWithoutBase, - } - ) - )); - assert!(snapshot_test_support::decode_access_rules("https://example.com/rules.db").is_err()); - assert!(snapshot_test_support::decode_access_rules("sqlite://host/tmp/rules.db").is_err()); -} - -#[test] -fn access_rules_rejects_literal_and_percent_encoded_nul_in_text_and_snapshot() { - for uri in ["sqlite:/tmp/rules\0.db", "sqlite:/tmp/rules%00.db"] { - let failure = - crate::parse::parse_config_str_for_test(&format!("pishoo {{ access_rules {uri}; }}")) - .expect_err("text access_rules path containing NUL must fail"); - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("NUL byte") - ); - - let error = snapshot_test_support::decode_access_rules(uri) - .expect_err("snapshot access_rules path containing NUL must fail"); - assert!(matches!( - error, - crate::parse::snapshot::RootConfigSnapshotError::AccessRules { - source: crate::parse::types::AccessRulesUriValidationError::NulPath, - } - )); - } -} - -#[test] -fn access_rules_preserves_percent_encoded_path_and_query_in_text_and_snapshot() { - #[cfg(unix)] - let source = "sqlite:/tmp/rules%3Fpart%23name%FF.db?mode=ro%23strict"; - #[cfg(not(unix))] - let source = "sqlite:/tmp/rules%3Fpart%23name.db?mode=ro%23strict"; - let expected = url::Url::parse(source).expect("fixture URL"); - - let document = - crate::parse::parse_config_str_for_test(&format!("pishoo {{ access_rules {source}; }}")) - .expect("text access_rules URL should parse losslessly"); - let text = first_pishoo(&document) - .require::("access_rules") - .expect("text access_rules should be typed"); - let snapshot = snapshot_test_support::decode_access_rules(source) - .expect("snapshot access_rules URL should decode losslessly"); - - assert_eq!(text.0, expected); - assert_eq!(snapshot.0, expected); -} - -#[test] -fn snapshot_header_values_revalidate_from_bytes() { - assert!(snapshot_test_support::decode_header_value(b"text/plain").is_ok()); - assert!(snapshot_test_support::decode_header_value(b"bad\nvalue").is_err()); -} - -#[test] -fn snapshot_mime_entries_are_sorted_and_reject_duplicates() { - let types = MimeTypes(std::collections::HashMap::from([ - ("z".to_owned(), http::HeaderValue::from_static("text/z")), - ("a".to_owned(), http::HeaderValue::from_static("text/a")), - ])); - assert_eq!( - snapshot_test_support::mime_wire_extensions(&types), - ["a", "z"] - ); - assert!( - snapshot_test_support::decode_mime_entries(&[("a", b"text/a"), ("a", b"text/b")]).is_err() - ); - assert!(snapshot_test_support::decode_mime_entries(&[("", b"text/a")]).is_err()); -} - -#[test] -fn text_and_snapshot_mime_decode_use_the_same_validation_category() { - let text_failure = - crate::parse::parse_config_str_for_test("pishoo { types { text/a a; 'bad\nvalue' a; } }") - .expect_err("text MIME value containing a newline must fail"); - let mut text_error: &(dyn std::error::Error + 'static) = &text_failure.error; - let text_category = loop { - if let Some(category) = - text_error.downcast_ref::() - { - break category; - } - text_error = text_error - .source() - .expect("text MIME failure should retain the domain error"); - }; - assert!(matches!( - text_category, - crate::parse::types::MimeTypesValidationError::HeaderValue { .. } - )); - - assert!(matches!( - snapshot_test_support::decode_mime_entries(&[("a", b"text/a"), ("a", b"bad\nvalue")]), - Err(crate::parse::snapshot::RootConfigSnapshotError::MimeTypes { - source: crate::parse::types::MimeTypesValidationError::HeaderValue { .. }, - }) - )); -} - -#[test] -fn snapshot_preserves_absence_and_gzip_fallbacks() { - let fixture = TempConfigDir::new("snapshot_fallbacks"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let root = parse_root_fragment( - &mut parser, - "pishoo {}", - &fixture.join("root/pishoo.conf"), - None, - ); - let snapshot = build_global_tree(®istry, root, Vec::new()) - .unwrap() - .root_snapshot() - .unwrap(); - - assert!(snapshot.access_rules().is_none()); - assert!(!snapshot.gzip().0); - assert!(!snapshot.gzip_vary().0); - assert_eq!(snapshot.gzip_min_length().0, 20); - assert_eq!(snapshot.gzip_comp_level().0, 1); - assert!(snapshot.gzip_types().is_none()); - assert!(snapshot.default_type().is_none()); - assert!(snapshot.types().is_none()); - assert!(!snapshot_test_support::has_access_log(&snapshot)); -} - -#[test] -fn snapshot_equality_includes_origin() { - let fixture = TempConfigDir::new("snapshot_origin_equality"); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let first = parse_root_fragment( - &mut parser, - "pishoo { gzip on; }", - &fixture.join("one/pishoo.conf"), - None, - ); - let second = parse_root_fragment( - &mut parser, - "pishoo {\n gzip on;\n}", - &fixture.join("two/pishoo.conf"), - None, - ); - let first = build_global_tree(®istry, first, Vec::new()) - .unwrap() - .root_snapshot() - .unwrap(); - let second = build_global_tree(®istry, second, Vec::new()) - .unwrap() - .root_snapshot() - .unwrap(); - - assert_eq!( - snapshot_test_support::checked_wire_round_trip(&first).unwrap(), - first - ); - assert_ne!(first, second); assert_eq!( - snapshot_test_support::values_without_origins(&first), - snapshot_test_support::values_without_origins(&second) + worker + .pishoo() + .http() + .access_log() + .effective() + .resolved_path(), + Some(Path::new("/etc/pishoo/logs/access.log")) ); } diff --git a/gateway/src/parse/tree.rs b/gateway/src/parse/tree.rs deleted file mode 100644 index 5e08447f..00000000 --- a/gateway/src/parse/tree.rs +++ /dev/null @@ -1,836 +0,0 @@ -use std::{collections::HashMap, num::NonZeroU32, path::Path, sync::Arc}; - -use snafu::Snafu; - -use crate::parse::{ - cascade::{CascadedValue, ConfigOrigin, DirectiveKey, InheritedSourceLocation}, - document::ConfigNode, - domain::{ - ConfigDocumentId, ConfigDocumentIdAllocator, ConfigDocumentIdError, ConfigSourceSpan, - DirectiveName, - }, - error::{ConfigQueryError, config_query_error}, - fragment::{ParsedPishooFragment, ParsedServerFragment}, - registry::{ - CascadePolicy, ConfigRegistry, ContextPayloadKey, DirectiveContract, LocalDirectiveKey, - RepeatedDirectiveKey, V1SnapshotSchemaError, ValidatedV1SnapshotSchema, context, - }, - snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, - source::{ConfigDocumentSourceMap, SourceId, SourceMap, SourceSpan}, - value::ConfigValue, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ConfigNodeId(usize); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ParentLink { - Root, - Node(ConfigNodeId), -} - -#[derive(Debug)] -struct SealedConfigNode { - document_id: Option, - node: Arc, - parent: ParentLink, - children: Vec, -} - -#[derive(Clone, Copy)] -pub(crate) struct AttachedConfigNode<'tree> { - nodes: &'tree [SealedConfigNode], - node: ConfigNodeId, -} - -impl<'tree> AttachedConfigNode<'tree> { - fn new(nodes: &'tree [SealedConfigNode], node: ConfigNodeId) -> Self { - Self { nodes, node } - } - - pub(crate) fn context(self) -> crate::parse::registry::ContextKey { - self.nodes[self.node.0].node.context - } - - pub(crate) fn config(self) -> &'tree ConfigNode { - &self.nodes[self.node.0].node - } - - pub(crate) fn parent(self) -> Option { - match self.nodes[self.node.0].parent { - ParentLink::Root => None, - ParentLink::Node(parent) => Some(Self::new(self.nodes, parent)), - } - } - - pub(crate) fn children(self) -> impl Iterator + 'tree { - self.nodes[self.node.0] - .children - .iter() - .copied() - .map(|node| Self::new(self.nodes, node)) - } -} - -#[derive(Debug)] -struct ConfigSourceOwner { - document_id: ConfigDocumentId, - sources: Arc, -} - -impl ConfigSourceOwner { - fn source_map(&self) -> &SourceMap { - self.sources.source_map() - } -} - -#[derive(Debug)] -pub struct HomeConfigTree { - nodes: Box<[SealedConfigNode]>, - root: ConfigNodeId, - pishoo: ConfigNodeId, - servers: Box<[ConfigNodeId]>, - inherited_root: Option>, - contract_tables: HashMap>, - sources: ConfigSourceBundle, - snapshot_schema: ValidatedV1SnapshotSchema, -} - -#[derive(Debug)] -pub struct ConfigSourceBundle { - owners: Box<[ConfigSourceOwner]>, -} - -#[derive(Debug, Clone)] -pub struct ConfigNodeRef { - tree: Arc, - node: ConfigNodeId, -} - -#[derive(Debug, Clone)] -pub struct ServerConfigRef(ConfigNodeRef); - -#[derive(Debug, Clone)] -pub struct LocationConfigRef(ConfigNodeRef); - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum HomeConfigTreeError { - #[snafu(display("failed to allocate a sealed configuration document identity"))] - DocumentId { source: ConfigDocumentIdError }, - #[snafu(display("non-root configuration node is missing its semantic parent"))] - MissingParent { node: ConfigNodeId }, - #[snafu(display("sealed configuration node has an invalid semantic parent"))] - InvalidParent { node: ConfigNodeId }, - #[snafu(display("sealed configuration node has an invalid context"))] - InvalidContext { node: ConfigNodeId }, - #[snafu(display("sealed configuration node has no owning source document"))] - MissingSource { node: ConfigNodeId }, - #[snafu(display("failed to finalize an attached configuration context"))] - FinalizeAttached { - node: ConfigNodeId, - source: Box, - }, - #[snafu(display("registry is incompatible with the root snapshot schema"))] - SnapshotContract { source: V1SnapshotSchemaError }, - #[snafu(display("detached configuration fragment was parsed with another registry contract"))] - RegistryContractMismatch, -} - -pub fn build_global_tree( - registry: &ConfigRegistry, - root_fragment: ParsedPishooFragment, - identity_fragments: I, -) -> Result, HomeConfigTreeError> -where - I: IntoIterator, -{ - HomeConfigTreeBuilder::global(registry, root_fragment, identity_fragments.into_iter())?.seal() -} - -pub fn build_worker_tree( - registry: &ConfigRegistry, - root_snapshot: RootConfigSnapshot, - worker_fragment: Option, - identity_fragments: I, -) -> Result, HomeConfigTreeError> -where - I: IntoIterator, -{ - HomeConfigTreeBuilder::worker( - registry, - root_snapshot, - worker_fragment, - identity_fragments.into_iter(), - )? - .seal() -} - -struct HomeConfigTreeBuilder<'registry> { - registry: &'registry ConfigRegistry, - nodes: Vec, - root: ConfigNodeId, - pishoo: ConfigNodeId, - servers: Vec, - inherited_root: Option>, - contract_tables: HashMap>, - sources: Vec, - document_ids: ConfigDocumentIdAllocator, - snapshot_schema: ValidatedV1SnapshotSchema, -} - -impl<'registry> HomeConfigTreeBuilder<'registry> { - fn global( - registry: &'registry ConfigRegistry, - fragment: ParsedPishooFragment, - identity_fragments: impl Iterator, - ) -> Result { - if !registry.matches_contract(fragment.registry_contract()) { - return Err(HomeConfigTreeError::RegistryContractMismatch); - } - let snapshot_schema = registry - .validate_v1_snapshot_schema() - .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; - let synthetic_span = fragment.node().span; - let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); - let mut builder = Self { - registry, - nodes: Vec::new(), - root: ConfigNodeId(0), - pishoo: ConfigNodeId(0), - servers: Vec::new(), - inherited_root: None, - contract_tables: registry.frozen_contract_tables(), - sources: Vec::new(), - document_ids: ConfigDocumentIdAllocator::new(), - snapshot_schema, - }; - let document_id = builder.allocate_document_id()?; - builder.root = builder.push_node(None, root_node, ParentLink::Root); - builder.pishoo = builder.push_node( - Some(document_id), - Arc::clone(fragment.node()), - ParentLink::Node(builder.root), - ); - builder.nodes[builder.root.0].children.push(builder.pishoo); - for server in fragment.servers() { - builder.attach_server(server, document_id); - } - let sources = fragment.source_owner(); - builder.sources.push(ConfigSourceOwner { - document_id, - sources, - }); - for fragment in identity_fragments { - builder.attach_identity_fragment(fragment)?; - } - Ok(builder) - } - - fn worker( - registry: &'registry ConfigRegistry, - root_snapshot: RootConfigSnapshot, - worker_fragment: Option, - identity_fragments: impl Iterator, - ) -> Result { - if worker_fragment - .as_ref() - .is_some_and(|fragment| !registry.matches_contract(fragment.registry_contract())) - { - return Err(HomeConfigTreeError::RegistryContractMismatch); - } - let snapshot_schema = registry - .validate_v1_snapshot_schema() - .map_err(|source| HomeConfigTreeError::SnapshotContract { source })?; - let synthetic_span = worker_fragment - .as_ref() - .map_or_else(synthetic_span, |fragment| fragment.node().span); - let root_node = Arc::new(ConfigNode::new(context::ROOT, None, synthetic_span)); - let pishoo_node = worker_fragment.as_ref().map_or_else( - || Arc::new(ConfigNode::new(context::PISHOO, None, synthetic_span)), - |fragment| Arc::clone(fragment.node()), - ); - let mut builder = Self { - registry, - nodes: Vec::new(), - root: ConfigNodeId(0), - pishoo: ConfigNodeId(0), - servers: Vec::new(), - inherited_root: Some(Arc::new(root_snapshot)), - contract_tables: registry.frozen_contract_tables(), - sources: Vec::new(), - document_ids: ConfigDocumentIdAllocator::new(), - snapshot_schema, - }; - builder.root = builder.push_node(None, root_node, ParentLink::Root); - let worker_document_id = worker_fragment - .as_ref() - .map(|_| builder.allocate_document_id()) - .transpose()?; - builder.pishoo = builder.push_node( - worker_document_id, - pishoo_node, - ParentLink::Node(builder.root), - ); - builder.nodes[builder.root.0].children.push(builder.pishoo); - if let Some(fragment) = worker_fragment { - let Some(document_id) = worker_document_id else { - return Err(HomeConfigTreeError::MissingSource { - node: builder.pishoo, - }); - }; - let sources = fragment.source_owner(); - builder.sources.push(ConfigSourceOwner { - document_id, - sources, - }); - } - for fragment in identity_fragments { - builder.attach_identity_fragment(fragment)?; - } - Ok(builder) - } - - fn attach_identity_fragment( - &mut self, - fragment: ParsedServerFragment, - ) -> Result<(), HomeConfigTreeError> { - if !self.registry.matches_contract(fragment.registry_contract()) { - return Err(HomeConfigTreeError::RegistryContractMismatch); - } - if let Some(document_id) = self.document_id(fragment.source_map()) { - self.attach_server(&fragment, document_id); - } else { - let document_id = self.allocate_document_id()?; - self.attach_server(&fragment, document_id); - let sources = fragment.source_owner(); - self.sources.push(ConfigSourceOwner { - document_id, - sources, - }); - } - Ok(()) - } - - fn attach_server(&mut self, fragment: &ParsedServerFragment, document_id: ConfigDocumentId) { - let server = self.push_node( - Some(document_id), - Arc::clone(fragment.node()), - ParentLink::Node(self.pishoo), - ); - self.servers.push(server); - for location in fragment.locations() { - let location = self.push_node( - Some(document_id), - Arc::clone(location.node()), - ParentLink::Node(server), - ); - self.nodes[server.0].children.push(location); - } - self.nodes[self.pishoo.0].children.push(server); - } - - fn allocate_document_id(&mut self) -> Result { - self.document_ids - .allocate() - .map_err(|source| HomeConfigTreeError::DocumentId { source }) - } - - fn document_id(&self, source_map: &SourceMap) -> Option { - self.sources - .iter() - .find(|owner| std::ptr::eq(owner.source_map(), source_map)) - .map(|owner| owner.document_id) - } - - fn push_node( - &mut self, - document_id: Option, - node: Arc, - parent: ParentLink, - ) -> ConfigNodeId { - let id = ConfigNodeId(self.nodes.len()); - self.nodes.push(SealedConfigNode { - document_id, - node, - parent, - children: Vec::new(), - }); - id - } - - fn finalize_attached(&self) -> Result<(), HomeConfigTreeError> { - for (index, node) in self.nodes.iter().enumerate() { - let id = ConfigNodeId(index); - if id != self.root && !matches!(node.parent, ParentLink::Node(_)) { - return Err(HomeConfigTreeError::MissingParent { node: id }); - } - if node.document_id.is_some_and(|document_id| { - !self - .sources - .iter() - .any(|source| source.document_id == document_id) - }) { - return Err(HomeConfigTreeError::MissingSource { node: id }); - } - } - - self.verify_node(self.root, context::ROOT, ParentLink::Root)?; - self.verify_node(self.pishoo, context::PISHOO, ParentLink::Node(self.root))?; - if self.nodes[self.root.0].children.as_slice() != [self.pishoo] { - return Err(HomeConfigTreeError::InvalidParent { node: self.pishoo }); - } - if self.nodes[self.pishoo.0].children.as_slice() != self.servers.as_slice() { - return Err(HomeConfigTreeError::InvalidParent { node: self.pishoo }); - } - for &server in &self.servers { - self.verify_node(server, context::SERVER, ParentLink::Node(self.pishoo))?; - for &location in &self.nodes[server.0].children { - self.verify_node(location, context::LOCATION, ParentLink::Node(server))?; - } - } - for index in 0..self.nodes.len() { - let node = ConfigNodeId(index); - self.registry - .finalize_attached(AttachedConfigNode::new(&self.nodes, node)) - .map_err(|source| HomeConfigTreeError::FinalizeAttached { node, source })?; - } - Ok(()) - } - - fn verify_node( - &self, - node: ConfigNodeId, - context: crate::parse::registry::ContextKey, - parent: ParentLink, - ) -> Result<(), HomeConfigTreeError> { - let sealed = &self.nodes[node.0]; - if sealed.node.context != context { - return Err(HomeConfigTreeError::InvalidContext { node }); - } - if sealed.parent != parent { - return Err(HomeConfigTreeError::InvalidParent { node }); - } - Ok(()) - } - - fn seal(self) -> Result, HomeConfigTreeError> { - self.finalize_attached()?; - Ok(Arc::new(HomeConfigTree { - nodes: self.nodes.into_boxed_slice(), - root: self.root, - pishoo: self.pishoo, - servers: self.servers.into_boxed_slice(), - inherited_root: self.inherited_root, - contract_tables: self.contract_tables, - sources: ConfigSourceBundle { - owners: self.sources.into_boxed_slice(), - }, - snapshot_schema: self.snapshot_schema, - })) - } -} - -fn synthetic_span() -> SourceSpan { - SourceSpan::new(SourceId(0), 0, 0) -} - -impl HomeConfigTree { - pub fn root(self: &Arc) -> ConfigNodeRef { - ConfigNodeRef::new(Arc::clone(self), self.root) - } - - pub fn pishoo(self: &Arc) -> ConfigNodeRef { - ConfigNodeRef::new(Arc::clone(self), self.pishoo) - } - - pub fn servers(self: &Arc) -> impl Iterator + '_ { - self.servers - .iter() - .copied() - .map(|node| ServerConfigRef(ConfigNodeRef::new(Arc::clone(self), node))) - } - - pub fn root_snapshot(self: &Arc) -> Result { - RootConfigSnapshot::project(self) - } - - pub(crate) const fn v1_snapshot_schema(&self) -> ValidatedV1SnapshotSchema { - self.snapshot_schema - } - - pub fn source_path(&self, span: ConfigSourceSpan) -> Option<&Path> { - self.sources.source_path(span) - } - - pub fn sources(&self) -> &ConfigSourceBundle { - &self.sources - } - - pub(crate) fn inherited_source_location( - &self, - span: ConfigSourceSpan, - ) -> Result { - self.sources.inherited_source_location(span) - } - - fn node(&self, id: ConfigNodeId) -> &SealedConfigNode { - &self.nodes[id.0] - } - - fn cascaded( - &self, - node: ConfigNodeId, - key: DirectiveKey, - ) -> Result>>, ConfigQueryError> - where - T: ConfigValue, - { - let mut chain = Vec::new(); - let mut current = node; - loop { - chain.push(current); - match self.node(current).parent { - ParentLink::Root => break, - ParentLink::Node(parent) => current = parent, - } - } - chain.reverse(); - self.check_cascaded_contracts(&chain, key)?; - let policy = self.cascade_policy(&chain, key.name())?; - if !matches!( - policy, - CascadePolicy::NearestWins | CascadePolicy::ReplaceWhole - ) { - return Err(ConfigQueryError::UnsupportedCascadePolicy { - directive: key.name().as_str().to_owned(), - policy, - }); - } - - let mut lineage = Vec::new(); - let mut effective = key.builtin(); - if effective.is_some() { - lineage.push(ConfigOrigin::Builtin { - directive: key.name(), - }); - } - if let Some(snapshot) = &self.inherited_root - && let Some((value, origin)) = key.snapshot(snapshot) - { - effective = Some(value); - if !matches!(origin, ConfigOrigin::Builtin { .. }) { - lineage.push(origin); - } - } - - for id in chain { - let node = self.node(id); - if let Some((value, span)) = node.node.get_with_span::(key.name().as_str())? { - effective = Some(value); - let origin = node.document_id.map_or( - ConfigOrigin::Builtin { - directive: key.name(), - }, - |document_id| ConfigOrigin::Source(ConfigSourceSpan::new(document_id, span)), - ); - lineage.push(origin); - } - } - Ok(effective.map(|effective| CascadedValue::new(effective, lineage.into_boxed_slice()))) - } - - fn cascade_policy( - &self, - chain: &[ConfigNodeId], - directive: DirectiveName, - ) -> Result { - let mut inherited = None::<(crate::parse::registry::ContextKey, CascadePolicy)>; - for &node in chain { - let context = self.node(node).node.context; - let Some(local) = self - .directive_contract(context, directive) - .map(DirectiveContract::cascade) - else { - continue; - }; - if let Some((inherited_context, inherited_policy)) = inherited - && inherited_policy != local - { - return Err(ConfigQueryError::CascadePolicyMismatch { - directive: directive.as_str().to_owned(), - inherited_context, - inherited: inherited_policy, - local_context: context, - local, - }); - } - inherited = Some((context, local)); - } - inherited - .map(|(_, policy)| policy) - .ok_or_else(|| ConfigQueryError::MissingCascadePolicy { - directive: directive.as_str().to_owned(), - }) - } - - fn check_contract( - &self, - node: ConfigNodeId, - expected: DirectiveContract, - ) -> Result<(), ConfigQueryError> { - let context = self.node(node).node.context; - let actual = self.directive_contract(context, expected.name()); - Self::ensure_contract(expected, actual, context)?; - Ok(()) - } - - fn check_cascaded_contracts( - &self, - chain: &[ConfigNodeId], - key: DirectiveKey, - ) -> Result<(), ConfigQueryError> { - let expected = key.contracts().collect::>(); - let target = *chain.last().expect("a cascade chain always has a target"); - let target_context = self.node(target).node.context; - let target_expected = *expected - .last() - .expect("a directive key always has a target contract"); - Self::ensure_contract( - target_expected, - self.directive_contract(target_context, key.name()), - target_context, - )?; - - for &expected in expected[..expected.len() - 1].iter().rev() { - let actual = chain - .iter() - .rev() - .map(|&node| self.node(node).node.context) - .find(|&context| context == expected.context()) - .and_then(|context| self.directive_contract(context, key.name())); - Self::ensure_contract(expected, actual, expected.context())?; - } - - for actual in chain[..chain.len() - 1].iter().filter_map(|&node| { - let context = self.node(node).node.context; - self.directive_contract(context, key.name()) - }) { - if !expected - .iter() - .any(|expected| expected.context() == actual.context()) - { - return Err(ConfigQueryError::ContractMismatch { - directive: key.name(), - context: actual.context(), - mismatch: crate::parse::registry::DirectiveContractMismatch::Context { - expected: target_expected.context(), - actual: actual.context(), - }, - }); - } - } - Ok(()) - } - - fn ensure_contract( - expected: DirectiveContract, - actual: Option, - context: crate::parse::registry::ContextKey, - ) -> Result<(), ConfigQueryError> { - let mismatch = match actual { - Some(actual) => expected.mismatch(actual), - None => Some(crate::parse::registry::DirectiveContractMismatch::Name { - expected: expected.name(), - actual: None, - }), - }; - if let Some(mismatch) = mismatch { - return Err(ConfigQueryError::ContractMismatch { - directive: expected.name(), - context, - mismatch, - }); - } - Ok(()) - } - - fn directive_contract( - &self, - context: crate::parse::registry::ContextKey, - directive: DirectiveName, - ) -> Option { - self.contract_tables - .get(&context)? - .iter() - .find(|contract| contract.name() == directive) - .copied() - } - - #[cfg(test)] - pub(crate) fn contract_tables_shared(&self, first: ConfigNodeId, second: ConfigNodeId) -> bool { - let first_context = self.node(first).node.context; - let second_context = self.node(second).node.context; - match ( - self.contract_tables.get(&first_context), - self.contract_tables.get(&second_context), - ) { - (Some(first), Some(second)) => Arc::ptr_eq(first, second), - _ => false, - } - } -} - -impl ConfigSourceBundle { - pub fn source_path(&self, span: ConfigSourceSpan) -> Option<&Path> { - self.source_map(span.document_id()) - .and_then(|sources| sources.get(span.source_span().source_id)) - .and_then(|source| source.path.as_deref()) - } - - fn inherited_source_location( - &self, - span: ConfigSourceSpan, - ) -> Result { - let source_map = self - .source_map(span.document_id()) - .ok_or(RootConfigSnapshotError::MissingSourceLocation)?; - let source_span = span.source_span(); - let source = source_map - .get(source_span.source_id) - .ok_or(RootConfigSnapshotError::MissingSourceLocation)?; - if let Some(path) = &source.path - && (!path.is_absolute() || path.as_os_str().as_encoded_bytes().contains(&0)) - { - return Err(RootConfigSnapshotError::SourcePath { path: path.clone() }); - } - let location = source_map - .line_column(source_span) - .ok_or(RootConfigSnapshotError::MissingSourceLocation)?; - let line = u32::try_from(location.line) - .ok() - .and_then(NonZeroU32::new) - .ok_or(RootConfigSnapshotError::SourceLocation { - line: location.line, - column: location.column, - })?; - let column = u32::try_from(location.column) - .ok() - .and_then(NonZeroU32::new) - .ok_or(RootConfigSnapshotError::SourceLocation { - line: location.line, - column: location.column, - })?; - Ok(InheritedSourceLocation::new( - source.path.clone(), - line, - column, - )) - } - - fn source_map(&self, document_id: ConfigDocumentId) -> Option<&SourceMap> { - self.owners - .iter() - .find(|owner| owner.document_id == document_id) - .map(ConfigSourceOwner::source_map) - } -} - -impl ConfigNodeRef { - fn new(tree: Arc, node: ConfigNodeId) -> Self { - Self { tree, node } - } - - pub const fn id(&self) -> ConfigNodeId { - self.node - } - - pub fn parent_link(&self) -> ParentLink { - self.tree.node(self.node).parent - } - - pub fn source_span(&self) -> Option { - let node = self.tree.node(self.node); - node.document_id - .map(|document_id| ConfigSourceSpan::new(document_id, node.node.span)) - } - - pub fn tree(&self) -> &Arc { - &self.tree - } - - pub fn cascaded( - &self, - key: DirectiveKey, - ) -> Result>>, ConfigQueryError> - where - T: ConfigValue, - { - self.tree.cascaded(self.node, key) - } - - pub fn local(&self, key: LocalDirectiveKey) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - self.tree.check_contract(self.node, key.contract())?; - self.tree.node(self.node).node.get(key.name().as_str()) - } - - pub fn repeated(&self, key: RepeatedDirectiveKey) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - self.tree.check_contract(self.node, key.contract())?; - self.tree.node(self.node).node.get_all(key.name().as_str()) - } -} - -impl ServerConfigRef { - pub fn node(&self) -> &ConfigNodeRef { - &self.0 - } - - pub fn tree(&self) -> &Arc { - self.0.tree() - } - - pub fn locations(&self) -> impl Iterator + '_ { - self.0 - .tree - .node(self.0.node) - .children - .iter() - .copied() - .map(|node| LocationConfigRef(ConfigNodeRef::new(Arc::clone(&self.0.tree), node))) - } -} - -impl LocationConfigRef { - pub fn node(&self) -> &ConfigNodeRef { - &self.0 - } - - pub fn tree(&self) -> &Arc { - self.0.tree() - } - - pub fn payload(&self, key: ContextPayloadKey) -> Result, ConfigQueryError> - where - T: ConfigValue, - { - self.0.tree.check_contract(self.0.node, key.contract())?; - self.0 - .tree - .node(self.0.node) - .node - .payload()? - .ok_or_else(|| { - config_query_error::MissingRequiredSnafu { - directive: key.name().as_str().to_owned(), - span: self.0.tree.node(self.0.node).node.span, - } - .build() - }) - } -} diff --git a/gateway/src/parse/value.rs b/gateway/src/parse/value.rs deleted file mode 100644 index 1a42b9ca..00000000 --- a/gateway/src/parse/value.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::{ - any::{Any, TypeId}, - fmt, - sync::Arc, -}; - -use crate::parse::source::SourceSpan; - -pub trait ConfigValue: fmt::Debug + Send + Sync + 'static {} - -impl ConfigValue for T where T: fmt::Debug + Send + Sync + 'static {} - -#[derive(Clone)] -pub struct TypedValue { - value: Arc, - type_id: TypeId, - type_name: &'static str, - span: SourceSpan, -} - -impl TypedValue { - pub fn new(value: T, span: SourceSpan) -> Self - where - T: ConfigValue, - { - Self { - value: Arc::new(value), - type_id: TypeId::of::(), - type_name: std::any::type_name::(), - span, - } - } - - pub fn downcast(&self) -> Option> - where - T: ConfigValue, - { - if self.type_id != TypeId::of::() { - return None; - } - Arc::clone(&self.value).downcast::().ok() - } - - pub fn type_name(&self) -> &'static str { - self.type_name - } - - pub fn span(&self) -> SourceSpan { - self.span - } -} - -impl fmt::Debug for TypedValue { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TypedValue") - .field("type_name", &self.type_name) - .field("span", &self.span) - .finish_non_exhaustive() - } -} diff --git a/gateway/src/reverse/access_log.rs b/gateway/src/reverse/access_log.rs index da542ebc..cb74d0af 100644 --- a/gateway/src/reverse/access_log.rs +++ b/gateway/src/reverse/access_log.rs @@ -1,69 +1,177 @@ -use std::{io::Write, net::SocketAddr}; +mod body; + +use std::{net::SocketAddr, sync::Arc}; use axum::{ + body::Body, extract::{Request, State}, middleware::Next, response::Response, }; -use chrono::Local; -use http::HeaderValue; +use body::{AccessLogBody, AccessRecordSeed}; +use dhttp::log::access::{ + AccessLogRecord, AccessRequestTarget, BodyBytesEmitted, ClientAddress, OptionalReferer, + OptionalUserAgent, +}; + +use super::log::AccessLogOutput; -use super::log::AccessLogWriter; +#[derive(Clone, Debug)] +pub enum ActiveAccessLog { + Disabled, + Enabled(Arc), +} + +impl ActiveAccessLog { + pub fn from_output(output: Option>) -> Self { + match output { + Some(output) => Self::Enabled(output), + None => Self::Disabled, + } + } +} -/// Shared state for the access log middleware. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct AccessLogState { - pub writer: AccessLogWriter, + pub server: ActiveAccessLog, } -/// Axum middleware that logs every request/response in Combined Log Format. pub async fn access_log( State(state): State, request: Request, next: Next, ) -> Response { - // Capture cheap-to-clone / Copy values before the request is consumed. - let method = request.method().clone(); - let uri = request.uri().clone(); - let version = request.version(); - let user_agent: Option = request.headers().get("user-agent").cloned(); - let referer: Option = request.headers().get("referer").cloned(); - let client_addr: Option = request.extensions().get::().copied(); - + let seed = RequestSeed::capture(&request); let response = next.run(request).await; - - let status = response.status().as_u16(); - let body_size = response - .headers() - .get(http::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - - // Format the complete CLF line directly into the writer (one write_all). - let time_local = Local::now().format("%d/%b/%Y:%H:%M:%S %z"); - let ua = user_agent - .as_ref() - .and_then(|v| v.to_str().ok()) - .unwrap_or("-"); - let rf = referer - .as_ref() - .and_then(|v| v.to_str().ok()) - .unwrap_or("-"); - - // Best-effort: silently drop if the channel is full. - let mut writer = state.writer.clone(); - let _ = match client_addr { - Some(addr) => writeln!( - writer, - "{} - - [{time_local}] \"{method} {uri} {version:?}\" {status} {body_size} \"{rf}\" \"{ua}\"", - addr.ip() - ), - None => writeln!( - writer, - "- - - [{time_local}] \"{method} {uri} {version:?}\" {status} {body_size} \"{rf}\" \"{ua}\"" - ), + let active = response + .extensions() + .get::() + .cloned() + .unwrap_or(state.server); + let ActiveAccessLog::Enabled(output) = active else { + return response; }; - response + let (parts, body) = response.into_parts(); + let record = seed.complete(parts.status); + if record.has_no_body() { + output.write(&record.finish(BodyBytesEmitted::ZERO)); + return Response::from_parts(parts, body); + } + + Response::from_parts(parts, Body::new(AccessLogBody::new(body, output, record))) +} + +struct RequestSeed { + client: ClientAddress, + method: http::Method, + target: AccessRequestTarget, + version: http::Version, + referer: OptionalReferer, + user_agent: OptionalUserAgent, +} + +impl RequestSeed { + fn capture(request: &Request) -> Self { + let client = request + .extensions() + .get::() + .map_or(ClientAddress::Unknown, |address| { + ClientAddress::Ip(address.ip()) + }); + Self { + client, + method: request.method().clone(), + target: AccessRequestTarget::from(request.uri()), + version: request.version(), + referer: OptionalReferer::from(request.headers()), + user_agent: OptionalUserAgent::from(request.headers()), + } + } + + fn complete(self, status: http::StatusCode) -> AccessRecordSeed { + AccessRecordSeed { + client: self.client, + method: self.method, + target: self.target, + version: self.version, + referer: self.referer, + user_agent: self.user_agent, + status, + } + } +} + +impl AccessRecordSeed { + fn has_no_body(&self) -> bool { + self.method == http::Method::HEAD + || self.status.is_informational() + || self.status == http::StatusCode::NO_CONTENT + || self.status == http::StatusCode::NOT_MODIFIED + } + + fn finish(self, body_bytes: BodyBytesEmitted) -> AccessLogRecord { + AccessLogRecord { + completed_at: chrono::Local::now().fixed_offset(), + client: self.client, + method: self.method, + target: self.target, + version: self.version, + status: self.status, + body_bytes, + referer: self.referer, + user_agent: self.user_agent, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(method: http::Method, status: http::StatusCode) -> AccessRecordSeed { + RequestSeed::capture( + &Request::builder() + .method(method) + .uri("/private?token=secret") + .body(Body::empty()) + .unwrap(), + ) + .complete(status) + } + + #[test] + fn request_capture_discards_query_and_unallowlisted_headers() { + let mut request = Request::builder() + .uri("/private?token=secret") + .header(http::header::AUTHORIZATION, "Bearer secret") + .header(http::header::COOKIE, "session=secret") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(SocketAddr::from(([127, 0, 0, 1], 443))); + + let record = RequestSeed::capture(&request) + .complete(http::StatusCode::OK) + .finish(BodyBytesEmitted::ZERO); + + assert_eq!(record.target.path(), Some("/private")); + assert_eq!(record.client, ClientAddress::Ip([127, 0, 0, 1].into())); + assert_eq!(record.referer.value(), None); + assert_eq!(record.user_agent.value(), None); + } + + #[test] + fn head_and_bodyless_statuses_finalize_without_observing_a_body() { + assert!(record(http::Method::HEAD, http::StatusCode::OK).has_no_body()); + for status in [ + http::StatusCode::CONTINUE, + http::StatusCode::NO_CONTENT, + http::StatusCode::NOT_MODIFIED, + ] { + assert!(record(http::Method::GET, status).has_no_body()); + } + assert!(!record(http::Method::GET, http::StatusCode::OK).has_no_body()); + } } diff --git a/gateway/src/reverse/access_log/body.rs b/gateway/src/reverse/access_log/body.rs new file mode 100644 index 00000000..9d21603f --- /dev/null +++ b/gateway/src/reverse/access_log/body.rs @@ -0,0 +1,215 @@ +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use bytes::Buf; +use dhttp::log::access::{ + AccessRequestTarget, BodyBytesEmitted, ClientAddress, OptionalReferer, OptionalUserAgent, +}; +use http_body::{Body, Frame}; + +use crate::reverse::log::AccessLogOutput; + +pub(super) struct AccessRecordSeed { + pub client: ClientAddress, + pub method: http::Method, + pub target: AccessRequestTarget, + pub version: http::Version, + pub referer: OptionalReferer, + pub user_agent: OptionalUserAgent, + pub status: http::StatusCode, +} + +pub(super) struct AccessLogBody { + body: B, + output: Arc, + seed: Option, + bytes: BodyBytesEmitted, +} + +impl AccessLogBody { + pub fn new(body: B, output: Arc, seed: AccessRecordSeed) -> Self { + Self { + body, + output, + seed: Some(seed), + bytes: BodyBytesEmitted::ZERO, + } + } + + fn finalize(&mut self) { + let Some(seed) = self.seed.take() else { return }; + self.output.write(&seed.finish(self.bytes)); + } +} + +impl Body for AccessLogBody +where + B: Body + Unpin, +{ + type Data = B::Data; + type Error = B::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let frame = match Pin::new(&mut self.body).poll_frame(context) { + Poll::Pending => return Poll::Pending, + Poll::Ready(frame) => frame, + }; + + match &frame { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() { + match self.bytes.checked_add(data.remaining()) { + Some(bytes) => self.bytes = bytes, + None => { + tracing::warn!("access log response body byte count overflowed"); + self.seed = None; + } + } + } + } + Some(Err(_)) | None => self.finalize(), + } + Poll::Ready(frame) + } + + fn is_end_stream(&self) -> bool { + self.body.is_end_stream() + } + + fn size_hint(&self) -> http_body::SizeHint { + self.body.size_hint() + } +} + +impl Drop for AccessLogBody { + fn drop(&mut self) { + self.finalize(); + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, time::Duration}; + + use bytes::Bytes; + use futures::{StreamExt, stream}; + use http_body_util::{BodyExt, StreamBody}; + + use super::*; + use crate::parse::domain::ResolvedConfigPath; + + struct TempLog(PathBuf); + + impl TempLog { + fn new() -> Self { + Self(std::env::temp_dir().join(format!( + "gateway-access-body-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ))) + } + + fn output(&self) -> Arc { + Arc::new( + AccessLogOutput::open(ResolvedConfigPath::try_from(self.0.clone()).unwrap()) + .unwrap(), + ) + } + + fn wait_for_line(&self) -> String { + for _ in 0..100 { + if let Ok(contents) = std::fs::read_to_string(&self.0) + && contents.ends_with('\n') + { + return contents; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("access record was not delivered to {}", self.0.display()); + } + } + + impl Drop for TempLog { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn seed() -> AccessRecordSeed { + AccessRecordSeed { + client: ClientAddress::Unknown, + method: http::Method::GET, + target: "/body".parse().unwrap(), + version: http::Version::HTTP_3, + referer: OptionalReferer::default(), + user_agent: OptionalUserAgent::default(), + status: http::StatusCode::OK, + } + } + + #[tokio::test] + async fn body_error_and_drop_record_actual_data_once() { + let log = TempLog::new(); + let output = log.output(); + let frames = stream::iter([ + Ok(Frame::data(Bytes::from_static(b"abc"))), + Err(std::io::Error::other("boom")), + ]); + let mut body = std::pin::pin!(AccessLogBody::new(StreamBody::new(frames), output, seed(),)); + + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + Bytes::from_static(b"abc") + ); + assert!(body.frame().await.unwrap().is_err()); + let contents = log.wait_for_line(); + assert_eq!(contents.lines().count(), 1); + assert!(contents.contains(" 200 3 "), "{contents}"); + } + + #[tokio::test] + async fn normal_end_counts_only_data_frames_once() { + let log = TempLog::new(); + let output = log.output(); + let frames = stream::iter([ + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"ab"))), + Ok(Frame::trailers(http::HeaderMap::new())), + Ok(Frame::data(Bytes::from_static(b"c"))), + ]); + let body = AccessLogBody::new(StreamBody::new(frames), output, seed()); + + let collected = body.collect().await.unwrap(); + assert_eq!(collected.to_bytes(), Bytes::from_static(b"abc")); + + let contents = log.wait_for_line(); + assert_eq!(contents.lines().count(), 1); + assert!(contents.contains(" 200 3 "), "{contents}"); + } + + #[tokio::test] + async fn dropping_a_pending_body_records_only_emitted_data() { + let log = TempLog::new(); + let output = log.output(); + let frames = stream::iter([Ok::<_, std::io::Error>(Frame::data(Bytes::from_static( + b"ab", + )))]) + .chain(stream::pending()); + let mut body = Box::pin(AccessLogBody::new(StreamBody::new(frames), output, seed())); + + assert!(body.frame().await.unwrap().is_ok()); + drop(body); + + let contents = log.wait_for_line(); + assert_eq!(contents.lines().count(), 1); + assert!(contents.contains(" 200 2 "), "{contents}"); + } +} diff --git a/gateway/src/reverse/file.rs b/gateway/src/reverse/file.rs index f08f049c..a403a0fb 100644 --- a/gateway/src/reverse/file.rs +++ b/gateway/src/reverse/file.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn}; use crate::{ command::{self, IndexError, content_type, index}, - parse::{document::ConfigNode, domain::ResolvedConfigPath}, + parse::config::LocationConfig, reverse::location::LocationMatch, }; @@ -35,11 +35,10 @@ pub async fn file_handle( ) -> impl IntoResponse { let location = &loc.location; - let file_path = if let Some(alias) = location.get::("alias").ok().flatten() - { - static_file_path(alias.as_ref().as_ref(), &loc.remaining) - } else if let Some(root) = location.get::("root").ok().flatten() { - static_file_path(root.as_ref().as_ref(), req.uri().path()) + let file_path = if let Some(alias) = location.alias() { + static_file_path(alias.as_ref(), &loc.remaining) + } else if let Some(root) = location.root() { + static_file_path(root.as_ref(), req.uri().path()) } else { unreachable!("file_handle requires root or alias") }; @@ -76,7 +75,7 @@ fn static_file_path(base: &Path, requested: &str) -> Result, + location: Arc, file_path: &Path, uri: &http::Uri, accept_encoding: Option<&str>, diff --git a/gateway/src/reverse/gzip.rs b/gateway/src/reverse/gzip.rs index 093a87e0..d1ebce7c 100644 --- a/gateway/src/reverse/gzip.rs +++ b/gateway/src/reverse/gzip.rs @@ -4,10 +4,7 @@ use async_compression::{Level, tokio::bufread::GzipEncoder}; use http::response::Parts; use tokio_util::io::ReaderStream; -use crate::parse::{ - document::ConfigNode, - types::{BoolConfig, GzipCompLevel, GzipMinLength, StringList}, -}; +use crate::parse::config::LocationConfig; /// Gzip 压缩配置,从 location 节点中提取 pub struct GzipConfig { @@ -21,40 +18,17 @@ pub struct GzipConfig { impl GzipConfig { /// 从 location 配置和请求头中提取 gzip 配置 - pub fn from_location(location: &Arc, accept_encoding: Option<&str>) -> Self { + pub fn from_location(location: &Arc, accept_encoding: Option<&str>) -> Self { let accept_gzip = accept_encoding.map(|v| v.contains("gzip")).unwrap_or(false); Self { - enabled: location - .inherited::("gzip") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false), + enabled: location.http().gzip().effective().0, accept_gzip, - vary: location - .inherited::("gzip_vary") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false), - min_length: location - .inherited::("gzip_min_length") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(20), - comp_level: location - .inherited::("gzip_comp_level") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(1), - types: location - .inherited::("gzip_types") - .ok() - .flatten() - .map(|value| value.0.clone()), + vary: location.http().gzip_vary().effective().0, + min_length: location.http().gzip_min_length().effective().0, + comp_level: location.http().gzip_comp_level().effective().0, + types: Some(location.http().gzip_types().effective().0.clone()) + .filter(|types| !types.is_empty()), } } @@ -113,7 +87,7 @@ impl GzipConfig { /// /// Returns a new response with the body wrapped in gzip, or the original response unchanged. pub fn compress_response( - location: &Arc, + location: &Arc, accept_encoding: Option<&str>, response: http::Response, ) -> http::Response { @@ -150,7 +124,7 @@ pub fn compress_response( /// /// Returns the response with appropriate headers and optionally compressed body. pub fn compress_file_response( - location: &Arc, + location: &Arc, accept_encoding: Option<&str>, mut parts: http::response::Parts, file: tokio::fs::File, diff --git a/gateway/src/reverse/location.rs b/gateway/src/reverse/location.rs index 4685b479..7239f954 100644 --- a/gateway/src/reverse/location.rs +++ b/gateway/src/reverse/location.rs @@ -1,6 +1,37 @@ use std::sync::Arc; -use crate::parse::{document::ConfigNode, pattern::Pattern}; +use crate::{ + parse::{config::LocationConfig, pattern::Pattern}, + reverse::access_log::ActiveAccessLog, +}; + +#[derive(Clone, Debug)] +pub struct ConfiguredLocation { + config: Arc, + access_log: ActiveAccessLog, +} + +impl ConfiguredLocation { + pub fn new(config: Arc, access_log: ActiveAccessLog) -> Self { + Self { config, access_log } + } + + pub fn access_log(&self) -> &ActiveAccessLog { + &self.access_log + } + + pub fn config(&self) -> &Arc { + &self.config + } +} + +impl std::ops::Deref for ConfiguredLocation { + type Target = LocationConfig; + + fn deref(&self) -> &Self::Target { + &self.config + } +} /// Result of matching a request path against configured location blocks. /// @@ -10,7 +41,8 @@ use crate::parse::{document::ConfigNode, pattern::Pattern}; #[derive(Clone, Debug)] pub struct LocationMatch { /// The matched location configuration node. - pub location: Arc, + pub location: Arc, + pub access_log: ActiveAccessLog, /// The portion of the path that was matched by the pattern. /// /// For prefix patterns, this is the pattern string itself (e.g. "/ssh/"). @@ -28,18 +60,13 @@ pub struct LocationMatch { impl LocationMatch { /// Extract the `Pattern` from the location node. pub fn pattern(&self) -> Pattern { - self.location - .payload::() - .expect("location payload type should be a pattern") - .expect("location node should contain a pattern payload") - .as_ref() - .clone() + self.location.matcher().clone() } } /// Match a request path against all configured location blocks, following /// nginx's location selection order. -pub fn match_location(locations: &[Arc], path: &str) -> Option { +pub fn match_location(locations: &[Arc], path: &str) -> Option { tracing::debug!("all locations {:#?}, path: {:?}", locations, path); let mut exact: Option = None; @@ -48,13 +75,7 @@ pub fn match_location(locations: &[Arc], path: &str) -> Option() - .ok() - .flatten() - .expect("location node should contain a pattern payload"); - - match pattern.as_ref() { + match location.matcher() { Pattern::Exact(expected) if path == expected => { exact = Some(build_location_match(location, expected.clone(), path)); break; @@ -95,35 +116,30 @@ pub fn match_location(locations: &[Arc], path: &str) -> Option() - .ok() - .flatten() - .expect("location node should contain a pattern payload"); - if let Ok(Some(matched)) = pattern.try_match(path) { + if let Ok(Some(matched)) = location.matcher().try_match(path) { return Some(build_location_match(&location, matched.to_owned(), path)); } } longest_prefix } -fn build_location_match(location: &Arc, matched: String, path: &str) -> LocationMatch { +fn build_location_match( + location: &Arc, + matched: String, + path: &str, +) -> LocationMatch { let remaining = compute_remaining(location, path, &matched); LocationMatch { - location: Arc::clone(location), + location: Arc::clone(location.config()), + access_log: location.access_log().clone(), matched, remaining, } } /// Compute the remaining path suffix after the matched portion. -fn compute_remaining(location: &ConfigNode, path: &str, matched: &str) -> String { - let pattern = location - .payload::() - .expect("location payload type should be a pattern") - .expect("location node should contain a pattern payload"); - - match pattern.as_ref() { +fn compute_remaining(location: &ConfiguredLocation, path: &str, matched: &str) -> String { + match location.matcher() { // For prefix-style patterns, the matched string IS the prefix — strip it. Pattern::Exact(_) | Pattern::Prefix(_) | Pattern::NormalPrefix(_) | Pattern::Common => { path.strip_prefix(matched).unwrap_or("").to_string() @@ -136,20 +152,22 @@ fn compute_remaining(location: &ConfigNode, path: &str, matched: &str) -> String #[cfg(test)] mod tests { - use regex::Regex; - use super::*; - use crate::parse::{ - registry::context, - source::{SourceId, SourceSpan}, - value::TypedValue, - }; - - fn make_location(pattern: Pattern) -> Arc { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = ConfigNode::new(context::LOCATION, None, span); - node.set_payload(TypedValue::new(pattern, span)); - Arc::new(node) + use crate::parse::tests::parse_location_pattern; + + fn make_location(pattern: Pattern) -> Arc { + let syntax = match &pattern { + Pattern::Exact(value) => format!("= {value}"), + Pattern::Prefix(value) => format!("^~ {value}"), + Pattern::NormalPrefix(value) => value.clone(), + Pattern::Regex(value) => format!("~ '{}';", value.as_str()), + Pattern::CRegex(value) => format!("~* '{}';", value.as_str()), + Pattern::Common => "/".to_owned(), + }; + Arc::new(ConfiguredLocation::new( + parse_location_pattern(syntax.trim_end_matches(';'), "").unwrap(), + ActiveAccessLog::Disabled, + )) } #[test] @@ -182,7 +200,7 @@ mod tests { #[test] fn test_regex_match_remaining_is_full_path() { let locations = vec![make_location(Pattern::Regex( - Regex::new(r"\.(jpg|gif)$").unwrap(), + regex::Regex::new(r"\.(jpg|gif)$").unwrap(), ))]; let m = match_location(&locations, "/images/cat.jpg").unwrap(); diff --git a/gateway/src/reverse/log.rs b/gateway/src/reverse/log.rs index fec35e61..f8bde040 100644 --- a/gateway/src/reverse/log.rs +++ b/gateway/src/reverse/log.rs @@ -1,69 +1,195 @@ -use std::{io::Write, path::PathBuf, sync::Arc}; +use std::{ + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; -use dhttp::home::identity::IdentityProfile; +use dhttp::log::access::{AccessLogRecord, DefaultAccessFormatter}; +use snafu::{Report, ResultExt, Snafu}; use tracing_appender::{ non_blocking::{NonBlocking, WorkerGuard}, rolling::{RollingFileAppender, Rotation}, }; -/// A non-blocking, clone-able access log writer backed by -/// `tracing_appender::non_blocking`. -/// -/// Created once at service startup per identity; shared across all requests -/// for that identity via axum middleware state. The internal [`WorkerGuard`] -/// is held via `Arc` so that clones keep the background I/O thread alive -/// until the last reference is dropped. +use crate::parse::domain::ResolvedConfigPath; + +#[derive(Debug, Snafu)] +#[snafu(module(open_access_log_output_error))] +pub enum OpenAccessLogOutputError { + #[snafu(display("access log path `{}` has no file name", path.display()))] + MissingFileName { path: PathBuf }, + #[snafu(display("access log file name at `{}` is not UTF-8", path.display()))] + NonUtf8FileName { path: PathBuf }, + #[snafu(display("failed to create access log parent directory `{}`", path.display()))] + CreateParent { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to open access log output `{}`", path.display()))] + Open { + path: PathBuf, + source: tracing_appender::rolling::InitError, + }, +} + +/// One process-owned destination for fully formatted access records. +#[derive(Debug)] +pub struct AccessLogOutput { + path: ResolvedConfigPath, + writer: AccessLogWriter, +} + +impl AccessLogOutput { + pub fn open(path: ResolvedConfigPath) -> Result { + let writer = AccessLogWriter::new(path.as_ref())?; + Ok(Self { path, writer }) + } + + pub fn path(&self) -> &ResolvedConfigPath { + &self.path + } + + /// Formatting and delivery are deliberately best-effort after the output + /// has been acquired. A request response must not depend on log delivery. + pub fn write(&self, record: &AccessLogRecord) { + let formatted = match DefaultAccessFormatter::format(record) { + Ok(formatted) => formatted, + Err(error) => { + tracing::warn!( + path = %self.path.as_ref().display(), + error = %Report::from_error(&error), + "failed to format access log record" + ); + return; + } + }; + + let mut writer = self.writer.clone(); + if let Err(error) = writer.write_all(formatted.as_bytes()) { + tracing::warn!( + path = %self.path.as_ref().display(), + error = %Report::from_error(&error), + "failed to write access log record" + ); + } + } +} + +/// Cloneable non-blocking writer for one exact file path. #[derive(Clone, Debug)] pub struct AccessLogWriter { inner: NonBlocking, - /// Prevent the background thread from shutting down while any clone exists. _guard: Arc, } impl AccessLogWriter { - /// Create a new writer that appends to `{log_dir}/access.log`. - /// - /// The directory is created if it does not exist. - pub fn new(log_dir: PathBuf) -> std::io::Result { - std::fs::create_dir_all(&log_dir)?; - - let appender = - RollingFileAppender::new(Rotation::NEVER, log_dir, IdentityProfile::ACCESS_LOG_FILE); - let (non_blocking, guard) = tracing_appender::non_blocking(appender); + pub fn new(path: &Path) -> Result { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = + path.file_name() + .ok_or_else(|| OpenAccessLogOutputError::MissingFileName { + path: path.to_path_buf(), + })?; + let file_name = + file_name + .to_str() + .ok_or_else(|| OpenAccessLogOutputError::NonUtf8FileName { + path: path.to_path_buf(), + })?; + std::fs::create_dir_all(parent) + .context(open_access_log_output_error::CreateParentSnafu { path: parent })?; + let appender = RollingFileAppender::builder() + .rotation(Rotation::NEVER) + .filename_prefix(file_name) + .build(parent) + .context(open_access_log_output_error::OpenSnafu { path })?; + let (inner, guard) = tracing_appender::non_blocking(appender); Ok(Self { - inner: non_blocking, + inner, _guard: Arc::new(guard), }) } - - /// Create a writer that discards all output. - /// - /// Used as a fallback when the log directory cannot be created. - pub fn disabled() -> Self { - let (non_blocking, guard) = tracing_appender::non_blocking(std::io::sink()); - Self { - inner: non_blocking, - _guard: Arc::new(guard), - } - } } impl Write for AccessLogWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.inner.write(buf) + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.inner.write(bytes) } fn flush(&mut self) -> std::io::Result<()> { self.inner.flush() } - /// Write the entire buffer in a single call. - /// - /// A single `write_all` is required because [`NonBlocking`] sends each - /// call as an independent message; splitting across two calls may - /// interleave with other writers. - fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { - self.inner.write_all(buf) + fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> { + self.inner.write_all(bytes) + } +} + +#[cfg(test)] +mod tests { + use std::{net::Ipv4Addr, time::Duration}; + + use chrono::Local; + use dhttp::log::access::{ + AccessRequestTarget, BodyBytesEmitted, ClientAddress, OptionalReferer, OptionalUserAgent, + }; + + use super::*; + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "gateway-access-output-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn sample_record() -> AccessLogRecord { + AccessLogRecord { + completed_at: Local::now().fixed_offset(), + client: ClientAddress::Ip(Ipv4Addr::LOCALHOST.into()), + method: http::Method::GET, + target: "/health".parse::().unwrap(), + version: http::Version::HTTP_3, + status: http::StatusCode::OK, + body_bytes: BodyBytesEmitted::from(2), + referer: OptionalReferer::default(), + user_agent: OptionalUserAgent::default(), + } + } + + #[test] + fn writer_opens_the_exact_resolved_file() { + let temp = TempDir::new(); + let path = temp.0.join("nested/custom.log"); + let resolved = ResolvedConfigPath::try_from(path.clone()).unwrap(); + let output = AccessLogOutput::open(resolved).unwrap(); + output.write(&sample_record()); + drop(output); + + for _ in 0..100 { + if path.exists() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(path.exists()); + assert!(!temp.0.join("nested/access.log").exists()); } } diff --git a/gateway/src/reverse/proxy.rs b/gateway/src/reverse/proxy.rs index cae172e3..39dc73f8 100644 --- a/gateway/src/reverse/proxy.rs +++ b/gateway/src/reverse/proxy.rs @@ -61,11 +61,11 @@ async fn proxy_inner( // add custom response headers command::add_header(location, &mut parts); - if let Ok(Some(proxy_pass)) = location.get::("proxy_pass") - && let Some(public_base) = public_base_for_proxy_redirect(loc, &proxy_pass) + if let Some(proxy_pass) = location.proxy_pass() + && let Some(public_base) = public_base_for_proxy_redirect(loc, proxy_pass) && let Some(public_origin) = public_origin.as_deref() { - rewrite_proxy_redirect_default(&proxy_pass, public_origin, public_base, &mut parts.headers); + rewrite_proxy_redirect_default(proxy_pass, public_origin, public_base, &mut parts.headers); } let resp = http::Response::from_parts(parts, body); @@ -86,15 +86,15 @@ fn pass( async move { let (parts, body) = req.into_parts(); let proxy_pass = location - .require::("proxy_pass") - .whatever_context::<_, Whatever>("failed to read proxy_pass directive")?; + .proxy_pass() + .expect("proxy handler requires proxy_pass"); tracing::debug!(proxy_pass = %proxy_pass.raw, "resolved proxy_pass target"); let normalized = super::request_uri::normalize_request_uri(&parts.uri) .whatever_context::<_, Whatever>("failed to normalize request uri")?; let target = - super::request_uri::build_upstream_request_target(&proxy_pass, &loc, &normalized) + super::request_uri::build_upstream_request_target(proxy_pass, &loc, &normalized) .whatever_context::<_, Whatever>("failed to build proxy upstream request target")?; tracing::info!(target, "proxying request to upstream path and query"); diff --git a/gateway/src/reverse/request_uri.rs b/gateway/src/reverse/request_uri.rs index 8a097309..32ce748e 100644 --- a/gateway/src/reverse/request_uri.rs +++ b/gateway/src/reverse/request_uri.rs @@ -177,22 +177,22 @@ fn prepend_public_origin(public_origin: Option<&str>, target: String) -> String mod tests { use super::*; use crate::{ - parse::{ - pattern::Pattern, - registry::context, - source::{SourceId, SourceSpan}, - types::ProxyPass, - value::TypedValue, - }, + parse::{pattern::Pattern, tests::parse_location_pattern, types::ProxyPass}, reverse::location::LocationMatch, }; fn make_location_match(pattern: Pattern, matched: &str, remaining: &str) -> LocationMatch { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = crate::parse::document::ConfigNode::new(context::LOCATION, None, span); - node.set_payload(TypedValue::new(pattern, span)); + let syntax = match pattern { + Pattern::Exact(value) => format!("= {value}"), + Pattern::Prefix(value) => format!("^~ {value}"), + Pattern::NormalPrefix(value) => value, + Pattern::Regex(value) => format!("~ '{}'", value.as_str()), + Pattern::CRegex(value) => format!("~* '{}'", value.as_str()), + Pattern::Common => "/".to_owned(), + }; LocationMatch { - location: std::sync::Arc::new(node), + location: parse_location_pattern(&syntax, "").unwrap(), + access_log: crate::reverse::access_log::ActiveAccessLog::Disabled, matched: matched.to_owned(), remaining: remaining.to_owned(), } diff --git a/gateway/src/reverse/router.rs b/gateway/src/reverse/router.rs index 0dcf57c3..f84a131a 100644 --- a/gateway/src/reverse/router.rs +++ b/gateway/src/reverse/router.rs @@ -10,13 +10,10 @@ use http::{StatusCode, header}; #[cfg(feature = "sshd")] use tokio_util::sync::CancellationToken; -use super::location::match_location; -#[cfg(feature = "sshd")] -use crate::parse::types::SshLoginMethods; -use crate::parse::{ - document::ConfigNode, domain::ResolvedConfigPath, pattern::Pattern, types::ProxyPass, +use super::{ + access_log::ActiveAccessLog, + location::{ConfiguredLocation, match_location}, }; - #[cfg(feature = "sshd")] pub trait DynTaskScope: Send + Sync { fn token(&self) -> CancellationToken; @@ -45,13 +42,22 @@ pub struct RouterState { /// handler (proxy, file, or sshd). #[derive(Clone)] pub struct NginxRouter { - locations: Vec>, + locations: Vec>, + server_access_log: ActiveAccessLog, state: RouterState, } impl NginxRouter { - pub fn new(locations: Vec>, state: RouterState) -> Self { - Self { locations, state } + pub fn new( + locations: Vec>, + server_access_log: ActiveAccessLog, + state: RouterState, + ) -> Self { + Self { + locations, + server_access_log, + state, + } } } @@ -66,6 +72,7 @@ impl tower_service::Service> for NginxRouter { fn call(&mut self, mut request: http::Request) -> Self::Future { let locations = self.locations.clone(); + let server_access_log = self.server_access_log.clone(); let state = self.state.clone(); Box::pin(async move { @@ -73,112 +80,153 @@ impl tower_service::Service> for NginxRouter { .expect("request uri should always normalize"); let public_origin = super::request_uri::request_public_origin(&request); - if !has_exact_match(&locations, &normalized.path) - && let Some(target) = find_proxy_prefix_slash_redirect( - &locations, - &normalized, - public_origin.as_deref(), - ) - { - return Ok( - (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, target)]).into_response(), - ); - } - let loc_match = match match_location(&locations, &normalized.path) { Some(m) => m, - None => return Ok(StatusCode::NOT_FOUND.into_response()), + None => { + let mut response = StatusCode::NOT_FOUND.into_response(); + response.extensions_mut().insert(server_access_log); + return Ok(response); + } }; + let active_access_log = loc_match.access_log.clone(); + + if let Some(target) = + proxy_prefix_slash_redirect(&loc_match, &normalized, public_origin.as_deref()) + { + let mut response = + (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, target)]).into_response(); + response.extensions_mut().insert(active_access_log); + return Ok(response); + } // Inject LocationMatch into request extensions for extractors request.extensions_mut().insert(loc_match.clone()); let location = &loc_match.location; - let response = if location - .get::("proxy_pass") - .ok() - .flatten() - .is_some() - { + let response = if location.proxy_pass().is_some() { Handler::call(super::proxy::proxy_handle, request, state).await - } else if location - .get::("root") - .ok() - .flatten() - .is_some() - || location - .get::("alias") - .ok() - .flatten() - .is_some() - { + } else if location.root().is_some() || location.alias().is_some() { Handler::call(super::file::file_handle, request, state).await } else { #[cfg(feature = "sshd")] - if location - .get::("ssh_login") - .ok() - .flatten() - .is_some() - { - return Ok(Handler::call(super::sshd::sshd_handle, request, state).await); + if location.ssh_login().is_some() { + let mut response = + Handler::call(super::sshd::sshd_handle, request, state).await; + response.extensions_mut().insert(active_access_log); + return Ok(response); } StatusCode::NOT_FOUND.into_response() }; + let mut response = response; + response.extensions_mut().insert(active_access_log); Ok(response) }) } } -fn has_exact_match(locations: &[Arc], path: &str) -> bool { - locations.iter().any(|location| { - location.payload::().ok().flatten().is_some_and( - |pattern| matches!(pattern.as_ref(), Pattern::Exact(expected) if expected == path), - ) - }) -} - -fn find_proxy_prefix_slash_redirect( - locations: &[Arc], +fn proxy_prefix_slash_redirect( + route: &super::location::LocationMatch, normalized: &super::request_uri::NormalizedRequestUri, public_origin: Option<&str>, ) -> Option { - let mut best: Option<(usize, String)> = None; - - for location in locations { - let has_proxy = location - .get::("proxy_pass") - .ok() - .flatten() - .is_some(); - if !has_proxy { - continue; + route.location.proxy_pass()?; + let prefix = match route.location.matcher() { + crate::parse::pattern::Pattern::Prefix(prefix) + | crate::parse::pattern::Pattern::NormalPrefix(prefix) => prefix, + _ => return None, + }; + super::request_uri::build_prefix_slash_redirect(prefix, normalized, public_origin) +} + +#[cfg(test)] +mod tests { + use tower::ServiceExt; + + use super::*; + use crate::{ + parse::{domain::ResolvedConfigPath, tests::parse_location}, + reverse::{location::ConfiguredLocation, log::AccessLogOutput}, + }; + + fn router_state() -> RouterState { + RouterState { + #[cfg(feature = "sshd")] + session_spawner: Arc::new(DummySpawner), + #[cfg(feature = "sshd")] + task_scope: Arc::new(DummyScope), } + } - let Some(pattern) = location.payload::().ok().flatten() else { - continue; - }; + #[cfg(feature = "sshd")] + struct DummySpawner; - let prefix = match pattern.as_ref() { - Pattern::Prefix(prefix) | Pattern::NormalPrefix(prefix) => prefix, - _ => continue, - }; + #[cfg(feature = "sshd")] + impl crate::control_plane::DynSpawnSession for DummySpawner { + fn spawn_session<'a>( + &'a self, + _username: &'a str, + ) -> BoxFuture< + 'a, + Result< + crate::control_plane::SessionTransport, + Box, + >, + > { + Box::pin(std::future::pending()) + } + } - let Some(target) = - super::request_uri::build_prefix_slash_redirect(prefix, normalized, public_origin) - else { - continue; - }; + #[cfg(feature = "sshd")] + struct DummyScope; - if best - .as_ref() - .is_none_or(|(current_len, _)| prefix.len() > *current_len) - { - best = Some((prefix.len(), target)); + #[cfg(feature = "sshd")] + impl DynTaskScope for DummyScope { + fn token(&self) -> CancellationToken { + CancellationToken::new() } + + fn spawn(&self, _task: BoxFuture<'static, ()>) {} } - best.map(|(_, target)| target) + #[tokio::test] + async fn selected_location_hands_its_output_to_the_response() { + let path = std::env::temp_dir().join(format!( + "gateway-route-access-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let output = Arc::new( + AccessLogOutput::open(ResolvedConfigPath::try_from(path.clone()).unwrap()).unwrap(), + ); + let location = Arc::new(ConfiguredLocation::new( + parse_location("").unwrap(), + ActiveAccessLog::Enabled(output.clone()), + )); + let router = NginxRouter::new(vec![location], ActiveAccessLog::Disabled, router_state()); + + let response = router + .oneshot( + http::Request::builder() + .uri("/anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let Some(ActiveAccessLog::Enabled(selected)) = + response.extensions().get::() + else { + panic!("selected route did not attach its access output"); + }; + assert!(Arc::ptr_eq(selected, &output)); + drop(response); + drop(output); + let _ = std::fs::remove_file(path); + } } diff --git a/gateway/src/reverse/sshd.rs b/gateway/src/reverse/sshd.rs index e3726f71..595b5d25 100644 --- a/gateway/src/reverse/sshd.rs +++ b/gateway/src/reverse/sshd.rs @@ -18,7 +18,6 @@ use tracing::Instrument; use crate::{ control_plane::DynSpawnSession, - parse::types::StringList, reverse::{location::LocationMatch, router::RouterState}, }; @@ -109,9 +108,7 @@ pub async fn sshd_handle( let ssh_deny = loc .location - .get::("ssh_deny") - .ok() - .flatten() + .ssh_deny() .map(|value| value.0.clone()) .unwrap_or_default(); diff --git a/gateway/src/reverse/upstream_tls.rs b/gateway/src/reverse/upstream_tls.rs index e0a63b42..2b9ced31 100644 --- a/gateway/src/reverse/upstream_tls.rs +++ b/gateway/src/reverse/upstream_tls.rs @@ -15,10 +15,7 @@ use tokio::net::TcpStream; use tokio_rustls::{TlsConnector, client::TlsStream}; use tracing::debug; -use crate::{ - error::Whatever, - parse::{document::ConfigNode, domain::ResolvedConfigPath}, -}; +use crate::{error::Whatever, parse::config::LocationConfig}; type Result = std::result::Result; @@ -31,18 +28,25 @@ struct UpstreamTlsSettings { impl UpstreamTlsSettings { /// 从 location 配置节点中提取上游 TLS 所需的证书路径。 - fn from_location(location: &ConfigNode) -> Self { + fn from_location(location: &LocationConfig) -> Self { + let tls = location.proxy_tls(); Self { - client_cert: path_from_config(location, "proxy_ssl_certificate"), - client_key: path_from_config(location, "proxy_ssl_certificate_key"), - trusted_ca: path_from_config(location, "proxy_ssl_trusted_certificate"), + client_cert: tls + .and_then(|tls| tls.certificate()) + .map(|path| path.as_ref().to_path_buf()), + client_key: tls + .and_then(|tls| tls.private_key()) + .map(|path| path.as_ref().to_path_buf()), + trusted_ca: tls + .and_then(|tls| tls.trusted_certificate()) + .map(|p| p.as_ref().to_path_buf()), } } } /// 连接 HTTPS 上游,并在 TCP 连接之上完成 TLS 握手。 pub(super) async fn connect_https( - location: &ConfigNode, + location: &LocationConfig, proxy_pass: &Uri, ) -> Result> { // 先从 proxy_pass 中解析服务端主机名和端口,作为 TCP 连接与 SNI 的输入。 @@ -74,7 +78,7 @@ pub(super) async fn connect_https( } /// 构建上游 TLS 客户端配置,支持自定义 CA 和可选的双向 TLS 客户端证书。 -fn build_client_config(location: &ConfigNode) -> Result> { +fn build_client_config(location: &LocationConfig) -> Result> { let settings = UpstreamTlsSettings::from_location(location); // 客户端证书和私钥必须成对出现,否则无法完成双向 TLS 配置。 @@ -185,24 +189,12 @@ fn load_private_key(path: &Path) -> Result> { Ok(private_key) } -fn path_from_config(location: &ConfigNode, name: &str) -> Option { - location - .get::(name) - .ok() - .flatten() - .map(|path| path.as_ref().as_ref().to_path_buf()) -} - #[cfg(test)] mod tests { use std::{path::PathBuf, sync::Once}; use super::*; - use crate::parse::{ - registry::context, - source::{SourceId, SourceSpan}, - value::TypedValue, - }; + use crate::parse::tests::parse_location; static INSTALL_CRYPTO_PROVIDER: Once = Once::new(); @@ -220,15 +212,13 @@ mod tests { .join(relative) } - fn test_location(values: &[(&'static str, PathBuf)]) -> ConfigNode { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut location = ConfigNode::new(context::LOCATION, None, span); - for (name, path) in values { - let path = ResolvedConfigPath::try_from(path.clone()) - .expect("TLS fixture path should already be absolute"); - location.insert_slot(name, TypedValue::new(path, span)); - } - location + fn test_location(values: &[(&'static str, PathBuf)]) -> LocationConfig { + let body = values + .iter() + .map(|(name, path)| format!("{name} {};", path.display())) + .collect::>() + .join(" "); + Arc::try_unwrap(parse_location(&body).unwrap()).expect("unique test location") } #[test] @@ -281,9 +271,9 @@ mod tests { #[test] fn test_build_client_config_rejects_incomplete_identity() { let client_cert = fixture_path("keychain/test.genmeta.net/test.genmeta.net.pem"); - let location = test_location(&[("proxy_ssl_certificate", client_cert)]); - let error = build_client_config(&location).expect_err("incomplete identity should fail"); + let error = parse_location(&format!("proxy_ssl_certificate {};", client_cert.display())) + .expect_err("incomplete identity should fail"); - assert!(error.to_string().contains("must be configured together")); + assert!(error.contains("must be configured together")); } } diff --git a/gateway/src/stun/config.rs b/gateway/src/stun/config.rs index 6a2a29a4..fa8e8a1d 100644 --- a/gateway/src/stun/config.rs +++ b/gateway/src/stun/config.rs @@ -1,8 +1,6 @@ use std::{collections::HashSet, net::SocketAddr}; -use snafu::{ResultExt, Snafu}; - -use crate::parse::{error::ConfigQueryError, tree::ServerConfigRef}; +use crate::parse::config::ServerConfig; /// 本节点的 STUN 运行时配置。 #[derive(Debug, Clone)] @@ -22,29 +20,11 @@ pub struct StunBindConfig { pub change_port: Option, } -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum StunConfigError { - #[snafu(display("failed to query stun directive"))] - Stun { source: ConfigQueryError }, - #[snafu(display("failed to query relay directive"))] - Relay { source: ConfigQueryError }, - #[snafu(display("failed to query stun_server compounds"))] - Servers { source: ConfigQueryError }, -} - impl StunNodeConfig { /// Materializes STUN configuration exclusively from the sealed SERVER-local typed slots. - pub fn from_server_ref(server: &ServerConfigRef) -> Result, StunConfigError> { - let stun_enabled = server - .node() - .local(crate::parse::keys::server::STUN) - .context(stun_config_error::StunSnafu)? - .is_some_and(|value| value.0); - let compounds = server - .node() - .repeated(crate::parse::keys::server::STUN_SERVERS) - .context(stun_config_error::ServersSnafu)?; + pub fn from_server(server: &ServerConfig) -> Option { + let stun_enabled = server.stun().is_some_and(|value| value.0); + let compounds = server.stun_servers(); let binds = compounds .iter() .flat_map(|compound| compound.binds.iter()) @@ -56,15 +36,11 @@ impl StunNodeConfig { }) .collect::>(); if !stun_enabled && binds.is_empty() { - return Ok(None); + return None; } - let relay = server - .node() - .local(crate::parse::keys::server::RELAY) - .context(stun_config_error::RelaySnafu)? - .is_some_and(|value| value.0); - Ok(Some(Self { relay, binds })) + let relay = server.relay().is_some_and(|value| value.0); + Some(Self { relay, binds }) } pub fn has_configured_binds(&self) -> bool { @@ -75,62 +51,3 @@ impl StunNodeConfig { self.binds.iter().map(|bind| bind.bind_address).collect() } } - -#[cfg(test)] -mod tests { - use super::StunNodeConfig; - use crate::parse::{ - ConfigDocumentParser, domain::ConfigDocumentRole, fragment::ParsedConfigDocument, - tree::build_global_tree, - }; - - fn server(extra: &str) -> crate::parse::tree::ServerConfigRef { - let text = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; {extra} }} }}" - ); - let registry = crate::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let ParsedConfigDocument::HypervisorRoot(root) = parser - .parse_text( - &text, - std::path::Path::new("/tmp/pishoo.conf"), - ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .unwrap() - else { - panic!("expected root fragment") - }; - build_global_tree(®istry, root, []) - .unwrap() - .servers() - .next() - .unwrap() - } - - #[test] - fn stun_on_without_bind_remains_dynamic() { - let config = StunNodeConfig::from_server_ref(&server("stun on;")) - .unwrap() - .unwrap(); - assert!(!config.has_configured_binds()); - } - - #[test] - fn stun_off_with_bind_remains_configured() { - let config = StunNodeConfig::from_server_ref(&server( - "stun off; stun_server { bind 127.0.0.1:1000; }", - )) - .unwrap() - .unwrap(); - assert!(config.has_configured_binds()); - } - - #[test] - fn relay_on_alone_remains_disabled_without_stun_or_bind() { - assert!( - StunNodeConfig::from_server_ref(&server("relay on;")) - .unwrap() - .is_none() - ); - } -} diff --git a/gateway/tests/relative_static_root.rs b/gateway/tests/relative_static_root.rs index 0944dbae..4222413d 100644 --- a/gateway/tests/relative_static_root.rs +++ b/gateway/tests/relative_static_root.rs @@ -64,25 +64,35 @@ async fn router_serves_static_file_from_config_relative_root() { .expect("write index"); std::fs::write( dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./fullchain.crt;\n ssl_certificate_key ./privkey.pem;\n location / { root .; index index.html; }\n}\n", + "pishoo { server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./fullchain.crt;\n ssl_certificate_key ./privkey.pem;\n location / { root .; index index.html; }\n} }\n", ) .expect("write config"); - let registry = parse::default_registry(); - let document = parse::load_config_file( - &dir.join("server.conf"), - ®istry, - parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = document.root.children("server").expect("server children")[0].clone(); + let text = std::fs::read_to_string(dir.join("server.conf")).unwrap(); + let parsed = parse::TypedConfigParser::new() + .parse_root(&text, &dir.join("server.conf"), None) + .expect("config should load"); + let server = parsed.servers()[0] + .result() + .as_ref() + .expect("server should build"); let locations = server - .children("location") - .expect("location children") - .to_vec(); - let router = NginxRouter::new(locations, dummy_router_state()); + .locations() + .iter() + .cloned() + .map(std::sync::Arc::new) + .map(|location| { + std::sync::Arc::new(gateway::reverse::location::ConfiguredLocation::new( + location, + gateway::reverse::access_log::ActiveAccessLog::Disabled, + )) + }) + .collect(); + let router = NginxRouter::new( + locations, + gateway::reverse::access_log::ActiveAccessLog::Disabled, + dummy_router_state(), + ); let response = router .oneshot( diff --git a/pishoo/Cargo.toml b/pishoo/Cargo.toml index f47a7807..9dd30c88 100644 --- a/pishoo/Cargo.toml +++ b/pishoo/Cargo.toml @@ -87,4 +87,4 @@ sqlx-sqlite = ["sea-orm-migration/sqlx-sqlite"] [dev-dependencies] rcgen = "0.14" -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/pishoo/src/bin/pishoo_worker.rs b/pishoo/src/bin/pishoo_worker.rs index c78d5254..212fbea4 100644 --- a/pishoo/src/bin/pishoo_worker.rs +++ b/pishoo/src/bin/pishoo_worker.rs @@ -10,13 +10,13 @@ use std::sync::Arc; -use dhttp::{h3x::ipc::transport::MuxChannel, home::DhttpHome}; +use dhttp::h3x::ipc::transport::MuxChannel; use gateway::error::Whatever; use pishoo::{ ipc::{WorkerBootstrap, WorkerHello}, worker::remote_plane::RemoteControlPlane, }; -use snafu::{OptionExt, Report, ResultExt}; +use snafu::{FromString, OptionExt, Report, ResultExt}; use tracing::Instrument; #[tokio::main] @@ -56,6 +56,8 @@ async fn main() -> Result<(), Whatever> { .await .whatever_context("failed to establish remoc transport")?; let worker_tasks = Arc::new(pishoo::hypervisor::task_scope::TaskScope::new()); + let transport_ended = tokio_util::sync::CancellationToken::new(); + let connection_ended = transport_ended.clone(); worker_tasks.spawn(|token| async move { tokio::select! { biased; @@ -67,6 +69,7 @@ async fn main() -> Result<(), Whatever> { "root remoc connection ended" ); } + connection_ended.cancel(); } } }); @@ -79,9 +82,9 @@ async fn main() -> Result<(), Whatever> { .whatever_context("root closed channel without sending bootstrap")?; tracing::debug!( - uid = bootstrap.uid, - username = %bootstrap.username, - home = %bootstrap.home.display(), + uid = bootstrap.account.uid().as_raw(), + username = %bootstrap.account.name(), + home = %bootstrap.account.login_home().display(), "bootstrap received" ); @@ -101,12 +104,14 @@ async fn main() -> Result<(), Whatever> { // Create the RemoteControlPlane from the bootstrap's ControlPlane client. // Pass FdTransfer so worker-side requests can reserve receiver-chosen FD IDs. - let plane = Arc::new(RemoteControlPlane::new( - bootstrap.control_plane, - fd_transfer, - )); - - let dhttp_home = DhttpHome::for_user_home_dir(bootstrap.home.clone()); + let WorkerBootstrap { + account, + root_defaults, + mut root_defaults_rx, + control_plane, + } = bootstrap; + let plane = Arc::new(RemoteControlPlane::new(control_plane, fd_transfer)); + let dhttp_home = account.dhttp_home().clone(); let mut term_signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .whatever_context("failed to create SIGTERM listener")?; @@ -126,9 +131,19 @@ async fn main() -> Result<(), Whatever> { #[cfg(feature = "sshd")] task_scope: worker_tasks.clone(), }; - let mut runtime = - pishoo::service::runtime::WorkerRuntime::new(plane.clone(), dhttp_home, router_state); - runtime.start().await; + let mut runtime = pishoo::service::runtime::WorkerRuntime::new( + plane.clone(), + dhttp_home, + root_defaults, + router_state, + ); + if let Err(error) = runtime.start().await { + runtime.shutdown().await; + return Err(Whatever::with_source( + Box::new(error), + "initial worker configuration failed".to_owned(), + )); + } loop { tokio::select! { @@ -146,9 +161,46 @@ async fn main() -> Result<(), Whatever> { } _ = hup_signal.recv() => { tracing::info!(signal = "SIGHUP", "received reload signal"); - runtime.reload().await; + if let Err(error) = runtime.reload().await { + tracing::error!(error = %snafu::Report::from_error(&error), "worker reload failed; shutting down"); + runtime.shutdown().await; + return Err(Whatever::with_source( + Box::new(error), + "worker reload failed".to_owned(), + )); + } tracing::info!(signal = "SIGHUP", "reload complete"); } + changed = root_defaults_rx.changed() => { + if let Err(error) = changed { + tracing::error!(error = %error, "root defaults channel closed; shutting down worker"); + runtime.shutdown().await; + worker_tasks.cancel_and_wait().await; + return Err(Whatever::with_source(Box::new(error), "root defaults channel closed".to_owned())); + } + let defaults = root_defaults_rx + .borrow_and_update() + .whatever_context("failed to receive root defaults snapshot")? + .clone(); + tracing::info!("received root defaults reload"); + if let Err(error) = runtime.reload_with_root_defaults(defaults).await { + tracing::error!(error = %snafu::Report::from_error(&error), "root defaults reload failed; shutting down"); + runtime.shutdown().await; + worker_tasks.cancel_and_wait().await; + return Err(Whatever::with_source(Box::new(error), "root defaults reload failed".to_owned())); + } + tracing::info!(source = "root_defaults", "reload complete"); + } + _ = transport_ended.cancelled() => { + tracing::error!("root control-plane transport ended; shutting down worker"); + runtime.shutdown().await; + worker_tasks.cancel_and_wait().await; + return Err(Whatever::without_source("root control-plane transport ended".to_owned())); + } + name = runtime.wait_service_completion() => { + runtime.handle_service_exit(name).await; + tracing::warn!("worker server service exited; released its resources"); + } _ = usr1_signal.recv() => { tracing::info!(signal = "SIGUSR1", "received reopen signal"); } diff --git a/pishoo/src/config.rs b/pishoo/src/config.rs index 683cb361..83beeef2 100644 --- a/pishoo/src/config.rs +++ b/pishoo/src/config.rs @@ -1,104 +1,61 @@ -use std::{path::PathBuf, sync::Arc}; +use std::path::PathBuf; -use gateway::parse::{document::ConfigNode, domain::ResolvedConfigPath, error::ConfigQueryError}; -use snafu::{OptionExt, ResultExt, Snafu}; +use snafu::Snafu; -#[allow(dead_code)] // prepared as one domain before the atomic runtime cutover mod account; -mod discovery; -pub mod entry; -#[allow(dead_code)] // prepared as one sealed model before the atomic runtime cutover -mod home_tree; -pub mod root; +pub(crate) mod plan; pub mod source; pub mod worker_target; -#[cfg(test)] -mod tests; - -pub use discovery::load_identity_servers; -pub use entry::{EntryConfig, parse_entry_config}; -pub use root::{RootConfig, parse_root_config}; -pub use source::{PishooConfigSource, ResolveConfigSourceError}; -pub use worker_target::{ - ResolvedWorkerTarget, WorkerDiff, WorkerTarget, compute_worker_diff, - resolve_entry_worker_targets, resolve_worker_targets, +pub use account::{WorkerAccount, WorkerDiff, WorkerRoster, compute_worker_diff}; +pub use plan::{ + GlobalPishooPlan, IdentityServerCandidate, WorkerHomePlan, load_global_pishoo_plan, + load_identity_server_candidates, load_worker_home_plan, }; +pub use source::{PishooConfigSource, ResolveConfigSourceError}; +pub use worker_target::{ResolvedWorkerTarget, WorkerTarget, resolve_worker_targets}; #[derive(Debug, Snafu)] pub enum ConfigError { - #[snafu(display("pishoo block not found in configuration"))] - MissingPishoo, - - #[snafu(display("invalid workers directive: expected string list"))] - InvalidWorkers, - - #[snafu(display("invalid pid directive: expected path"))] - InvalidPid, - - #[snafu(display("failed to read typed configuration value"))] - ConfigQuery { source: ConfigQueryError }, - - #[snafu(display("invalid groups directive: expected string list"))] - InvalidGroups, - #[snafu(display("worker username cannot be empty"))] EmptyWorkerName, - #[snafu(display("failed to resolve users in group `{group_name}`"))] GroupResolve { group_name: String, source: nix::Error, }, - #[snafu(display("group `{group_name}` not found"))] GroupNotFound { group_name: String }, - #[snafu(display("failed to enumerate users with primary group `{group_name}`"))] PrimaryGroupUserResolve { group_name: String, source: nix::errno::Errno, }, - #[snafu(display("failed to resolve macOS membership uuid for uid {uid}"))] MacosUserUuid { uid: u32, source: nix::errno::Errno }, - #[snafu(display("failed to resolve macOS membership uuid for gid {gid}"))] MacosGroupUuid { gid: u32, source: nix::errno::Errno }, - #[snafu(display("failed to check macOS group membership for uid {uid} in gid {gid}"))] MacosMembershipCheck { uid: u32, gid: u32, source: nix::errno::Errno, }, - #[snafu(display("failed to resolve user `{username}` via system passwd database"))] UserNotFound { username: String }, - #[snafu(display("failed to resolve user `{username}`"))] UserResolve { username: String, source: nix::Error, }, - #[snafu(display("resolved user `{username}` has no home directory"))] MissingHome { username: String }, } pub const PID_FILE_DEFAULT: &str = "/var/run/pishoo.pid"; - -fn first_pishoo_node(root: &Arc) -> Result, ConfigError> { - root.children("pishoo") - .ok() - .and_then(|nodes| nodes.first().cloned()) - .context(MissingPishooSnafu) -} - -fn parse_pid_file(pishoo: &Arc) -> Result { - Ok(pishoo - .get::("pid") - .context(ConfigQuerySnafu)? - .map(|pid_file| pid_file.as_ref().as_ref().to_path_buf()) - .unwrap_or_else(|| PathBuf::from(PID_FILE_DEFAULT))) +pub fn pid_path(config: &gateway::parse::config::PishooConfig) -> PathBuf { + config + .pid() + .map(|path| path.as_ref().to_path_buf()) + .unwrap_or_else(|| PID_FILE_DEFAULT.into()) } diff --git a/pishoo/src/config/account.rs b/pishoo/src/config/account.rs index 21c2e9a5..3745533b 100644 --- a/pishoo/src/config/account.rs +++ b/pishoo/src/config/account.rs @@ -1,4 +1,7 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::{BTreeMap, HashMap}, + path::PathBuf, +}; use dhttp::home::DhttpHome; use nix::unistd::{Gid, Uid}; @@ -19,7 +22,7 @@ pub enum WorkerAccountError { } #[derive(Clone, Debug)] -pub(crate) struct WorkerAccount { +pub struct WorkerAccount { name: String, uid: Uid, primary_gid: Gid, @@ -68,23 +71,23 @@ impl WorkerAccount { ) } - pub(crate) fn name(&self) -> &str { + pub fn name(&self) -> &str { &self.name } - pub(crate) const fn uid(&self) -> Uid { + pub const fn uid(&self) -> Uid { self.uid } - pub(crate) const fn primary_gid(&self) -> Gid { + pub const fn primary_gid(&self) -> Gid { self.primary_gid } - pub(crate) fn login_home(&self) -> &std::path::Path { + pub fn login_home(&self) -> &std::path::Path { &self.login_home } - pub(crate) fn dhttp_home(&self) -> &DhttpHome { + pub fn dhttp_home(&self) -> &DhttpHome { &self.dhttp_home } } @@ -147,22 +150,14 @@ pub(crate) fn select_worker_dhttp_home(target: &ResolvedWorkerTarget) -> DhttpHo DhttpHome::for_user_home_dir(target.dir.clone()) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct WorkerCredentialSnapshot { - pub(crate) real_uid: Uid, - pub(crate) effective_uid: Uid, - pub(crate) real_gid: Gid, - pub(crate) effective_gid: Gid, -} - #[derive(Debug, Snafu)] -pub(crate) enum BuildWorkerRosterError { +pub enum BuildWorkerRosterError { #[snafu(display("duplicate worker uid {uid}"))] DuplicateUid { uid: Uid }, } #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct WorkerRoster(BTreeMap); +pub struct WorkerRoster(BTreeMap); impl WorkerRoster { pub(crate) fn new( @@ -180,8 +175,49 @@ impl WorkerRoster { Ok(Self(roster)) } - pub(crate) fn get(&self, uid: Uid) -> Option<&WorkerAccount> { - self.0.get(&uid.as_raw()) + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn to_vec(&self) -> Vec { + self.0.values().cloned().collect() + } +} + +#[derive(Debug)] +pub struct WorkerDiff { + pub unchanged: Vec, + pub added: Vec, + pub removed: Vec, + pub changed: Vec<(WorkerAccount, WorkerAccount)>, +} +pub fn compute_worker_diff(current: &[WorkerAccount], next: &[WorkerAccount]) -> WorkerDiff { + let current_map: HashMap<&str, &WorkerAccount> = + current.iter().map(|v| (v.name(), v)).collect(); + let next_map: HashMap<&str, &WorkerAccount> = next.iter().map(|v| (v.name(), v)).collect(); + let mut unchanged = Vec::new(); + let mut added = Vec::new(); + let mut changed = Vec::new(); + let mut removed = Vec::new(); + for n in next { + match current_map.get(n.name()) { + Some(c) if c.uid() == n.uid() => unchanged.push(n.clone()), + Some(c) => changed.push(((*c).clone(), n.clone())), + None => added.push(n.clone()), + } + } + for c in current { + if !next_map.contains_key(c.name()) { + removed.push(c.clone()) + } + } + WorkerDiff { + unchanged, + added, + removed, + changed, } } @@ -193,8 +229,8 @@ mod tests { use nix::unistd::{Gid, Uid, User}; use super::{ - BuildWorkerRosterError, WorkerAccount, WorkerAccountError, WorkerCredentialSnapshot, - WorkerRoster, select_worker_dhttp_home, + BuildWorkerRosterError, WorkerAccount, WorkerAccountError, WorkerRoster, + select_worker_dhttp_home, }; fn account() -> WorkerAccount { @@ -320,20 +356,6 @@ mod tests { assert_ne!(decoded.login_home(), decoded.dhttp_home().as_path()); } - #[test] - fn worker_credentials_keep_real_and_effective_ids_typed() { - let credentials = WorkerCredentialSnapshot { - real_uid: Uid::from_raw(1), - effective_uid: Uid::from_raw(2), - real_gid: Gid::from_raw(3), - effective_gid: Gid::from_raw(4), - }; - assert_eq!(credentials.real_uid, Uid::from_raw(1)); - assert_eq!(credentials.effective_uid, Uid::from_raw(2)); - assert_eq!(credentials.real_gid, Gid::from_raw(3)); - assert_eq!(credentials.effective_gid, Gid::from_raw(4)); - } - #[test] fn default_worker_home_selection_uses_the_login_home_domain_entry_point() { let target = User { diff --git a/pishoo/src/config/discovery.rs b/pishoo/src/config/discovery.rs deleted file mode 100644 index b88e9ce8..00000000 --- a/pishoo/src/config/discovery.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::sync::Arc; - -use dhttp::home::{DhttpHome, identity::IdentityProfile}; -use gateway::parse::{document::ConfigNode, error::ConfigLoadFailure}; - -pub async fn load_identity_servers( - home: &DhttpHome, - identity_profile: &IdentityProfile, -) -> Result>, ConfigLoadFailure> { - let conf_path = identity_profile.server_conf_path(); - let registry = gateway::parse::default_registry(); - let parsed = gateway::parse::load_config_file( - &conf_path, - ®istry, - gateway::parse::registry::BuildOptions { - dhttp_home: Some(home), - identity_profile: Some(identity_profile), - }, - ) - .await?; - - Ok(parsed.root.children_optional("server").to_vec()) -} diff --git a/pishoo/src/config/entry.rs b/pishoo/src/config/entry.rs deleted file mode 100644 index 875c271a..00000000 --- a/pishoo/src/config/entry.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::{path::PathBuf, sync::Arc}; - -use gateway::parse::document::ConfigNode; - -use super::{ - ConfigError, first_pishoo_node, parse_pid_file, - worker_target::{ - AccountDirectory, SystemAccountDirectory, WorkerDiscoveryMode, WorkerTarget, - resolve_all_workers_with_directory, - }, -}; - -#[derive(Debug, Clone)] -pub struct EntryConfig { - pub pid_file: PathBuf, - pub workers: Vec, - pub config_services: Vec>, -} - -fn parse_config_services(pishoo: &Arc) -> Vec> { - pishoo.children_optional("server").to_vec() -} - -pub fn parse_entry_config(root: &Arc) -> Result { - parse_entry_config_with_mode(root, WorkerDiscoveryMode::ExplicitConfig) -} - -pub fn parse_entry_config_with_mode( - root: &Arc, - mode: WorkerDiscoveryMode, -) -> Result { - let directory = SystemAccountDirectory; - parse_entry_config_with_directory_and_mode(root, &directory, mode) -} - -#[cfg(test)] -pub(super) fn parse_entry_config_with_directory( - root: &Arc, - directory: &D, -) -> Result { - parse_entry_config_with_directory_and_mode(root, directory, WorkerDiscoveryMode::ExplicitConfig) -} - -pub(super) fn parse_entry_config_with_directory_and_mode( - root: &Arc, - directory: &D, - mode: WorkerDiscoveryMode, -) -> Result { - let pishoo = first_pishoo_node(root)?; - let pid_file = parse_pid_file(&pishoo)?; - let config_services = parse_config_services(&pishoo); - let workers = resolve_all_workers_with_directory(&pishoo, directory, mode)?; - - Ok(EntryConfig { - pid_file, - workers, - config_services, - }) -} diff --git a/pishoo/src/config/home_tree.rs b/pishoo/src/config/home_tree.rs deleted file mode 100644 index 3701f7bf..00000000 --- a/pishoo/src/config/home_tree.rs +++ /dev/null @@ -1,634 +0,0 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::Arc, -}; - -use dhttp::{ - home::{ - DhttpHome, - identity::{IdentityProfile, ssl::ListIdentityProfilesStrictError}, - }, - name::DhttpName, -}; -use gateway::parse::{ - ConfigDocumentParser, - domain::{ConfigDocumentRole, ConfigSourceSpan}, - error::{ConfigLoadFailure, ConfigQueryError}, - fragment::{ParsedConfigDocument, ParsedPishooFragment, ParsedServerFragment}, - snapshot::{RootConfigSnapshot, RootConfigSnapshotError}, - tree::{ConfigNodeId, HomeConfigTree, HomeConfigTreeError, ServerConfigRef}, -}; -use snafu::{IntoError, ResultExt, Snafu}; - -use super::{account::WorkerAccount, source::PishooConfigSource}; - -#[derive(Clone, Debug)] -pub(crate) enum ServiceScope { - Global, - Worker(Arc), -} - -#[derive(Clone, Debug)] -pub(crate) enum ServiceBindingOrigin { - Direct { - node: ConfigNodeId, - source: Option, - }, - Identity { - node: ConfigNodeId, - source: Option, - profile: IdentityProfile, - }, -} - -#[derive(Clone, Debug)] -pub(crate) struct ScopedServerConfig { - server: ServerConfigRef, - identity_profile: Option, - service_names: Box<[DhttpName<'static>]>, -} - -impl ScopedServerConfig { - pub(crate) fn server(&self) -> &ServerConfigRef { - &self.server - } - - pub(crate) fn identity_profile(&self) -> Option<&IdentityProfile> { - self.identity_profile.as_ref() - } - - pub(crate) fn service_names(&self) -> &[DhttpName<'static>] { - &self.service_names - } -} - -#[derive(Debug)] -pub(crate) struct ScopedHomeConfig { - scope: ServiceScope, - tree: Arc, - servers: Box<[ScopedServerConfig]>, -} - -impl ScopedHomeConfig { - pub(crate) fn scope(&self) -> &ServiceScope { - &self.scope - } - - pub(crate) fn tree(&self) -> &Arc { - &self.tree - } - - pub(crate) fn servers(&self) -> &[ScopedServerConfig] { - &self.servers - } - - pub(crate) fn root_snapshot(&self) -> Result { - self.tree.root_snapshot() - } -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub(crate) enum BuildHomeConfigError { - #[snafu(display("failed to inspect configuration source {}", path.display()))] - SourceMetadata { - path: PathBuf, - source: std::io::Error, - }, - #[snafu(display("failed to read configuration source {}", path.display()))] - ReadSource { - path: PathBuf, - source: std::io::Error, - }, - #[snafu(display("failed to parse configuration source {}", path.display()))] - ParseSource { - path: PathBuf, - source: Box, - }, - #[snafu(display("configuration source {} has the wrong document role", path.display()))] - DocumentRole { path: PathBuf }, - #[snafu(display("failed to enumerate identity profiles in {}", home.display()))] - EnumerateProfiles { - home: PathBuf, - source: Box, - }, - #[snafu(display("identity profile {} contains multiple server blocks", profile.path().display()))] - MultipleIdentityServers { profile: IdentityProfile }, - #[snafu(display("worker pishoo configuration cannot declare direct server blocks"))] - WorkerDirectServers, - #[snafu(display("identity server {} does not bind exactly its profile name", profile.path().display()))] - IdentityServerNames { profile: IdentityProfile }, - #[snafu(display("identity server {} overrides its profile TLS paths", profile.path().display()))] - IdentityTlsOverride { profile: IdentityProfile }, - #[snafu(display("direct server {node:?} does not provide a complete TLS pair"))] - DirectTlsPair { node: ConfigNodeId }, - #[snafu(display("failed to query sealed server configuration"))] - QueryServer { source: ConfigQueryError }, - #[snafu(display("failed to seal the home configuration tree"))] - SealTree { source: HomeConfigTreeError }, - #[snafu(display("duplicate service identity {name}"))] - DuplicateServiceIdentity { - name: DhttpName<'static>, - first: Box, - second: Box, - }, -} - -pub(crate) async fn build_global_home_config( - source: &PishooConfigSource, -) -> Result, BuildHomeConfigError> { - let registry = gateway::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let root_text = read_required(source.config_path()).await?; - let root = parse_root( - &mut parser, - &root_text, - source.config_path(), - source.dhttp_home(), - )?; - let direct_count = root.servers().len(); - - let (identity_fragments, identity_profiles) = match source.dhttp_home() { - Some(home) => load_identity_fragments(&mut parser, home).await?, - None => (Vec::new(), Vec::new()), - }; - let tree = gateway::parse::tree::build_global_tree(®istry, root, identity_fragments) - .context(build_home_config_error::SealTreeSnafu)?; - let all_servers = tree.servers().collect::>(); - let (direct, identity) = all_servers.split_at(direct_count); - let mut sealed = Vec::with_capacity(all_servers.len()); - for (server, profile) in identity.iter().cloned().zip(identity_profiles) { - sealed.push(seal_server(server, Some(profile))?); - } - for server in direct.iter().cloned() { - sealed.push(seal_server(server, None)?); - } - reject_duplicate_names(&sealed)?; - - Ok(Arc::new(ScopedHomeConfig { - scope: ServiceScope::Global, - tree, - servers: sealed.into_boxed_slice(), - })) -} - -pub(crate) async fn build_worker_home_config( - account: WorkerAccount, - root: Arc, -) -> Result, BuildHomeConfigError> { - let registry = gateway::parse::default_registry(); - let mut parser = ConfigDocumentParser::new(®istry); - let home = account.dhttp_home(); - let worker_path = home.join(PishooConfigSource::CONFIG_FILE_NAME); - let worker = match read_optional(&worker_path).await? { - Some(text) => { - let worker = parse_worker(&mut parser, &text, &worker_path, home)?; - if !worker.servers().is_empty() { - return build_home_config_error::WorkerDirectServersSnafu.fail(); - } - Some(worker) - } - None => None, - }; - let (identity_fragments, identity_profiles) = - load_identity_fragments(&mut parser, home).await?; - let tree = gateway::parse::tree::build_worker_tree( - ®istry, - root.as_ref().clone(), - worker, - identity_fragments, - ) - .context(build_home_config_error::SealTreeSnafu)?; - let mut sealed = Vec::new(); - for (server, profile) in tree.servers().zip(identity_profiles) { - sealed.push(seal_server(server, Some(profile))?); - } - reject_duplicate_names(&sealed)?; - - Ok(Arc::new(ScopedHomeConfig { - scope: ServiceScope::Worker(Arc::new(account)), - tree, - servers: sealed.into_boxed_slice(), - })) -} - -async fn load_identity_fragments( - parser: &mut ConfigDocumentParser<'_>, - home: &DhttpHome, -) -> Result<(Vec, Vec), BuildHomeConfigError> { - let profiles = home - .list_identity_profiles_strict() - .await - .map_err(|source| BuildHomeConfigError::EnumerateProfiles { - home: home.as_path().to_path_buf(), - source: Box::new(source), - })?; - let mut fragments = Vec::new(); - let mut owners = Vec::new(); - for profile in profiles.into_vec() { - let path = profile.server_conf_path(); - let Some(text) = read_optional(&path).await? else { - continue; - }; - let ParsedConfigDocument::IdentityServers(servers) = parser - .parse_text( - &text, - &path, - ConfigDocumentRole::IdentityServer { - home, - profile: &profile, - }, - ) - .map_err(|source| BuildHomeConfigError::ParseSource { - path: path.clone(), - source: Box::new(source), - })? - else { - return build_home_config_error::DocumentRoleSnafu { path }.fail(); - }; - if servers.len() != 1 { - return build_home_config_error::MultipleIdentityServersSnafu { profile }.fail(); - } - fragments.push( - servers - .into_vec() - .pop() - .expect("one identity server was validated"), - ); - owners.push(profile); - } - Ok((fragments, owners)) -} - -fn parse_root( - parser: &mut ConfigDocumentParser<'_>, - text: &str, - path: &Path, - home: Option<&DhttpHome>, -) -> Result { - let parsed = parser - .parse_text(text, path, ConfigDocumentRole::HypervisorRoot { home }) - .map_err(|source| BuildHomeConfigError::ParseSource { - path: path.to_path_buf(), - source: Box::new(source), - })?; - match parsed { - ParsedConfigDocument::HypervisorRoot(root) => Ok(root), - _ => build_home_config_error::DocumentRoleSnafu { path }.fail(), - } -} - -fn parse_worker( - parser: &mut ConfigDocumentParser<'_>, - text: &str, - path: &Path, - home: &DhttpHome, -) -> Result { - let parsed = parser - .parse_text(text, path, ConfigDocumentRole::WorkerPishoo { home }) - .map_err(|source| BuildHomeConfigError::ParseSource { - path: path.to_path_buf(), - source: Box::new(source), - })?; - match parsed { - ParsedConfigDocument::WorkerPishoo(worker) => Ok(worker), - _ => build_home_config_error::DocumentRoleSnafu { path }.fail(), - } -} - -async fn read_required(path: &Path) -> Result { - tokio::fs::read_to_string(path) - .await - .context(build_home_config_error::ReadSourceSnafu { path }) -} - -async fn read_optional(path: &Path) -> Result, BuildHomeConfigError> { - match tokio::fs::symlink_metadata(path).await { - Ok(_) => tokio::fs::read_to_string(path) - .await - .map(Some) - .context(build_home_config_error::ReadSourceSnafu { path }), - Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(source) => { - Err(build_home_config_error::SourceMetadataSnafu { path }.into_error(source)) - } - } -} - -fn seal_server( - server: ServerConfigRef, - identity_profile: Option, -) -> Result { - let names = server - .node() - .local(gateway::parse::keys::server::SERVER_NAME) - .context(build_home_config_error::QueryServerSnafu)? - .map(|names| { - names - .0 - .iter() - .map(|name| name.name.clone()) - .collect::>() - }) - .unwrap_or_default(); - let certificate = server - .node() - .local(gateway::parse::keys::server::SSL_CERTIFICATE) - .context(build_home_config_error::QueryServerSnafu)?; - let key = server - .node() - .local(gateway::parse::keys::server::SSL_CERTIFICATE_KEY) - .context(build_home_config_error::QueryServerSnafu)?; - - if let Some(profile) = &identity_profile { - if names.as_slice() != [profile.name().clone()] { - return build_home_config_error::IdentityServerNamesSnafu { - profile: profile.clone(), - } - .fail(); - } - if certificate.is_some() || key.is_some() { - return build_home_config_error::IdentityTlsOverrideSnafu { - profile: profile.clone(), - } - .fail(); - } - } else if certificate.is_none() || key.is_none() { - return build_home_config_error::DirectTlsPairSnafu { - node: server.node().id(), - } - .fail(); - } - - Ok(ScopedServerConfig { - server, - identity_profile, - service_names: names.into_boxed_slice(), - }) -} - -fn reject_duplicate_names(servers: &[ScopedServerConfig]) -> Result<(), BuildHomeConfigError> { - let mut seen = HashMap::, ServiceBindingOrigin>::new(); - for server in servers { - let origin = match &server.identity_profile { - Some(profile) => ServiceBindingOrigin::Identity { - node: server.server.node().id(), - source: server.server.node().source_span(), - profile: profile.clone(), - }, - None => ServiceBindingOrigin::Direct { - node: server.server.node().id(), - source: server.server.node().source_span(), - }, - }; - for name in &server.service_names { - if let Some(first) = seen.insert(name.clone(), origin.clone()) { - return Err(BuildHomeConfigError::DuplicateServiceIdentity { - name: name.clone(), - first: Box::new(first), - second: Box::new(origin), - }); - } - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{path::PathBuf, sync::Arc, time::SystemTime}; - - use dhttp::home::DhttpHome; - use nix::unistd::{Gid, Uid}; - - use super::{ - BuildHomeConfigError, ServiceScope, build_global_home_config, build_worker_home_config, - }; - use crate::config::{account::WorkerAccount, source::PishooConfigSource}; - - struct TempHome(PathBuf); - - impl TempHome { - fn new(label: &str) -> Self { - let nonce = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "pishoo-home-tree-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&path).unwrap(); - Self(path) - } - - fn home(&self) -> DhttpHome { - DhttpHome::new(self.0.clone()) - } - - fn write(&self, relative: &str, text: &str) { - let path = self.0.join(relative); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, text).unwrap(); - } - - fn profile(&self, name: &str, server: Option<&str>) { - std::fs::create_dir_all(self.0.join(name).join("ssl")).unwrap(); - if let Some(server) = server { - self.write(&format!("{name}/server.conf"), server); - } - } - } - - impl Drop for TempHome { - fn drop(&mut self) { - _ = std::fs::remove_dir_all(&self.0); - } - } - - fn global_source(temp: &TempHome, config: &str) -> PishooConfigSource { - temp.write("pishoo.conf", config); - PishooConfigSource::from_global_home_at(temp.home(), std::path::Path::new("/ignored")) - .unwrap() - } - - async fn root_snapshot() -> Arc { - let temp = TempHome::new("root-snapshot"); - let global = build_global_home_config(&global_source(&temp, "pishoo {}")) - .await - .unwrap(); - Arc::new(global.root_snapshot().unwrap()) - } - - fn account(temp: &TempHome) -> WorkerAccount { - WorkerAccount::new( - "alice".to_owned(), - Uid::from_raw(1000), - Gid::from_raw(100), - PathBuf::from("/home/alice"), - temp.home(), - ) - .unwrap() - } - - #[tokio::test] - async fn missing_worker_pishoo_file_is_an_empty_overlay() { - let worker = TempHome::new("missing-worker-overlay"); - let config = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap(); - assert!(matches!(config.scope(), ServiceScope::Worker(_))); - assert!(config.servers().is_empty()); - } - - #[tokio::test] - async fn bad_worker_pishoo_file_fails_the_whole_home() { - let worker = TempHome::new("bad-worker-overlay"); - worker.write("pishoo.conf", "pishoo {"); - let error = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap_err(); - assert!(matches!(error, BuildHomeConfigError::ParseSource { .. })); - } - - #[tokio::test] - async fn missing_identity_server_conf_is_skipped() { - let worker = TempHome::new("missing-identity-server"); - worker.profile("reimu.pilot", None); - let config = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap(); - assert!(config.servers().is_empty()); - } - - #[tokio::test] - async fn bad_identity_server_fails_the_whole_home() { - let worker = TempHome::new("bad-identity-server"); - worker.profile("reimu.pilot", Some("server {")); - let error = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap_err(); - assert!(matches!(error, BuildHomeConfigError::ParseSource { .. })); - } - - #[tokio::test] - async fn identity_server_without_server_name_uses_captured_profile_name() { - let worker = TempHome::new("identity-default-name"); - worker.profile("reimu.pilot", Some("server { listen all 443; }")); - let config = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap(); - assert_eq!(config.servers().len(), 1); - assert_eq!( - config.servers()[0].service_names()[0].as_full(), - "reimu.pilot.dhttp.net" - ); - assert_eq!( - config.servers()[0].identity_profile().unwrap().path(), - worker.0.join("reimu.pilot") - ); - } - - #[tokio::test] - async fn identity_server_rejects_mismatched_or_multiple_server_names() { - let worker = TempHome::new("identity-mismatch"); - worker.profile( - "reimu.pilot", - Some("server { listen all 443; server_name youmu.pilot; }"), - ); - let error = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap_err(); - assert!(matches!( - error, - BuildHomeConfigError::IdentityServerNames { .. } - )); - } - - #[tokio::test] - async fn identity_server_rejects_multiple_server_blocks() { - let worker = TempHome::new("identity-multiple-servers"); - worker.profile( - "reimu.pilot", - Some("server { listen all 443; } server { listen all 444; }"), - ); - let error = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap_err(); - assert!(matches!( - error, - BuildHomeConfigError::MultipleIdentityServers { .. } - )); - } - - #[tokio::test] - async fn identity_server_rejects_explicit_tls_path_override() { - let worker = TempHome::new("identity-tls-override"); - worker.profile( - "reimu.pilot", - Some( - "server { listen all 443; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; }", - ), - ); - let error = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap_err(); - assert!(matches!( - error, - BuildHomeConfigError::IdentityTlsOverride { .. } - )); - } - - #[tokio::test] - async fn duplicate_service_name_between_canonical_profile_paths_fails_whole_home() { - let worker = TempHome::new("identity-duplicate-canonical"); - worker.profile("reimu.pilot", Some("server { listen all 443; }")); - worker.profile("reimu.pilot.dhttp.net", Some("server { listen all 444; }")); - let error = build_worker_home_config(account(&worker), root_snapshot().await) - .await - .unwrap_err(); - assert!(matches!( - error, - BuildHomeConfigError::DuplicateServiceIdentity { .. } - )); - } - - #[tokio::test] - async fn explicit_config_does_not_enumerate_home_identities() { - let temp = TempHome::new("explicit-no-identities"); - temp.profile("123", None); - temp.write( - "explicit.conf", - "pishoo { server { listen all 443; server_name reimu.pilot; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; } }", - ); - let source = PishooConfigSource::explicit_at( - temp.0.join("explicit.conf"), - std::path::Path::new("/ignored"), - ) - .unwrap(); - let config = build_global_home_config(&source).await.unwrap(); - assert_eq!(config.servers().len(), 1); - assert!(config.servers()[0].identity_profile().is_none()); - } - - #[tokio::test] - async fn global_home_servers_are_identity_total_order_then_direct_source_order() { - let temp = TempHome::new("global-order"); - temp.profile("youmu.pilot", Some("server { listen all 445; }")); - temp.profile("reimu.pilot", Some("server { listen all 444; }")); - let source = global_source( - &temp, - "pishoo { server { listen all 443; server_name sanae.pilot; ssl_certificate /tmp/cert; ssl_certificate_key /tmp/key; } }", - ); - let config = build_global_home_config(&source).await.unwrap(); - let names = config - .servers() - .iter() - .map(|server| server.service_names()[0].as_partial()) - .collect::>(); - assert_eq!(names, ["reimu.pilot", "youmu.pilot", "sanae.pilot"]); - assert!(matches!(config.scope(), ServiceScope::Global)); - assert_eq!(config.tree().servers().count(), 3); - } -} diff --git a/pishoo/src/config/plan.rs b/pishoo/src/config/plan.rs new file mode 100644 index 00000000..1ed74924 --- /dev/null +++ b/pishoo/src/config/plan.rs @@ -0,0 +1,349 @@ +use dhttp::home::{ + DhttpHome, + identity::{IdentityProfile, ssl::IdentityProfileCandidateError}, +}; +use gateway::parse::{ + TypedConfigParser, + build::BuildTypedConfigError, + config::{PishooConfig, RootWorkerDefaultsSnapshot, ServerConfig}, + error::ConfigLoadFailure, +}; +use snafu::{ResultExt, Snafu}; + +use super::{ + ConfigError, PishooConfigSource, + account::{ + BuildWorkerRosterError, WorkerAccount, WorkerAccountError, WorkerRoster, + select_worker_dhttp_home, + }, + worker_target::{WorkerDiscoveryMode, resolve_worker_targets}, +}; + +#[derive(Debug, Snafu)] +#[snafu(module(load_global_pishoo_plan_error))] +pub enum LoadGlobalPishooPlanError { + #[snafu(display("failed to load root configuration"))] + Config { source: ConfigLoadFailure }, + #[snafu(display("failed to resolve worker directives"))] + WorkerDirectives { source: ConfigError }, + #[snafu(display("failed to resolve worker accounts"))] + WorkerAccounts { source: ConfigError }, + #[snafu(display("failed to construct worker account"))] + WorkerAccount { source: WorkerAccountError }, + #[snafu(display("failed to construct worker roster"))] + WorkerRoster { source: BuildWorkerRosterError }, +} + +#[derive(Debug)] +pub struct GlobalPishooPlan { + source: PishooConfigSource, + pishoo: PishooConfig, + worker_defaults: RootWorkerDefaultsSnapshot, + desired_workers: WorkerRoster, + direct_servers: Box<[gateway::parse::ServerConfigCandidate]>, +} +impl GlobalPishooPlan { + pub fn source(&self) -> &PishooConfigSource { + &self.source + } + pub fn home(&self) -> Option<&DhttpHome> { + self.source.dhttp_home() + } + pub fn pishoo(&self) -> &PishooConfig { + &self.pishoo + } + pub fn worker_defaults(&self) -> &RootWorkerDefaultsSnapshot { + &self.worker_defaults + } + pub fn desired_workers(&self) -> &WorkerRoster { + &self.desired_workers + } + pub fn direct_servers(&self) -> &[gateway::parse::ServerConfigCandidate] { + &self.direct_servers + } + pub fn into_parts( + self, + ) -> ( + Option, + RootWorkerDefaultsSnapshot, + WorkerRoster, + Box<[gateway::parse::ServerConfigCandidate]>, + ) { + ( + self.source.dhttp_home().cloned(), + self.worker_defaults, + self.desired_workers, + self.direct_servers, + ) + } +} + +pub async fn load_global_pishoo_plan( + source: &PishooConfigSource, +) -> Result { + let mut parser = TypedConfigParser::new(); + let parsed = gateway::parse::load_root_config_file( + &mut parser, + source.config_path(), + source.dhttp_home(), + ) + .await + .context(load_global_pishoo_plan_error::ConfigSnafu)?; + let (pishoo, direct_servers) = parsed.into_parts(); + let mode = if source.default_worker_groups_enabled() { + WorkerDiscoveryMode::DefaultGlobalHome + } else { + WorkerDiscoveryMode::ExplicitConfig + }; + let workers = resolve_all_workers_with_mode(&pishoo, mode) + .context(load_global_pishoo_plan_error::WorkerDirectivesSnafu)?; + let targets = resolve_worker_targets(&workers) + .context(load_global_pishoo_plan_error::WorkerAccountsSnafu)?; + let accounts = targets + .iter() + .map(|target| { + WorkerAccount::from_target(target, select_worker_dhttp_home(target)) + .context(load_global_pishoo_plan_error::WorkerAccountSnafu) + }) + .collect::, _>>()?; + let desired_workers = + WorkerRoster::new(accounts).context(load_global_pishoo_plan_error::WorkerRosterSnafu)?; + let worker_defaults = pishoo.worker_defaults(); + Ok(GlobalPishooPlan { + source: source.clone(), + pishoo, + worker_defaults, + desired_workers, + direct_servers, + }) +} + +fn resolve_all_workers_with_mode( + config: &PishooConfig, + mode: WorkerDiscoveryMode, +) -> Result, ConfigError> { + super::worker_target::resolve_all_workers_mode(config, mode) +} + +#[derive(Debug, Snafu)] +#[snafu(module(identity_server_error))] +pub enum IdentityServerError { + #[snafu(display("invalid identity profile candidate"))] + Profile { + source: IdentityProfileCandidateError, + }, + #[snafu(display("failed to load identity server configuration"))] + Config { source: ConfigLoadFailure }, + #[snafu(display("identity server configuration is invalid"))] + Server { source: BuildTypedConfigError }, +} + +#[derive(Debug)] +pub struct IdentityServerCandidate { + profile: Option, + result: Result, IdentityServerError>, +} +impl IdentityServerCandidate { + pub fn profile(&self) -> Option<&IdentityProfile> { + self.profile.as_ref() + } + pub fn result(&self) -> &Result, IdentityServerError> { + &self.result + } + pub fn into_parts( + self, + ) -> ( + Option, + Result, IdentityServerError>, + ) { + (self.profile, self.result) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module(load_identity_server_candidates_error))] +pub enum LoadIdentityServerCandidatesError { + #[snafu(display("failed to enumerate identity profiles"))] + Enumerate { + source: dhttp::home::identity::ssl::ListIdentityProfilesError, + }, +} + +pub async fn load_identity_server_candidates( + home: &DhttpHome, + defaults: &RootWorkerDefaultsSnapshot, +) -> Result, LoadIdentityServerCandidatesError> { + let profiles = home + .identity_profile_candidates() + .await + .context(load_identity_server_candidates_error::EnumerateSnafu)?; + let mut parser = TypedConfigParser::new(); + let mut results = Vec::with_capacity(profiles.len()); + for profile in profiles.into_vec() { + match profile { + Err(source) => results.push(IdentityServerCandidate { + profile: None, + result: Err(IdentityServerError::Profile { source }), + }), + Ok(profile) => { + let loaded = gateway::parse::load_identity_config_file( + &mut parser, + profile.clone(), + defaults, + ) + .await; + let result = match loaded { + Ok(None) => Ok(None), + Ok(Some(candidate)) => candidate + .into_parts() + .1 + .map(Some) + .map_err(|source| IdentityServerError::Server { source }), + Err(source) => Err(IdentityServerError::Config { source }), + }; + results.push(IdentityServerCandidate { + profile: Some(profile), + result, + }); + } + } + } + Ok(results.into_boxed_slice()) +} + +#[derive(Debug)] +pub struct WorkerHomePlan { + account: WorkerAccount, + defaults: RootWorkerDefaultsSnapshot, + servers: Box<[IdentityServerCandidate]>, +} +impl WorkerHomePlan { + pub fn account(&self) -> &WorkerAccount { + &self.account + } + pub fn defaults(&self) -> &RootWorkerDefaultsSnapshot { + &self.defaults + } + pub fn servers(&self) -> &[IdentityServerCandidate] { + &self.servers + } +} + +#[derive(Debug, Snafu)] +#[snafu(module(load_worker_home_plan_error))] +pub enum LoadWorkerHomePlanError { + #[snafu(display("failed to load worker pishoo configuration"))] + Config { source: ConfigLoadFailure }, + #[snafu(display("failed to load worker identity candidates"))] + Identities { + source: LoadIdentityServerCandidatesError, + }, +} + +pub async fn load_worker_home_plan( + account: WorkerAccount, + root: RootWorkerDefaultsSnapshot, +) -> Result { + let home = account.dhttp_home(); + let path = home.join(PishooConfigSource::CONFIG_FILE_NAME); + let mut parser = TypedConfigParser::new(); + let defaults = match gateway::parse::load_worker_config_file(&mut parser, &path, home, &root) + .await + .context(load_worker_home_plan_error::ConfigSnafu)? + { + Some(parsed) => parsed.pishoo().worker_defaults(), + None => root, + }; + let servers = load_identity_server_candidates(home, &defaults) + .await + .context(load_worker_home_plan_error::IdentitiesSnafu)?; + Ok(WorkerHomePlan { + account, + defaults, + servers, + }) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use dhttp::home::DhttpHome; + + use super::*; + + struct TempDir(PathBuf); + impl TempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "pishoo-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[tokio::test] + async fn snapshot_and_roster_come_from_one_valid_root_plan() { + let temp = TempDir::new("root-plan"); + let path = temp.path().join("pishoo.conf"); + std::fs::write(&path, "pishoo { gzip on; }").unwrap(); + let source = PishooConfigSource::explicit_at(path, temp.path()).unwrap(); + + let plan = load_global_pishoo_plan(&source).await.unwrap(); + + assert!(plan.worker_defaults().http().gzip().effective().0); + assert!(plan.desired_workers().is_empty()); + } + + #[tokio::test] + async fn bad_identity_server_does_not_fail_sibling_candidate() { + let temp = TempDir::new("identity-siblings"); + for (name, server) in [ + ("good.dhttp.net", "server { listen all 443; }"), + ("bad.dhttp.net", "server {"), + ] { + let profile = temp.path().join(name); + std::fs::create_dir_all(profile.join("ssl")).unwrap(); + std::fs::write(profile.join("server.conf"), server).unwrap(); + } + let home = DhttpHome::new(temp.path().to_path_buf()); + let defaults = gateway::parse::TypedConfigParser::new() + .parse_root("pishoo {}", &temp.path().join("root.conf"), Some(&home)) + .unwrap() + .pishoo() + .worker_defaults(); + + let children = load_identity_server_candidates(&home, &defaults) + .await + .unwrap(); + + assert_eq!( + children + .iter() + .filter(|child| child.result().is_ok()) + .count(), + 1 + ); + assert_eq!( + children + .iter() + .filter(|child| child.result().is_err()) + .count(), + 1 + ); + } +} diff --git a/pishoo/src/config/root.rs b/pishoo/src/config/root.rs deleted file mode 100644 index 28738ff9..00000000 --- a/pishoo/src/config/root.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::path::PathBuf; - -use gateway::parse::{document::ConfigNode, types::StringList}; -use snafu::ResultExt; - -use super::{ - ConfigError, first_pishoo_node, parse_pid_file, - worker_target::{WorkerTarget, resolve_all_workers}, -}; - -#[derive(Debug, Clone)] -pub struct RootConfig { - pub pid_file: PathBuf, - pub groups: Vec, - pub workers: Vec, -} - -pub fn parse_root_config(root: &std::sync::Arc) -> Result { - let pishoo = first_pishoo_node(root)?; - let pid_file = parse_pid_file(&pishoo)?; - - let groups = pishoo - .get::("groups") - .context(super::ConfigQuerySnafu)? - .map(|groups| groups.0.clone()) - .unwrap_or_default(); - - let workers = resolve_all_workers(&pishoo)?; - - Ok(RootConfig { - pid_file, - groups, - workers, - }) -} diff --git a/pishoo/src/config/source.rs b/pishoo/src/config/source.rs index 609f8f99..c7c317de 100644 --- a/pishoo/src/config/source.rs +++ b/pishoo/src/config/source.rs @@ -113,13 +113,6 @@ impl PishooConfigSource { self.home.as_ref() } - pub fn build_options(&self) -> gateway::parse::registry::BuildOptions<'_> { - gateway::parse::registry::BuildOptions { - dhttp_home: self.dhttp_home(), - identity_profile: None, - } - } - pub fn default_worker_groups_enabled(&self) -> bool { self.kind == ConfigSourceKind::GlobalHome } diff --git a/pishoo/src/config/tests.rs b/pishoo/src/config/tests.rs deleted file mode 100644 index 06ade786..00000000 --- a/pishoo/src/config/tests.rs +++ /dev/null @@ -1,557 +0,0 @@ -use std::{ffi::CString, path::PathBuf}; - -use crate::config::{ - PID_FILE_DEFAULT, - entry::parse_entry_config, - root::parse_root_config, - worker_target::{ - AccountDirectory, AccountGroup, Gid, ResolvedWorkerTarget, Uid, WorkerTarget, - compute_worker_diff, resolve_worker_targets, - }, -}; - -#[test] -fn pishoo_public_stun_servers_key_is_repeated() { - fn accepts_repeated(_: gateway::parse::registry::RepeatedDirectiveKey) {} - - accepts_repeated::( - gateway::parse::keys::server::STUN_SERVERS, - ); -} - -fn create_temp_tls_files() -> (PathBuf, PathBuf) { - let base = std::env::temp_dir().join(format!( - "pishoo-config-test-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after epoch") - .as_nanos() - )); - std::fs::create_dir_all(&base).expect("create temp tls dir"); - let cert = base.join("server.pem"); - let key = base.join("server.key"); - std::fs::write(&cert, b"dummy cert").expect("write cert fixture"); - std::fs::write(&key, b"dummy key").expect("write key fixture"); - (cert, key) -} - -#[test] -fn invalid_workers_type_is_rejected() { - let failure = gateway::parse::parse_config_str_for_test("pishoo { workers { nested on; } }") - .expect_err("workers block should be rejected before config parse"); - assert!(!failure.error.to_string().contains('\n')); -} - -#[test] -fn extracts_pid_and_workers() { - let conf = "pishoo { pid /tmp/pishoo-test.pid; workers alice bob; }"; - let parsed = gateway::parse::parse_config_str_for_test(conf).expect("parse config"); - let root = parse_root_config(&parsed.root).expect("parse root config"); - assert_eq!(root.pid_file, PathBuf::from("/tmp/pishoo-test.pid")); - assert_eq!(root.workers.len(), 2); - assert_eq!(root.workers[0].username, "alice"); - assert_eq!(root.workers[1].username, "bob"); -} - -#[test] -fn pishoo_keys_query_pid_workers_and_groups_across_crate() { - let base = std::env::temp_dir().join(format!( - "pishoo-typed-keys-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after epoch") - .as_nanos() - )); - let source_path = base.join("pishoo.conf"); - let registry = gateway::parse::default_registry(); - let mut parser = gateway::parse::ConfigDocumentParser::new(®istry); - let gateway::parse::fragment::ParsedConfigDocument::HypervisorRoot(fragment) = parser - .parse_text( - "pishoo { pid run/pishoo.pid; workers alice bob; groups admin web; }", - &source_path, - gateway::parse::domain::ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .expect("root config should parse") - else { - panic!("expected pishoo fragment"); - }; - let tree = gateway::parse::tree::build_global_tree(®istry, fragment, Vec::new()) - .expect("tree should seal"); - let pishoo = tree.pishoo(); - - let pid = pishoo - .local(gateway::parse::keys::pishoo::PID) - .expect("pid query") - .expect("pid should exist"); - let workers = pishoo - .local(gateway::parse::keys::pishoo::WORKERS) - .expect("workers query") - .expect("workers should exist"); - let groups = pishoo - .local(gateway::parse::keys::pishoo::GROUPS) - .expect("groups query") - .expect("groups should exist"); - - assert_eq!(pid.as_ref().as_ref(), base.join("run/pishoo.pid")); - assert_eq!(workers.0, vec!["alice", "bob"]); - assert_eq!(groups.0, vec!["admin", "web"]); -} - -#[test] -fn public_key_inventory_has_semantic_query_domains() { - fn local(_: gateway::parse::registry::LocalDirectiveKey) {} - fn repeated(_: gateway::parse::registry::RepeatedDirectiveKey) {} - fn payload(_: gateway::parse::registry::ContextPayloadKey) {} - fn cascaded(_: gateway::parse::cascade::DirectiveKey) {} - - local(gateway::parse::keys::pishoo::PID); - local(gateway::parse::keys::pishoo::WORKERS); - local(gateway::parse::keys::pishoo::GROUPS); - - repeated(gateway::parse::keys::server::LISTEN); - local(gateway::parse::keys::server::SERVER_NAME); - local(gateway::parse::keys::server::DNS); - cascaded(gateway::parse::keys::server::GZIP); - cascaded(gateway::parse::keys::server::GZIP_VARY); - cascaded(gateway::parse::keys::server::GZIP_MIN_LENGTH); - cascaded(gateway::parse::keys::server::GZIP_COMP_LEVEL); - cascaded(gateway::parse::keys::server::GZIP_TYPES); - local(gateway::parse::keys::server::SSL_CERTIFICATE); - local(gateway::parse::keys::server::SSL_CERTIFICATE_KEY); - cascaded(gateway::parse::keys::server::DEFAULT_TYPE); - cascaded(gateway::parse::keys::server::ACCESS_RULES); - local(gateway::parse::keys::server::RELAY); - local(gateway::parse::keys::server::STUN); - cascaded(gateway::parse::keys::server::TYPES); - - payload(gateway::parse::keys::location::PATTERN); - local(gateway::parse::keys::location::ROOT); - local(gateway::parse::keys::location::ALIAS); - cascaded(gateway::parse::keys::location::GZIP); - cascaded(gateway::parse::keys::location::GZIP_VARY); - cascaded(gateway::parse::keys::location::GZIP_MIN_LENGTH); - cascaded(gateway::parse::keys::location::GZIP_COMP_LEVEL); - cascaded(gateway::parse::keys::location::GZIP_TYPES); - local(gateway::parse::keys::location::INDEX); - repeated(gateway::parse::keys::location::ADD_HEADER); - repeated(gateway::parse::keys::location::PROXY_SET_HEADER); - local(gateway::parse::keys::location::PROXY_PASS); - local(gateway::parse::keys::location::PROXY_SSL_CERTIFICATE); - local(gateway::parse::keys::location::PROXY_SSL_CERTIFICATE_KEY); - local(gateway::parse::keys::location::PROXY_SSL_TRUSTED_CERTIFICATE); - local(gateway::parse::keys::location::SSH_LOGIN); - repeated(gateway::parse::keys::location::SSH_SSL_USER); - local(gateway::parse::keys::location::SSH_DENY); - cascaded(gateway::parse::keys::location::DEFAULT_TYPE); - cascaded(gateway::parse::keys::location::TYPES); -} - -#[test] -fn pishoo_public_cascaded_keys_query_server_and_location_across_crate() { - fn cascaded(_: gateway::parse::cascade::DirectiveKey) {} - - cascaded(gateway::parse::keys::server::GZIP); - cascaded(gateway::parse::keys::server::GZIP_VARY); - cascaded(gateway::parse::keys::server::GZIP_MIN_LENGTH); - cascaded(gateway::parse::keys::server::GZIP_COMP_LEVEL); - cascaded(gateway::parse::keys::server::GZIP_TYPES); - cascaded(gateway::parse::keys::server::DEFAULT_TYPE); - cascaded(gateway::parse::keys::server::ACCESS_RULES); - cascaded(gateway::parse::keys::server::TYPES); - cascaded(gateway::parse::keys::location::GZIP); - cascaded(gateway::parse::keys::location::GZIP_VARY); - cascaded(gateway::parse::keys::location::GZIP_MIN_LENGTH); - cascaded(gateway::parse::keys::location::GZIP_COMP_LEVEL); - cascaded(gateway::parse::keys::location::GZIP_TYPES); - cascaded(gateway::parse::keys::location::DEFAULT_TYPE); - cascaded(gateway::parse::keys::location::TYPES); - - let base = std::env::temp_dir().join(format!( - "pishoo-cascaded-keys-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after epoch") - .as_nanos() - )); - let source_path = base.join("pishoo.conf"); - let mut registry = gateway::parse::default_registry(); - registry.register_directive( - gateway::parse::registry::context::SERVER, - gateway::parse::registry::DirectiveSpec::leaf_value::( - "gzip", - vec![gateway::parse::registry::context::SERVER], - gateway::parse::registry::DuplicatePolicy::Reject, - gateway::parse::registry::CascadePolicy::NearestWins, - gateway::parse::registry::TransportPolicy::WorkerInheritable, - gateway::parse::registry::ReloadImpact::ListenerSet, - ), - ); - let mut parser = gateway::parse::ConfigDocumentParser::new(®istry); - let gateway::parse::fragment::ParsedConfigDocument::HypervisorRoot(fragment) = parser - .parse_text( - "pishoo { gzip on; server { listen all 4101; ssl_certificate cert.pem; ssl_certificate_key key.pem; gzip off; location / { gzip on; } } }", - &source_path, - gateway::parse::domain::ConfigDocumentRole::HypervisorRoot { home: None }, - ) - .expect("root config should parse") - else { - panic!("expected pishoo fragment"); - }; - let tree = gateway::parse::tree::build_global_tree(®istry, fragment, Vec::new()) - .expect("tree should seal"); - let server = tree.servers().next().expect("server should attach"); - let location = server.locations().next().expect("location should attach"); - - let server_gzip = server - .node() - .cascaded(gateway::parse::keys::server::GZIP) - .expect("server gzip query") - .expect("server gzip should inherit a value"); - let location_gzip = location - .node() - .cascaded(gateway::parse::keys::location::GZIP) - .expect("location gzip query") - .expect("location gzip should inherit a value"); - - assert!(!server_gzip.effective().0); - assert!(location_gzip.effective().0); -} - -#[test] -fn parse_workers_from_root_config() { - let conf = "pishoo { workers alice bob; }"; - let parsed = gateway::parse::parse_config_str_for_test(conf).expect("parse config"); - let root = parse_root_config(&parsed.root).expect("parse root config"); - assert_eq!(root.pid_file, PathBuf::from(PID_FILE_DEFAULT)); - assert_eq!(root.workers.len(), 2); - assert_eq!(root.workers[0].username, "alice"); - assert_eq!(root.workers[1].username, "bob"); -} - -#[test] -fn resolve_existing_user_target() { - let me = nix::unistd::User::from_uid(nix::unistd::getuid()) - .expect("resolve current uid") - .expect("current user exists"); - let workers = vec![WorkerTarget { username: me.name }]; - let resolved = resolve_worker_targets(&workers).expect("resolve user target"); - assert_eq!(resolved.len(), 1); - assert!(!resolved[0].dir.as_os_str().is_empty()); -} - -#[test] -fn parse_entry_config_servers_only() { - let (cert, key) = create_temp_tls_files(); - let conf = format!( - "pishoo {{ server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root /tmp; }} }} }}", - cert.display(), - key.display() - ); - let parsed = gateway::parse::parse_config_str_for_test(&conf).expect("parse config"); - let entry = crate::config::entry::parse_entry_config_with_directory( - &parsed.root, - &FakeAccountDirectory::default(), - ) - .expect("parse entry config"); - assert!(entry.workers.is_empty()); - assert_eq!(entry.config_services.len(), 1); -} - -#[tokio::test] -async fn parse_entry_config_servers_only_from_file_config() { - let (cert, key) = create_temp_tls_files(); - let dir = cert.parent().expect("temp dir").to_path_buf(); - let conf_path = dir.join("pishoo.conf"); - std::fs::write( - &conf_path, - format!( - "pishoo {{ server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root .; }} }} }}", - cert.display(), - key.display(), - ), - ) - .expect("write config"); - - let registry = gateway::parse::default_registry(); - let parsed = gateway::parse::load_config_file( - &conf_path, - ®istry, - gateway::parse::registry::BuildOptions::default(), - ) - .await - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory( - &parsed.root, - &FakeAccountDirectory::default(), - ) - .expect("parse entry config"); - assert!(entry.workers.is_empty()); - assert_eq!(entry.config_services.len(), 1); - - let location = entry.config_services[0] - .children("location") - .expect("location children")[0] - .clone(); - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .as_ref() - .as_ref(), - dir, - ); -} - -#[test] -fn parse_entry_config_workers_and_servers() { - let (cert, key) = create_temp_tls_files(); - let conf = format!( - "pishoo {{ workers alice; server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root /tmp; }} }} }}", - cert.display(), - key.display() - ); - let parsed = gateway::parse::parse_config_str_for_test(&conf).expect("parse config"); - let entry = parse_entry_config(&parsed.root).expect("parse entry config"); - assert_eq!(entry.workers.len(), 1); - assert_eq!(entry.config_services.len(), 1); -} - -#[derive(Default)] -struct FakeAccountDirectory { - groups: std::collections::HashMap, - group_members: std::collections::HashMap>, -} - -impl FakeAccountDirectory { - fn with_group_members(mut self, name: &str, gid: libc::gid_t, members: &[&str]) -> Self { - self.groups.insert( - name.to_string(), - AccountGroup { - name: name.to_string(), - gid: Gid::from_raw(gid), - members: Vec::new(), - }, - ); - self.group_members.insert( - name.to_string(), - members.iter().map(|member| (*member).to_string()).collect(), - ); - self - } -} - -impl AccountDirectory for FakeAccountDirectory { - fn group_by_name( - &self, - group_name: &str, - ) -> Result, crate::config::ConfigError> { - Ok(self.groups.get(group_name).cloned()) - } - - fn group_member_usernames( - &self, - group_name: &str, - ) -> Result>, crate::config::ConfigError> { - Ok(self.group_members.get(group_name).cloned()) - } -} - -#[cfg(not(target_os = "macos"))] -#[test] -fn default_global_mode_loads_pishoo_group_when_no_worker_directives() { - let directory = - FakeAccountDirectory::default().with_group_members("pishoo", 42, &["alice", "bob"]); - let parsed = gateway::parse::parse_config_str_for_test("pishoo { pid /tmp/pishoo.pid; }") - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("parse entry config"); - - let names: Vec<_> = entry - .workers - .iter() - .map(|worker| worker.username.as_str()) - .collect(); - assert_eq!(names, ["alice", "bob"]); -} - -#[cfg(target_os = "macos")] -#[test] -fn default_global_mode_loads_www_group_when_no_worker_directives() { - let directory = - FakeAccountDirectory::default().with_group_members("_www", 70, &["alice", "bob"]); - let parsed = gateway::parse::parse_config_str_for_test("pishoo { pid /tmp/pishoo.pid; }") - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("parse entry config"); - - let names: Vec<_> = entry - .workers - .iter() - .map(|worker| worker.username.as_str()) - .collect(); - assert_eq!(names, ["alice", "bob"]); -} - -#[test] -fn explicit_config_mode_does_not_load_default_pishoo_group() { - let directory = - FakeAccountDirectory::default().with_group_members("pishoo", 42, &["alice", "bob"]); - let parsed = gateway::parse::parse_config_str_for_test("pishoo { pid /tmp/pishoo.pid; }") - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::ExplicitConfig, - ) - .expect("parse entry config"); - - assert!(entry.workers.is_empty()); -} - -#[test] -fn default_global_mode_uses_default_group_even_with_config_servers() { - let (cert, key) = create_temp_tls_files(); - let conf = format!( - "pishoo {{ server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root /tmp; }} }} }}", - cert.display(), - key.display() - ); - let parsed = gateway::parse::parse_config_str_for_test(&conf).expect("parse config"); - let directory = FakeAccountDirectory::default().with_group_members("pishoo", 42, &["alice"]); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("parse entry config"); - - assert_eq!(entry.config_services.len(), 1); - assert_eq!(entry.workers.len(), 1); - assert_eq!(entry.workers[0].username, "alice"); -} - -#[test] -fn worker_diff_unchanged_ignores_order() { - let current = vec![ - ResolvedWorkerTarget { - uid: Uid::from_raw(1), - gid: Gid::from_raw(11), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }, - ResolvedWorkerTarget { - uid: Uid::from_raw(2), - gid: Gid::from_raw(22), - name: "bob".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/bob"), - shell: PathBuf::new(), - }, - ]; - let next = vec![ - ResolvedWorkerTarget { - uid: Uid::from_raw(2), - gid: Gid::from_raw(22), - name: "bob".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/bob"), - shell: PathBuf::new(), - }, - ResolvedWorkerTarget { - uid: Uid::from_raw(1), - gid: Gid::from_raw(11), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }, - ]; - - let diff = compute_worker_diff(¤t, &next); - assert_eq!(diff.unchanged.len(), 2); - assert!(diff.added.is_empty()); - assert!(diff.removed.is_empty()); - assert!(diff.changed.is_empty()); -} - -#[test] -fn worker_diff_detects_add_remove() { - let current = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(1), - gid: Gid::from_raw(11), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }]; - let next = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(2), - gid: Gid::from_raw(22), - name: "bob".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/bob"), - shell: PathBuf::new(), - }]; - - let diff = compute_worker_diff(¤t, &next); - assert!(diff.unchanged.is_empty()); - assert_eq!(diff.added.len(), 1); - assert_eq!(diff.added[0].name, "bob"); - assert_eq!(diff.removed.len(), 1); - assert_eq!(diff.removed[0].name, "alice"); - assert!(diff.changed.is_empty()); -} - -#[test] -fn worker_diff_detects_uid_change() { - let current = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(1000), - gid: Gid::from_raw(1000), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }]; - let next = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(1001), - gid: Gid::from_raw(1001), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }]; - - let diff = compute_worker_diff(¤t, &next); - assert!(diff.unchanged.is_empty()); - assert!(diff.added.is_empty()); - assert!(diff.removed.is_empty()); - assert_eq!(diff.changed.len(), 1); - assert_eq!(diff.changed[0].0.uid, Uid::from_raw(1000)); - assert_eq!(diff.changed[0].1.uid, Uid::from_raw(1001)); - assert_eq!(diff.changed[0].1.name, "alice"); -} diff --git a/pishoo/src/config/worker_target.rs b/pishoo/src/config/worker_target.rs index b77b216b..92668cb5 100644 --- a/pishoo/src/config/worker_target.rs +++ b/pishoo/src/config/worker_target.rs @@ -1,12 +1,10 @@ -use std::collections::HashMap; - -use gateway::parse::{document::ConfigNode, types::StringList}; +use gateway::parse::config::PishooConfig; pub use nix::unistd::{Gid, Uid, User as ResolvedWorkerTarget}; use snafu::{OptionExt, ResultExt}; use super::{ - ConfigError, ConfigQuerySnafu, EmptyWorkerNameSnafu, GroupNotFoundSnafu, GroupResolveSnafu, - MissingHomeSnafu, PrimaryGroupUserResolveSnafu, UserNotFoundSnafu, UserResolveSnafu, + ConfigError, EmptyWorkerNameSnafu, GroupNotFoundSnafu, GroupResolveSnafu, MissingHomeSnafu, + PrimaryGroupUserResolveSnafu, UserNotFoundSnafu, UserResolveSnafu, }; #[derive(Debug, Clone)] @@ -47,22 +45,17 @@ fn parse_worker_names(names: &[String]) -> Result, ConfigError .collect() } -fn parse_configured_workers(pishoo: &ConfigNode) -> Result, ConfigError> { - match pishoo - .get::("workers") - .context(ConfigQuerySnafu)? - { - Some(names) => parse_worker_names(&names.0), - None => Ok(Vec::new()), - } +fn parse_configured_workers(pishoo: &PishooConfig) -> Result, ConfigError> { + pishoo + .workers() + .map_or(Ok(Vec::new()), |names| parse_worker_names(&names.0)) } -fn parse_groups(pishoo: &ConfigNode) -> Result, ConfigError> { - Ok(pishoo - .get::("groups") - .context(ConfigQuerySnafu)? +fn parse_groups(pishoo: &PishooConfig) -> Vec { + pishoo + .groups() .map(|names| names.0.clone()) - .unwrap_or_default()) + .unwrap_or_default() } #[derive(Debug, Clone)] @@ -273,18 +266,20 @@ fn resolve_default_group_members( Ok(targets) } -pub(super) fn resolve_all_workers(pishoo: &ConfigNode) -> Result, ConfigError> { - let directory = SystemAccountDirectory; - resolve_all_workers_with_directory(pishoo, &directory, WorkerDiscoveryMode::ExplicitConfig) +pub(super) fn resolve_all_workers_mode( + pishoo: &PishooConfig, + mode: WorkerDiscoveryMode, +) -> Result, ConfigError> { + resolve_all_workers_with_directory(pishoo, &SystemAccountDirectory, mode) } pub(super) fn resolve_all_workers_with_directory( - pishoo: &ConfigNode, + pishoo: &PishooConfig, directory: &D, mode: WorkerDiscoveryMode, ) -> Result, ConfigError> { let explicit_workers = parse_configured_workers(pishoo)?; - let groups = parse_groups(pishoo)?; + let groups = parse_groups(pishoo); let group_members = if groups.is_empty() && explicit_workers.is_empty() && mode.default_groups_enabled() { @@ -306,12 +301,6 @@ pub(super) fn resolve_all_workers_with_directory( Ok(result) } -pub fn resolve_entry_worker_targets( - entry_config: &super::EntryConfig, -) -> Result, ConfigError> { - resolve_worker_targets(&entry_config.workers) -} - pub fn resolve_worker_targets( workers: &[WorkerTarget], ) -> Result, ConfigError> { @@ -334,214 +323,3 @@ pub fn resolve_worker_targets( } Ok(resolved) } - -/// Diff result describing which workers are unchanged, added, removed, or changed. -#[derive(Debug)] -pub struct WorkerDiff { - /// Workers present in both current and next with same (username, uid). - pub unchanged: Vec, - /// Workers present in next but not in current. - pub added: Vec, - /// Workers present in current but not in next. - pub removed: Vec, - /// Workers where username matches but uid changed — these need kill + respawn. - pub changed: Vec<(ResolvedWorkerTarget, ResolvedWorkerTarget)>, -} - -/// Compute a diff between current and next worker targets. -pub fn compute_worker_diff( - current: &[ResolvedWorkerTarget], - next: &[ResolvedWorkerTarget], -) -> WorkerDiff { - let current_map: HashMap<&str, &ResolvedWorkerTarget> = - current.iter().map(|t| (t.name.as_str(), t)).collect(); - let next_map: HashMap<&str, &ResolvedWorkerTarget> = - next.iter().map(|t| (t.name.as_str(), t)).collect(); - - let mut unchanged = Vec::new(); - let mut added = Vec::new(); - let mut changed = Vec::new(); - let mut removed = Vec::new(); - - for next_target in next { - match current_map.get(next_target.name.as_str()) { - Some(cur) if cur.uid == next_target.uid => { - unchanged.push(next_target.clone()); - } - Some(cur) => { - changed.push(((*cur).clone(), next_target.clone())); - } - None => { - added.push(next_target.clone()); - } - } - } - - for cur_target in current { - if !next_map.contains_key(cur_target.name.as_str()) { - removed.push(cur_target.clone()); - } - } - - WorkerDiff { - unchanged, - added, - removed, - changed, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use gateway::parse::document::ConfigNode; - - use super::*; - - #[derive(Default)] - struct FakeAccountDirectory { - groups: HashMap, - primary_users: HashMap>, - } - - impl FakeAccountDirectory { - fn with_group(mut self, name: &str, gid: libc::gid_t, members: &[&str]) -> Self { - self.groups.insert( - name.to_string(), - AccountGroup { - name: name.to_string(), - gid: Gid::from_raw(gid), - members: members.iter().map(|member| (*member).to_string()).collect(), - }, - ); - self - } - - fn with_primary_users(mut self, gid: libc::gid_t, users: &[&str]) -> Self { - self.primary_users - .insert(gid, users.iter().map(|user| (*user).to_string()).collect()); - self - } - } - - impl AccountDirectory for FakeAccountDirectory { - fn group_by_name(&self, group_name: &str) -> Result, ConfigError> { - Ok(self.groups.get(group_name).cloned()) - } - - fn primary_group_usernames( - &self, - _group_name: &str, - gid: Gid, - ) -> Result, ConfigError> { - Ok(self - .primary_users - .get(&gid.as_raw()) - .cloned() - .unwrap_or_default()) - } - } - - fn first_pishoo(conf: &str) -> std::sync::Arc { - let parsed = gateway::parse::parse_config_str_for_test(conf).expect("parse config"); - parsed - .root - .children("pishoo") - .expect("pishoo block should exist") - .first() - .expect("pishoo block should not be empty") - .clone() - } - - #[test] - fn default_group_missing_is_not_a_config_error() { - let pishoo = first_pishoo("pishoo { }"); - let directory = FakeAccountDirectory::default(); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("missing default pishoo group should warn and continue"); - - assert!(workers.is_empty()); - } - - #[test] - fn explicit_group_missing_remains_a_config_error() { - let pishoo = first_pishoo("pishoo { groups pishoo; }"); - let directory = FakeAccountDirectory::default(); - - let error = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::ExplicitConfig, - ) - .expect_err("explicit missing group should fail"); - - assert_eq!(error.to_string(), "group `pishoo` not found"); - } - - #[test] - fn default_group_includes_primary_gid_users() { - let pishoo = first_pishoo("pishoo { }"); - let directory = FakeAccountDirectory::default() - .with_group("pishoo", 42, &["alice"]) - .with_primary_users(42, &["bob", "carol"]); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("default group should resolve"); - - let usernames = workers - .iter() - .map(|worker| worker.username.as_str()) - .collect::>(); - assert_eq!(usernames, ["alice", "bob", "carol"]); - } - - #[test] - fn default_group_deduplicates_supplementary_and_primary_users() { - let pishoo = first_pishoo("pishoo { }"); - let directory = FakeAccountDirectory::default() - .with_group("pishoo", 42, &["alice", "bob"]) - .with_primary_users(42, &["bob", "carol"]); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("default group should resolve"); - - let usernames = workers - .iter() - .map(|worker| worker.username.as_str()) - .collect::>(); - assert_eq!(usernames, ["alice", "bob", "carol"]); - } - - #[test] - fn explicit_workers_are_kept_before_default_group_users() { - let pishoo = first_pishoo("pishoo { workers zoe alice; }"); - let directory = FakeAccountDirectory::default().with_group("pishoo", 42, &["bob"]); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::ExplicitConfig, - ) - .expect("explicit workers should resolve without default group lookup"); - - let usernames = workers - .iter() - .map(|worker| worker.username.as_str()) - .collect::>(); - assert_eq!(usernames, ["zoe", "alice"]); - } -} diff --git a/pishoo/src/hypervisor/global_service.rs b/pishoo/src/hypervisor/global_service.rs index 01fb9ef7..ab0ad915 100644 --- a/pishoo/src/hypervisor/global_service.rs +++ b/pishoo/src/hypervisor/global_service.rs @@ -5,47 +5,14 @@ use std::sync::Arc; -use snafu::{ResultExt, Snafu}; - use crate::{ hypervisor::{in_process_plane::InProcessControlPlane, state::RootState}, service::{ runtime::RuntimeRegistry, - source::{PishooConfigServiceSource, PrepareContext, ServerSource}, + source::{PrepareContext, ServerSource, TypedServerSource}, }, }; -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildGlobalSourcesError { - #[snafu(display("failed to load identity services"))] - IdentityServices { - source: crate::worker::config::BuildConfigError, - }, - #[snafu(display("failed to load pishoo config services"))] - ConfigServices { - source: crate::service::source::BuildConfigServiceSourcesError, - }, - #[snafu(display("failed to prepare global service context"))] - PrepareContext { - source: crate::service::source::BuildPrepareContextError, - }, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum SpawnGlobalServiceError { - #[snafu(display("failed to build global service sources"))] - Build { source: BuildGlobalSourcesError }, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum ReplaceGlobalServiceError { - #[snafu(display("failed to build global service sources"))] - Build { source: BuildGlobalSourcesError }, -} - /// Handle to a running global service, used for shutdown and replacement. pub struct GlobalServiceHandle { registry: Option>, @@ -58,6 +25,31 @@ impl GlobalServiceHandle { registry.shutdown().await; } } + + pub async fn wait_service_completion(&mut self) -> dhttp::name::DhttpName<'static> { + self.registry + .as_mut() + .expect("a live global service handle owns its registry") + .wait_service_completion() + .await + } + + pub async fn handle_service_exit(&mut self, name: dhttp::name::DhttpName<'static>) { + self.registry + .as_mut() + .expect("a live global service handle owns its registry") + .handle_service_exit(name) + .await; + } +} + +pub async fn wait_global_service_completion( + handle: &mut Option, +) -> dhttp::name::DhttpName<'static> { + match handle { + Some(handle) => handle.wait_service_completion().await, + None => std::future::pending().await, + } } impl Drop for GlobalServiceHandle { @@ -71,64 +63,47 @@ impl Drop for GlobalServiceHandle { } async fn build_global_sources( - source: &crate::config::PishooConfigSource, - entry_config: &crate::config::EntryConfig, + plan: &crate::config::GlobalPishooPlan, router_state: gateway::reverse::router::RouterState, -) -> Result<(Vec, PrepareContext), BuildGlobalSourcesError> { - let mut sources = Vec::new(); - let mut ctx = None; - - if source.load_identity_services() { - let home = source.dhttp_home().expect("global source has dhttp home"); - let identity_sources = crate::worker::config::load_identity_service_sources(home) - .await - .context(build_global_sources_error::IdentityServicesSnafu)?; - sources.extend( - identity_sources - .into_iter() - .map(ServerSource::IdentityService), - ); - } - - if !entry_config.config_services.is_empty() { - let (config_sources, config_ctx) = PishooConfigServiceSource::load_all( - &entry_config.config_services, - source.dhttp_home(), - router_state.clone(), - ) - .await - .context(build_global_sources_error::ConfigServicesSnafu)?; - sources.extend(config_sources); - ctx = Some(config_ctx); +) -> (Vec, PrepareContext) { + let mut configs = Vec::new(); + for candidate in plan.direct_servers() { + match candidate.result() { + Ok(server) => configs.push(Arc::new(server.clone())), + Err(error) => { + tracing::warn!(error = %snafu::Report::from_error(error), "direct server config rejected") + } + } } - - let ctx = match ctx { - Some(ctx) => ctx, - None => { - let Some(home) = source.dhttp_home() else { - return Ok(( - sources, - PrepareContext::load_config_service(None, router_state) - .await - .context(build_global_sources_error::PrepareContextSnafu)?, - )); - }; - PrepareContext::load_worker(home, router_state) - .await - .context(build_global_sources_error::PrepareContextSnafu)? + if let Some(home) = plan.home() { + match crate::config::load_identity_server_candidates(home, plan.worker_defaults()).await { + Ok(candidates) => { + for candidate in candidates.into_vec() { + let (profile, result) = candidate.into_parts(); + match result { + Ok(Some(server)) => configs.push(Arc::new(server)), + Ok(None) => {} + Err(error) => { + tracing::warn!(profile = ?profile.map(|p| p.name().to_string()), error = %snafu::Report::from_error(&error), "identity server config rejected") + } + } + } + } + Err(error) => tracing::warn!( + error = %snafu::Report::from_error(&error), + "global identity service discovery failed" + ), } - }; - - Ok((sources, ctx)) + } + TypedServerSource::load_all(configs, router_state).await } /// Spawn the global service from configuration. Returns `None` if no global /// services are configured. pub async fn spawn_global_service( state: &Arc, - source: &crate::config::PishooConfigSource, - entry_config: &crate::config::EntryConfig, -) -> Result, SpawnGlobalServiceError> { + plan: &crate::config::GlobalPishooPlan, +) -> Option { let plane = Arc::new(InProcessControlPlane::new(state.clone())); let router_state = gateway::reverse::router::RouterState { @@ -138,13 +113,11 @@ pub async fn spawn_global_service( task_scope: Arc::new(state.local_task_scope()), }; - let (sources, ctx) = build_global_sources(source, entry_config, router_state) - .await - .context(spawn_global_service_error::BuildSnafu)?; + let (sources, ctx) = build_global_sources(plan, router_state).await; if sources.is_empty() { tracing::debug!("no global services configured"); - return Ok(None); + return None; } let service_count = sources.len(); @@ -153,18 +126,17 @@ pub async fn spawn_global_service( tracing::info!(services = service_count, "global service started"); - Ok(Some(GlobalServiceHandle { + Some(GlobalServiceHandle { registry: Some(registry), - })) + }) } /// Replace the running global service with a freshly built one. pub async fn replace_global_service( state: &Arc, handle: &mut Option, - source: &crate::config::PishooConfigSource, - entry_config: &crate::config::EntryConfig, -) -> Result<(), ReplaceGlobalServiceError> { + plan: &crate::config::GlobalPishooPlan, +) { let plane = Arc::new(InProcessControlPlane::new(state.clone())); let router_state = gateway::reverse::router::RouterState { @@ -174,15 +146,13 @@ pub async fn replace_global_service( task_scope: Arc::new(state.local_task_scope()), }; - let (sources, ctx) = build_global_sources(source, entry_config, router_state) - .await - .context(replace_global_service_error::BuildSnafu)?; + let (sources, ctx) = build_global_sources(plan, router_state).await; if sources.is_empty() { if let Some(old) = handle.take() { old.shutdown().await; } - return Ok(()); + return; } if let Some(registry) = handle @@ -190,7 +160,7 @@ pub async fn replace_global_service( .and_then(|existing| existing.registry.as_mut()) { registry.apply_sources(sources, &ctx).await; - return Ok(()); + return; } let mut new_registry = RuntimeRegistry::new(plane); @@ -201,6 +171,4 @@ pub async fn replace_global_service( *handle = Some(GlobalServiceHandle { registry: Some(new_registry), }); - - Ok(()) } diff --git a/pishoo/src/hypervisor/in_process_plane.rs b/pishoo/src/hypervisor/in_process_plane.rs index be7cf064..4c58992b 100644 --- a/pishoo/src/hypervisor/in_process_plane.rs +++ b/pishoo/src/hypervisor/in_process_plane.rs @@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken; use crate::{ hypervisor::{ endpoint_factory, - state::{AcquireListenerError, RebuildListenerError, owner::Owner}, + state::{AcquireListenerError, owner::Owner}, }, listen::RegisteredEndpoint, }; @@ -255,22 +255,10 @@ fn send_local_session_signal(child_pid: Pid, signal: Signal) { impl gateway::control_plane::ProvideListener for InProcessControlPlane { type Listener = RegisteredEndpoint; type ListenError = AcquireListenerError; - type RebuildError = RebuildListenerError; async fn listener(&self, request: ListenRequest) -> Result { self.state.acquire_listener(Owner::Local, request).await } - - async fn rebuild_listener( - &self, - _old: Self::Listener, - request: ListenRequest, - ) -> Result { - // _old is consumed and dropped; root disarms its release guard when the - // rebuild transition starts, so the drop cannot release the - // replacement listener. - self.state.rebuild_listener(Owner::Local, request).await - } } impl gateway::control_plane::ProvideConnector for InProcessControlPlane { diff --git a/pishoo/src/hypervisor/ipc_server.rs b/pishoo/src/hypervisor/ipc_server.rs index 92d815be..ceb68938 100644 --- a/pishoo/src/hypervisor/ipc_server.rs +++ b/pishoo/src/hypervisor/ipc_server.rs @@ -34,8 +34,8 @@ use tracing::Instrument; use super::{endpoint_factory, state::RootState, task_scope::TaskScope}; use crate::{ - hypervisor::state::{AcquireListenerError, RebuildListenerError}, - ipc::{ConnectError, ListenError, RebuildListenError}, + hypervisor::state::AcquireListenerError, + ipc::{ConnectError, ListenError}, }; /// Per-worker [`ControlPlane`](crate::ipc::ControlPlane) implementation. @@ -146,64 +146,6 @@ impl crate::ipc::ControlPlane for WorkerControlPlane { Ok(self.wrap_listener(task_scope, server_name.clone(), adapter)) } - async fn rebuild_listener( - &self, - request: ListenRequest, - ) -> Result { - let server_name = request.identity.name().as_full().to_owned(); - let owner = self - .state - .owner_for_pid(self.caller_pid) - .await - .ok_or_else(|| RebuildListenError::Internal { - message: format!("unknown caller pid {}", self.caller_pid), - })?; - let task_scope = self - .state - .task_scope_for_owner(owner) - .await - .ok_or_else(|| RebuildListenError::Internal { - message: format!("unknown caller pid {}", self.caller_pid), - })?; - - let adapter = self - .state - .rebuild_listener(owner, request) - .await - .map_err(|error| { - let report = snafu::Report::from_error(&error).to_string(); - tracing::warn!( - caller_pid = %self.caller_pid, - %server_name, - error = %report, - "rebuild listener request failed" - ); - match error { - RebuildListenerError::NotOwner => RebuildListenError::NotOwner, - RebuildListenerError::ConflictedName => RebuildListenError::Conflict, - RebuildListenerError::TransitionStopped => RebuildListenError::Internal { - message: format!("failed to replace endpoint for `{server_name}`"), - }, - RebuildListenerError::Replacement { source } => match source { - AcquireListenerError::BuildBindPatterns { .. } => { - RebuildListenError::InvalidRequest { reason: report } - } - AcquireListenerError::DuplicateListen - | AcquireListenerError::ConflictedName => RebuildListenError::Conflict, - AcquireListenerError::BuildEndpoint { .. } - | AcquireListenerError::CreatePublisher { .. } - | AcquireListenerError::MissingPublisher - | AcquireListenerError::OwnerUnavailable - | AcquireListenerError::TransitionStopped => { - RebuildListenError::Replacement { reason: report } - } - }, - } - })?; - - Ok(self.wrap_listener(task_scope, server_name.clone(), adapter)) - } - async fn connector(&self, request: ConnectorRequest) -> Result { // Verify caller is a registered worker. let owner = self diff --git a/pishoo/src/hypervisor/process/batch.rs b/pishoo/src/hypervisor/process/batch.rs index 43db5f82..fd1e5667 100644 --- a/pishoo/src/hypervisor/process/batch.rs +++ b/pishoo/src/hypervisor/process/batch.rs @@ -48,7 +48,8 @@ pub(crate) fn worker_binary_path() -> PathBuf { /// from being spawned. pub async fn spawn_configured_workers( state: &Arc, - worker_targets: Vec, + worker_targets: Vec, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, ) { if worker_targets.is_empty() { tracing::info!("no worker targets resolved"); @@ -60,14 +61,14 @@ pub async fn spawn_configured_workers( let worker_bin = worker_binary_path(); for target in &worker_targets { - match spawn_worker(&worker_bin, target, state.clone()).await { + match spawn_worker(&worker_bin, target, state.clone(), root_defaults.clone()).await { Ok(_) => spawned += 1, Err(error) => { // Worker spawn failures (fork/exec, IPC negotiation, hello // timeout) are per-user problems that must not bring down the // entire root process. Log and continue to the next worker. tracing::error!( - user = %target.name, + user = %target.name(), error = %Report::from_error(&error), "failed to spawn worker, skipping user" ); diff --git a/pishoo/src/hypervisor/process/spawn.rs b/pishoo/src/hypervisor/process/spawn.rs index 2527eb09..3fa0cfec 100644 --- a/pishoo/src/hypervisor/process/spawn.rs +++ b/pishoo/src/hypervisor/process/spawn.rs @@ -8,7 +8,7 @@ use snafu::{ResultExt, Snafu}; use tracing::Instrument; use crate::{ - config::ResolvedWorkerTarget, + config::WorkerAccount, hypervisor::{ ipc_server::WorkerControlPlane, state::{RootState, WorkerProcessError, WorkerStartupError, worker_startup_error}, @@ -41,27 +41,33 @@ pub enum SpawnWorkerError { /// 3. Schedule the startup handshake inside the worker task scope pub async fn spawn_worker( worker_bin: &std::path::Path, - target: &ResolvedWorkerTarget, + target: &WorkerAccount, state: Arc, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, ) -> Result { let launched = crate::hypervisor::launcher::launch_worker( worker_bin, - target.uid, - target.gid, - &target.name, - &target.dir, + target.uid(), + target.primary_gid(), + target.name(), + target.login_home(), ) .context(spawn_worker_error::LaunchWorkerSnafu)?; let pid = launched.handle.pid(); let mux_fd = launched.mux_fd; + let (root_defaults_tx, root_defaults_rx) = remoc::rch::watch::channel(root_defaults.clone()); state - .register_worker(pid, target.uid, target.name.clone(), launched.handle) + .register_worker_with_defaults( + pid, + target.uid(), + target.name().to_owned(), + launched.handle, + root_defaults_tx, + ) .await; - let uid = target.uid.as_raw(); - let username = target.name.clone(); - let home = target.dir.clone(); + let account = target.clone(); let startup_state = state.clone(); let spawned_startup_task = state .spawn_worker_task(pid, move |token| { @@ -71,7 +77,7 @@ pub async fn spawn_worker( () = token.cancelled() => return, result = tokio::time::timeout( WORKER_STARTUP_TIMEOUT, - start_worker_ipc(pid, state.clone(), mux_fd, uid, username, home), + start_worker_ipc(pid, state.clone(), mux_fd, account, root_defaults, root_defaults_rx), ) => result, }; let error = match result { @@ -92,7 +98,7 @@ pub async fn spawn_worker( return Err(SpawnWorkerError::ScheduleStartup { pid }); } - tracing::info!(pid = %pid, username = %target.name, "worker launch scheduled"); + tracing::info!(pid = %pid, username = %target.name(), "worker launch scheduled"); Ok(SpawnedWorker { pid }) } @@ -101,9 +107,12 @@ async fn start_worker_ipc( pid: nix::unistd::Pid, state: Arc, mux_fd: OwnedFd, - uid: u32, - username: String, - home: std::path::PathBuf, + account: WorkerAccount, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, + root_defaults_rx: remoc::rch::watch::Receiver< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, ) -> Result<(), WorkerStartupError> { let mux = MuxChannel::from_fd(mux_fd).context(worker_startup_error::MuxChannelFromFdSnafu)?; let (sink, stream) = mux @@ -147,11 +156,11 @@ async fn start_worker_ipc( let rpc_impl = WorkerControlPlane::new(pid, state.clone(), fd_transfer); let (server, client) = crate::ipc::ControlPlaneServerShared::new(Arc::new(rpc_impl), 1); - let username_for_log = username.clone(); + let username_for_log = account.name().to_owned(); let bootstrap = WorkerBootstrap { - uid, - username, - home, + account, + root_defaults, + root_defaults_rx, control_plane: client, }; let server_state = state.clone(); diff --git a/pishoo/src/hypervisor/reload.rs b/pishoo/src/hypervisor/reload.rs index c0a47c20..cb0f7423 100644 --- a/pishoo/src/hypervisor/reload.rs +++ b/pishoo/src/hypervisor/reload.rs @@ -4,4 +4,4 @@ mod orchestrate; mod snapshot; pub use orchestrate::run_reload; -pub use snapshot::{RootReloadSnapshot, load_root_reload_snapshot}; +pub use snapshot::load_root_reload_snapshot; diff --git a/pishoo/src/hypervisor/reload/orchestrate.rs b/pishoo/src/hypervisor/reload/orchestrate.rs index 98bc0f0e..f6f95ed9 100644 --- a/pishoo/src/hypervisor/reload/orchestrate.rs +++ b/pishoo/src/hypervisor/reload/orchestrate.rs @@ -1,4 +1,4 @@ -//! Reload orchestration — SigHup handler logic. +//! Serialized root reload orchestration. use std::sync::Arc; @@ -7,164 +7,119 @@ use snafu::Report; use tracing::Instrument; use crate::{ - config::ResolvedWorkerTarget, + config::{WorkerAccount, WorkerRoster}, hypervisor::{ global_service::GlobalServiceHandle, state::{RootState, WorkerProcessError}, }, }; -/// Run the full reload sequence: -/// -/// 1. Preflight: load and validate the new configuration. -/// 2. Replace global services. -/// 3. Compute worker diff (unchanged / added / removed / changed). -/// 4. Kill removed + changed workers (parallel SIGTERM + grace). -/// 5. Scrub conflicted server names. -/// 6. Forward SIGHUP to unchanged workers. -/// 7. Spawn added + changed workers. -/// -/// On preflight or global-service failure, the reload is aborted and the -/// previous state is preserved. pub async fn run_reload( state: &Arc, config_source: &crate::config::PishooConfigSource, - current_worker_targets: &mut Vec, - global_service_handle: &mut Option, + current_workers: &mut Vec, + global_service: &mut Option, ) { tracing::info!("received reload signal"); - - let next_snapshot = match super::load_root_reload_snapshot(config_source).await { - Ok(snapshot) => snapshot, + let plan = match super::load_root_reload_snapshot(config_source).await { + Ok(plan) => plan, Err(error) => { - tracing::warn!( + tracing::error!( error = %Report::from_error(&error), path = %config_source.config_path().display(), - "reload preflight failed; keeping current root state" + "global pishoo reload failed; entering config-failed state" ); + enter_config_failed(state, current_workers, global_service).await; return; } }; - if let Err(error) = crate::hypervisor::global_service::replace_global_service( - state, - global_service_handle, - config_source, - &next_snapshot.entry_config, - ) - .await - { - tracing::warn!( - error = %Report::from_error(&error), - "failed to reload global services; keeping previous worker state" - ); - return; - } + state.clear_listener_poison().await; + let next_workers = plan.desired_workers().clone(); + let snapshot = plan.worker_defaults().clone(); - // Compute worker diff. - let diff = - crate::config::compute_worker_diff(current_worker_targets, &next_snapshot.worker_targets); + let worker_branch = reconcile_workers(state, current_workers, next_workers, snapshot); + let global_branch = + crate::hypervisor::global_service::replace_global_service(state, global_service, &plan); + tokio::join!(worker_branch, global_branch); + tracing::info!("reload complete"); +} - fn target_names(targets: &[ResolvedWorkerTarget]) -> Vec<&str> { - targets.iter().map(|t| t.name.as_str()).collect() +async fn enter_config_failed( + state: &Arc, + current_workers: &mut Vec, + global_service: &mut Option, +) { + if let Some(service) = global_service.take() { + service.shutdown().await; } - tracing::info!( - unchanged = ?target_names(&diff.unchanged), - added = ?target_names(&diff.added), - removed = ?target_names(&diff.removed), - changed = ?diff.changed.iter().map(|(_, new)| new.name.as_str()).collect::>(), - "reload diff" - ); - - state - .set_desired_workers(next_snapshot.worker_targets.clone()) - .await; + crate::hypervisor::shutdown::run_shutdown(state, Signal::SIGTERM).await; + current_workers.clear(); + state.wait_resource_transitions().await; +} - // Phase 1: Kill removed + changed workers (parallel). - if !diff.removed.is_empty() || !diff.changed.is_empty() { - let mut kill_tasks = tokio::task::JoinSet::new(); - for target in &diff.removed { - let state = state.clone(); - let uid = target.uid; - kill_tasks.spawn( - async move { - if let Some(pid) = state.pid_for_uid(uid).await { - let error = WorkerProcessError::ReloadRemoved; - // SIGTERM + 2s grace - state.send_signal_to_user(uid, Signal::SIGTERM).await; - if !state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await - { - state.force_kill_worker(pid, &error).await; - state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await; - } - state.cleanup_worker(pid, error).await; - } - } - .in_current_span(), - ); - } - for (old, _) in &diff.changed { - let state = state.clone(); - let uid = old.uid; - kill_tasks.spawn( - async move { - if let Some(pid) = state.pid_for_uid(uid).await { - let error = WorkerProcessError::ReloadChanged; - // SIGTERM + 2s grace - state.send_signal_to_user(uid, Signal::SIGTERM).await; - if !state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await - { - state.force_kill_worker(pid, &error).await; - state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await; - } - state.cleanup_worker(pid, error).await; - } +async fn reconcile_workers( + state: &Arc, + current_workers: &mut Vec, + next_roster: WorkerRoster, + snapshot: gateway::parse::config::RootWorkerDefaultsSnapshot, +) { + let next_workers = next_roster.to_vec(); + let diff = crate::config::compute_worker_diff(current_workers, &next_workers); + state.set_desired_workers(next_workers.clone()).await; + state.dispatch_worker_defaults(snapshot.clone()).await; + + let mut stopping = tokio::task::JoinSet::new(); + for target in diff + .removed + .iter() + .chain(diff.changed.iter().map(|(old, _)| old)) + { + let state = state.clone(); + let uid = target.uid(); + let reason = if diff.removed.iter().any(|removed| removed.uid() == uid) { + WorkerProcessError::ReloadRemoved + } else { + WorkerProcessError::ReloadChanged + }; + stopping.spawn( + async move { + let Some(pid) = state.pid_for_uid(uid).await else { + return; + }; + state.send_signal_to_user(uid, Signal::SIGTERM).await; + if !state + .wait_worker_exit(pid, std::time::Duration::from_secs(2)) + .await + { + state.force_kill_worker(pid, &reason).await; + state + .wait_worker_exit(pid, std::time::Duration::from_secs(2)) + .await; } - .in_current_span(), - ); - } - while kill_tasks.join_next().await.is_some() {} - } - - // Scrub conflicted names before forwarding reload to workers. - state.clear_listener_poison().await; - - // Phase 2: Forward SIGHUP to unchanged workers. - for target in &diff.unchanged { - state.send_signal_to_user(target.uid, Signal::SIGHUP).await; + state.cleanup_worker(pid, reason).await; + } + .in_current_span(), + ); } + while stopping.join_next().await.is_some() {} let mut missing_unchanged_workers = Vec::new(); for target in &diff.unchanged { - if state.pid_for_uid(target.uid).await.is_none() { + if state.pid_for_uid(target.uid()).await.is_none() { missing_unchanged_workers.push(target.clone()); } } - - let failed_desired_workers = state.take_failed_desired_workers().await; - - // Phase 3: Spawn added + changed workers, unchanged desired workers that - // were already cleaned up before this reload finished, and any desired - // workers parked in the failed registry from prior restartable failures. - let workers_to_spawn: Vec<_> = diff + let failed = state.take_failed_desired_workers().await; + let mut scheduled_uids = std::collections::HashSet::new(); + let to_spawn = diff .added .into_iter() - .chain(diff.changed.into_iter().map(|(_old, new)| new)) + .chain(diff.changed.into_iter().map(|(_, new)| new)) .chain(missing_unchanged_workers) - .chain(failed_desired_workers) - .collect(); - if !workers_to_spawn.is_empty() { - crate::hypervisor::process::spawn_configured_workers(state, workers_to_spawn).await; - } - - *current_worker_targets = next_snapshot.worker_targets; - tracing::info!("reload complete"); + .chain(failed) + .filter(|worker| scheduled_uids.insert(worker.uid())) + .collect::>(); + crate::hypervisor::process::spawn_configured_workers(state, to_spawn, snapshot).await; + *current_workers = next_workers; } diff --git a/pishoo/src/hypervisor/reload/snapshot.rs b/pishoo/src/hypervisor/reload/snapshot.rs index be106d30..062191e3 100644 --- a/pishoo/src/hypervisor/reload/snapshot.rs +++ b/pishoo/src/hypervisor/reload/snapshot.rs @@ -1,56 +1,7 @@ -//! Configuration reload helpers for the root process. +//! Root reload configuration phase. -use gateway::error::Whatever; -use snafu::{FromString, ResultExt}; - -/// Snapshot of root-level configuration loaded during a reload preflight. -pub struct RootReloadSnapshot { - pub entry_config: crate::config::EntryConfig, - pub worker_targets: Vec, -} - -/// Load and validate the root configuration from disk. -/// -/// Used during SIGHUP reload to preflight the new configuration before -/// applying any changes. Returns the parsed entry config and resolved -/// worker targets. pub async fn load_root_reload_snapshot( - config_source: &crate::config::PishooConfigSource, -) -> Result { - let registry = gateway::parse::default_registry(); - let config = match gateway::parse::load_config_file( - config_source.config_path(), - ®istry, - config_source.build_options(), - ) - .await - { - Ok(config) => config, - Err(failure) => { - tracing::warn!( - error = %snafu::Report::from_error(&failure.error), - diagnostic = %failure.diagnostic(), - "failed to reload configuration" - ); - return Err(Whatever::with_source( - Box::new(failure), - "failed to reload configuration".to_owned(), - )); - } - }; - let worker_mode = if config_source.default_worker_groups_enabled() { - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome - } else { - crate::config::worker_target::WorkerDiscoveryMode::ExplicitConfig - }; - let entry_config = - crate::config::entry::parse_entry_config_with_mode(&config.root, worker_mode) - .whatever_context("failed to parse pishoo entry configuration")?; - let worker_targets = crate::config::resolve_entry_worker_targets(&entry_config) - .whatever_context("failed to resolve configured worker users during reload")?; - - Ok(RootReloadSnapshot { - entry_config, - worker_targets, - }) + source: &crate::config::PishooConfigSource, +) -> Result { + crate::config::load_global_pishoo_plan(source).await } diff --git a/pishoo/src/hypervisor/state.rs b/pishoo/src/hypervisor/state.rs index 0f429e39..ad1b09bf 100644 --- a/pishoo/src/hypervisor/state.rs +++ b/pishoo/src/hypervisor/state.rs @@ -80,19 +80,6 @@ pub enum ReleaseListenerError { NotOwner, } -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RebuildListenerError { - #[snafu(display("listener is not owned by caller"))] - NotOwner, - #[snafu(display("server name conflicted"))] - ConflictedName, - #[snafu(display("failed to acquire replacement listener"))] - Replacement { source: AcquireListenerError }, - #[snafu(display("listener transition stopped before result delivery"))] - TransitionStopped, -} - #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum WorkerStartupError { @@ -171,7 +158,7 @@ pub struct WorkerFailure { } pub(super) struct FailedWorkerRecord { - pub(super) target: crate::config::ResolvedWorkerTarget, + pub(super) target: crate::config::WorkerAccount, pub(super) reason: String, } @@ -185,6 +172,11 @@ pub(super) struct WorkerProcessRecord { pub(super) tasks: TaskScope, /// Handle to the spawned worker process. pub(super) worker_handle: WorkerHandle, + /// Latest root defaults are delivered without an apply acknowledgement. + pub(super) root_defaults_tx: remoc::rch::watch::Sender< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, } /// Summary produced by worker cleanup. @@ -206,7 +198,7 @@ pub(super) struct Inner { /// uid → pid mapping (one worker per uid). pub(super) users: HashMap, /// uid → desired worker target from the current root configuration. - pub(super) desired_workers: HashMap, + pub(super) desired_workers: HashMap, /// uid → failed worker target waiting for the next reload retry. pub(super) failed_workers: HashMap, /// Pending process-level failures reported by worker-scoped tasks. diff --git a/pishoo/src/hypervisor/state/listener_registry.rs b/pishoo/src/hypervisor/state/listener_registry.rs index f992b9a3..c7c705da 100644 --- a/pishoo/src/hypervisor/state/listener_registry.rs +++ b/pishoo/src/hypervisor/state/listener_registry.rs @@ -56,19 +56,6 @@ pub(super) enum ReleasePlan { Poisoned, } -#[derive(Debug)] -pub(super) enum RebuildPlan { - Rebuild { - resource: R, - guard: AsyncReleaseGuard, - done: Completion, - }, - Wait(Completion), - NotOwner, - NotFound, - Conflict, -} - #[derive(Debug)] pub(super) struct ListenerRegistry { entries: HashMap, ListenerSlot>, @@ -331,68 +318,6 @@ impl ListenerRegistry { } } - pub(super) fn plan_rebuild( - &mut self, - owner: Owner, - name: &DhttpName<'static>, - ) -> RebuildPlan { - match self.entries.remove(name) { - None => RebuildPlan::NotFound, - Some(ListenerSlot::Transition { - owner: existing_owner, - done, - }) => { - self.entries.insert( - name.clone(), - ListenerSlot::Transition { - owner: existing_owner, - done: done.clone(), - }, - ); - RebuildPlan::Wait(done) - } - Some(ListenerSlot::Active { - owner: existing_owner, - resource, - guard, - }) if existing_owner == owner => { - let done = Completion::new(); - self.entries.insert( - name.clone(), - ListenerSlot::Transition { - owner: existing_owner, - done: done.clone(), - }, - ); - RebuildPlan::Rebuild { - resource, - guard, - done, - } - } - Some(ListenerSlot::Active { - owner: existing_owner, - resource, - guard, - }) => { - self.entries.insert( - name.clone(), - ListenerSlot::Active { - owner: existing_owner, - resource, - guard, - }, - ); - RebuildPlan::NotOwner - } - Some(ListenerSlot::Poisoned { reason }) => { - self.entries - .insert(name.clone(), ListenerSlot::Poisoned { reason }); - RebuildPlan::Conflict - } - } - } - pub(super) fn clear_poisoned(&mut self) -> Vec> { let poisoned = self .entries diff --git a/pishoo/src/hypervisor/state/process_ops.rs b/pishoo/src/hypervisor/state/process_ops.rs index fa2e261f..586f9d3e 100644 --- a/pishoo/src/hypervisor/state/process_ops.rs +++ b/pishoo/src/hypervisor/state/process_ops.rs @@ -64,12 +64,16 @@ impl RootState { /// /// If another worker already holds the same UID, the old one is cleaned /// up first (uid-replaced). - pub async fn register_worker( + pub async fn register_worker_with_defaults( self: &Arc, pid: Pid, uid: Uid, username: String, worker_handle: WorkerHandle, + root_defaults_tx: remoc::rch::watch::Sender< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, ) { let replaced_pid = { let inner = self.inner.lock().await; @@ -94,12 +98,47 @@ impl RootState { username: username.clone(), tasks: TaskScope::new(), worker_handle, + root_defaults_tx, }, ); inner.users.insert(uid, pid); tracing::debug!(pid = %pid, uid = uid.as_raw(), %username, "registered worker"); } + pub async fn dispatch_worker_defaults( + &self, + snapshot: gateway::parse::config::RootWorkerDefaultsSnapshot, + ) { + let inner = self.inner.lock().await; + for (pid, record) in &inner.processes { + if let Err(error) = record.root_defaults_tx.send(snapshot.clone()) { + tracing::warn!(pid = %pid, error = %error, "failed to dispatch worker defaults snapshot"); + } + } + } + + #[cfg(test)] + pub async fn register_worker( + self: &Arc, + pid: Pid, + uid: Uid, + username: String, + worker_handle: WorkerHandle, + ) { + let defaults = gateway::parse::TypedConfigParser::new() + .parse_root( + "pishoo {}", + std::path::Path::new("/tmp/test-root.conf"), + None, + ) + .unwrap() + .pishoo() + .worker_defaults(); + let (root_defaults_tx, _root_defaults_rx) = remoc::rch::watch::channel(defaults); + self.register_worker_with_defaults(pid, uid, username, worker_handle, root_defaults_tx) + .await; + } + /// Check whether a worker with the given PID is registered. pub async fn has_worker(&self, pid: Pid) -> bool { self.inner.lock().await.processes.contains_key(&pid) @@ -393,11 +432,11 @@ impl RootState { } } - pub async fn set_desired_workers(&self, targets: Vec) { + pub async fn set_desired_workers(&self, targets: Vec) { let mut inner = self.inner.lock().await; let desired: std::collections::HashMap<_, _> = targets .into_iter() - .map(|target| (target.uid, target)) + .map(|target| (target.uid(), target)) .collect(); inner .failed_workers @@ -408,7 +447,7 @@ impl RootState { /// Drain the set of failed-but-still-desired workers so the reload /// orchestrator can retry spawning them. After draining, the registry /// is empty until the next worker failure parks another entry. - pub async fn take_failed_desired_workers(&self) -> Vec { + pub async fn take_failed_desired_workers(&self) -> Vec { let mut inner = self.inner.lock().await; inner .failed_workers @@ -416,7 +455,7 @@ impl RootState { .map(|(uid, record)| { tracing::info!( uid = uid.as_raw(), - user = %record.target.name, + user = %record.target.name(), reason = %record.reason, "retrying failed worker on reload" ); @@ -429,10 +468,7 @@ impl RootState { self.inner.lock().await.desired_workers.clear(); } - pub async fn desired_worker_target( - &self, - uid: Uid, - ) -> Option { + pub async fn desired_worker_target(&self, uid: Uid) -> Option { self.inner.lock().await.desired_workers.get(&uid).cloned() } } diff --git a/pishoo/src/hypervisor/state/server_ops.rs b/pishoo/src/hypervisor/state/server_ops.rs index 2b2e9e6e..66365f30 100644 --- a/pishoo/src/hypervisor/state/server_ops.rs +++ b/pishoo/src/hypervisor/state/server_ops.rs @@ -10,11 +10,10 @@ use tokio_util::sync::CancellationToken; use tracing::Instrument; use super::{ - AcquireListenerError, ListenerResource, RebuildListenerError, ReleaseListenerError, RootState, + AcquireListenerError, ListenerResource, ReleaseListenerError, RootState, acquire_listener_error, - listener_registry::{AcquirePlan, RebuildPlan, ReleasePlan}, + listener_registry::{AcquirePlan, ReleasePlan}, owner::Owner, - rebuild_listener_error, }; use crate::{ hypervisor::{endpoint_factory, resource::AsyncReleaseGuard}, @@ -41,7 +40,6 @@ struct BuiltListener { } type AcquireListenerSender = oneshot::Sender>; -type RebuildListenerSender = oneshot::Sender>; struct AcquireListenerTransition { owner: Owner, @@ -52,16 +50,6 @@ struct AcquireListenerTransition { tx: AcquireListenerSender, } -struct RebuildListenerTransition { - owner: Owner, - server_name: DhttpName<'static>, - request: ListenRequest, - bind_patterns: Arc>, - old_resource: ListenerResource, - done: crate::hypervisor::resource::Completion, - tx: RebuildListenerSender, -} - impl RootState { // ----------------------------------------------------------------------- // Listener registry @@ -209,65 +197,6 @@ impl RootState { } } - /// Rebuild an owned listener without exposing a vacant interleaving window. - pub async fn rebuild_listener( - self: &Arc, - owner: Owner, - request: ListenRequest, - ) -> Result { - let server_name = self.listener_name(&request); - let bind_patterns = request - .bind - .iter() - .map(gateway::parse::types::Listens::try_to_bind_patterns) - .collect::, _>>() - .context(acquire_listener_error::BuildBindPatternsSnafu) - .context(rebuild_listener_error::ReplacementSnafu)? - .into_iter() - .flatten() - .collect::>(); - let bind_patterns = Arc::new(bind_patterns); - - loop { - let plan = { - let mut registry = self.listeners.write().await; - registry.plan_rebuild(owner, &server_name) - }; - - match plan { - RebuildPlan::Rebuild { - resource, - guard, - done, - } => { - guard.disarm(); - let (tx, rx) = oneshot::channel(); - self.spawn_rebuild_listener_transition(RebuildListenerTransition { - owner, - server_name, - request, - bind_patterns, - old_resource: resource, - done, - tx, - }); - return rx - .await - .unwrap_or(Err(RebuildListenerError::TransitionStopped)); - } - RebuildPlan::Wait(done) => done.wait().await, - RebuildPlan::NotOwner => return Err(RebuildListenerError::NotOwner), - RebuildPlan::NotFound => { - return self - .acquire_listener(owner, request) - .await - .context(rebuild_listener_error::ReplacementSnafu); - } - RebuildPlan::Conflict => return Err(RebuildListenerError::ConflictedName), - } - } - } - /// Remove all poisoned listener entries from the registry. pub async fn clear_listener_poison(&self) -> Vec> { let mut registry = self.listeners.write().await; @@ -388,57 +317,6 @@ impl RootState { ); } - fn spawn_rebuild_listener_transition(self: &Arc, transition: RebuildListenerTransition) { - let state = self.clone(); - self.spawn_resource_transition( - async move { - state.run_rebuild_listener_transition(transition).await; - } - .in_current_span(), - ); - } - - async fn run_rebuild_listener_transition( - self: Arc, - transition: RebuildListenerTransition, - ) { - self.destroy_listener_resource(transition.old_resource) - .await; - - let built = self - .build_listener_resource( - transition.owner, - &transition.server_name, - &transition.request, - transition.bind_patterns, - ) - .await; - match built { - Ok(built) => { - self.pause_listener_delivery_for_test().await; - self.commit_and_deliver_rebuilt_listener( - transition.owner, - transition.server_name, - transition.done, - built, - transition.tx, - ) - .await; - } - Err(error) => { - self.finish_listener_transition_vacant( - transition.owner, - &transition.server_name, - &transition.done, - ) - .await; - let _ = transition - .tx - .send(Err(RebuildListenerError::Replacement { source: error })); - } - } - } - async fn commit_and_deliver_acquired_listener( &self, owner: Owner, @@ -475,42 +353,6 @@ impl RootState { } } - async fn commit_and_deliver_rebuilt_listener( - &self, - owner: Owner, - server_name: DhttpName<'static>, - done: crate::hypervisor::resource::Completion, - built: BuiltListener, - tx: oneshot::Sender>, - ) { - let BuiltListener { - resource, - registered, - guard, - } = built; - match self - .commit_listener_transition_active( - owner, - server_name.clone(), - &done, - resource, - guard.clone(), - ) - .await - { - Ok(()) => { - self.deliver_rebuilt_listener(server_name, registered, tx) - .await; - } - Err(resource) => { - done.complete(); - self.destroy_uncommitted_listener(resource, registered, guard) - .await; - let _ = tx.send(Err(RebuildListenerError::ConflictedName)); - } - } - } - async fn commit_listener_transition_active( &self, owner: Owner, @@ -539,22 +381,6 @@ impl RootState { } } - async fn deliver_rebuilt_listener( - &self, - server_name: DhttpName<'static>, - registered: RegisteredEndpoint, - tx: oneshot::Sender>, - ) { - match tx.send(Ok(registered)) { - Ok(()) => {} - Err(Ok(registered)) => { - self.shutdown_undelivered_listener(server_name, registered) - .await; - } - Err(Err(_)) => {} - } - } - async fn shutdown_undelivered_listener( &self, server_name: DhttpName<'static>, diff --git a/pishoo/src/hypervisor/state/tests.rs b/pishoo/src/hypervisor/state/tests.rs index 47ad131f..8039b8f5 100644 --- a/pishoo/src/hypervisor/state/tests.rs +++ b/pishoo/src/hypervisor/state/tests.rs @@ -1,9 +1,6 @@ -use std::{ - ffi::CString, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; use dhttp::{ @@ -18,7 +15,7 @@ use gateway::{ control_plane::ListenRequest, parse::types::{IfaceRange, IpFamilies, Listens}, }; -use nix::unistd::{Pid, Uid}; +use nix::unistd::{Gid, Pid, Uid}; use super::{owner::Owner, *}; use crate::hypervisor::worker_handle::WorkerHandle; @@ -404,7 +401,7 @@ async fn test_cancelled_acquire_listener_destroys_built_endpoint() { } #[tokio::test] -async fn test_stale_registered_endpoint_drop_after_rebuild_does_not_release_replacement() { +async fn closed_listener_can_be_reacquired_by_the_same_owner() { let state = test_state(); let owner = Owner::Local; let server_name = "stale-rebuild.user.dhttp.net"; @@ -415,17 +412,20 @@ async fn test_stale_registered_endpoint_drop_after_rebuild_does_not_release_repl .expect("initial acquire should succeed"); assert!(is_active(&state, server_name).await); + dhttp::h3x::quic::Listen::shutdown(&old_listener) + .await + .unwrap(); let _replacement = state - .rebuild_listener(owner, test_request("stale-rebuild")) + .acquire_listener(owner, test_request("stale-rebuild")) .await - .expect("rebuild should succeed"); + .expect("reacquire should succeed after normal shutdown"); drop(old_listener); tokio::time::sleep(std::time::Duration::from_millis(50)).await; assert!( is_active(&state, server_name).await, - "dropping the consumed old handle must not release the replacement" + "dropping the stale old handle must not release the replacement" ); state @@ -705,15 +705,14 @@ async fn test_fail_worker_queues_typed_error_without_cleanup() { #[tokio::test] async fn test_desired_worker_targets_are_uid_keyed() { let state = test_state(); - let target = nix::unistd::User { - name: "restart-user".to_owned(), - passwd: CString::new("x").unwrap(), - uid: Uid::from_raw(7001), - gid: nix::unistd::Gid::from_raw(7001), - gecos: CString::new("").unwrap(), - dir: std::path::PathBuf::from("/home/restart-user"), - shell: std::path::PathBuf::from("/bin/sh"), - }; + let target = crate::config::WorkerAccount::new( + "restart-user".to_owned(), + Uid::from_raw(7001), + Gid::from_raw(7001), + std::path::PathBuf::from("/home/restart-user"), + dhttp::home::DhttpHome::for_user_home_dir("/home/restart-user"), + ) + .unwrap(); state.set_desired_workers(vec![target.clone()]).await; @@ -721,8 +720,8 @@ async fn test_desired_worker_targets_are_uid_keyed() { state .desired_worker_target(Uid::from_raw(7001)) .await - .map(|worker| worker.name), - Some(target.name) + .map(|worker| worker.name().to_owned()), + Some(target.name().to_owned()) ); assert!( state diff --git a/pishoo/src/ipc.rs b/pishoo/src/ipc.rs index 729fb254..8248992e 100644 --- a/pishoo/src/ipc.rs +++ b/pishoo/src/ipc.rs @@ -6,8 +6,6 @@ //! - [`WorkerBootstrap`] / [`WorkerHello`]: one-shot bootstrap handshake. //! - Error types for control plane operations. -use std::path::PathBuf; - use dhttp::h3x::ipc::quic::{IpcConnectClient, IpcListenClient}; use gateway::control_plane::{ConnectorRequest, ListenRequest}; use serde::{Deserialize, Serialize}; @@ -20,9 +18,12 @@ use snafu::Snafu; /// Sent from root to worker immediately after establishing the remoc channel. #[derive(Debug, Serialize, Deserialize)] pub struct WorkerBootstrap { - pub uid: u32, - pub username: String, - pub home: PathBuf, + pub account: crate::config::WorkerAccount, + pub root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, + pub root_defaults_rx: remoc::rch::watch::Receiver< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, /// RPC client for calling the root control plane. pub control_plane: ControlPlaneClient, } @@ -55,27 +56,6 @@ pub enum ListenError { Call { source: remoc::rtc::CallError }, } -/// Error returned by [`ControlPlane::rebuild_listener`]. -/// -/// Rebuild atomically replaces an owned listener so the server name is never -/// momentarily vacant during reload. -#[derive(Debug, Clone, Serialize, Deserialize, Snafu)] -#[snafu(module)] -pub enum RebuildListenError { - #[snafu(display("listener is not owned by this worker"))] - NotOwner, - #[snafu(display("server name conflicts with an existing listener"))] - Conflict, - #[snafu(display("replacement listener failed after old listener was destroyed: {reason}"))] - Replacement { reason: String }, - #[snafu(display("invalid rebuild request: {reason}"))] - InvalidRequest { reason: String }, - #[snafu(display("internal error: {message}"))] - Internal { message: String }, - #[snafu(transparent)] - Call { source: remoc::rtc::CallError }, -} - /// Error returned by [`ControlPlane::connect`]. #[derive(Debug, Clone, Serialize, Deserialize, Snafu)] #[snafu(module)] @@ -119,14 +99,6 @@ pub trait ControlPlane: Send + Sync { /// [`IpcListener`](dhttp::h3x::ipc::capability::listener::IpcListener) from. async fn listener(&self, request: ListenRequest) -> Result; - /// Atomically replace a previously acquired listener with one matching the - /// new request. The previous listener is destroyed by root as part of the - /// same critical section, so the server name is never observed vacant. - async fn rebuild_listener( - &self, - request: ListenRequest, - ) -> Result; - /// Request an outbound QUIC connector. /// /// Root creates the connector, wraps it in an IPC `ConnectAdapter`, and @@ -142,3 +114,30 @@ pub trait ControlPlane: Send + Sync { /// the local mux writer FIFO. async fn spawn_session(&self, username: String, fd_id: u64) -> Result; } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + fn snapshot(label: &str) -> gateway::parse::config::RootWorkerDefaultsSnapshot { + let path = PathBuf::from(format!("/tmp/{label}.conf")); + gateway::parse::TypedConfigParser::new() + .parse_root("pishoo { gzip on; }", &path, None) + .unwrap() + .pishoo() + .worker_defaults() + } + + #[tokio::test] + async fn root_defaults_watch_coalesces_to_latest_snapshot() { + let (sender, mut receiver) = + remoc::rch::watch::channel::<_, remoc::codec::Default>(snapshot("root-a")); + sender.send(snapshot("root-b")).unwrap(); + sender.send(snapshot("root-c")).unwrap(); + + receiver.changed().await.unwrap(); + let actual = receiver.borrow_and_update().unwrap().clone(); + + assert_eq!(actual, snapshot("root-c")); + } +} diff --git a/pishoo/src/main.rs b/pishoo/src/main.rs index 0eef2ba0..b3e4f0f9 100644 --- a/pishoo/src/main.rs +++ b/pishoo/src/main.rs @@ -41,58 +41,41 @@ async fn main() -> Result<(), Whatever> { .whatever_context("failed to resolve pishoo configuration source")?; let config_file = config_source.config_path().to_path_buf(); - let registry = gateway::parse::default_registry(); - let config = match gateway::parse::load_config_file( - &config_file, - ®istry, - config_source.build_options(), - ) - .await - { - Ok(config) => config, - Err(failure) => { + let initial_plan = pishoo::config::load_global_pishoo_plan(&config_source).await; + if args.test_config || args.signal.is_some() { + let plan = initial_plan.map_err(|error| { tracing::error!( - error = %snafu::Report::from_error(&failure.error), - diagnostic = %failure.diagnostic(), + error = %snafu::Report::from_error(&error), "failed to load configuration" ); - return Err(Whatever::with_source( - Box::new(failure), - "failed to load configuration".to_owned(), - )); + Whatever::with_source(Box::new(error), "failed to load configuration".to_owned()) + })?; + let pid_file = pishoo::config::pid_path(plan.pishoo()); + if args.test_config { + tracing::info!(path = %config_file.display(), "configuration parsed successfully"); + return Ok(()); } - }; - - let worker_mode = if config_source.default_worker_groups_enabled() { - pishoo::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome - } else { - pishoo::config::worker_target::WorkerDiscoveryMode::ExplicitConfig - }; - let entry_config = - pishoo::config::entry::parse_entry_config_with_mode(&config.root, worker_mode) - .whatever_context("failed to parse pishoo entry configuration")?; - - if args.test_config { - tracing::info!( - path = %config_file.display(), - "configuration parsed successfully" - ); - return Ok(()); + return signal::send_signal(&pid_file, args.signal.expect("signal checked above")).await; } - let pid_file = entry_config.pid_file.clone(); - - if let Some(signal) = args.signal { - return signal::send_signal(&pid_file, signal).await; - } - - let current_entry_config = entry_config; - let mut current_worker_targets = - pishoo::config::resolve_entry_worker_targets(¤t_entry_config) - .whatever_context("failed to resolve configured worker users")?; + let plan = match initial_plan { + Ok(plan) => Some(plan), + Err(error) => { + tracing::error!( + error = %snafu::Report::from_error(&error), + "global pishoo configuration failed; starting in config-failed state" + ); + None + } + }; + let pid_file = plan.as_ref().map_or_else( + || PathBuf::from(pishoo::config::PID_FILE_DEFAULT), + |plan| pishoo::config::pid_path(plan.pishoo()), + ); + let mut current_worker_targets = Vec::new(); tracing::info!( - pid_file = %current_entry_config.pid_file.display(), + pid_file = %pid_file.display(), "pishoo starting" ); @@ -111,20 +94,21 @@ async fn main() -> Result<(), Whatever> { // Write PID file (root only) signal::init_pid_file(&pid_file).await?; - let mut global_service_handle = pishoo::hypervisor::global_service::spawn_global_service( - &state, - &config_source, - ¤t_entry_config, - ) - .await - .whatever_context("failed to spawn global service")?; - drop(current_entry_config); - - state - .set_desired_workers(current_worker_targets.clone()) - .await; - pishoo::hypervisor::process::spawn_configured_workers(&state, current_worker_targets.clone()) - .await; + let mut global_service_handle = None; + if let Some(plan) = plan { + current_worker_targets = plan.desired_workers().to_vec(); + state + .set_desired_workers(current_worker_targets.clone()) + .await; + let global_branch = pishoo::hypervisor::global_service::spawn_global_service(&state, &plan); + let worker_branch = pishoo::hypervisor::process::spawn_configured_workers( + &state, + current_worker_targets.clone(), + plan.worker_defaults().clone(), + ); + let (service, ()) = tokio::join!(global_branch, worker_branch); + global_service_handle = service; + } // Create signal handler once — reused across the main loop so that signals // arriving during reload are never lost. @@ -140,7 +124,20 @@ async fn main() -> Result<(), Whatever> { tracing::info!("pishoo ready"); loop { - let sig = signals.wait().await; + let sig = tokio::select! { + sig = signals.wait() => sig, + name = pishoo::hypervisor::global_service::wait_global_service_completion( + &mut global_service_handle, + ) => { + global_service_handle + .as_mut() + .expect("service completion requires a global service handle") + .handle_service_exit(name) + .await; + tracing::warn!("global server service exited; released its resources"); + continue; + } + }; match sig { signal::RootSignal::SigTerm diff --git a/pishoo/src/naming.rs b/pishoo/src/naming.rs index 53b66dda..24505a28 100644 --- a/pishoo/src/naming.rs +++ b/pishoo/src/naming.rs @@ -1,10 +1,5 @@ -use std::sync::Arc; - use dhttp::name::DhttpName; -use gateway::{ - error::Whatever, - parse::{document::ConfigNode, types::ServerName}, -}; +use gateway::{error::Whatever, parse::types::ServerName}; use snafu::ResultExt; pub fn canonicalize_genmeta_name(name: &str) -> Result, Whatever> { @@ -22,12 +17,6 @@ pub fn canonicalize_server_names(server_names: &[ServerName]) -> Result], -) -> Result>, Whatever> { - Ok(servers.to_vec()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/pishoo/src/service.rs b/pishoo/src/service.rs index d2d2ec0e..ce5c3dff 100644 --- a/pishoo/src/service.rs +++ b/pishoo/src/service.rs @@ -9,6 +9,8 @@ //! - [`source`]: [`source::ServerSource`] enum and per-variant loaders. pub mod accept; +pub mod resource; pub mod runtime; +pub mod set; pub mod snapshot; pub mod source; diff --git a/pishoo/src/service/accept.rs b/pishoo/src/service/accept.rs index 0c82306a..864c60c0 100644 --- a/pishoo/src/service/accept.rs +++ b/pishoo/src/service/accept.rs @@ -1,10 +1,10 @@ -use std::{future::Future, pin::Pin}; +use std::time::Duration; -use snafu::{ResultExt, Snafu}; -use tokio::sync::oneshot; use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; use tracing::Instrument; +pub const SERVICE_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + pub(crate) trait AcceptDriver: Send + Sync + 'static { fn drive( self: std::sync::Arc, @@ -13,272 +13,122 @@ pub(crate) trait AcceptDriver: Send + Sync + 'static { ) -> impl std::future::Future + Send; } -pub enum AcceptState { - Running { - shutdown: CancellationToken, - task: AbortOnDropHandle, - }, - Stopping { - receiver: oneshot::Receiver>, - task: AbortOnDropHandle<()>, - }, - Stopped { - listener: L, - }, - Taken, +pub enum DrainOutcome { + Returned(L), + Aborted, } -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum StopAcceptError { - #[snafu(display("accept task panicked or was cancelled"))] - Join { source: tokio::task::JoinError }, - #[snafu(display("accept stop transition ended before returning listener"))] - StopTaskLost { source: oneshot::error::RecvError }, - #[snafu(display("accept listener is not available"))] - Taken, +pub struct AcceptState { + shutdown: CancellationToken, + task: AbortOnDropHandle, } impl AcceptState where L: Send + 'static, { - pub(crate) fn start(listener: L, driver: std::sync::Arc) -> Self + pub(crate) fn start(listener: L, driver: std::sync::Arc, completed: C) -> Self where D: AcceptDriver, + C: FnOnce() + Send + 'static, { let shutdown = CancellationToken::new(); let task_shutdown = shutdown.clone(); let task = AbortOnDropHandle::new(tokio::spawn( - async move { driver.drive(listener, task_shutdown).await }.in_current_span(), + async move { + let listener = driver.drive(listener, task_shutdown).await; + completed(); + listener + } + .in_current_span(), )); - Self::Running { shutdown, task } + Self { shutdown, task } } - pub async fn stop(&mut self) -> Result<&mut L, StopAcceptError> { - loop { - match self { - Self::Running { .. } => { - let prev = std::mem::replace(self, Self::Taken); - let Self::Running { shutdown, task } = prev else { - unreachable!("matched running state") - }; - shutdown.cancel(); - let (tx, receiver) = oneshot::channel(); - let stop_task = AbortOnDropHandle::new(tokio::spawn( - async move { - let result = task.await; - let _ = tx.send(result); - } - .in_current_span(), - )); - *self = Self::Stopping { - receiver, - task: stop_task, - }; - } - Self::Stopping { receiver, .. } => { - let result = std::future::poll_fn(|cx| Pin::new(&mut *receiver).poll(cx)).await; - match result.context(stop_accept_error::StopTaskLostSnafu)? { - Ok(listener) => { - let prev = std::mem::replace(self, Self::Stopped { listener }); - drop(prev); - } - Err(source) => { - *self = Self::Taken; - return Err(StopAcceptError::Join { source }); - } - } - } - Self::Stopped { listener } => return Ok(listener), - Self::Taken => return Err(StopAcceptError::Taken), + pub async fn drain(mut self) -> DrainOutcome { + self.shutdown.cancel(); + match tokio::time::timeout(SERVICE_DRAIN_TIMEOUT, &mut self.task).await { + Ok(Ok(listener)) => DrainOutcome::Returned(listener), + Ok(Err(error)) => { + tracing::warn!(error = %error, "server service task ended without returning its listener"); + DrainOutcome::Aborted + } + Err(_) => { + self.task.abort(); + let _ = (&mut self.task).await; + tracing::warn!( + timeout_seconds = SERVICE_DRAIN_TIMEOUT.as_secs(), + "server service drain timed out; task aborted" + ); + DrainOutcome::Aborted } } } - pub async fn into_listener(mut self) -> Result { - self.take_listener().await - } - - pub async fn take_listener(&mut self) -> Result { - self.stop().await?; - let prev = std::mem::replace(self, Self::Taken); - match self { - Self::Taken => match prev { - Self::Stopped { listener } => Ok(listener), - _ => unreachable!("stop leaves Stopped before take"), - }, - _ => unreachable!("take leaves Taken"), - } + pub fn is_finished(&self) -> bool { + self.task.is_finished() } } #[cfg(test)] mod tests { - use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; - use tokio::time::timeout; - use super::*; - struct FakeDriver { - entered: AtomicBool, - } - - struct FakeListener { - id: usize, - } - - struct PendingDriver; - - struct PausedStopDriver { - stop_started: tokio::sync::Notify, - resume: tokio::sync::Notify, - } - - struct DropSignalListener { - dropped: Option>, - } - - impl AcceptDriver for FakeDriver { - async fn drive( - self: Arc, - listener: FakeListener, - shutdown: CancellationToken, - ) -> FakeListener { - self.entered.store(true, Ordering::SeqCst); + struct ReturningDriver; + impl AcceptDriver for ReturningDriver { + async fn drive(self: Arc, listener: usize, shutdown: CancellationToken) -> usize { shutdown.cancelled().await; listener } } - impl AcceptDriver for PendingDriver { - async fn drive( - self: Arc, - listener: DropSignalListener, - _shutdown: CancellationToken, - ) -> DropSignalListener { - std::future::pending::<()>().await; - listener + struct PendingDriver { + dropped: Arc, + } + struct DropListener(Arc); + impl Drop for DropListener { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); } } - - impl AcceptDriver for PausedStopDriver { + impl AcceptDriver for PendingDriver { async fn drive( self: Arc, - listener: FakeListener, - shutdown: CancellationToken, - ) -> FakeListener { - shutdown.cancelled().await; - self.stop_started.notify_waiters(); - self.resume.notified().await; - listener - } - } - - impl Drop for DropSignalListener { - fn drop(&mut self) { - if let Some(dropped) = self.dropped.take() { - let _ = dropped.send(()); - } + listener: DropListener, + _shutdown: CancellationToken, + ) -> DropListener { + let _ = &self.dropped; + let _listener = listener; + std::future::pending().await } } #[tokio::test] - async fn stop_returns_listener_after_accept_task_shutdown() { - let driver = Arc::new(FakeDriver { - entered: AtomicBool::new(false), - }); - let mut state = AcceptState::start(FakeListener { id: 7 }, driver.clone()); - - // Yield once so the freshly-spawned task can be polled to its first - // await point; otherwise `entered` may still read false on a - // single-threaded runtime. - tokio::task::yield_now().await; - assert!(driver.entered.load(Ordering::SeqCst)); - - let listener = timeout(Duration::from_secs(1), state.stop()) - .await - .expect("stop should complete within timeout") - .expect("stop should return listener"); - - assert_eq!(listener.id, 7); - } - - #[tokio::test] - async fn into_listener_consumes_state() { - let driver = Arc::new(FakeDriver { - entered: AtomicBool::new(false), - }); - let state = AcceptState::start(FakeListener { id: 42 }, driver); - - let listener = timeout(Duration::from_secs(1), state.into_listener()) - .await - .expect("into_listener should complete within timeout") - .expect("into_listener should return listener"); - - assert_eq!(listener.id, 42); - } - - #[tokio::test] - async fn cancelled_stop_does_not_abort_accept_task() { - let driver = Arc::new(PausedStopDriver { - stop_started: tokio::sync::Notify::new(), - resume: tokio::sync::Notify::new(), - }); - let mut state = AcceptState::start(FakeListener { id: 9 }, driver.clone()); - - let mut stop = Box::pin(state.stop()); - tokio::select! { - () = driver.stop_started.notified() => {} - _ = &mut stop => panic!("stop completed before pause"), - } - drop(stop); - - driver.resume.notify_waiters(); - - let listener = timeout(Duration::from_secs(1), state.stop()) - .await - .expect("second stop should complete") - .expect("second stop should return listener"); - assert_eq!(listener.id, 9); + async fn graceful_drain_returns_listener() { + let state = AcceptState::start(7, Arc::new(ReturningDriver), || {}); + assert!(matches!(state.drain().await, DrainOutcome::Returned(7))); } - #[tokio::test] - async fn dropping_running_state_aborts_accept_task() { - let driver = Arc::new(PendingDriver); - let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + #[tokio::test(start_paused = true)] + async fn drain_aborts_after_exactly_five_seconds() { + let dropped = Arc::new(AtomicBool::new(false)); let state = AcceptState::start( - DropSignalListener { - dropped: Some(dropped_tx), - }, - driver, + DropListener(dropped.clone()), + Arc::new(PendingDriver { + dropped: dropped.clone(), + }), + || {}, ); - - drop(state); - - timeout(Duration::from_secs(1), dropped_rx) - .await - .expect("listener should be dropped after abort") - .expect("drop signal sender should fire"); - } - - #[tokio::test] - async fn second_stop_after_completion_is_noop() { - let driver = Arc::new(FakeDriver { - entered: AtomicBool::new(false), - }); - let mut state = AcceptState::start(FakeListener { id: 1 }, driver); - - let first = state.stop().await.expect("first stop").id; - let second = state.stop().await.expect("second stop").id; - assert_eq!(first, 1); - assert_eq!(second, 1); + let drain = tokio::spawn(state.drain()); + tokio::time::advance(Duration::from_secs(4)).await; + assert!(!drain.is_finished()); + tokio::time::advance(Duration::from_secs(1)).await; + assert!(matches!(drain.await.unwrap(), DrainOutcome::Aborted)); + assert!(dropped.load(Ordering::SeqCst)); } } diff --git a/pishoo/src/service/resource.rs b/pishoo/src/service/resource.rs new file mode 100644 index 00000000..dbf59f68 --- /dev/null +++ b/pishoo/src/service/resource.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use gateway::{parse::config::ResolvedAccessLogConfig, reverse::log::AccessLogOutput}; + +use super::source::ListenerSpec; + +#[derive(Clone, Debug)] +pub struct AccessLogResourcePlan { + pub server: ResolvedAccessLogConfig, + pub locations: Box<[ResolvedAccessLogConfig]>, +} + +#[derive(Clone, Debug)] +pub struct AccessLogResources { + pub server: Option>, + pub locations: Box<[Option>]>, +} + +pub struct ServerResources { + listener: Option, + listener_spec: ListenerSpec, + access_logs: AccessLogResources, +} + +impl ServerResources { + pub fn new(listener: L, listener_spec: ListenerSpec, access_logs: AccessLogResources) -> Self { + Self { + listener: Some(listener), + listener_spec, + access_logs, + } + } + + pub fn listener_spec(&self) -> &ListenerSpec { + &self.listener_spec + } + + pub fn take_listener(&mut self) -> L { + self.listener + .take() + .expect("a stopped server owns its listener") + } + + pub fn put_listener(&mut self, listener: L) { + assert!( + self.listener.replace(listener).is_none(), + "listener returned exactly once" + ); + } + + pub fn replace_access_logs(&mut self, access_logs: AccessLogResources) { + self.access_logs = access_logs; + } + + pub fn access_logs(&self) -> &AccessLogResources { + &self.access_logs + } +} diff --git a/pishoo/src/service/runtime.rs b/pishoo/src/service/runtime.rs index e974b174..033c7219 100644 --- a/pishoo/src/service/runtime.rs +++ b/pishoo/src/service/runtime.rs @@ -1,303 +1,196 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashSet, sync::Arc}; -use dhttp::name::DhttpName; +use dhttp::{h3x::quic::Listen as _, name::DhttpName}; use gateway::control_plane::{ControlPlane, ProvideListener}; -use snafu::Report; +use snafu::{Report, ResultExt}; use super::{ - accept::AcceptState, - snapshot::ServerService, - source::{ListenerSpec, PrepareContext, PreparedServerUpdate, ServerFingerprint, ServerSource}, + accept::DrainOutcome, + resource::ServerResources, + set::{ResourceSet, ServerServiceHandle, ServiceSet}, + source::{PrepareContext, ServerSource}, }; -pub struct ServerRuntime

+pub struct RuntimeRegistry

where P: ProvideListener, { - name: DhttpName<'static>, - source: ServerSource, - listener_spec: ListenerSpec, - service: Arc, - accept: AcceptState, - fingerprint: ServerFingerprint, plane: Arc

, + resources: ResourceSet, + services: ServiceSet, } -pub enum ReloadServerOutcome { - KeptOldOnPrepareFailure, - SkippedAcceptNotRunning, - SwappedServiceOnReusedListener, - RebuiltListener, - StoppedAfterFatalRebuild, -} - -impl

ServerRuntime

+impl

RuntimeRegistry

where P: ProvideListener + Send + Sync + 'static, P::Listener: dhttp::h3x::quic::Listen + Send + 'static, ::Error: std::error::Error + Send + Sync + 'static, ::Connection: Send + 'static, - <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: - Send + Sync, - <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: - Send + Sync, + <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: Send + Sync, + <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: Send + Sync, { - pub fn start( - source: ServerSource, - prepared: PreparedServerUpdate, - plane: Arc

, - listener: P::Listener, - ) -> Self { - Self { - name: prepared.name, - source, - listener_spec: prepared.listener_spec, - service: prepared.service.clone(), - accept: AcceptState::start(listener, prepared.service), - fingerprint: prepared.fingerprint, - plane, - } + pub fn new(plane: Arc

) -> Self { + Self { plane, resources: ResourceSet::default(), services: ServiceSet::default() } } - pub fn name(&self) -> &DhttpName<'static> { - &self.name + pub async fn apply_sources(&mut self, sources: Vec, ctx: &PrepareContext) { + self.stop_finished_services().await; + let desired = sources.iter().map(|source| source.name().clone()).collect::>(); + let removed = self.resources.servers.keys().filter(|name| !desired.contains(*name)).cloned().collect::>(); + for name in removed { self.stop_server(&name).await; } + for source in sources { self.reconcile_server(source, ctx).await; } } - pub fn fingerprint(&self) -> &ServerFingerprint { - &self.fingerprint - } + async fn reconcile_server(&mut self, source: ServerSource, ctx: &PrepareContext) { + let name = source.name().clone(); + self.stop_service(&name).await; - pub async fn reload( - &mut self, - new_source: ServerSource, - ctx: &PrepareContext, - ) -> ReloadServerOutcome { - let prepared = match new_source.prepare(ctx).await { + let prepared = match source.prepare(ctx).await { Ok(prepared) => prepared, Err(error) => { - tracing::warn!( - server_name = %self.name, - error = %Report::from_error(&error), - "failed to prepare server reload" - ); - return ReloadServerOutcome::KeptOldOnPrepareFailure; + tracing::warn!(server_name = %name, error = %Report::from_error(&error), "server service preparation failed"); + self.release_resource(&name).await; + return; } }; - self.apply_reload(new_source, prepared).await - } - - #[cfg(test)] - pub(crate) async fn reload_with_prepared( - &mut self, - new_source: ServerSource, - prepared_result: Result< - PreparedServerUpdate, - crate::service::source::PrepareServerUpdateError, - >, - ) -> ReloadServerOutcome { - let prepared = match prepared_result { - Ok(p) => p, + let access_logs = match self.resources.acquire_access_logs(prepared.access_logs) { + Ok(access_logs) => access_logs, Err(error) => { tracing::warn!( - server_name = %self.name, + server_name = %name, error = %Report::from_error(&error), - "failed to prepare server reload" + "server access log resource acquisition failed" ); - return ReloadServerOutcome::KeptOldOnPrepareFailure; + self.release_resource(&name).await; + return; } }; - self.apply_reload(new_source, prepared).await - } - - async fn apply_reload( - &mut self, - new_source: ServerSource, - prepared: PreparedServerUpdate, - ) -> ReloadServerOutcome { - // Future refinement: split hard listener identity from soft updateable fields. - // The first implementation rebuilds on any ListenRequest fingerprint change, - // but TLS material or resolver metadata may later become updateable without - // rebuilding the underlying listener. - if prepared.listener_spec == self.listener_spec { - let Some(listener) = self.stop_accept().await else { - tracing::error!( - server_name = %self.name, - "accept loop was not running during reload; skipping" - ); - return ReloadServerOutcome::SkippedAcceptNotRunning; - }; - self.commit(new_source, prepared); - self.start_accept(listener); + let reusable = self.resources.servers.get(&name) + .is_some_and(|resources| resources.listener_spec() == &prepared.listener_spec); + if !reusable { self.release_resource(&name).await; } - ReloadServerOutcome::SwappedServiceOnReusedListener - } else { - let old_listener = match self.stop_accept().await { - Some(listener) => listener, - None => { - tracing::error!( - server_name = %self.name, - "failed to recover listener before rebuild (was not running)" - ); - return ReloadServerOutcome::StoppedAfterFatalRebuild; + if !self.resources.servers.contains_key(&name) { + let listener = match self.plane.listener(prepared.listen_request.clone()).await { + Ok(listener) => listener, + Err(error) => { + tracing::error!(server_name = %name, error = %Report::from_error(&error), "server listener acquisition failed"); + return; } }; + self.resources.servers.insert( + name.clone(), + ServerResources::new(listener, prepared.listener_spec.clone(), access_logs), + ); + } else { + self.resources + .servers + .get_mut(&name) + .expect("reusable resource exists") + .replace_access_logs(access_logs); + } - let request = prepared.listen_request.clone(); + let listener = self.resources.servers.get_mut(&name).expect("resource inserted").take_listener(); + let service = Arc::new(prepared.service.activate( + self.resources + .servers + .get(&name) + .expect("resource inserted") + .access_logs() + .clone(), + )); + let completed = self.services.completion_sender(); + self.services.servers.insert( + name.clone(), + ServerServiceHandle::start(name, listener, service, completed), + ); + } - match self.plane.rebuild_listener(old_listener, request).await { - Ok(new_listener) => { - self.commit(new_source, prepared); - self.start_accept(new_listener); - ReloadServerOutcome::RebuiltListener - } - Err(error) => { - tracing::error!( - server_name = %self.name, - error = %Report::from_error(&error), - "listener rebuild failed; old listener was consumed by the control plane" - ); - ReloadServerOutcome::StoppedAfterFatalRebuild - } - } + async fn stop_finished_services(&mut self) { + let finished = self + .services + .servers + .iter() + .filter(|(_, service)| service.is_finished()) + .map(|(name, _)| name.clone()) + .collect::>(); + for name in finished { + tracing::warn!(server_name = %name, "server service exited unexpectedly"); + self.stop_server(&name).await; } } - pub async fn remove(mut self) { - let recovered = self.stop_accept().await; - if let Some(listener) = recovered { - let _ = dhttp::h3x::quic::Listen::shutdown(&listener) - .await - .inspect_err(|error| { - tracing::error!( - server_name = %self.name, - error = %Report::from_error(error), - "failed to shut down listener during removal" - ); - }); + pub async fn wait_service_completion(&mut self) -> DhttpName<'static> { + loop { + let completion = self.services.next_completed().await; + if self + .services + .servers + .get(&completion.name) + .is_some_and(|service| service.owns_completion(&completion)) + { + return completion.name; + } } } - async fn stop_accept(&mut self) -> Option { - self.accept.take_listener().await.ok() + pub async fn handle_service_exit(&mut self, name: DhttpName<'static>) { + tracing::warn!(server_name = %name, "server service exited unexpectedly"); + self.stop_server(&name).await; } - fn start_accept(&mut self, listener: P::Listener) { - self.accept = AcceptState::start(listener, self.service.clone()); + async fn stop_service(&mut self, name: &DhttpName<'static>) { + let Some(service) = self.services.servers.remove(name) else { return }; + match service.drain().await { + DrainOutcome::Returned(listener) => { + if let Some(resources) = self.resources.servers.get_mut(name) { + resources.put_listener(listener); + } else { + let _ = listener.shutdown().await; + } + } + DrainOutcome::Aborted => { + self.resources.servers.remove(name); + } + } } - fn commit(&mut self, new_source: ServerSource, prepared: PreparedServerUpdate) { - self.source = new_source; - self.listener_spec = prepared.listener_spec; - self.fingerprint = prepared.fingerprint; - self.service = prepared.service; + async fn release_resource(&mut self, name: &DhttpName<'static>) { + let Some(mut resources) = self.resources.servers.remove(name) else { return }; + let listener = resources.take_listener(); + if let Err(error) = listener.shutdown().await { + tracing::warn!(server_name = %name, error = %Report::from_error(&error), "server listener shutdown failed"); + } } - #[cfg(test)] - pub(crate) fn accept_state(&self) -> &AcceptState { - &self.accept + async fn stop_server(&mut self, name: &DhttpName<'static>) { + self.stop_service(name).await; + self.release_resource(name).await; } -} - -pub struct RuntimeRegistry

-where - P: ProvideListener, -{ - plane: Arc

, - servers: HashMap, ServerRuntime

>, -} -impl

RuntimeRegistry

-where - P: ProvideListener + Send + Sync + 'static, - P::Listener: dhttp::h3x::quic::Listen + Send + 'static, - ::Error: std::error::Error + Send + Sync + 'static, - ::Connection: Send + 'static, - <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: - Send + Sync, - <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: - Send + Sync, -{ - pub fn new(plane: Arc

) -> Self { - Self { - plane, - servers: HashMap::new(), - } + pub async fn shutdown(mut self) { + let names = self.resources.servers.keys().cloned().collect::>(); + for name in names { self.stop_server(&name).await; } } - pub async fn apply_sources(&mut self, sources: Vec, ctx: &PrepareContext) { - let mut new_names = std::collections::HashSet::new(); - - for source in sources { - let name = source.name().clone(); - new_names.insert(name.clone()); - - let is_fatal = match self.servers.entry(name.clone()) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - let outcome = entry.get_mut().reload(source, ctx).await; - matches!(outcome, ReloadServerOutcome::StoppedAfterFatalRebuild) - } - std::collections::hash_map::Entry::Vacant(entry) => { - let prepared = match source.prepare(ctx).await { - Ok(prepared) => prepared, - Err(error) => { - tracing::warn!( - server_name = %name, - error = %snafu::Report::from_error(&error), - "failed to prepare new server" - ); - continue; - } - }; - - let listener = match self.plane.listener(prepared.listen_request.clone()).await - { - Ok(listener) => listener, - Err(error) => { - tracing::error!( - server_name = %name, - error = %snafu::Report::from_error(&error), - "control plane failed to provide listener" - ); - continue; - } - }; - - let server = - ServerRuntime::start(source, prepared, self.plane.clone(), listener); - entry.insert(server); - false - } - }; - - if is_fatal { - let removed = self.servers.remove(&name); - if let Some(server) = removed { - server.remove().await; - } - } - } - - let mut to_remove = Vec::new(); - for name in self.servers.keys() { - if !new_names.contains(name) { - to_remove.push(name.clone()); - } - } - for name in to_remove { - if let Some(server) = self.servers.remove(&name) { - server.remove().await; - } - } + #[cfg(test)] + pub(crate) fn contains_service(&self, name: &str) -> bool { + self.services.servers.keys().any(|candidate| candidate.as_full() == name) } +} - pub async fn shutdown(mut self) { - let servers = std::mem::take(&mut self.servers); - for (_, server) in servers { - server.remove().await; - } - } +#[derive(Debug, snafu::Snafu)] +#[snafu(module(worker_reload_error))] +pub enum WorkerReloadError { + #[snafu(display("failed to load worker configuration"))] + Config { + source: gateway::parse::error::ConfigLoadFailure, + }, + #[snafu(display("failed to enumerate worker identities"))] + Identities { + source: crate::config::plan::LoadIdentityServerCandidatesError, + }, } pub struct WorkerRuntime

@@ -305,7 +198,8 @@ where P: ControlPlane + ProvideListener, { registry: RuntimeRegistry

, - dhttp_config: dhttp::home::DhttpHome, + dhttp_home: dhttp::home::DhttpHome, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, router_state: gateway::reverse::router::RouterState, } @@ -315,250 +209,194 @@ where P::Listener: dhttp::h3x::quic::Listen + Send + 'static, ::Error: std::error::Error + Send + Sync + 'static, ::Connection: Send + 'static, - <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: - Send + Sync, - <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: - Send + Sync, + <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: Send + Sync, + <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: Send + Sync, { - pub fn new( - plane: Arc

, - dhttp_config: dhttp::home::DhttpHome, - router_state: gateway::reverse::router::RouterState, - ) -> Self { - Self { - registry: RuntimeRegistry::new(plane), - dhttp_config, - router_state, - } + pub fn new(plane: Arc

, dhttp_home: dhttp::home::DhttpHome, root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, router_state: gateway::reverse::router::RouterState) -> Self { + Self { registry: RuntimeRegistry::new(plane), dhttp_home, root_defaults, router_state } } - pub async fn start(&mut self) { - self.reload().await; + pub async fn start(&mut self) -> Result<(), WorkerReloadError> { self.reload().await } + + pub async fn reload(&mut self) -> Result<(), WorkerReloadError> { + let mut parser = gateway::parse::TypedConfigParser::new(); + let path = self.dhttp_home.join(crate::config::PishooConfigSource::CONFIG_FILE_NAME); + let defaults = match gateway::parse::load_worker_config_file(&mut parser, &path, &self.dhttp_home, &self.root_defaults).await.context(worker_reload_error::ConfigSnafu)? { + Some(parsed) => parsed.pishoo().worker_defaults(), + None => self.root_defaults.clone(), + }; + let candidates = crate::config::load_identity_server_candidates(&self.dhttp_home, &defaults).await.context(worker_reload_error::IdentitiesSnafu)?; + let mut configs = Vec::new(); + for candidate in candidates.into_vec() { + let (profile, result) = candidate.into_parts(); + match result { + Ok(Some(server)) => configs.push(Arc::new(server)), + Ok(None) => {} + Err(error) => tracing::warn!(profile = ?profile.map(|profile| profile.name().to_string()), error = %Report::from_error(&error), "identity server config rejected"), + } + } + let (sources, ctx) = crate::service::source::TypedServerSource::load_all(configs, self.router_state.clone()).await; + self.registry.apply_sources(sources, &ctx).await; + Ok(()) } - pub async fn reload(&mut self) { - let sources = - match crate::worker::config::load_identity_service_sources(&self.dhttp_config).await { - Ok(sources) => sources, - Err(error) => { - tracing::warn!( - error = %snafu::Report::from_error(&error), - "failed to load identity service sources" - ); - return; - } - }; + pub async fn reload_with_root_defaults(&mut self, root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot) -> Result<(), WorkerReloadError> { + self.root_defaults = root_defaults; + self.reload().await + } - let ctx = match crate::service::source::PrepareContext::load_worker( - &self.dhttp_config, - self.router_state.clone(), - ) - .await - { - Ok(ctx) => ctx, - Err(error) => { - tracing::warn!( - error = %snafu::Report::from_error(&error), - "failed to load worker prepare context" - ); - return; - } - }; + pub async fn shutdown(self) { self.registry.shutdown().await; } - let server_sources: Vec = - sources.into_iter().map(ServerSource::IdentityService).collect(); - self.registry.apply_sources(server_sources, &ctx).await; + pub async fn wait_service_completion(&mut self) -> DhttpName<'static> { + self.registry.wait_service_completion().await } - pub async fn shutdown(self) { - self.registry.shutdown().await; + pub async fn handle_service_exit(&mut self, name: DhttpName<'static>) { + self.registry.handle_service_exit(name).await; } } #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use snafu::Snafu; use super::*; use crate::service::source::{ListenerSpec, ServerSource}; - struct FakeListener; - + struct FakeListener { + operations: Arc>>, + } #[derive(Debug, Snafu)] - #[snafu(display("fake listener never errors in these tests"))] + #[snafu(display("fake listener error"))] struct FakeListenerError; - impl dhttp::h3x::quic::Listen for FakeListener { type Connection = dhttp::h3x::dquic::prelude::Connection; type Error = FakeListenerError; - async fn accept(&mut self) -> Result, Self::Error> { std::future::pending().await } - async fn shutdown(&self) -> Result<(), Self::Error> { + self.operations.lock().unwrap().push("shutdown"); Ok(()) } } - #[derive(Debug, Snafu)] - #[snafu(display("fake plane never rebuilds in these tests"))] - struct FakeRebuildError; - - struct FakePlane; - + struct FakePlane { + operations: Arc>>, + } impl gateway::control_plane::ProvideListener for FakePlane { type Listener = FakeListener; - type ListenError = FakeRebuildError; - type RebuildError = FakeRebuildError; - + type ListenError = FakeListenerError; async fn listener( &self, _request: gateway::control_plane::ListenRequest, ) -> Result { - Err(FakeRebuildError) - } - - async fn rebuild_listener( - &self, - _old: Self::Listener, - _request: gateway::control_plane::ListenRequest, - ) -> Result { - Err(FakeRebuildError) + self.operations.lock().unwrap().push("acquire"); + Ok(FakeListener { + operations: self.operations.clone(), + }) } } - fn fake_runtime(name: &str, generation: u64) -> ServerRuntime { - let spec = ListenerSpec::fake("same"); - let source = ServerSource::fake_success(name, generation, spec.clone()); - let name_owned = DhttpName::try_from(name.to_owned()).expect("valid dhttp name"); - ServerRuntime { - name: name_owned, - source, - listener_spec: spec.clone(), - service: Arc::new(ServerService::fake()), - accept: AcceptState::Stopped { - listener: FakeListener, + fn context() -> PrepareContext { + PrepareContext { + h3_settings: Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), + router_state: gateway::reverse::router::RouterState { + #[cfg(feature = "sshd")] + session_spawner: Arc::new(DummySpawner), + #[cfg(feature = "sshd")] + task_scope: Arc::new(DummyScope), }, - fingerprint: ServerFingerprint { - listener_spec: spec, - service_generation: generation, - }, - plane: Arc::new(FakePlane), } } - #[tokio::test] - async fn reload_prepare_failure_keeps_current_fingerprint() { - let mut runtime = fake_runtime("alpha.example", 1); - let failing_source = ServerSource::fake_prepare_error("alpha.example"); - let prepared_result = if let ServerSource::Fake(fake) = &failing_source { - fake.prepare() - } else { - unreachable!() - }; - - let outcome = runtime - .reload_with_prepared(failing_source, prepared_result) - .await; - - assert!(matches!( - outcome, - ReloadServerOutcome::KeptOldOnPrepareFailure - )); - assert_eq!(runtime.fingerprint().generation_for_test(), 1); + #[cfg(feature = "sshd")] + struct DummySpawner; + #[cfg(feature = "sshd")] + impl gateway::control_plane::DynSpawnSession for DummySpawner { + fn spawn_session<'a>( + &'a self, + _username: &'a str, + ) -> futures::future::BoxFuture< + 'a, + Result< + gateway::control_plane::SessionTransport, + Box, + >, + > { + Box::pin(async { std::future::pending().await }) + } } - - #[tokio::test] - async fn unchanged_listener_reload_swaps_service_for_future_accepts() { - let mut runtime = fake_runtime("alpha.example", 1); - let source = ServerSource::fake_success("alpha.example", 2, ListenerSpec::fake("same")); - let prepared_result = if let ServerSource::Fake(fake) = &source { - fake.prepare() - } else { - unreachable!() - }; - - let outcome = runtime.reload_with_prepared(source, prepared_result).await; - - assert!(matches!( - outcome, - ReloadServerOutcome::SwappedServiceOnReusedListener - )); - assert_eq!(runtime.fingerprint().generation_for_test(), 2); + #[cfg(feature = "sshd")] + struct DummyScope; + #[cfg(feature = "sshd")] + impl gateway::reverse::router::DynTaskScope for DummyScope { + fn token(&self) -> tokio_util::sync::CancellationToken { + tokio_util::sync::CancellationToken::new() + } + fn spawn(&self, _task: futures::future::BoxFuture<'static, ()>) {} } #[tokio::test] - async fn reused_listener_reload_keeps_accept_running() { - let mut runtime = fake_runtime("alpha.example", 1); - let source = ServerSource::fake_success("alpha.example", 2, ListenerSpec::fake("same")); - let prepared_result = if let ServerSource::Fake(fake) = &source { - fake.prepare() - } else { - unreachable!() - }; - - let _ = runtime.reload_with_prepared(source, prepared_result).await; + async fn changed_listener_drains_closes_and_reacquires_in_one_round() { + let operations = Arc::new(Mutex::new(Vec::new())); + let mut runtime = RuntimeRegistry::new(Arc::new(FakePlane { + operations: operations.clone(), + })); + runtime + .apply_sources( + vec![ServerSource::fake_success( + "alpha.dhttp.net", + 1, + ListenerSpec::fake("a"), + )], + &context(), + ) + .await; + runtime + .apply_sources( + vec![ServerSource::fake_success( + "alpha.dhttp.net", + 2, + ListenerSpec::fake("b"), + )], + &context(), + ) + .await; - assert!(matches!( - runtime.accept_state(), - AcceptState::Running { .. } - )); + assert_eq!( + *operations.lock().unwrap(), + ["acquire", "shutdown", "acquire"] + ); + assert!(runtime.contains_service("alpha.dhttp.net")); } #[tokio::test] - async fn remove_completes_even_when_release_fails() { - struct FailingListener {} - impl dhttp::h3x::quic::Listen for FailingListener { - type Connection = dhttp::h3x::dquic::prelude::Connection; - type Error = FakeListenerError; - async fn accept(&mut self) -> Result, Self::Error> { - std::future::pending().await - } - async fn shutdown(&self) -> Result<(), Self::Error> { - Err(FakeListenerError) - } - } - - struct FailingPlane; - - impl gateway::control_plane::ProvideListener for FailingPlane { - type Listener = FailingListener; - type ListenError = FakeRebuildError; - type RebuildError = FakeRebuildError; - - async fn listener( - &self, - _request: gateway::control_plane::ListenRequest, - ) -> Result { - Err(FakeRebuildError) - } - async fn rebuild_listener( - &self, - _old: Self::Listener, - _request: gateway::control_plane::ListenRequest, - ) -> Result { - Err(FakeRebuildError) - } - } - - let runtime = ServerRuntime { - name: DhttpName::try_from("alpha.example".to_owned()).unwrap(), - source: ServerSource::fake_success("alpha.example", 1, ListenerSpec::fake("same")), - listener_spec: ListenerSpec::fake("same"), - service: Arc::new(ServerService::fake()), - accept: AcceptState::Stopped { - listener: FailingListener {}, - }, - fingerprint: ServerFingerprint { - listener_spec: ListenerSpec::fake("same"), - service_generation: 1, - }, - plane: Arc::new(FailingPlane), - }; + async fn preparation_failure_stops_only_that_server() { + let operations = Arc::new(Mutex::new(Vec::new())); + let mut runtime = RuntimeRegistry::new(Arc::new(FakePlane { operations })); + runtime + .apply_sources( + vec![ + ServerSource::fake_success("good.dhttp.net", 1, ListenerSpec::fake("good")), + ServerSource::fake_success("bad.dhttp.net", 1, ListenerSpec::fake("bad")), + ], + &context(), + ) + .await; + runtime + .apply_sources( + vec![ + ServerSource::fake_success("good.dhttp.net", 2, ListenerSpec::fake("good")), + ServerSource::fake_prepare_error("bad.dhttp.net"), + ], + &context(), + ) + .await; - runtime.remove().await; // Should not panic or hang + assert!(runtime.contains_service("good.dhttp.net")); + assert!(!runtime.contains_service("bad.dhttp.net")); } } diff --git a/pishoo/src/service/set.rs b/pishoo/src/service/set.rs new file mode 100644 index 00000000..aae7a151 --- /dev/null +++ b/pishoo/src/service/set.rs @@ -0,0 +1,237 @@ +use std::{ + collections::HashMap, + sync::{Arc, Weak}, +}; + +use dhttp::name::DhttpName; +use gateway::{ + parse::domain::ResolvedConfigPath, + reverse::log::{AccessLogOutput, OpenAccessLogOutputError}, +}; + +use super::{ + accept::{AcceptState, DrainOutcome}, + resource::{AccessLogResourcePlan, AccessLogResources, ServerResources}, +}; + +pub struct ResourceSet { + pub(crate) servers: HashMap, ServerResources>, + pub(crate) access_output_cache: HashMap>, +} + +impl Default for ResourceSet { + fn default() -> Self { + Self { + servers: HashMap::new(), + access_output_cache: HashMap::new(), + } + } +} + +impl ResourceSet { + pub(crate) fn acquire_output( + &mut self, + path: ResolvedConfigPath, + ) -> Result, OpenAccessLogOutputError> { + if let Some(output) = self.access_output_cache.get(&path).and_then(Weak::upgrade) { + return Ok(output); + } + + let output = Arc::new(AccessLogOutput::open(path.clone())?); + self.access_output_cache + .insert(path, Arc::downgrade(&output)); + Ok(output) + } + + pub(crate) fn acquire_access_logs( + &mut self, + plan: AccessLogResourcePlan, + ) -> Result { + let server = self.acquire_configured_output(plan.server)?; + let locations = plan + .locations + .into_vec() + .into_iter() + .map(|config| self.acquire_configured_output(config)) + .collect::, _>>()?; + Ok(AccessLogResources { server, locations }) + } + + fn acquire_configured_output( + &mut self, + config: gateway::parse::config::ResolvedAccessLogConfig, + ) -> Result>, OpenAccessLogOutputError> { + match config { + gateway::parse::config::ResolvedAccessLogConfig::Disabled => Ok(None), + gateway::parse::config::ResolvedAccessLogConfig::Enabled(path) => { + self.acquire_output(path).map(Some) + } + } + } +} + +pub(crate) struct ServiceCompletion { + pub(crate) name: DhttpName<'static>, + marker: Arc<()>, +} + +#[derive(Clone)] +struct ServiceCompletionToken { + name: DhttpName<'static>, + marker: Arc<()>, +} + +impl ServiceCompletionToken { + fn new(name: DhttpName<'static>) -> Self { + Self { + name, + marker: Arc::new(()), + } + } + + fn completion(&self) -> ServiceCompletion { + ServiceCompletion { + name: self.name.clone(), + marker: Arc::clone(&self.marker), + } + } + + fn owns(&self, completion: &ServiceCompletion) -> bool { + Arc::ptr_eq(&self.marker, &completion.marker) + } +} + +pub struct ServerServiceHandle { + accept: AcceptState, + completion: ServiceCompletionToken, +} + +impl ServerServiceHandle +where + L: Send + 'static, +{ + pub(crate) fn start( + name: DhttpName<'static>, + listener: L, + service: std::sync::Arc, + completed: tokio::sync::mpsc::UnboundedSender, + ) -> Self + where + L: dhttp::h3x::quic::Listen, + L::Error: Send, + L::Connection: Send + 'static, + ::LocalAuthority: Send + Sync, + ::RemoteAuthority: Send + Sync, + { + let completion = ServiceCompletionToken::new(name); + let task_completion = completion.clone(); + Self { + accept: AcceptState::start(listener, service, move || { + let _ = completed.send(task_completion.completion()); + }), + completion, + } + } + + pub async fn drain(self) -> DrainOutcome { + self.accept.drain().await + } + pub fn is_finished(&self) -> bool { + self.accept.is_finished() + } + + pub(crate) fn owns_completion(&self, completion: &ServiceCompletion) -> bool { + self.completion.owns(completion) + } +} + +pub struct ServiceSet { + pub(crate) servers: HashMap, ServerServiceHandle>, + completed_tx: tokio::sync::mpsc::UnboundedSender, + completed_rx: tokio::sync::mpsc::UnboundedReceiver, +} + +impl Default for ServiceSet { + fn default() -> Self { + let (completed_tx, completed_rx) = tokio::sync::mpsc::unbounded_channel(); + Self { + servers: HashMap::new(), + completed_tx, + completed_rx, + } + } +} + +impl ServiceSet { + pub(crate) fn completion_sender( + &self, + ) -> tokio::sync::mpsc::UnboundedSender { + self.completed_tx.clone() + } + + pub(crate) async fn next_completed(&mut self) -> ServiceCompletion { + self.completed_rx + .recv() + .await + .expect("service set owns the completion sender") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use gateway::parse::domain::ResolvedConfigPath; + + use super::*; + + #[test] + fn completion_only_belongs_to_the_service_instance_that_created_it() { + let name = "alice.dhttp.net".parse().unwrap(); + let first = ServiceCompletionToken::new(name); + let replacement = ServiceCompletionToken::new("alice.dhttp.net".parse().unwrap()); + let completion = first.completion(); + + assert!(first.owns(&completion)); + assert!(!replacement.owns(&completion)); + } + + #[test] + fn same_process_path_reuses_output_and_last_binding_closes_it() { + let mut resources = ResourceSet::<()>::default(); + let path = ResolvedConfigPath::try_from(std::env::temp_dir().join(format!( + "pishoo-shared-access-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ))) + .unwrap(); + + let first = resources.acquire_output(path.clone()).unwrap(); + let second = resources.acquire_output(path.clone()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &second)); + drop(first); + drop(second); + assert!( + resources + .access_output_cache + .get(&path) + .unwrap() + .upgrade() + .is_none() + ); + + let _ = std::fs::remove_file(PathBuf::from(path.as_ref())); + } + + #[test] + fn output_open_failure_does_not_create_a_binding() { + let mut resources = ResourceSet::<()>::default(); + let path = ResolvedConfigPath::try_from(PathBuf::from("/proc/pishoo/access.log")).unwrap(); + + assert!(resources.acquire_output(path).is_err()); + assert!(resources.access_output_cache.is_empty()); + } +} diff --git a/pishoo/src/service/snapshot.rs b/pishoo/src/service/snapshot.rs index fb7278e0..e9b88e49 100644 --- a/pishoo/src/service/snapshot.rs +++ b/pishoo/src/service/snapshot.rs @@ -1,4 +1,4 @@ -use std::{future::Future, path::PathBuf, sync::Arc}; +use std::{future::Future, sync::Arc}; use axum::middleware::from_fn_with_state; use dhttp::h3x::{ @@ -7,9 +7,9 @@ use dhttp::h3x::{ }; use gateway::reverse::{ access_control::{AccessControlState, access_control}, - access_log::{AccessLogState, access_log}, + access_log::{AccessLogState, ActiveAccessLog, access_log}, body_adapter::BodyAdapterLayer, - log::AccessLogWriter, + location::ConfiguredLocation, router::NginxRouter, }; use snafu::Report; @@ -17,13 +17,36 @@ use tokio_util::sync::CancellationToken; use tower::ServiceBuilder; use tracing::Instrument; +use super::resource::AccessLogResources; + +pub struct PreparedServerService { + pub h3_settings: Arc, + pub access_rules: Arc, + pub router_state: gateway::reverse::router::RouterState, + pub server_config: Arc, + pub server_name: dhttp::name::DhttpName<'static>, +} + +impl PreparedServerService { + pub fn activate(self, access_logs: AccessLogResources) -> ServerService { + ServerService { + h3_settings: self.h3_settings, + access_rules: self.access_rules, + router_state: self.router_state, + server_config: self.server_config, + server_name: self.server_name, + access_logs, + } + } +} + pub struct ServerService { pub h3_settings: Arc, pub access_rules: Arc, pub router_state: gateway::reverse::router::RouterState, - pub server_node: Arc, - pub access_log_dir: Option, + pub server_config: Arc, pub server_name: dhttp::name::DhttpName<'static>, + pub access_logs: AccessLogResources, } impl ServerService { @@ -39,37 +62,37 @@ impl ServerService { ::LocalAuthority: Send + Sync, ::RemoteAuthority: Send + Sync, { - let locations = self.server_node.children_optional("location").to_vec(); + assert_eq!( + self.server_config.locations().len(), + self.access_logs.locations.len(), + "access log resources correspond to every configured location" + ); + let locations = self + .server_config + .locations() + .iter() + .cloned() + .zip(self.access_logs.locations.iter().cloned()) + .map(|(location, output)| { + Arc::new(ConfiguredLocation::new( + Arc::new(location), + ActiveAccessLog::from_output(output), + )) + }) + .collect(); - let nginx_router = NginxRouter::new(locations, self.router_state.clone()); + let server_access_log = ActiveAccessLog::from_output(self.access_logs.server.clone()); + let nginx_router = NginxRouter::new( + locations, + server_access_log.clone(), + self.router_state.clone(), + ); let access_state = AccessControlState { access_rules: self.access_rules.clone(), server_name: Arc::from(self.server_name.as_full()), }; - - let access_log_writer = match &self.access_log_dir { - Some(dir) => match AccessLogWriter::new(dir.clone()) { - Ok(writer) => { - tracing::debug!( - server_name = %self.server_name, - dir = %dir.display(), - "access log writer created" - ); - writer - } - Err(error) => { - tracing::warn!( - server_name = %self.server_name, - error = %Report::from_error(&error), - "failed to create access log writer, access logging disabled" - ); - AccessLogWriter::disabled() - } - }, - None => AccessLogWriter::disabled(), - }; let access_log_state = AccessLogState { - writer: access_log_writer, + server: server_access_log, }; let service_stack = ServiceBuilder::new() @@ -137,8 +160,8 @@ where #[cfg(test)] impl ServerService { - pub(crate) fn fake() -> Self { - Self { + pub(crate) fn fake() -> PreparedServerService { + PreparedServerService { h3_settings: Arc::new(Settings::default()), access_rules: Arc::new(dhttp::access::matcher::LocationRulesMatcher::default()), router_state: { @@ -185,21 +208,24 @@ impl ServerService { ), } }, - server_node: { - let registry = gateway::parse::default_registry(); - let doc = gateway::parse::load_config_text( - "", + server_config: { + let mut parser = gateway::parse::TypedConfigParser::new(); + let parsed = parser.parse_root( + "pishoo { server { listen all 443; server_name test.example; ssl_certificate /tmp/test.crt; ssl_certificate_key /tmp/test.key; } }", + std::path::Path::new("/tmp/pishoo.conf"), None, - ®istry, - gateway::parse::registry::BuildOptions { - dhttp_home: None, - identity_profile: None, - }, + ).unwrap(); + Arc::new( + parsed + .into_parts() + .1 + .into_vec() + .pop() + .unwrap() + .into_result() + .unwrap(), ) - .unwrap(); - doc.root }, - access_log_dir: None, server_name: dhttp::name::DhttpName::try_from("test.example").unwrap(), } } diff --git a/pishoo/src/service/source.rs b/pishoo/src/service/source.rs index 9a1412a8..9dcab056 100644 --- a/pishoo/src/service/source.rs +++ b/pishoo/src/service/source.rs @@ -1,25 +1,17 @@ use std::sync::Arc; -use dhttp::{ - home::{DhttpHome, identity::IdentityProfile}, - name::DhttpName, -}; +use dhttp::name::DhttpName; use gateway::{ control_plane::ListenRequest, parse::{ - document::ConfigNode, - domain::ResolvedConfigPath, - types::{AccessRulesUri, ListenConfig, Listens, ResolverConfig, ServerNames}, + config::{ServerConfig, ServerIdentity}, + types::Listens, }, reverse::router::RouterState, }; -use snafu::{OptionExt, ResultExt, Snafu}; - -use super::snapshot::ServerService; -use crate::{config::load_identity_servers, worker::config::BuildConfigError}; +use snafu::{ResultExt, Snafu}; -type SharedLocationRuleEvaluator = - Arc; +use super::{resource::AccessLogResourcePlan, snapshot::PreparedServerService}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListenRequestFingerprint { @@ -44,16 +36,23 @@ pub struct PreparedServerUpdate { pub name: DhttpName<'static>, pub listen_request: ListenRequest, pub listener_spec: ListenerSpec, - pub service: Arc, + pub service: PreparedServerService, + pub access_logs: AccessLogResourcePlan, pub fingerprint: ServerFingerprint, } #[derive(Debug, Snafu)] -#[snafu(module)] +#[snafu(module(prepare_server_update_error))] pub enum PrepareServerUpdateError { - #[snafu(display("failed to build identity service config"))] - IdentityService { - source: crate::worker::config::BuildConfigError, + #[snafu(display("failed to load access policy for server `{name}`"))] + Policy { + name: String, + source: crate::policy::PolicyError, + }, + #[snafu(display("failed to materialize access log configuration for server `{name}`"))] + AccessLog { + name: String, + source: gateway::parse::config::MaterializeAccessLogError, }, #[cfg(test)] #[snafu(display("synthetic prepare failure for {server_name}"))] @@ -61,174 +60,194 @@ pub enum PrepareServerUpdateError { } pub enum ServerSource { - IdentityService(IdentityServiceSource), - PishooConfig(PishooConfigServiceSource), + Typed(TypedServerSource), #[cfg(test)] Fake(FakeServerSource), } -pub struct PrepareContext { - pub h3_settings: Arc, - pub access_rules: SharedLocationRuleEvaluator, - pub router_state: gateway::reverse::router::RouterState, -} - -fn h3_settings() -> Arc { - let settings = dhttp::h3x::dhttp::settings::Settings::default(); - #[cfg(feature = "sshd")] - let settings = settings - .with_all(dhttp::h3x::dhttp::webtransport::settings::WebTransportSupport::default()); - Arc::new(settings) -} - -pub struct IdentityServiceSource { - pub name: DhttpName<'static>, - pub home: DhttpHome, - pub identity_profile: IdentityProfile, +pub struct TypedServerSource { + name: DhttpName<'static>, + identity: dhttp::identity::Identity, + bind: Vec, + dns_resolver_url: Option, + server_config: Arc, } -pub struct PishooConfigServiceSource { - pub name: DhttpName<'static>, - pub identity: dhttp::identity::Identity, - pub bind: Vec, - pub dns_resolver_url: Option, - pub server_node: Arc, +pub struct PrepareContext { + pub h3_settings: Arc, + pub router_state: RouterState, } -#[cfg(test)] -pub struct FakeServerSource { - pub(crate) name: DhttpName<'static>, - pub(crate) outcome: FakePrepareOutcome, +impl PrepareContext { + pub fn new(router_state: RouterState) -> Self { + let settings = dhttp::h3x::dhttp::settings::Settings::default(); + #[cfg(feature = "sshd")] + let settings = settings + .with_all(dhttp::h3x::dhttp::webtransport::settings::WebTransportSupport::default()); + Self { + h3_settings: Arc::new(settings), + router_state, + } + } } -#[cfg(test)] -pub enum FakePrepareOutcome { - Success { - listener_spec: ListenerSpec, - service_generation: u64, +#[derive(Debug, Snafu)] +#[snafu(module(build_typed_server_source_error))] +pub enum BuildTypedServerSourceError { + #[snafu(display("server has no listen directive"))] + MissingListen, + #[snafu(display("server has no server_name"))] + MissingName, + #[snafu(display("failed to read certificate at `{}`", path.display()))] + ReadCert { + path: std::path::PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to read private key at `{}`", path.display()))] + ReadKey { + path: std::path::PathBuf, + source: std::io::Error, + }, + #[snafu(display("invalid TLS material"))] + InvalidTls { + source: crate::tls::TlsMaterialError, + }, + #[snafu(display("failed to load identity `{name}`"))] + LoadIdentity { + name: String, + source: dhttp::home::identity::ssl::LoadIdentityError, }, - Failure, } -impl PishooConfigServiceSource { +impl TypedServerSource { pub async fn load_all( - config_servers: &[Arc], - home: Option<&DhttpHome>, + configs: impl IntoIterator>, router_state: RouterState, - ) -> Result<(Vec, PrepareContext), BuildConfigServiceSourcesError> { - let canonicalized = crate::naming::canonicalize_server_nodes(config_servers) - .context(build_config_service_sources_error::CanonicalizeSnafu)?; - - let mut access_rules_uri: Option = None; + ) -> (Vec, PrepareContext) { let mut sources = Vec::new(); - let mut seen_server_names = std::collections::HashSet::new(); - - for server in &canonicalized { - let listens = listen_values(server) - .context(build_config_service_sources_error::ConfigQuerySnafu)?; - if listens.is_empty() { - return build_config_service_sources_error::MissingListenSnafu.fail(); - } - - let server_names = server - .get::("server_name") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .context(build_config_service_sources_error::MissingDirectiveSnafu { - directive: "server_name", - })?; - - let has_cert = server - .get::("ssl_certificate") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .is_some(); - let has_key = server - .get::("ssl_certificate_key") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .is_some(); - if home.is_some() && !has_cert && !has_key && server_names.0.len() > 1 { - return build_config_service_sources_error::AmbiguousImplicitTlsSnafu.fail(); - } - - if access_rules_uri.is_none() - && let Some(uri) = server - .get::("access_rules") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - { - access_rules_uri = Some(uri.0.as_str().to_owned()); - } - - let dns_resolver_url = server - .get::("dns") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .map(|resolver| resolver.0.clone()); - - for configured_name in &server_names.0 { - let server_name = configured_name.name.clone(); - if !seen_server_names.insert(server_name.clone()) { - return build_config_service_sources_error::DuplicateServerNameSnafu { - name: server_name.to_string(), + let mut names = std::collections::HashSet::new(); + let loads = configs.into_iter().map(Self::load_config); + for result in futures::future::join_all(loads).await { + match result { + Ok(config_sources) => { + for source in config_sources { + if names.insert(source.name.clone()) { + sources.push(ServerSource::Typed(source)); + } else { + tracing::warn!(server_name = %source.name, "duplicate server name stopped"); + } } - .fail(); } - - let identity = load_identity_for_server(home, server, &server_name).await?; - sources.push(ServerSource::PishooConfig(PishooConfigServiceSource { - name: server_name, - identity, - bind: listens.clone(), - dns_resolver_url: dns_resolver_url.clone(), - server_node: server.clone(), - })); + Err(error) => tracing::warn!( + error = %snafu::Report::from_error(&error), + "server resource construction failed" + ), } } - - let ctx = PrepareContext::load_config_service(access_rules_uri.as_deref(), router_state) - .await - .context(build_config_service_sources_error::PrepareContextSnafu)?; - - Ok((sources, ctx)) - } - - pub async fn prepare( + (sources, PrepareContext::new(router_state)) + } + + async fn load_config( + config: Arc, + ) -> Result, BuildTypedServerSourceError> { + let bind = config + .listens() + .iter() + .flat_map(|listen| listen.0.clone()) + .collect::>(); + if bind.is_empty() { + return Err(BuildTypedServerSourceError::MissingListen); + } + if config.names().is_empty() { + return Err(BuildTypedServerSourceError::MissingName); + } + let identity = load_identity(&config).await?; + let resolver = config.resolver().map(|resolver| resolver.0.clone()); + Ok(config + .names() + .iter() + .map(|name| Self { + name: name.clone(), + identity: identity.clone(), + bind: bind.clone(), + dns_resolver_url: resolver.clone(), + server_config: config.clone(), + }) + .collect()) + } + + async fn prepare( &self, - ctx: &PrepareContext, + context: &PrepareContext, ) -> Result { + let access_rules_uri = self + .server_config + .http() + .access_rules() + .effective() + .as_ref() + .map(|uri| uri.0.as_str()); + let policy = crate::policy::load_policy_bundle(access_rules_uri) + .await + .context(prepare_server_update_error::PolicySnafu { + name: self.name.to_string(), + })?; let listen_request = ListenRequest { identity: self.identity.clone(), bind: self.bind.clone(), dns_resolver_url: self.dns_resolver_url.clone(), }; - - let request_fingerprint = ListenRequestFingerprint { - server_name: self.name.clone(), - bind_debug: format!("{:?}", self.bind), - identity_debug: compute_identity_fingerprint(&self.identity), - dns_resolver_debug: self.dns_resolver_url.as_ref().map(|u| u.to_string()), - }; - let listener_spec = ListenerSpec { - request_fingerprint, + request_fingerprint: ListenRequestFingerprint { + server_name: self.name.clone(), + bind_debug: format!("{:?}", self.bind), + identity_debug: compute_identity_fingerprint(&self.identity), + dns_resolver_debug: self.dns_resolver_url.as_ref().map(ToString::to_string), + }, + }; + let access_logs = AccessLogResourcePlan { + server: self + .server_config + .http() + .access_log() + .effective() + .materialize(self.server_config.identity()) + .context(prepare_server_update_error::AccessLogSnafu { + name: self.name.to_string(), + })?, + locations: self + .server_config + .locations() + .iter() + .map(|location| { + location + .http() + .access_log() + .effective() + .materialize(self.server_config.identity()) + .context(prepare_server_update_error::AccessLogSnafu { + name: self.name.to_string(), + }) + }) + .collect::, _>>()?, + }; + let service = PreparedServerService { + h3_settings: context.h3_settings.clone(), + access_rules: policy.location_rules, + router_state: context.router_state.clone(), + server_config: self.server_config.clone(), + server_name: self.name.clone(), }; - let service_generation = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_secs(); - - let service = Arc::new(ServerService { - h3_settings: ctx.h3_settings.clone(), - access_rules: ctx.access_rules.clone(), - router_state: ctx.router_state.clone(), - server_node: self.server_node.clone(), - access_log_dir: None, - server_name: self.name.clone(), - }); - + .as_nanos() as u64; Ok(PreparedServerUpdate { name: self.name.clone(), listen_request, listener_spec: listener_spec.clone(), service, + access_logs, fingerprint: ServerFingerprint { listener_spec, service_generation, @@ -237,202 +256,139 @@ impl PishooConfigServiceSource { } } -fn listen_values( - server: &ConfigNode, -) -> Result, gateway::parse::error::ConfigQueryError> { - Ok(server - .get_all::("listen")? - .into_iter() - .flat_map(|node| node.0.clone()) - .collect()) -} - -async fn load_identity_for_server( - home: Option<&DhttpHome>, - server: &ConfigNode, - server_name: &DhttpName<'static>, -) -> Result { - let cert_path = server - .get::("ssl_certificate") - .context(build_config_service_sources_error::ConfigQuerySnafu)?; - let key_path = server - .get::("ssl_certificate_key") - .context(build_config_service_sources_error::ConfigQuerySnafu)?; - - match (cert_path, key_path) { - (Some(cert_path), Some(key_path)) => { - load_identity_from_paths( - server_name, - cert_path.as_ref().as_ref(), - key_path.as_ref().as_ref(), - ) - .await - } - (None, None) => { - let home = home.context(build_config_service_sources_error::MissingDirectiveSnafu { - directive: "ssl_certificate", - })?; - let profile = home - .resolve_identity_profile(server_name.clone()) - .await - .context(build_config_service_sources_error::ResolveIdentitySnafu { - name: server_name.to_string(), - })?; - profile.load_identity().await.context( - build_config_service_sources_error::LoadIdentitySnafu { - name: server_name.to_string(), +async fn load_identity( + config: &ServerConfig, +) -> Result { + match config.identity() { + ServerIdentity::Profile(profile) => profile.load_identity().await.context( + build_typed_server_source_error::LoadIdentitySnafu { + name: profile.name().to_string(), + }, + ), + ServerIdentity::Direct { + certificate, + private_key, + } => { + let name = config + .names() + .first() + .ok_or(BuildTypedServerSourceError::MissingName)?; + let cert = tokio::fs::read(certificate.as_ref()).await.context( + build_typed_server_source_error::ReadCertSnafu { + path: certificate.as_ref(), }, - ) - } - (None, Some(_)) => build_config_service_sources_error::MissingDirectiveSnafu { - directive: "ssl_certificate", - } - .fail(), - (Some(_), None) => build_config_service_sources_error::MissingDirectiveSnafu { - directive: "ssl_certificate_key", + )?; + let key = tokio::fs::read(private_key.as_ref()).await.context( + build_typed_server_source_error::ReadKeySnafu { + path: private_key.as_ref(), + }, + )?; + let (certs, key) = crate::tls::validate_tls_material(&cert, &key) + .context(build_typed_server_source_error::InvalidTlsSnafu)?; + Ok(dhttp::identity::Identity::new( + name.clone().into(), + certs, + key, + )) } - .fail(), } } -async fn load_identity_from_paths( - server_name: &DhttpName<'static>, - cert_path: &std::path::Path, - key_path: &std::path::Path, -) -> Result { - let cert_pem = tokio::fs::read(cert_path) - .await - .context(build_config_service_sources_error::ReadCertSnafu { path: cert_path })?; - let key_pem = tokio::fs::read(key_path) - .await - .context(build_config_service_sources_error::ReadKeySnafu { path: key_path })?; - let (certs, key) = crate::tls::validate_tls_material(&cert_pem, &key_pem) - .context(build_config_service_sources_error::InvalidTlsSnafu)?; - Ok(dhttp::identity::Identity::new( - server_name.clone().into(), - certs, - key, - )) -} - impl ServerSource { pub fn name(&self) -> &DhttpName<'static> { match self { - Self::IdentityService(source) => &source.name, - Self::PishooConfig(source) => &source.name, + Self::Typed(source) => &source.name, #[cfg(test)] Self::Fake(source) => &source.name, } } - pub async fn prepare( &self, - ctx: &PrepareContext, + context: &PrepareContext, ) -> Result { match self { - Self::IdentityService(source) => source.prepare(ctx).await, - Self::PishooConfig(source) => source.prepare(ctx).await, + Self::Typed(source) => source.prepare(context).await, #[cfg(test)] Self::Fake(source) => source.prepare(), } } } +pub(crate) fn compute_identity_fingerprint(identity: &dhttp::identity::Identity) -> String { + use sha2::{Digest, Sha256}; + fn hex(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + let mut certs = Sha256::new(); + for cert in identity.certs.iter() { + certs.update(cert.as_ref()); + } + let mut key = Sha256::new(); + key.update(identity.key.secret_der()); + format!( + "{}@{}@{}", + identity.name(), + hex(certs.finalize()), + hex(key.finalize()) + ) +} + +#[cfg(test)] +pub struct FakeServerSource { + pub(crate) name: DhttpName<'static>, + outcome: FakePrepareOutcome, +} +#[cfg(test)] +enum FakePrepareOutcome { + Success { + listener_spec: ListenerSpec, + service_generation: u64, + }, + Failure, +} #[cfg(test)] impl FakeServerSource { - pub(crate) fn prepare(&self) -> Result { + fn prepare(&self) -> Result { match &self.outcome { FakePrepareOutcome::Success { listener_spec, service_generation, } => Ok(PreparedServerUpdate { name: self.name.clone(), - listen_request: Self::fake_listen_request(&self.name), + listen_request: fake_listen_request(&self.name), listener_spec: listener_spec.clone(), - service: Arc::new(ServerService::fake()), + service: super::snapshot::ServerService::fake(), + access_logs: AccessLogResourcePlan { + server: gateway::parse::config::ResolvedAccessLogConfig::Disabled, + locations: Box::new([]), + }, fingerprint: ServerFingerprint { listener_spec: listener_spec.clone(), service_generation: *service_generation, }, }), FakePrepareOutcome::Failure => Err(PrepareServerUpdateError::SyntheticFailure { - server_name: self.name.as_full().to_owned(), + server_name: self.name.to_string(), }), } } - - fn dhttp_subject_key_identifier_der() -> Vec { - use dhttp::certificate::{ - CertificateChainKey, CertificateChainKind, CertificateSequence, - DhttpSubjectKeyIdentifier, OwnerHash, - }; - - let owner_hash = - OwnerHash::try_from("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") - .unwrap(); - let value = DhttpSubjectKeyIdentifier::new( - CertificateChainKey::new( - CertificateSequence::from(0u8), - CertificateChainKind::Primary, - ), - owner_hash, - ) - .to_string(); - - let bytes = value.as_bytes(); - assert!(bytes.len() < 128, "test dhttp ski must use short-form DER"); - let mut der = Vec::with_capacity(bytes.len() + 2); - der.push(0x04); - der.push(bytes.len() as u8); - der.extend_from_slice(bytes); - der - } - - fn fake_listen_request(name: &DhttpName<'static>) -> ListenRequest { - use dhttp::identity::Identity; - - let fqdn = name.as_full().to_owned(); - let key_pair = rcgen::KeyPair::generate().expect("rcgen key generation"); - let mut params = rcgen::CertificateParams::new(vec![fqdn.clone()]).expect("rcgen params"); - params.distinguished_name = rcgen::DistinguishedName::new(); - params - .distinguished_name - .push(rcgen::DnType::CommonName, &fqdn); - params - .custom_extensions - .push(rcgen::CustomExtension::from_oid_content( - &[2, 5, 29, 14], - Self::dhttp_subject_key_identifier_der(), - )); - let cert = params.self_signed(&key_pair).expect("rcgen self-sign"); - let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec()); - let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()) - .expect("rcgen key der"); - let identity = Identity::new(name.clone().into(), vec![cert_der], key_der); - ListenRequest { - identity, - bind: vec![], - dns_resolver_url: None, - } - } } #[cfg(test)] impl ServerSource { - pub(crate) fn fake_success( - name: &str, - service_generation: u64, - listener_spec: ListenerSpec, - ) -> Self { + pub(crate) fn fake_success(name: &str, generation: u64, listener_spec: ListenerSpec) -> Self { Self::Fake(FakeServerSource { name: fake_name(name), outcome: FakePrepareOutcome::Success { listener_spec, - service_generation, + service_generation: generation, }, }) } - pub(crate) fn fake_prepare_error(name: &str) -> Self { Self::Fake(FakeServerSource { name: fake_name(name), @@ -446,7 +402,7 @@ impl ListenerSpec { pub(crate) fn fake(label: &str) -> Self { Self { request_fingerprint: ListenRequestFingerprint { - server_name: fake_name(label), + server_name: fake_name("fixture.dhttp.net"), bind_debug: format!("bind:{label}"), identity_debug: format!("identity:{label}"), dns_resolver_debug: None, @@ -455,766 +411,28 @@ impl ListenerSpec { } } -#[cfg(test)] -impl ServerFingerprint { - pub(crate) fn generation_for_test(&self) -> u64 { - self.service_generation - } -} - #[cfg(test)] fn fake_name(name: &str) -> DhttpName<'static> { - DhttpName::try_from(name.to_owned()).expect("test name must be a valid dhttp name") -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildConfigServiceSourcesError { - #[snafu(display("failed to canonicalize pishoo config server nodes"))] - Canonicalize { source: gateway::error::Whatever }, - - #[snafu(display("pishoo config service missing `{directive}`"))] - MissingDirective { directive: &'static str }, - - #[snafu(display("failed to read typed configuration value"))] - ConfigQuery { - source: gateway::parse::error::ConfigQueryError, - }, - - #[snafu(display("failed to read certificate at `{}`", path.display()))] - ReadCert { - path: std::path::PathBuf, - source: std::io::Error, - }, - - #[snafu(display("failed to read private key at `{}`", path.display()))] - ReadKey { - path: std::path::PathBuf, - source: std::io::Error, - }, - - #[snafu(display("invalid TLS material"))] - InvalidTls { - source: crate::tls::TlsMaterialError, - }, - - #[snafu(display("failed to resolve identity `{name}` in dhttp home"))] - ResolveIdentity { - name: String, - source: dhttp::home::identity::ssl::ResolveIdentityProfileError, - }, - - #[snafu(display("failed to load identity `{name}` from dhttp home"))] - LoadIdentity { - name: String, - source: dhttp::home::identity::ssl::LoadIdentityError, - }, - - #[snafu(display("implicit dhttp home TLS is ambiguous for multiple server_name values"))] - AmbiguousImplicitTls, - - #[snafu(display("duplicate pishoo config server_name `{name}`"))] - DuplicateServerName { name: String }, - - #[snafu(display("pishoo config service missing `listen`"))] - MissingListen, - - #[snafu(display("failed to prepare context"))] - PrepareContext { source: BuildPrepareContextError }, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildPrepareContextError { - #[snafu(display("failed to load default policy bundle"))] - Policy { source: crate::policy::PolicyError }, -} - -impl PrepareContext { - pub async fn load_config_service( - access_rules_uri: Option<&str>, - router_state: RouterState, - ) -> Result { - let policy_bundle = crate::policy::load_policy_bundle(access_rules_uri) - .await - .context(build_prepare_context_error::PolicySnafu)?; - - Ok(Self { - h3_settings: h3_settings(), - access_rules: policy_bundle.location_rules, - router_state, - }) - } - - pub async fn load_worker( - _home: &DhttpHome, - router_state: RouterState, - ) -> Result { - let policy_bundle = crate::policy::load_policy_bundle(None) - .await - .context(build_prepare_context_error::PolicySnafu)?; - - Ok(Self { - h3_settings: h3_settings(), - access_rules: policy_bundle.location_rules, - router_state, - }) - } -} - -fn sqlite_access_rules_uri(path: &std::path::Path) -> String { - format!("sqlite://{}?mode=rw", path.display()) -} - -async fn load_identity_access_rules( - target_node: Option<&Arc>, - identity_profile: &IdentityProfile, - fallback: SharedLocationRuleEvaluator, -) -> Result { - if let Some(uri) = - target_node.and_then(|node| node.get::("access_rules").ok().flatten()) - { - let uri_str = uri.0.to_string(); - return crate::policy::load_policy_bundle(Some(&uri_str)) - .await - .map(|bundle| bundle.location_rules) - .map_err(|source| BuildConfigError::Policy { source }); - } - - let default_access_db = identity_profile.access_db_path(); - match std::fs::metadata(&default_access_db) { - Ok(_) => { - let uri = sqlite_access_rules_uri(&default_access_db); - crate::policy::load_policy_bundle(Some(&uri)) - .await - .map(|bundle| bundle.location_rules) - .map_err(|source| BuildConfigError::Policy { source }) - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(fallback), - Err(source) => Err(BuildConfigError::InspectDefaultAccessRules { - path: default_access_db, - source, - }), - } -} - -impl IdentityServiceSource { - pub async fn prepare( - &self, - ctx: &PrepareContext, - ) -> Result { - let identity = self - .identity_profile - .load_identity() - .await - .whatever_context::<_, BuildConfigError>("failed to load TLS material") - .context(prepare_server_update_error::IdentityServiceSnafu)?; - - let conf_path = self.identity_profile.server_conf_path(); - let identity_server_nodes = if conf_path.is_file() { - load_identity_servers(&self.home, &self.identity_profile) - .await - .whatever_context::<_, BuildConfigError>("failed to load identity config") - .context(prepare_server_update_error::IdentityServiceSnafu)? - } else { - Vec::new() - }; - - let mut target_node = None; - let mut target_binds = Vec::new(); - - for server_node in &identity_server_nodes { - let server_names = match server_node.get::("server_name") { - Ok(Some(sn)) => sn, - _ => continue, - }; - - let matches = server_names.0.iter().any(|sn| sn.name == self.name); - if matches { - let listens: Vec = server_node - .get_all::("listen") - .whatever_context::<_, BuildConfigError>("failed to read listen") - .context(prepare_server_update_error::IdentityServiceSnafu)? - .into_iter() - .flat_map(|listen| listen.0.clone()) - .collect(); - if !listens.is_empty() { - target_binds = listens; - target_node = Some(server_node.clone()); - break; - } - } - } - - if target_binds.is_empty() { - let error = ::without_source(format!( - "no listen specifications found for server {}", - self.name - )); - return Err(PrepareServerUpdateError::IdentityService { source: error }); - } - - let dns_resolver_url = target_node - .as_ref() - .and_then(|node| node.get::("dns").ok().flatten()) - .map(|resolver| resolver.0.clone()); - - let listen_request = ListenRequest { - identity: identity.clone(), - bind: target_binds.clone(), - dns_resolver_url: dns_resolver_url.clone(), - }; - - let request_fingerprint = ListenRequestFingerprint { - server_name: self.name.clone(), - bind_debug: format!("{:?}", target_binds), - identity_debug: crate::service::source::compute_identity_fingerprint(&identity), - dns_resolver_debug: dns_resolver_url.map(|u| u.to_string()), - }; - - let listener_spec = ListenerSpec { - request_fingerprint, - }; - - let service_generation = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let access_rules = load_identity_access_rules( - target_node.as_ref(), - &self.identity_profile, - ctx.access_rules.clone(), - ) - .await - .context(prepare_server_update_error::IdentityServiceSnafu)?; - - let server_node = target_node.clone().unwrap_or_else(|| { - let registry = gateway::parse::default_registry(); - let doc = gateway::parse::load_config_text( - "", - None, - ®istry, - gateway::parse::registry::BuildOptions { - dhttp_home: None, - identity_profile: None, - }, - ) - .unwrap(); - doc.root - }); - - let service = Arc::new(ServerService { - h3_settings: ctx.h3_settings.clone(), - access_rules, - router_state: ctx.router_state.clone(), - server_node, - access_log_dir: Some(self.identity_profile.logs_dir()), - server_name: self.name.clone(), - }); - - Ok(PreparedServerUpdate { - name: self.name.clone(), - listen_request, - listener_spec: listener_spec.clone(), - service, - fingerprint: ServerFingerprint { - listener_spec, - service_generation, - }, - }) - } -} - -pub(crate) fn compute_identity_fingerprint(identity: &dhttp::identity::Identity) -> String { - use sha2::{Digest, Sha256}; - let mut cert_digest = Sha256::new(); - for cert in identity.certs.iter() { - cert_digest.update(cert.as_ref()); - } - let cert_digest = cert_digest.finalize(); - - let mut key_digest = Sha256::new(); - key_digest.update(identity.key.secret_der()); - let key_digest = key_digest.finalize(); - - format!( - "{}@{}@{}", - identity.name(), - hex_lower(cert_digest.as_ref()), - hex_lower(key_digest.as_ref()) - ) -} - -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(bytes.len() * 2); - for &byte in bytes { - encoded.push(HEX[(byte >> 4) as usize] as char); - encoded.push(HEX[(byte & 0x0f) as usize] as char); - } - encoded + DhttpName::try_from(name.to_owned()).unwrap() } #[cfg(test)] -mod tests { - use dhttp::{ - access::{ - expr::atomics::{AtomicLocationRuleExpr, EvalError}, - policy::LocationRuleRequest, - }, - name::DhttpName, - }; - - use super::*; - - struct TestAccessRequest; - - impl LocationRuleRequest for TestAccessRequest { - fn eval_atomic(&self, expr: &AtomicLocationRuleExpr) -> Result { - Ok(matches!(expr, AtomicLocationRuleExpr::Any(..))) - } - } - - #[test] - fn fingerprint_incorporates_cert_and_key() { - let name = DhttpName::try_from("test.dhttp.net".to_owned()).unwrap(); - let req1 = FakeServerSource::fake_listen_request(&name); - let req2 = FakeServerSource::fake_listen_request(&name); - - let fp1 = compute_identity_fingerprint(&req1.identity); - let fp2 = compute_identity_fingerprint(&req2.identity); - - assert_ne!(fp1, fp2, "Fingerprints must differ when cert/key differ"); - assert!(fp1.starts_with("test.dhttp.net@")); - } - - #[cfg(feature = "sshd")] - struct DummySpawner; - #[cfg(feature = "sshd")] - impl gateway::control_plane::DynSpawnSession for DummySpawner { - fn spawn_session<'a>( - &'a self, - _username: &'a str, - ) -> futures::future::BoxFuture< - 'a, - Result< - gateway::control_plane::SessionTransport, - Box, - >, - > { - Box::pin(async { Err("dummy".into()) }) - } - } - #[cfg(feature = "sshd")] - struct DummyScope; - #[cfg(feature = "sshd")] - impl gateway::reverse::router::DynTaskScope for DummyScope { - fn token(&self) -> tokio_util::sync::CancellationToken { - tokio_util::sync::CancellationToken::new() - } - fn spawn(&self, _task: futures::future::BoxFuture<'static, ()>) {} - } - - fn dummy_router_state() -> gateway::reverse::router::RouterState { - gateway::reverse::router::RouterState { - #[cfg(feature = "sshd")] - session_spawner: std::sync::Arc::new(DummySpawner), - #[cfg(feature = "sshd")] - task_scope: std::sync::Arc::new(DummyScope), - } - } - - #[test] - fn server_source_uses_identity_service_variant_name() { - let source = ServerSource::IdentityService(IdentityServiceSource { - name: fake_name("identity-source.dhttp.net"), - home: dhttp::home::DhttpHome::new(std::path::PathBuf::from("/tmp/home")), - identity_profile: dhttp::home::DhttpHome::new(std::path::PathBuf::from("/tmp/home")) - .identity_profile(fake_name("identity-source.dhttp.net")), - }); - - assert_eq!(source.name().as_full(), "identity-source.dhttp.net"); - } - - fn unique_test_dir(label: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("pishoo-{label}-{nanos}")) - } - - fn identity_pem(name: &dhttp::name::DhttpName<'static>) -> (String, String) { - let fqdn = name.as_full().to_owned(); - let key_pair = rcgen::KeyPair::generate().expect("rcgen key generation"); - let mut params = rcgen::CertificateParams::new(vec![fqdn.clone()]).expect("rcgen params"); - params.distinguished_name = rcgen::DistinguishedName::new(); - params - .distinguished_name - .push(rcgen::DnType::CommonName, &fqdn); - params - .custom_extensions - .push(rcgen::CustomExtension::from_oid_content( - &[2, 5, 29, 14], - FakeServerSource::dhttp_subject_key_identifier_der(), - )); - let cert = params.self_signed(&key_pair).expect("rcgen self-sign"); - (cert.pem(), key_pair.serialize_pem()) - } - - async fn write_identity( - profile: &dhttp::home::identity::IdentityProfile, - name: &dhttp::name::DhttpName<'static>, - ) { - let (cert_pem, key_pem) = identity_pem(name); - profile - .save_identity(cert_pem.as_bytes(), key_pem.as_bytes()) - .await - .expect("save identity"); - } - - async fn write_identity_server_conf(profile: &dhttp::home::identity::IdentityProfile) { - tokio::fs::write( - profile.server_conf_path(), - "server { listen all 0; location / { root .; } }", - ) - .await - .expect("write identity server config"); - } - - async fn write_identity_server_conf_with_access_rules( - profile: &dhttp::home::identity::IdentityProfile, - access_rules: &str, - ) { - tokio::fs::write( - profile.server_conf_path(), - format!( - "server {{ listen all 0; access_rules {access_rules}; location / {{ root .; }} }}" - ), - ) - .await - .expect("write identity server config"); - } - - async fn write_minimal_access_db(profile: &dhttp::home::identity::IdentityProfile) { - use sea_orm::{ConnectionTrait, Database, Statement}; - - let path = profile.access_db_path(); - tokio::fs::create_dir_all(path.parent().expect("access db path has parent")) - .await - .expect("create access db directory"); - - let uri = format!("sqlite://{}?mode=rwc", path.display()); - let db = Database::connect(&uri).await.expect("connect access db"); - db.execute_unprepared( - "CREATE TABLE location_rule_sets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pattern JSON NOT NULL UNIQUE, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - );", - ) - .await - .expect("create location rule sets"); - db.execute_unprepared( - "CREATE TABLE location_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - location_id INTEGER NOT NULL, - action INTEGER NOT NULL, - exprs JSON NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - );", - ) - .await - .expect("create location rules"); - db.execute(Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - "INSERT INTO location_rule_sets (id, pattern, created_at, updated_at) - VALUES (1, '\"/\"', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" - .to_string(), - )) - .await - .expect("insert location rule set"); - db.execute(Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - "INSERT INTO location_rules (location_id, action, exprs, created_at, updated_at) - VALUES (1, 0, '{\"infix\":\"*?\",\"polish\":\"*? \"}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" - .to_string(), - )) - .await - .expect("insert access rule"); - } - - fn empty_prepare_context() -> PrepareContext { - PrepareContext { - h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), - access_rules: std::sync::Arc::new( - dhttp::access::matcher::LocationRulesMatcher::default(), - ), - router_state: dummy_router_state(), - } - } - - #[tokio::test] - async fn identity_service_prepare_loads_default_access_db_when_it_exists() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-default-access-db")); - let name = fake_name("default-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf(&profile).await; - write_minimal_access_db(&profile).await; - - let source = IdentityServiceSource { - name: name.clone(), - home, - identity_profile: profile, - }; - - let prepared = source - .prepare(&empty_prepare_context()) - .await - .expect("default access db should load"); - - let decision = prepared - .service - .access_rules - .evaluate("/", &TestAccessRequest) - .await - .expect("identity default access db should provide root rule decision"); - assert_eq!(decision.location.to_string(), "/"); - } - - #[tokio::test] - async fn identity_service_prepare_uses_empty_access_rules_when_default_db_is_missing() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-missing-access-db")); - let name = fake_name("missing-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf(&profile).await; - - let source = IdentityServiceSource { - name: name.clone(), - home, - identity_profile: profile, - }; - - let prepared = source - .prepare(&empty_prepare_context()) - .await - .expect("missing default access db should fall back to empty rules"); - - assert!( - prepared - .service - .access_rules - .evaluate("/", &TestAccessRequest) - .await - .is_err(), - "missing default access db should not synthesize rules" - ); - } - - #[tokio::test] - async fn identity_service_prepare_errors_when_default_access_db_exists_but_is_invalid() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-invalid-access-db")); - let name = fake_name("invalid-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf(&profile).await; - let db_path = profile.access_db_path(); - tokio::fs::create_dir_all(db_path.parent().expect("access db path has parent")) - .await - .expect("create access db directory"); - tokio::fs::write(&db_path, b"not sqlite") - .await - .expect("write invalid access db"); - - let source = IdentityServiceSource { - name, - home, - identity_profile: profile, - }; - - let error = match source.prepare(&empty_prepare_context()).await { - Ok(_) => panic!("invalid existing default access db should be an error"), - Err(error) => error, - }; - let report = snafu::Report::from_error(&error).to_string(); - - assert!( - report.contains("failed to load access rules"), - "unexpected error report: {report}" - ); - } - - #[tokio::test] - async fn identity_service_prepare_errors_when_explicit_access_rules_db_is_invalid() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-invalid-explicit-access")); - let name = fake_name("invalid-explicit-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf_with_access_rules(&profile, "sqlite:./db/access.db?mode=ro") - .await; - let db_path = profile.access_db_path(); - tokio::fs::create_dir_all(db_path.parent().expect("access db path has parent")) - .await - .expect("create access db directory"); - tokio::fs::write(&db_path, b"not sqlite") - .await - .expect("write invalid access db"); - - let source = IdentityServiceSource { - name, - home, - identity_profile: profile, - }; - - let error = match source.prepare(&empty_prepare_context()).await { - Ok(_) => panic!("invalid explicit access_rules db should be an error"), - Err(error) => error, - }; - let report = snafu::Report::from_error(&error).to_string(); - - assert!( - report.contains("failed to load access rules"), - "unexpected error report: {report}" - ); - } - - #[tokio::test] - async fn pishoo_config_service_loads_identity_from_home_when_tls_is_omitted() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("config-service-home")); - let name = fake_name("config-home.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - - let config = gateway::parse::load_config_text( - "pishoo { server { listen all 443; server_name config-home.dhttp.net; } }", - Some(home.as_path()), - &gateway::parse::default_registry(), - gateway::parse::registry::BuildOptions { - dhttp_home: Some(&home), - identity_profile: None, - }, - ) - .expect("parse config with home context"); - let pishoo = config.root.children("pishoo").unwrap()[0].clone(); - let server = pishoo.children("server").unwrap()[0].clone(); - - let (sources, _ctx) = - PishooConfigServiceSource::load_all(&[server], Some(&home), dummy_router_state()) - .await - .expect("load config service sources"); - - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].name().as_full(), "config-home.dhttp.net"); - } - - #[tokio::test] - async fn explicit_config_service_rejects_omitted_tls() { - let config = gateway::parse::parse_config_str_for_test( - "pishoo { server { listen all 443; server_name explicit-missing.dhttp.net; } }", - ) - .expect_err("explicit config parse should reject missing TLS"); - - let report = snafu::Report::from_error(&config.error).to_string(); - assert!(report.contains("missing ssl_certificate directive")); - } - - #[cfg(feature = "sshd")] - #[test] - fn h3_settings_advertise_webtransport_support_when_sshd_enabled() { - let settings = h3_settings(); - - assert!(settings.enable_connect_protocol()); - assert!(settings.enable_webtransport()); - assert!(settings.webtransport_flow_control_enabled()); - } - - #[tokio::test] - async fn pishoo_config_service_source_prepare_produces_listener_spec_with_cert_fingerprint() { - let name = DhttpName::try_from("test.dhttp.net".to_owned()).unwrap(); - let req1 = FakeServerSource::fake_listen_request(&name); - - let config = gateway::parse::parse_config_str_for_test( - "server { listen 127.0.0.1:443; server_name localhost; ssl_certificate /tmp/a; ssl_certificate_key /tmp/b; }", - ) - .unwrap(); - let server_node = config.root.children("server").unwrap()[0].clone(); - - let source = PishooConfigServiceSource { - name: name.clone(), - identity: req1.identity.clone(), - bind: req1.bind.clone(), - dns_resolver_url: req1.dns_resolver_url.clone(), - server_node, - }; - - let ctx = PrepareContext { - h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), - access_rules: std::sync::Arc::new( - dhttp::access::matcher::LocationRulesMatcher::default(), - ), - router_state: dummy_router_state(), - }; - - let prepared = source.prepare(&ctx).await.expect("prepare success"); - - let identity_debug = &prepared.listener_spec.request_fingerprint.identity_debug; - assert!( - identity_debug.starts_with("test.dhttp.net@"), - "fingerprint should contain name and separator: {}", - identity_debug - ); - assert!(identity_debug.len() > 20, "fingerprint should contain hash"); - } - - #[tokio::test] - async fn pishoo_config_service_source_prepare_two_rotated_certs_produce_distinct_fingerprints() - { - let name = DhttpName::try_from("rotated.dhttp.net".to_owned()).unwrap(); - let req1 = FakeServerSource::fake_listen_request(&name); - let req2 = FakeServerSource::fake_listen_request(&name); - - let config = gateway::parse::parse_config_str_for_test( - "server { listen 127.0.0.1:443; server_name localhost; ssl_certificate /tmp/a; ssl_certificate_key /tmp/b; }", - ) - .unwrap(); - let server_node = config.root.children("server").unwrap()[0].clone(); - - let source1 = PishooConfigServiceSource { - name: name.clone(), - identity: req1.identity.clone(), - bind: req1.bind.clone(), - dns_resolver_url: req1.dns_resolver_url.clone(), - server_node: server_node.clone(), - }; - - let source2 = PishooConfigServiceSource { - name: name.clone(), - identity: req2.identity.clone(), - bind: req2.bind.clone(), - dns_resolver_url: req2.dns_resolver_url.clone(), - server_node: server_node.clone(), - }; - - let ctx = PrepareContext { - h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), - access_rules: std::sync::Arc::new( - dhttp::access::matcher::LocationRulesMatcher::default(), - ), - router_state: dummy_router_state(), - }; - - let prepared1 = source1.prepare(&ctx).await.expect("prepare success"); - let prepared2 = source2.prepare(&ctx).await.expect("prepare success"); - - assert_ne!( - prepared1.listener_spec.request_fingerprint.identity_debug, - prepared2.listener_spec.request_fingerprint.identity_debug, - "Rotated certs should produce distinct fingerprints" - ); +fn fake_listen_request(name: &DhttpName<'static>) -> ListenRequest { + let fqdn = name.as_full().to_owned(); + let key_pair = rcgen::KeyPair::generate().unwrap(); + let mut params = rcgen::CertificateParams::new(vec![fqdn.clone()]).unwrap(); + params.distinguished_name = rcgen::DistinguishedName::new(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, &fqdn); + let cert = params.self_signed(&key_pair).unwrap(); + ListenRequest { + identity: dhttp::identity::Identity::new( + name.clone().into(), + vec![rustls::pki_types::CertificateDer::from(cert.der().to_vec())], + rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()).unwrap(), + ), + bind: vec![], + dns_resolver_url: None, } } diff --git a/pishoo/src/worker.rs b/pishoo/src/worker.rs index 1db9864d..7e4d620e 100644 --- a/pishoo/src/worker.rs +++ b/pishoo/src/worker.rs @@ -2,5 +2,4 @@ //! //! These modules are only linked into the worker binary (`pishoo-worker`). -pub mod config; pub mod remote_plane; diff --git a/pishoo/src/worker/config.rs b/pishoo/src/worker/config.rs deleted file mode 100644 index 6cb843a3..00000000 --- a/pishoo/src/worker/config.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Worker-side configuration source loading. -//! -//! Workers scan the configured DHTTP home for identities and return -//! [`IdentityServiceSource`](crate::service::source::IdentityServiceSource) values. -//! Runtime preparation happens in [`crate::service::source`]. - -use std::path::PathBuf; - -use dhttp::home::DhttpHome; -use futures::StreamExt; -use gateway::error::Whatever; -use snafu::Snafu; - -use crate::policy; - -/// Errors during worker configuration loading. -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildConfigError { - #[snafu(transparent)] - Whatever { source: Whatever }, - #[snafu(display("failed to inspect default access rules path `{}`", path.display()))] - InspectDefaultAccessRules { - path: PathBuf, - source: std::io::Error, - }, - #[snafu(display("failed to load access rules"))] - Policy { source: policy::PolicyError }, -} - -impl snafu::FromString for BuildConfigError { - type Source = ::Source; - - fn without_source(message: String) -> Self { - Whatever::without_source(message).into() - } - - fn with_source(source: Self::Source, message: String) -> Self { - Whatever::with_source(source, message).into() - } -} - -pub async fn load_identity_service_sources( - dhttp_home: &DhttpHome, -) -> Result, BuildConfigError> { - let mut identity_names = Vec::new(); - let mut stream = std::pin::pin!(dhttp_home.identity_profile_names()); - while let Some(result) = stream.next().await { - match result { - Ok(name) => identity_names.push(name), - Err(error) => { - tracing::warn!( - error = %snafu::Report::from_error(&error), - "failed to read identity entry, skipping" - ); - } - } - } - - let mut sources = Vec::new(); - for name in identity_names { - let identity_profile = match dhttp_home.resolve_identity_profile(name.borrow()).await { - Ok(home) => home, - Err(error) => { - tracing::warn!( - %name, - error = %snafu::Report::from_error(&error), - "failed to load identity home, skipping" - ); - continue; - } - }; - - let server_conf_path = identity_profile.server_conf_path(); - if !server_conf_path.is_file() { - tracing::debug!( - %name, - path = %server_conf_path.display(), - "identity profile has no server.conf, skipping" - ); - continue; - } - - sources.push(crate::service::source::IdentityServiceSource { - name, - home: dhttp_home.clone(), - identity_profile, - }); - } - - Ok(sources) -} - -#[cfg(test)] -mod tests { - use dhttp::{home::DhttpHome, name::DhttpName}; - - use super::load_identity_service_sources; - - fn unique_test_dir(label: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("pishoo-worker-config-{label}-{nanos}")) - } - - #[tokio::test] - async fn identity_service_sources_skip_profiles_without_server_conf() { - let home = DhttpHome::new(unique_test_dir("server-conf-filter")); - let with_conf = DhttpName::try_from("with-conf.dhttp.net".to_owned()).unwrap(); - let without_conf = DhttpName::try_from("without-conf.dhttp.net".to_owned()).unwrap(); - let with_conf_profile = home.identity_profile(with_conf.clone()); - let without_conf_profile = home.identity_profile(without_conf); - - tokio::fs::create_dir_all(with_conf_profile.ssl_dir()) - .await - .expect("create profile with server.conf"); - tokio::fs::write( - with_conf_profile.server_conf_path(), - "server { listen all 443; }", - ) - .await - .expect("write server.conf"); - tokio::fs::create_dir_all(without_conf_profile.ssl_dir()) - .await - .expect("create profile without server.conf"); - - let sources = load_identity_service_sources(&home) - .await - .expect("load identity service sources"); - - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].name, with_conf); - } -} diff --git a/pishoo/src/worker/remote_plane.rs b/pishoo/src/worker/remote_plane.rs index f2609061..ba0e41d5 100644 --- a/pishoo/src/worker/remote_plane.rs +++ b/pishoo/src/worker/remote_plane.rs @@ -124,39 +124,14 @@ pub enum RemoteConnectError { Protocol { source: crate::ipc::ConnectError }, } -/// Error from a remote rebuild request. -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RemoteRebuildError { - #[snafu(transparent)] - Protocol { - source: crate::ipc::RebuildListenError, - }, -} - impl gateway::control_plane::ProvideListener for RemoteControlPlane { type Listener = IpcListener; type ListenError = RemoteListenError; - type RebuildError = RemoteRebuildError; async fn listener(&self, request: ListenRequest) -> Result { let ipc_client = self.client.listener(request).await?; Ok(IpcListener::new(ipc_client, self.fd_transfer.clone())) } - - async fn rebuild_listener( - &self, - _old: Self::Listener, - request: ListenRequest, - ) -> Result { - // _old is consumed without explicit shutdown: root destroys its side - // of the listener as part of the rebuild critical section, so calling - // shutdown on the old IpcListener would race against a server that - // has already gone away. Root disarms the old handle's release guard - // before installing the replacement. - let ipc_client = self.client.rebuild_listener(request).await?; - Ok(IpcListener::new(ipc_client, self.fd_transfer.clone())) - } } impl gateway::control_plane::ProvideConnector for RemoteControlPlane { @@ -185,7 +160,7 @@ mod tests { use super::RemoteControlPlane; use crate::ipc::{ ConnectError, ControlPlane, ControlPlaneClient, ControlPlaneServerShared, ListenError, - RebuildListenError, SpawnSessionError, + SpawnSessionError, }; struct AckingSpawnPlane { @@ -202,15 +177,6 @@ mod tests { }) } - async fn rebuild_listener( - &self, - _request: ListenRequest, - ) -> Result { - Err(RebuildListenError::Internal { - message: "not used by this test".to_owned(), - }) - } - async fn connector( &self, _request: ConnectorRequest, diff --git a/pishoo/tests/main.rs b/pishoo/tests/main.rs index 68847cf8..92aebf45 100644 --- a/pishoo/tests/main.rs +++ b/pishoo/tests/main.rs @@ -250,7 +250,7 @@ fn root_state_exposes_explicit_listener_operations() { assert!(source.contains("pub async fn acquire_listener")); assert!(source.contains("pub async fn release_listener")); - assert!(source.contains("pub async fn rebuild_listener")); + assert!(!source.contains("pub async fn rebuild_listener")); assert!(source.contains("pub async fn clear_listener_poison")); assert!(!source.contains("pub async fn release_server")); } @@ -324,12 +324,11 @@ fn hypervisor_uses_global_service_module_name() { } #[test] -fn entry_config_uses_pishoo_config_service_names() { - let entry_source = include_str!("../src/config/entry.rs"); +fn global_plan_owns_direct_server_candidates() { + let plan_source = include_str!("../src/config/plan.rs"); let global_service_source = include_str!("../src/hypervisor/global_service.rs"); - assert!(entry_source.contains("config_services")); - assert!(!entry_source.contains("local_servers")); - assert!(!entry_source.contains("parse_local_servers")); + assert!(plan_source.contains("direct_servers")); + assert!(!plan_source.contains("local_servers")); assert!(!global_service_source.contains("local_servers")); } diff --git a/pishoo/tests/worker.rs b/pishoo/tests/worker.rs index 4f02520c..84e36943 100644 --- a/pishoo/tests/worker.rs +++ b/pishoo/tests/worker.rs @@ -4,7 +4,7 @@ fn worker_requests_root_owned_connector() { assert!( worker_source.contains("RemoteControlPlane::new(") - && worker_source.contains("bootstrap.control_plane"), + && worker_source.contains("control_plane,\n } = bootstrap"), "worker must use RemoteControlPlane backed by the root-provided client" ); assert!( @@ -52,32 +52,16 @@ fn worker_stops_server_runtimes_before_exit() { } #[test] -fn listener_rebuild_uses_control_plane_rebuild_call() { +fn listener_changes_use_only_shutdown_and_normal_acquisition() { let ipc_source = include_str!("../src/ipc.rs"); let remote_source = include_str!("../src/worker/remote_plane.rs"); let local_source = include_str!("../src/hypervisor/in_process_plane.rs"); let ipc_server_source = include_str!("../src/hypervisor/ipc_server.rs"); - assert!( - ipc_source.contains("async fn rebuild_listener"), - "ipc ControlPlane trait must expose async fn rebuild_listener" - ); - assert!( - ipc_source.contains("RebuildListenError"), - "ipc must define a dedicated RebuildListenError type" - ); - assert!( - ipc_server_source.contains("rebuild_listener"), - "root WorkerControlPlane must implement rebuild_listener" - ); - assert!( - remote_source.contains("rebuild_listener"), - "RemoteControlPlane must expose rebuild_listener consuming the old IpcListener" - ); - assert!( - local_source.contains("rebuild_listener"), - "InProcessControlPlane must expose rebuild_listener consuming the old RegisteredEndpoint" - ); + for source in [ipc_source, ipc_server_source, remote_source, local_source] { + assert!(!source.contains("rebuild_listener")); + assert!(!source.contains("RebuildListen")); + } } #[test] @@ -104,8 +88,8 @@ fn accept_state_aborts_listener_task_on_drop() { "service module must expose the accept submodule" ); assert!( - accept_source.contains("pub enum AcceptState"), - "service::accept must define an AcceptState enum" + accept_source.contains("pub struct AcceptState"), + "service::accept must define the owned AcceptState" ); assert!( accept_source.contains("task: AbortOnDropHandle"), @@ -139,8 +123,8 @@ fn worker_uses_dhttp_home_api_for_user_home() { let worker_source = include_str!("../src/bin/pishoo_worker.rs"); assert!( - worker_source.contains("DhttpHome::for_user_home_dir"), - "worker must construct user dhttp home through the dhttp home API" + worker_source.contains("account.dhttp_home().clone()"), + "worker must use the root-selected DhttpHome from its typed account bootstrap" ); assert!( !worker_source.contains("join(\".dhttp\")"), From 49b0c82a2a9fe8b1b4ab8c56fb971de01021a711 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 01:39:29 +0800 Subject: [PATCH 17/18] fix(gateway): redirect slash proxy before fallback --- gateway/src/reverse/router.rs | 87 ++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/gateway/src/reverse/router.rs b/gateway/src/reverse/router.rs index f84a131a..2708f793 100644 --- a/gateway/src/reverse/router.rs +++ b/gateway/src/reverse/router.rs @@ -88,17 +88,20 @@ impl tower_service::Service> for NginxRouter { return Ok(response); } }; - let active_access_log = loc_match.access_log.clone(); - - if let Some(target) = - proxy_prefix_slash_redirect(&loc_match, &normalized, public_origin.as_deref()) - { + if let Some((target, redirect_access_log)) = proxy_prefix_slash_redirect( + &locations, + &loc_match, + &normalized, + public_origin.as_deref(), + ) { let mut response = (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, target)]).into_response(); - response.extensions_mut().insert(active_access_log); + response.extensions_mut().insert(redirect_access_log); return Ok(response); } + let active_access_log = loc_match.access_log.clone(); + // Inject LocationMatch into request extensions for extractors request.extensions_mut().insert(loc_match.clone()); @@ -127,17 +130,37 @@ impl tower_service::Service> for NginxRouter { } fn proxy_prefix_slash_redirect( - route: &super::location::LocationMatch, + locations: &[Arc], + selected: &super::location::LocationMatch, normalized: &super::request_uri::NormalizedRequestUri, public_origin: Option<&str>, -) -> Option { - route.location.proxy_pass()?; - let prefix = match route.location.matcher() { - crate::parse::pattern::Pattern::Prefix(prefix) - | crate::parse::pattern::Pattern::NormalPrefix(prefix) => prefix, - _ => return None, - }; - super::request_uri::build_prefix_slash_redirect(prefix, normalized, public_origin) +) -> Option<(String, ActiveAccessLog)> { + match selected.pattern() { + crate::parse::pattern::Pattern::Exact(_) + | crate::parse::pattern::Pattern::Regex(_) + | crate::parse::pattern::Pattern::CRegex(_) => return None, + _ => {} + } + + locations + .iter() + .filter_map(|location| { + location.proxy_pass()?; + let prefix = match location.matcher() { + crate::parse::pattern::Pattern::Prefix(prefix) + | crate::parse::pattern::Pattern::NormalPrefix(prefix) => prefix, + _ => return None, + }; + let trimmed = prefix.strip_suffix('/')?; + if trimmed.len() <= selected.matched.len() { + return None; + } + let target = + super::request_uri::build_prefix_slash_redirect(prefix, normalized, public_origin)?; + Some((prefix.len(), target, location.access_log().clone())) + }) + .max_by_key(|(prefix_len, _, _)| *prefix_len) + .map(|(_, target, access_log)| (target, access_log)) } #[cfg(test)] @@ -146,7 +169,10 @@ mod tests { use super::*; use crate::{ - parse::{domain::ResolvedConfigPath, tests::parse_location}, + parse::{ + domain::ResolvedConfigPath, + tests::{parse_location, parse_location_pattern}, + }, reverse::{location::ConfiguredLocation, log::AccessLogOutput}, }; @@ -229,4 +255,33 @@ mod tests { drop(output); let _ = std::fs::remove_file(path); } + + #[tokio::test] + async fn proxy_prefix_redirect_is_checked_before_the_common_fallback() { + let api = Arc::new(ConfiguredLocation::new( + parse_location_pattern("/api/", "proxy_pass http://backend/;").unwrap(), + ActiveAccessLog::Disabled, + )); + let common = Arc::new(ConfiguredLocation::new( + parse_location("").unwrap(), + ActiveAccessLog::Disabled, + )); + let router = NginxRouter::new(vec![api, common], ActiveAccessLog::Disabled, router_state()); + + let response = router + .oneshot( + http::Request::builder() + .uri("https://frontend.example.com/api?x=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "https://frontend.example.com/api/?x=1" + ); + } } From 4bafb7412b9c6e126e15598f053bfcb3f6015bb5 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 16:29:12 +0800 Subject: [PATCH 18/18] chore: prepare gateway v0.8.0-beta.5 --- CHANGELOG.md | 35 +++ Cargo.lock | 453 ++++++++++++++++--------------------- Cargo.toml | 6 +- gateway/Cargo.toml | 2 +- gateway/src/parse/types.rs | 4 +- pishoo/Cargo.toml | 2 +- 6 files changed, 236 insertions(+), 266 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3e7068..6163cb1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +## [0.8.0-beta.5] - 2026-07-16 + +### Added + +- pishoo builds inherited per-account DHTTP home trees and compiles them into a + static service pipeline for reload execution. + +### Changed + +- Gateway configuration parsing now uses role-aware typed domains, sealed + inherited trees, and typed compound STUN server values. +- Reload snapshots and service plans now enforce exhaustive, immutable parser + and transport contracts before worker activation. + +### Fixed + +- Cascaded and inherited configuration queries preserve typed identity and + registry contracts, including native OS paths for SQLite databases. +- Slash-directory proxy requests redirect before fallback routing is evaluated. +- IP-family parse errors compile cleanly with the release packaging nightly toolchain. + +### Dependencies + +- Release manifests now target `dhttp` v0.6.0-beta.4, including + `dhttp-access` v0.4.0-beta.2 and `dhttp-home` v0.5.0-beta.1 through + the facade; discovery and transport dependencies target `dyns` + v0.7.0-beta.2, `h3x` v0.6.0-beta.4, `dquic` v0.7.0-beta.4, and + `dshell` v0.6.0-beta.3. + +### Components + +- `gateway` v0.8.0-beta.5 +- `pishoo` v0.8.0-beta.5 +- `pishoo-common` v0.5.1-1 (unchanged) + ## [0.8.0-beta.4] - 2026-07-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index 81c5d467..1884c6a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,15 +40,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-build" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -125,9 +116,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -153,7 +144,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -165,7 +156,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -211,7 +202,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -222,7 +213,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -348,14 +339,14 @@ checksum = "3ca6739863c590881f038d033a146c51ddae239186a4327014839fd864f44ed5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -421,7 +412,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -445,7 +436,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -459,9 +450,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "serde_core", @@ -515,7 +506,7 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -526,18 +517,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", @@ -588,9 +579,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -598,9 +589,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -617,7 +608,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -799,27 +790,27 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" @@ -871,7 +862,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -884,7 +875,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -895,7 +886,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -906,7 +897,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -981,7 +972,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -991,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1013,13 +1004,15 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] [[package]] name = "dhttp" -version = "0.5.0-beta.3" +version = "0.6.0-beta.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d2eb9b7468efa85ff15f00c6e876cee46c2b3c018664b7158b407955d6e931" dependencies = [ "bon", "bytes", @@ -1041,7 +1034,9 @@ dependencies = [ [[package]] name = "dhttp-access" -version = "0.4.0-beta.1" +version = "0.4.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de04ba6e93d5e47b75d8381377a5d710dac52baed5790c56ec93a4e31f7e2756" dependencies = [ "chrono", "derive_more", @@ -1061,7 +1056,9 @@ dependencies = [ [[package]] name = "dhttp-home" -version = "0.4.0-beta.1" +version = "0.5.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e64e413db3551f260e158d18dcd73906dbebf938399dd3bcb8a286e612fa0ee" dependencies = [ "dhttp-identity", "dirs", @@ -1092,7 +1089,9 @@ dependencies = [ [[package]] name = "dhttp-log" -version = "0.1.0" +version = "0.1.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e7070b6a8ead2399f5d8f959cd8c8b10ccd769651ed67e0b36d54cc4bce35e" dependencies = [ "chrono", "dhttp-identity", @@ -1166,7 +1165,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1188,9 +1187,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" -version = "0.7.0-beta.2" +version = "0.7.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2945f40d972d12cf06777eddf20b7104378d5a614dcca56800286cff370f489" +checksum = "02b6fd0cd4d134f74c5da30d9ca745a8875e56b139892a37f7806aeac150fb32" dependencies = [ "arc-swap", "dashmap", @@ -1206,9 +1205,9 @@ dependencies = [ [[package]] name = "dshell" -version = "0.6.0-beta.2" +version = "0.6.0-beta.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "024bc3e3dbed799db8ab771e51851a741de22c1caba2c82f98ef557a5be34d5a" +checksum = "218694a1b4424c69b9b3b5c7416cfceb6c39e35b20968caef61bf9b3c953f921" dependencies = [ "base64 0.22.1", "bytes", @@ -1236,9 +1235,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" -version = "0.6.0-beta.3" +version = "0.7.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8916a250117bdecaa9aa721465c646da282dc41523361b63c8cd01693c6869" +checksum = "7186611a2d3156f9de8a836624c8da907e59bb0f198f74b4cf5b675cae17b68d" dependencies = [ "base64 0.22.1", "bitfield-struct", @@ -1254,7 +1253,7 @@ dependencies = [ "http-body-util", "libc", "nom 8.0.0", - "rand 0.10.1", + "rand 0.10.2", "reqwest", "ring", "rustls", @@ -1295,7 +1294,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1488,7 +1487,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1522,7 +1521,7 @@ dependencies = [ [[package]] name = "gateway" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" dependencies = [ "async-compression", "axum", @@ -1640,7 +1639,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1651,9 +1650,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -1683,9 +1682,9 @@ dependencies = [ [[package]] name = "h3x" -version = "0.6.0-beta.3" +version = "0.6.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e242a82f0df4f9c7a8a83440a9943468ff99bd3a4f004c47cc717bb155a7a3" +checksum = "54d7e49e7fe31d63059488ac6635d663ba53a8211657faae18d5940757e034ae" dependencies = [ "arc-swap", "async-channel", @@ -1844,9 +1843,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1854,9 +1853,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1879,9 +1878,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" @@ -2131,7 +2130,7 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2233,7 +2232,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2261,7 +2260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2396,9 +2395,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2433,9 +2432,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2459,7 +2458,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2527,19 +2526,6 @@ dependencies = [ "log", ] -[[package]] -name = "netwatcher" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a73c134b9d8ca27ba209646851858cd112fb543b6ecf48019c64b29c91b3b18a" -dependencies = [ - "android-build", - "jni 0.22.4", - "ndk-context", - "nix 0.31.3", - "windows", -] - [[package]] name = "nix" version = "0.29.0" @@ -2602,9 +2588,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2621,7 +2607,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2643,11 +2629,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2819,7 +2804,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2956,7 +2941,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2967,7 +2952,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pishoo" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" dependencies = [ "axum", "clap", @@ -3033,9 +3018,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -3089,7 +3074,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3166,7 +3151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3197,7 +3182,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3217,7 +3202,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "version_check", "yansi", ] @@ -3242,7 +3227,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3291,7 +3276,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3308,7 +3293,7 @@ dependencies = [ "getset", "nom 8.0.0", "qmacro", - "rand 0.10.1", + "rand 0.10.2", "rustls", "serde", "smallvec", @@ -3325,7 +3310,7 @@ checksum = "92ff2c0dd91cf0e4cacca9e3b1ab8c2bd4bff93c17ba39f509e53f238a1fda42" dependencies = [ "qbase", "qevent", - "rand 0.10.1", + "rand 0.10.2", "thiserror 2.0.18", "tokio", "tracing", @@ -3333,9 +3318,9 @@ dependencies = [ [[package]] name = "qconnection" -version = "0.7.0-beta.1" +version = "0.8.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea5f6581e4d228dfe4c2a973c137f2a405d639ed0cdbdd75fe3baf8d642db89" +checksum = "bd0e696c1bd3b8525f744848368e57f54f7be45464763b07e72bd4692d4a6065" dependencies = [ "bytes", "dashmap", @@ -3392,9 +3377,9 @@ dependencies = [ [[package]] name = "qinterface" -version = "0.6.0" +version = "0.7.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d6859183d5d4547470fd3ea641d700b79e416269f229b03b0c8709e2c98575" +checksum = "ba79cb72588e962cd079efe8fb301d2dc8182edf4d495222c8fa93f0e2d31341" dependencies = [ "bytes", "dashmap", @@ -3402,7 +3387,6 @@ dependencies = [ "futures", "http", "netdev", - "netwatcher", "parking_lot", "qbase", "qevent", @@ -3423,7 +3407,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3455,9 +3439,9 @@ dependencies = [ [[package]] name = "qtraversal" -version = "0.7.0-beta.2" +version = "0.7.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16fc8219fe16d92319e4ccd1c41ea87661a27abfacc4d40e77273b55fa384cf9" +checksum = "854dbea8936703533d83456b6431bcad3996cde0b12f255e9cc582d925dee7aa" dependencies = [ "bon", "bytes", @@ -3468,7 +3452,7 @@ dependencies = [ "qevent", "qinterface", "qresolve", - "rand 0.10.1", + "rand 0.10.2", "smallvec", "snafu", "thiserror 2.0.18", @@ -3479,9 +3463,9 @@ dependencies = [ [[package]] name = "qudp" -version = "0.6.0" +version = "0.7.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce9009cdd2de1ef116833beeccf1a5426a0a8f426ee836fe6c05cba4322b915" +checksum = "85ad34a609083ce5fd9f6972d6d6ec7d5fb1fa2af9a87d6b7f19ec72117a5a50" dependencies = [ "bytes", "cfg-if", @@ -3496,9 +3480,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -3532,18 +3516,18 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rancor" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +checksum = "daff8b7b3ccf5f7ba270b3e7a0a4d4c701c5797e38dec27c7e2c3dbb830fed1c" dependencies = [ "ptr_meta 0.3.1", ] [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3552,9 +3536,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3562,9 +3546,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3675,14 +3659,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3692,9 +3676,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3717,7 +3701,7 @@ dependencies = [ "byteorder", "bytes", "futures", - "rand 0.9.4", + "rand 0.9.5", "remoc_macro", "serde", "tokio", @@ -3734,7 +3718,7 @@ checksum = "d89479d9d87f65ef573faf0167dd0a9f40d3a63fd95e7a2935d662fa57dbc30d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3748,9 +3732,9 @@ dependencies = [ [[package]] name = "rend" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" dependencies = [ "bytecheck 0.8.2", ] @@ -3829,9 +3813,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" dependencies = [ "bytecheck 0.8.2", "bytes", @@ -3840,8 +3824,8 @@ dependencies = [ "munge", "ptr_meta 0.3.1", "rancor", - "rend 0.5.3", - "rkyv_derive 0.8.16", + "rend 0.5.4", + "rkyv_derive 0.8.17", "smallvec", "tinyvec", "uuid", @@ -3860,13 +3844,13 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3899,7 +3883,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "rkyv 0.7.46", "serde", "serde_json", @@ -3939,9 +3923,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -4022,9 +4006,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4090,7 +4074,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4153,7 +4137,7 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.118", + "syn 2.0.119", "unicode-ident", ] @@ -4216,7 +4200,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "thiserror 2.0.18", ] @@ -4242,7 +4226,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4307,7 +4291,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4373,14 +4357,14 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4446,15 +4430,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4489,7 +4473,7 @@ checksum = "5e7ac95ddfb355a82e23e3bdce91b646b847307bb481b1f7bc7f44942461d571" dependencies = [ "either", "paste", - "rkyv 0.8.16", + "rkyv 0.8.17", "serde", "smallvec", ] @@ -4522,14 +4506,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4537,9 +4521,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -4618,7 +4602,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4641,7 +4625,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.118", + "syn 2.0.119", "tokio", "url", ] @@ -4677,7 +4661,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.7", "rsa", "rust_decimal", "serde", @@ -4721,7 +4705,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "rust_decimal", "serde", "serde_json", @@ -4823,9 +4807,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4849,7 +4833,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4905,7 +4889,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4916,14 +4900,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4972,9 +4956,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -5011,7 +4995,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5051,9 +5035,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -5075,9 +5059,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime", @@ -5096,9 +5080,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" @@ -5222,7 +5206,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5351,9 +5335,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5462,7 +5446,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5559,27 +5543,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -5593,17 +5556,6 @@ dependencies = [ "windows-strings", ] -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -5612,7 +5564,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5623,7 +5575,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5632,16 +5584,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -5753,15 +5695,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5896,9 +5829,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5977,28 +5910,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6018,7 +5951,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6058,11 +5991,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6cd61799..6004b0cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ tower = { version = "0.5", default-features = false, features = ["util"] } tower-service = "0.3" # --- QUIC Implementation --- -h3x = { version = "0.6.0-beta.3" } +h3x = { version = "0.6.0-beta.4" } # --- IPC / RPC --- remoc = { version = "0.18", default-features = false, features = [ @@ -60,7 +60,7 @@ remoc = { version = "0.18", default-features = false, features = [ ] } # --- DShell --- -dshell = { version = "0.6.0-beta.2" } +dshell = { version = "0.6.0-beta.3" } # --- Security & TLS --- getrandom = "0.4" @@ -89,7 +89,7 @@ form_urlencoded = "1" percent-encoding = "2" socket2 = "0.6" x509-parser = "0.18" -dhttp = { version = "0.5.0-beta.3" } +dhttp = { version = "0.6.0-beta.4" } # --- System Interaction --- libc = "0.2" diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index dc65fe87..b8a7745d 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gateway" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" edition = "2024" license = "Apache-2.0" diff --git a/gateway/src/parse/types.rs b/gateway/src/parse/types.rs index 018e8595..7d793050 100644 --- a/gateway/src/parse/types.rs +++ b/gateway/src/parse/types.rs @@ -269,7 +269,9 @@ impl FromStr for IpFamilies { "v4only" => Ok(IpFamilies::V4), "v6only" => Ok(IpFamilies::V6), "dual" => Ok(IpFamilies::Dual), - _ => whatever!("invalid ip families: {s}, expected `v4only`, `v6only` or `dual`"), + _ => { + whatever!("invalid ip families: {s}, expected `v4only`, `v6only` or `dual`"); + } } } } diff --git a/pishoo/Cargo.toml b/pishoo/Cargo.toml index 9dd30c88..6f2cf3ab 100644 --- a/pishoo/Cargo.toml +++ b/pishoo/Cargo.toml @@ -4,7 +4,7 @@ name = "pishoo" description = "modern, secure, QUIC-powered web/proxy engine" homepage = "https://www.dhttp.net" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" edition = "2024" license = "Apache-2.0"