Skip to main content

eggserve_core/
config.rs

1//! Configuration types for static file serving.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use crate::fs::PinnedRoot;
8use crate::limits::Limits;
9use crate::policy::{DirectoryListingPolicy, DotfilePolicy, StaticPolicy, SymlinkPolicy};
10use crate::primitives::canonical::is_hop_by_hop_header;
11use crate::primitives::header_block::{HeaderName, HeaderValue};
12
13#[derive(Debug, Clone)]
14#[must_use]
15pub struct ServeConfig {
16    pub bind: SocketAddr,
17    pub root: PathBuf,
18    pub limits: Limits,
19    pub static_policy: StaticPolicy,
20    pub default_content_type: String,
21    pub extra_response_headers: Vec<(String, String)>,
22}
23
24impl Default for ServeConfig {
25    fn default() -> Self {
26        Self {
27            bind: "127.0.0.1:8000".parse().unwrap(),
28            root: PathBuf::from("."),
29            limits: Limits::default(),
30            static_policy: StaticPolicy::safe_default(),
31            default_content_type: "application/octet-stream".to_string(),
32            extra_response_headers: Vec::new(),
33        }
34    }
35}
36
37/// Validate static representation metadata before a server is activated.
38pub fn validate_static_metadata(
39    default_content_type: &str,
40    extra_response_headers: &[(String, String)],
41) -> Result<(), String> {
42    if default_content_type.is_empty()
43        || default_content_type
44            .bytes()
45            .any(|b| b == b'\r' || b == b'\n' || b == 0)
46    {
47        return Err("default content type must be a non-empty value without CR/LF/NUL".into());
48    }
49    for (name, value) in extra_response_headers {
50        HeaderName::new(name.clone()).map_err(|e| format!("invalid extra response header: {e}"))?;
51        HeaderValue::new(value.clone())
52            .map_err(|e| format!("invalid extra response header: {e}"))?;
53        let lower = name.to_ascii_lowercase();
54        if is_hop_by_hop_header(&lower)
55            || matches!(
56                lower.as_str(),
57                "content-length"
58                    | "date"
59                    | "server"
60                    | "content-type"
61                    | "content-range"
62                    | "accept-ranges"
63                    | "etag"
64                    | "last-modified"
65                    | "x-content-type-options"
66            )
67        {
68            return Err(format!(
69                "extra response header is runtime- or representation-owned: {name}"
70            ));
71        }
72    }
73    Ok(())
74}
75
76#[derive(Debug, Clone, Copy)]
77#[must_use]
78pub struct StartupSummary {
79    pub bind_is_unspecified: bool,
80    pub directory_listing_enabled: bool,
81    pub symlinks_followed: bool,
82    pub dotfiles_served: bool,
83    pub max_connections: usize,
84    pub max_file_streams: usize,
85}
86
87impl ServeConfig {
88    /// Build a logging-friendly summary of this configuration.
89    ///
90    /// The binary crate uses this to print a startup banner. Callers that
91    /// embed `eggserve-core` directly can use it for their own logging.
92    pub fn startup_summary(&self) -> StartupSummary {
93        StartupSummary {
94            bind_is_unspecified: self.bind.ip().is_unspecified(),
95            directory_listing_enabled: matches!(
96                self.static_policy.directory_listing,
97                DirectoryListingPolicy::Enabled
98            ),
99            symlinks_followed: matches!(self.static_policy.symlinks, SymlinkPolicy::Follow),
100            dotfiles_served: matches!(self.static_policy.dotfiles, DotfilePolicy::Serve),
101            max_connections: self.limits.max_connections,
102            max_file_streams: self.limits.max_file_streams,
103        }
104    }
105}
106
107#[derive(Clone)]
108pub struct ServeState {
109    pub(crate) config: Arc<ServeConfig>,
110    pub(crate) pinned_root: Arc<PinnedRoot>,
111}
112
113impl ServeState {
114    pub fn new(config: Arc<ServeConfig>) -> Result<Self, std::io::Error> {
115        validate_static_metadata(&config.default_content_type, &config.extra_response_headers)
116            .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
117        let pinned_root = Arc::new(PinnedRoot::new(&config.root)?);
118        Ok(Self {
119            config,
120            pinned_root,
121        })
122    }
123
124    pub fn config(&self) -> &Arc<ServeConfig> {
125        &self.config
126    }
127
128    pub(crate) fn pinned_root(&self) -> &Arc<PinnedRoot> {
129        &self.pinned_root
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn default_config_binds_loopback() {
139        let config = ServeConfig::default();
140        assert!(config.bind.ip().is_loopback());
141    }
142
143    #[test]
144    fn default_config_binds_port_8000() {
145        let config = ServeConfig::default();
146        assert_eq!(config.bind.port(), 8000);
147    }
148
149    #[test]
150    fn default_startup_summary_is_safe() {
151        let summary = ServeConfig::default().startup_summary();
152        assert!(!summary.bind_is_unspecified);
153        assert!(!summary.directory_listing_enabled);
154        assert!(!summary.symlinks_followed);
155        assert!(!summary.dotfiles_served);
156        assert_eq!(summary.max_connections, 64);
157        assert_eq!(summary.max_file_streams, 32);
158    }
159}