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
+
+
+
+
+
+
+
+
+
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],