Skip to main content

spars_httpd/
handler.rs

1// SPDX-FileCopyrightText: 2025 Cullen Walsh <ckwalsh@cullenwalsh.com>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::BTreeMap;
5use std::collections::BTreeSet;
6use std::ffi::OsString;
7use std::path::Path;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use compact_str::CompactString;
12use compact_str::format_compact;
13use thiserror::Error;
14use zerotrie::ZeroTrieBuildError;
15use zerotrie::ZeroTrieSimpleAscii;
16
17use crate::response::Response;
18use crate::response::StatusCode;
19use crate::settings::ExposeHiddenFiles;
20use crate::settings::HandlerSettings;
21
22mod mime;
23pub struct Handler {
24    file_data: Vec<HandlerFileData>,
25    mime_types: Vec<&'static str>,
26    path_data: Vec<HandlerPathData>,
27    fallback_idx: Option<usize>,
28    path_trie: ZeroTrieSimpleAscii<Vec<u8>>,
29}
30
31struct HandlerFileData {
32    resolved_path: Arc<Path>,
33    len: u64,
34}
35
36enum HandlerPathData {
37    Found {
38        file_idx: usize,
39        mime_idx: Option<usize>,
40    },
41    DirRedirect(Arc<str>),
42}
43
44#[derive(Debug, Error)]
45pub enum HandlerBuildError {
46    #[error("Loop while walking paths")]
47    PathLoop,
48
49    #[error(transparent)]
50    IoError(#[from] std::io::Error),
51
52    #[error("Non-Utf8 path component")]
53    NonUtf8Component(OsString),
54
55    #[error("Non-ascii path component: {0}")]
56    NonAsciiComponent(CompactString),
57
58    #[error(transparent)]
59    ZeroTrieBuildError(#[from] ZeroTrieBuildError),
60}
61
62impl Handler {
63    pub fn build_from_root(
64        mut root: PathBuf,
65        index_file: &str,
66        fallback_path: Option<&str>,
67        expose_hidden: ExposeHiddenFiles,
68    ) -> Result<Self, HandlerBuildError> {
69        let mut file_data: Vec<HandlerFileData> = Vec::new();
70        let mut mime_types: Vec<&'static str> = Vec::new();
71        let mut path_data: Vec<HandlerPathData> = Vec::new();
72
73        let mut file_index: BTreeMap<PathBuf, usize> = BTreeMap::new();
74        let mut mime_index: BTreeMap<&'static str, usize> = BTreeMap::new();
75
76        let mut entries: Vec<(CompactString, usize)> = Vec::new();
77
78        let mut dirs_searched: BTreeSet<PathBuf> = BTreeSet::new();
79        let mut search: Vec<(CompactString, PathBuf)> = Vec::new();
80
81        {
82            root = root.canonicalize()?;
83            let metadata = root.metadata()?;
84
85            if !metadata.is_dir() {
86                return Err(HandlerBuildError::IoError(
87                    std::io::ErrorKind::NotADirectory.into(),
88                ));
89            }
90
91            dirs_searched.insert(root.clone());
92            search.push(("/".into(), root));
93        }
94
95        while let Some((http_prefix, dir_path)) = search.pop() {
96            'entries: for entry in std::fs::read_dir(dir_path)? {
97                let entry = entry?;
98                let original_path = entry.path();
99                let resolved_path = original_path.canonicalize()?;
100
101                if dirs_searched.contains(resolved_path.as_path()) {
102                    return Err(HandlerBuildError::PathLoop);
103                }
104
105                let file_name = entry.file_name();
106
107                let http_name = if let Some(s) = file_name.to_str() {
108                    if s.is_ascii() {
109                        s
110                    } else {
111                        return Err(HandlerBuildError::NonAsciiComponent(s.into()));
112                    }
113                } else {
114                    return Err(HandlerBuildError::NonUtf8Component(file_name));
115                };
116
117                if http_name.starts_with('.') {
118                    match expose_hidden {
119                        ExposeHiddenFiles::OnlyWellKnown => {
120                            if http_prefix == "/" && http_name == ".well-known" {
121                                // All good
122                            } else {
123                                continue;
124                            }
125                        }
126                        ExposeHiddenFiles::Hide => continue 'entries,
127                        ExposeHiddenFiles::Expose => (),
128                    }
129                }
130
131                let http_path = format_compact!("{http_prefix}{http_name}");
132
133                let file_idx = match file_index.get(&resolved_path) {
134                    Some(&file_idx) => file_idx,
135                    None => {
136                        let metadata = entry.metadata()?;
137
138                        if metadata.is_dir() {
139                            dirs_searched.insert(resolved_path.clone());
140                            search.push((
141                                format_compact!("{http_prefix}{http_name}/"),
142                                resolved_path,
143                            ));
144                            continue;
145                        }
146
147                        let file_idx = file_data.len();
148
149                        file_data.push(HandlerFileData {
150                            resolved_path: Arc::from(resolved_path.as_path()),
151                            len: metadata.len(),
152                        });
153
154                        file_index.insert(resolved_path, file_idx);
155
156                        file_idx
157                    }
158                };
159
160                let mime_idx = mime::mime_from_path(&original_path).map(|mime_type| {
161                    *mime_index.entry(mime_type).or_insert_with(|| {
162                        let idx = mime_types.len();
163                        mime_types.push(mime_type);
164                        idx
165                    })
166                });
167
168                let path_idx = path_data.len();
169
170                path_data.push(HandlerPathData::Found { file_idx, mime_idx });
171
172                entries.push((http_path, path_idx));
173
174                if http_name == index_file {
175                    entries.push((http_prefix.clone(), path_idx));
176
177                    path_data.push(HandlerPathData::DirRedirect(Arc::from(
178                        http_prefix.as_ref(),
179                    )));
180                    entries.push((http_prefix.trim_end_matches('/').into(), path_idx + 1));
181                }
182            }
183        }
184
185        let path_trie: ZeroTrieSimpleAscii<Vec<u8>> = entries.into_iter().collect();
186
187        let fallback_idx = fallback_path.and_then(|path| path_trie.get(path));
188
189        Ok(Self {
190            path_trie,
191            path_data,
192            file_data,
193            mime_types,
194            fallback_idx,
195        })
196    }
197
198    pub fn handle(&self, method: Option<&str>, path: Option<&str>) -> Result<Response, Response> {
199        let send_body = match method {
200            Some("HEAD") => false,
201            Some("GET") => true,
202            Some(_) | None => {
203                return Ok(Response::StatusStr(StatusCode::METHOD_NOT_ALLOWED));
204            }
205        };
206
207        let (path, query_sep, query) = match path {
208            Some(path) => {
209                if let Some((path, query)) = path.split_once('?') {
210                    (path, "?", query)
211                } else {
212                    (path, "", "")
213                }
214            }
215            None => {
216                return Ok(Response::StatusStr(StatusCode::BAD_REQUEST));
217            }
218        };
219
220        let path_idx = match self.path_trie.get(path).or(self.fallback_idx) {
221            Some(idx) => idx,
222            None => return Ok(Response::NotFound),
223        };
224
225        let path_data = match self.path_data.get(path_idx) {
226            Some(data) => data,
227            None => {
228                return Ok(Response::StatusStr(StatusCode::INTERNAL_SERVER_ERROR));
229            }
230        };
231
232        let (file_data, mime_idx) = match path_data {
233            HandlerPathData::Found { file_idx, mime_idx } => match self.file_data.get(*file_idx) {
234                Some(data) => (data, mime_idx),
235                None => return Ok(Response::NotFound),
236            },
237            HandlerPathData::DirRedirect(path) => {
238                let len = path.len() + query_sep.len() + query.len();
239
240                if len > 256 {
241                    return Ok(Response::StatusStr(StatusCode::URI_TOO_LONG));
242                } else {
243                    return Ok(Response::Redirect {
244                        path: Arc::clone(path),
245                        query: format_compact!("{query_sep}{query}"),
246                    });
247                }
248            }
249        };
250
251        let mime_type = mime_idx.and_then(|mime_idx| self.mime_types.get(mime_idx).cloned());
252
253        let resolved_path = if send_body {
254            Some(Arc::clone(&file_data.resolved_path))
255        } else {
256            None
257        };
258
259        Ok(Response::Found {
260            resolved_path,
261            len: file_data.len,
262            mime_type,
263        })
264    }
265}
266
267impl TryFrom<HandlerSettings> for Handler {
268    type Error = HandlerBuildError;
269
270    fn try_from(settings: HandlerSettings) -> Result<Self, Self::Error> {
271        Self::build_from_root(
272            settings.root,
273            settings.index_file.as_str(),
274            settings.fallback_path.as_deref(),
275            settings.expose_hidden,
276        )
277    }
278}