Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/file.rs

11.7 KiB, 24 runs

created by r1870400018:571, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! File request path handling and validation.
2//!
3//! This module provides functionality for handling and validating web request paths,
4//! including path normalisation, security checks, and content type detection.
5use crate::{
6 charset::Charset,
7 http::fields::HeaderFieldValue,
8 media::{
9 Application,
10 Audio,
11 ContentTypeValue,
12 Font,
13 Image,
14 MediaType,
15 MEDIA_PLAIN_TEXT,
16 Text,
17 Video,
18 },
19};
20
21use oxedyne_fe2o3_core::{
22 prelude::*,
23};
24
25use std::{
26 ffi::OsStr,
27 fmt,
28 path::{
29 Component,
30 Path,
31 PathBuf,
32 },
33};
34
35
36/// A validated request path for web server routes.
37///
38/// `RequestPath` wraps a string path and provides validation and normalisation
39/// functionality to ensure paths are safe and well-formed for serving web content.
40///
41/// # Examples
42/// ```
43/// use oxedyne_fe2o3_net::file::RequestPath;
44///
45/// let path = RequestPath::new("/index.html");
46/// assert_eq!(path.as_str(), "/index.html");
47/// ```
48#[derive(Clone, Debug, Default)]
49pub struct RequestPath {
50 path: String,
51}
52
53impl fmt::Display for RequestPath {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 write!(f, "{:?}", self.path)
56 }
57}
58
59impl RequestPath {
60
61 pub fn new<S: Into<String>>(path: S) -> Self {
62 Self {
63 path: path.into(),
64 }
65 }
66
67 pub fn as_str(&self) -> &str {
68 self.path.as_str()
69 }
70
71 pub fn as_string(&self) -> &String {
72 &self.path
73 }
74
75 pub fn as_path(&self) -> &Path {
76 Path::new(&self.path)
77 }
78
79 pub fn validate(
80 &self,
81 public_root_path: &String,
82 default_root_file: &String,
83 )
84 -> Outcome<PathBuf>
85 {
86 let mut path = self.path.clone();
87
88 if path.starts_with('/') {
89 path.remove(0); // Remove leading '/'
90 }
91
92 if path.len() > 0 && self.path.ends_with('/') {
93 return Err(err!("Path must not end with '/'"; IO, Network, Invalid, Input));
94 }
95
96 if path.len() == 0 {
97 path = default_root_file.to_string();
98 }
99 let path = Path::new(&path);
100
101 for component in path.components() {
102 match component {
103 Component::CurDir | Component::ParentDir => {
104 return Err(err!(
105 "Path must not contain relative components '.' or '..'";
106 IO, Network, Invalid, Input));
107 }
108 _ => ()
109 }
110 }
111 let mut pathbuf = PathBuf::from(public_root_path);
112 pathbuf.push(path);
113 Ok(pathbuf)
114 }
115
116 pub fn content_type(path: &Path) -> HeaderFieldValue {
117 HeaderFieldValue::ContentType(match path.extension().and_then(OsStr::to_str) {
118 Some("css") => ContentTypeValue::MediaType((
119 MediaType::Text(Text::Css),
120 Some(Charset::Utf_8),
121 )),
122 Some("gif") => ContentTypeValue::MediaType((
123 MediaType::Image(Image::Gif),
124 None,
125 )),
126 Some("html") => ContentTypeValue::MediaType((
127 MediaType::Text(Text::Html),
128 Some(Charset::Utf_8),
129 )),
130 Some("jpg") | Some("jpeg") => ContentTypeValue::MediaType((
131 MediaType::Image(Image::Jpeg),
132 None,
133 )),
134 Some("js") => ContentTypeValue::MediaType((
135 MediaType::Text(Text::Javascript),
136 Some(Charset::Utf_8),
137 )),
138 Some("otf") => ContentTypeValue::MediaType((
139 MediaType::Font(Font::Otf),
140 None,
141 )),
142 Some("png") => ContentTypeValue::MediaType((
143 MediaType::Image(Image::Png),
144 None,
145 )),
146 Some("svg") => ContentTypeValue::MediaType((
147 MediaType::Image(Image::SvgXml),
148 None,
149 )),
150 Some("ttf") => ContentTypeValue::MediaType((
151 MediaType::Font(Font::Ttf),
152 None,
153 )),
154 Some("woff") => ContentTypeValue::MediaType((
155 MediaType::Font(Font::Woff),
156 None,
157 )),
158 Some("woff2") => ContentTypeValue::MediaType((
159 MediaType::Font(Font::Woff2),
160 None,
161 )),
162 // Recordings. A browser plays what the server says a thing is, not
163 // what its name suggests, so a video served as text is a video that
164 // downloads instead of playing -- and one that never gets a scrubber,
165 // however well the server answers a `Range`.
166 Some("mp4") | Some("m4v") => ContentTypeValue::MediaType((
167 MediaType::Video(Video::Mp4),
168 None,
169 )),
170 Some("webm") => ContentTypeValue::MediaType((
171 MediaType::Video(Video::Webm),
172 None,
173 )),
174 Some("ogv") => ContentTypeValue::MediaType((
175 MediaType::Video(Video::Ogg),
176 None,
177 )),
178 Some("mov") => ContentTypeValue::MediaType((
179 MediaType::Video(Video::Quicktime),
180 None,
181 )),
182 Some("mkv") => ContentTypeValue::MediaType((
183 MediaType::Video(Video::XMatroska),
184 None,
185 )),
186 Some("avi") => ContentTypeValue::MediaType((
187 MediaType::Video(Video::XMsVideo),
188 None,
189 )),
190 Some("mpeg") | Some("mpg") => ContentTypeValue::MediaType((
191 MediaType::Video(Video::Mpeg),
192 None,
193 )),
194 Some("mp3") => ContentTypeValue::MediaType((
195 MediaType::Audio(Audio::Mpeg),
196 None,
197 )),
198 Some("m4a") => ContentTypeValue::MediaType((
199 MediaType::Audio(Audio::Mp4),
200 None,
201 )),
202 Some("oga") | Some("ogg") => ContentTypeValue::MediaType((
203 MediaType::Audio(Audio::Ogg),
204 None,
205 )),
206 Some("wav") => ContentTypeValue::MediaType((
207 MediaType::Audio(Audio::Wav),
208 None,
209 )),
210 Some("flac") => ContentTypeValue::MediaType((
211 MediaType::Audio(Audio::Flac),
212 None,
213 )),
214 Some("aac") => ContentTypeValue::MediaType((
215 MediaType::Audio(Audio::Aac),
216 None,
217 )),
218 // A WebAssembly module compiles as it arrives only when the server
219 // says `application/wasm`. Under any other type, and `text/plain`
220 // is what an unknown extension gets, `compileStreaming` refuses the
221 // response and the module is buffered whole before it starts.
222 Some("wasm") => ContentTypeValue::MediaType((
223 MediaType::Application(Application::Wasm),
224 None,
225 )),
226 // The manifest that makes an installable web application. Served as
227 // anything else, the browser declines to install it.
228 Some("webmanifest") => ContentTypeValue::MediaType((
229 MediaType::Application(Application::ManifestJson),
230 None,
231 )),
232 Some("json") => ContentTypeValue::MediaType((
233 MediaType::Application(Application::Json),
234 None,
235 )),
236 Some("pdf") => ContentTypeValue::MediaType((
237 MediaType::Application(Application::Pdf),
238 None,
239 )),
240 Some("zip") => ContentTypeValue::MediaType((
241 MediaType::Application(Application::Zip),
242 None,
243 )),
244 Some("webp") => ContentTypeValue::MediaType((
245 MediaType::Image(Image::Webp),
246 None,
247 )),
248 Some("avif") => ContentTypeValue::MediaType((
249 MediaType::Image(Image::Avif),
250 None,
251 )),
252 Some("ico") => ContentTypeValue::MediaType((
253 MediaType::Image(Image::Icon),
254 None,
255 )),
256 Some("tif") | Some("tiff") => ContentTypeValue::MediaType((
257 MediaType::Image(Image::Tiff),
258 None,
259 )),
260 Some("txt") => ContentTypeValue::MediaType((
261 MediaType::Text(Text::Plain),
262 Some(Charset::Utf_8),
263 )),
264 Some("csv") => ContentTypeValue::MediaType((
265 MediaType::Text(Text::Csv),
266 Some(Charset::Utf_8),
267 )),
268 // `text/xml` defaults to US-ASCII without a charset (RFC 7303 §3.1),
269 // so the charset is not decoration here.
270 Some("xml") => ContentTypeValue::MediaType((
271 MediaType::Text(Text::Xml),
272 Some(Charset::Utf_8),
273 )),
274 Some("mjs") => ContentTypeValue::MediaType((
275 MediaType::Text(Text::Javascript),
276 Some(Charset::Utf_8),
277 )),
278 _ => MEDIA_PLAIN_TEXT,
279 })
280 }
281
282}
283
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 /// A browser plays what the server says a thing is, not what its name
290 /// suggests. A recording served as `text/plain` downloads rather than plays,
291 /// and never gets a scrubber however well the server answers a `Range`.
292 #[test]
293 fn test_a_recording_is_served_as_a_recording() {
294 for (name, expected) in [
295 ("clip.mp4", "video/mp4"),
296 ("clip.m4v", "video/mp4"),
297 ("clip.webm", "video/webm"),
298 ("clip.ogv", "video/ogg"),
299 ("clip.mov", "video/quicktime"),
300 ("clip.mkv", "video/x-matroska"),
301 ("clip.avi", "video/x-msvideo"),
302 ("clip.mpg", "video/mpeg"),
303 ("track.mp3", "audio/mpeg"),
304 ("track.m4a", "audio/mp4"),
305 ("track.ogg", "audio/ogg"),
306 ("track.wav", "audio/wav"),
307 ("track.flac", "audio/flac"),
308 ("track.aac", "audio/aac"),
309 ] {
310 let got = fmt!("{}", RequestPath::content_type(Path::new(name)));
311 assert_eq!(got, expected, "{} was served as {}", name, got);
312 }
313 }
314
315 /// A module served as anything but `application/wasm` cannot be compiled as
316 /// it arrives, and the rest of these are the same fault: the browser does
317 /// what the type says, not what the name suggests.
318 #[test]
319 fn test_a_web_payload_is_served_as_what_it_is() {
320 for (name, expected) in [
321 ("module.wasm", "application/wasm"),
322 ("app.webmanifest", "application/manifest+json"),
323 ("data.json", "application/json"),
324 ("doc.pdf", "application/pdf"),
325 ("bundle.zip", "application/zip"),
326 ("photo.webp", "image/webp"),
327 ("photo.avif", "image/avif"),
328 ("favicon.ico", "image/vnd.microsoft.icon"),
329 ("scan.tiff", "image/tiff"),
330 ("scan.tif", "image/tiff"),
331 ("notes.txt", "text/plain; charset=utf-8"),
332 ("rows.csv", "text/csv; charset=utf-8"),
333 ("feed.xml", "text/xml; charset=utf-8"),
334 ("mod.mjs", "text/javascript; charset=utf-8"),
335 ] {
336 let got = fmt!("{}", RequestPath::content_type(Path::new(name)));
337 assert_eq!(got, expected, "{} was served as {}", name, got);
338 }
339 }
340
341 /// What the map does not know is still plain text, as it always was.
342 #[test]
343 fn test_an_unknown_extension_is_still_plain_text() {
344 assert_eq!(
345 fmt!("{}", RequestPath::content_type(Path::new("thing.xyzzy"))),
346 "text/plain; charset=utf-8",
347 );
348 }
349}