diff --git a/Cargo.lock b/Cargo.lock index 86dc3faa448..fc7c775274a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4061,6 +4061,7 @@ dependencies = [ "base64", "brotli", "bytes", + "chrono", "content-security-policy", "cookie 0.12.0", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index 081e3494b8f..f9d6711de24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ bluetooth_traits = { path = "components/shared/bluetooth" } byteorder = "1.5" canvas_traits = { path = "components/shared/canvas" } cfg-if = "1.0.0" +chrono = "0.4" compositing_traits = { path = "components/shared/compositing" } content-security-policy = { version = "0.5", features = ["serde"] } cookie = "0.12" diff --git a/components/config/prefs.rs b/components/config/prefs.rs index b48b6954914..c758e0cfe8a 100644 --- a/components/config/prefs.rs +++ b/components/config/prefs.rs @@ -551,6 +551,9 @@ mod gen { #[serde(rename = "network.http-cache.disabled")] disabled: bool, }, + local_directory_listing: { + enabled: bool, + }, mime: { sniff: bool, } diff --git a/components/devtools/Cargo.toml b/components/devtools/Cargo.toml index 4ed635416ab..5c4773fae74 100644 --- a/components/devtools/Cargo.toml +++ b/components/devtools/Cargo.toml @@ -11,11 +11,11 @@ name = "devtools" path = "lib.rs" [build-dependencies] -chrono = "0.4" +chrono = { workspace = true } [dependencies] base = { workspace = true } -chrono = "0.4" +chrono = { workspace = true } crossbeam-channel = { workspace = true } devtools_traits = { workspace = true } embedder_traits = { workspace = true } diff --git a/components/net/Cargo.toml b/components/net/Cargo.toml index 2ca1b035e3a..3ecb321c4b9 100644 --- a/components/net/Cargo.toml +++ b/components/net/Cargo.toml @@ -58,6 +58,7 @@ servo_config = { path = "../config" } servo_url = { path = "../url" } sha2 = "0.10" time = { workspace = true } +chrono = { workspace = true } tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] } tokio-rustls = { workspace = true } tokio-stream = "0.1" diff --git a/components/net/fetch/methods.rs b/components/net/fetch/methods.rs index d69360f9be4..1f8b70f4e61 100644 --- a/components/net/fetch/methods.rs +++ b/components/net/fetch/methods.rs @@ -50,6 +50,7 @@ use crate::http_loader::{ determine_requests_referrer, http_fetch, set_default_accept, set_default_accept_language, HttpState, }; +use crate::local_directory_listing; use crate::subresource_integrity::is_response_integrity_valid; lazy_static! { @@ -729,15 +730,11 @@ async fn scheme_fetch( )); } if let Ok(file_path) = url.to_file_path() { - if let Ok(file) = File::open(file_path.clone()) { - if let Ok(metadata) = file.metadata() { - if metadata.is_dir() { - return Response::network_error(NetworkError::Internal( - "Opening a directory is not supported".into(), - )); - } - } + if file_path.is_dir() { + return local_directory_listing::fetch(request, url, file_path); + } + if let Ok(file) = File::open(file_path.clone()) { // Get range bounds (if any) and try to seek to the requested offset. // If seeking fails, bail out with a NetworkError. let file_size = match file.metadata() { diff --git a/components/net/lib.rs b/components/net/lib.rs index 4d67dba4203..cff77a022a0 100644 --- a/components/net/lib.rs +++ b/components/net/lib.rs @@ -16,6 +16,7 @@ pub mod hsts; pub mod http_cache; pub mod http_loader; pub mod image_cache; +pub mod local_directory_listing; pub mod mime_classifier; pub mod resource_thread; mod storage_thread; diff --git a/components/net/local_directory_listing.rs b/components/net/local_directory_listing.rs new file mode 100644 index 00000000000..5a5b782a5fe --- /dev/null +++ b/components/net/local_directory_listing.rs @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use std::fs::{DirEntry, Metadata, ReadDir}; +use std::path::PathBuf; + +use chrono::{DateTime, Local}; +use embedder_traits::resources::{read_string, Resource}; +use headers::{ContentType, HeaderMapExt}; +use net_traits::request::Request; +use net_traits::response::{Response, ResponseBody}; +use net_traits::{NetworkError, ResourceFetchTiming}; +use servo_config::pref; +use servo_url::ServoUrl; +use url::Url; + +pub fn fetch(request: &mut Request, url: ServoUrl, path_buf: PathBuf) -> Response { + if !pref!(network.local_directory_listing.enabled) { + // If you want to be able to browse local directories, configure Servo prefs so that + // "network.local_directory_listing.enabled" is set to true. + return Response::network_error(NetworkError::Internal( + "Local directory listing feature has not been enabled in preferences".into(), + )); + } + + if !request.origin.is_opaque() { + // Checking for an opaque origin as a shorthand for user activation + // as opposed to a request originating from a script. + // TODO(32534): carefully consider security of this approach. + return Response::network_error(NetworkError::Internal( + "Cannot request local directory listing from non-local origin.".into(), + )); + } + + let directory_contents = match std::fs::read_dir(path_buf.clone()) { + Ok(directory_contents) => directory_contents, + Err(error) => { + return Response::network_error(NetworkError::Internal(format!( + "Unable to access directory: {error}" + ))); + }, + }; + + let output = build_html_directory_listing(url.as_url(), path_buf, directory_contents); + + let mut response = Response::new(url, ResourceFetchTiming::new(request.timing_type())); + response.headers.typed_insert(ContentType::html()); + *response.body.lock().unwrap() = ResponseBody::Done(output.into_bytes()); + + response +} + +/// Returns an the string of an JavaScript `\n"); + + page_html +} + +fn write_directory_entry(entry: DirEntry, metadata: Metadata, url: &Url, output: &mut String) { + let Ok(name) = entry.file_name().into_string() else { + return; + }; + + let mut file_url = url.clone(); + { + let Ok(mut path_segments) = file_url.path_segments_mut() else { + return; + }; + path_segments.push(&name); + } + + let class = if metadata.is_dir() { + "directory" + } else if metadata.is_symlink() { + "symlink" + } else { + "file" + }; + + let file_url_string = &file_url.to_string(); + let file_size = metadata_to_file_size_string(&metadata); + let last_modified = metadata + .modified() + .map(|time| DateTime::::from(time)) + .map(|time| time.format("%F %r").to_string()) + .unwrap_or_default(); + + output.push_str(&format!( + "[{class:?}, {name:?}, {file_url_string:?}, {file_size:?}, {last_modified:?}]," + )); +} + +pub fn metadata_to_file_size_string(metadata: &Metadata) -> String { + if !metadata.is_file() { + return String::new(); + } + + let mut float_size = metadata.len() as f64; + let mut prefix_power = 0; + while float_size > 1000.0 && prefix_power < 3 { + float_size /= 1000.0; + prefix_power += 1; + } + + let prefix = match prefix_power { + 0 => "B", + 1 => "KB", + 2 => "MB", + _ => "GB", + }; + + return format!("{:.2} {prefix}", float_size); +} diff --git a/components/script/Cargo.toml b/components/script/Cargo.toml index e09a26fd61d..96f5502fd0c 100644 --- a/components/script/Cargo.toml +++ b/components/script/Cargo.toml @@ -37,7 +37,7 @@ base64 = { workspace = true } bitflags = { workspace = true } bluetooth_traits = { workspace = true } canvas_traits = { workspace = true } -chrono = "0.4" +chrono = { workspace = true } content-security-policy = { workspace = true } cookie = { workspace = true } crossbeam-channel = { workspace = true } diff --git a/components/shared/embedder/resources.rs b/components/shared/embedder/resources.rs index fd1f44ff455..808e380287d 100644 --- a/components/shared/embedder/resources.rs +++ b/components/shared/embedder/resources.rs @@ -71,6 +71,7 @@ pub enum Resource { MediaControlsCSS, MediaControlsJS, CrashHTML, + DirectoryListingHTML, } impl Resource { @@ -90,6 +91,7 @@ impl Resource { Resource::MediaControlsCSS => "media-controls.css", Resource::MediaControlsJS => "media-controls.js", Resource::CrashHTML => "crash.html", + Resource::DirectoryListingHTML => "directory-listing.html", } } } @@ -146,6 +148,9 @@ fn resources_for_tests() -> Box { &include_bytes!("../../../resources/media-controls.js")[..] }, Resource::CrashHTML => &include_bytes!("../../../resources/crash.html")[..], + Resource::DirectoryListingHTML => { + &include_bytes!("../../../resources/directory-listing.html")[..] + }, } .to_owned() } diff --git a/components/shared/net/request.rs b/components/shared/net/request.rs index 77b37e17b75..2c644d9ba54 100644 --- a/components/shared/net/request.rs +++ b/components/shared/net/request.rs @@ -37,6 +37,12 @@ pub enum Origin { Origin(ImmutableOrigin), } +impl Origin { + pub fn is_opaque(&self) -> bool { + matches!(self, Origin::Origin(ImmutableOrigin::Opaque(_))) + } +} + /// A [referer](https://fetch.spec.whatwg.org/#concept-request-referrer) #[derive(Clone, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] pub enum Referrer { diff --git a/ports/servoshell/egl/android/simpleservo.rs b/ports/servoshell/egl/android/simpleservo.rs index d768773b1bf..2e3824068a0 100644 --- a/ports/servoshell/egl/android/simpleservo.rs +++ b/ports/servoshell/egl/android/simpleservo.rs @@ -931,6 +931,9 @@ impl ResourceReaderMethods for ResourceReaderInstance { &include_bytes!("../../../../resources/media-controls.js")[..] }, Resource::CrashHTML => &include_bytes!("../../../../resources/crash.html")[..], + Resource::DirectoryListingHTML => { + &include_bytes!("../../../../resources/directory-listing.html")[..] + }, }) } diff --git a/resources/directory-listing.html b/resources/directory-listing.html new file mode 100644 index 00000000000..0e32e3786ce --- /dev/null +++ b/resources/directory-listing.html @@ -0,0 +1,103 @@ + + + + Index of + + + + +

Index of

+ +
+
+ Name + Size + Last Modified +
+
+ + + diff --git a/resources/prefs.json b/resources/prefs.json index 8ebc7d450f5..b9de4aa1b28 100644 --- a/resources/prefs.json +++ b/resources/prefs.json @@ -111,6 +111,7 @@ "network.enforce_tls.localhost": false, "network.enforce_tls.onion": false, "network.http-cache.disabled": false, + "network.local_directory_listing.enabled": false, "network.mime.sniff": false, "session-history.max-length": 20, "shell.background-color.rgba": [1.0, 1.0, 1.0, 1.0],