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. |
| 5 | use 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 | |
| 21 | use oxedyne_fe2o3_core::{ |
| 22 | prelude::*, |
| 23 | }; |
| 24 | |
| 25 | use 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)] |
| 49 | pub struct RequestPath { |
| 50 | path: String, |
| 51 | } |
| 52 | |
| 53 | impl fmt::Display for RequestPath { |
| 54 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 55 | write!(f, "{:?}", self.path) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | impl 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)] |
| 286 | mod 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 | } |