Add a directory listing feature for file URLs (#32580)

Signed-off-by: Bobulous <Bobulous@users.noreply.github.com>
Signed-off-by: Martin Robinson <mrobinson@igalia.com>
Co-authored-by: Bobulous <Bobulous@users.noreply.github.com>
This commit is contained in:
Martin Robinson 2024-06-26 10:02:50 +02:00 committed by GitHub
parent b3d99a607f
commit 7ea894774f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 291 additions and 11 deletions

1
Cargo.lock generated
View file

@ -4061,6 +4061,7 @@ dependencies = [
"base64",
"brotli",
"bytes",
"chrono",
"content-security-policy",
"cookie 0.12.0",
"crossbeam-channel",

View file

@ -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"

View file

@ -551,6 +551,9 @@ mod gen {
#[serde(rename = "network.http-cache.disabled")]
disabled: bool,
},
local_directory_listing: {
enabled: bool,
},
mime: {
sniff: bool,
}

View file

@ -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 }

View file

@ -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"

View file

@ -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() {

View file

@ -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;

View file

@ -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 `<script>` tag calling the `setData` function with the
/// contents of the given [`ReadDir`] directory listing.
///
/// # Arguments
///
/// * `url` - the original URL of the request that triggered this directory listing.
/// * `path` - the full path to the local directory.
/// * `directory_contents` - a [`ReadDir`] with the contents of the directory.
pub fn build_html_directory_listing(
url: &Url,
path: PathBuf,
directory_contents: ReadDir,
) -> String {
let mut page_html = String::with_capacity(1024);
page_html.push_str("<!DOCTYPE html>");
let mut parent_url_string = String::new();
if path.parent().is_some() {
let mut parent_url = url.clone();
if let Ok(mut path_segments) = parent_url.path_segments_mut() {
path_segments.pop();
}
parent_url_string = parent_url.as_str().to_owned();
}
page_html.push_str(&read_string(Resource::DirectoryListingHTML));
page_html.push_str("<script>\n");
page_html.push_str(&format!(
"setData({:?}, {:?}, [",
url.as_str(),
parent_url_string
));
for directory_entry in directory_contents {
let Ok(directory_entry) = directory_entry else {
continue;
};
let Ok(metadata) = directory_entry.metadata() else {
continue;
};
write_directory_entry(directory_entry, metadata, url, &mut page_html);
}
page_html.push_str("]);");
page_html.push_str("</script>\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::<Local>::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);
}

View file

@ -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 }

View file

@ -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<dyn ResourceReaderMethods + Sync + Send> {
&include_bytes!("../../../resources/media-controls.js")[..]
},
Resource::CrashHTML => &include_bytes!("../../../resources/crash.html")[..],
Resource::DirectoryListingHTML => {
&include_bytes!("../../../resources/directory-listing.html")[..]
},
}
.to_owned()
}

View file

@ -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 {

View file

@ -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")[..]
},
})
}

View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<title>Index of</title>
<style>
h1 {
border-bottom: 1px solid black;
}
.listing {
margin: 0.5em;
display: table;
}
.listing .header,
.listing .entry {
display: table-row;
}
.listing .header {
font-weight: bold;
}
.listing .header span,
.listing .entry span {
display: table-cell;
padding-right: 1em;
}
.entry span:nth-child(1) {
min-width: 20em;
}
.entry span.size,
.entry span.last-modified {
white-space: nowrap;
}
.parent_link > a:before {
content: "📁 ";
}
.parent_link {
display: none;
}
.entry.directory > .name:before {
content: "📁 ";
}
.entry.file > .name:before {
content: "📄 ";
}
.entry.symlink > .name:before {
content: "🔗 ";
}
</style>
<script>
function setData(directoryName, parentLink, rows) {
document.title += directoryName;
document.querySelector("h1").innerHTML += " " + directoryName;
if (parentLink != "") {
document.querySelector(".parent_link > a").href = parentLink;
document.querySelector(".parent_link").style.display = "initial";
}
rows.sort((rowA, rowB) => rowA[1].localeCompare(rowB[1]));
let listing = document.querySelector(".listing");
let rowTemplate = document.getElementById("rowTemplate");
for (row of rows) {
let rowElement = rowTemplate.content.cloneNode(true);
rowElement.querySelector(".entry").classList.add(row[0]);
rowElement.querySelector(".name > .link").innerText = row[1];
rowElement.querySelector(".name > .link").href = row[2];
rowElement.querySelector(".size").innerText = row[3];
rowElement.querySelector(".last-modified").innerText = row[4];
listing.appendChild(rowElement);
}
}
</script>
</head>
<body>
<h1>Index of</h1>
<div class="parent_link"><a href="">Up to parent directory</a></div>
<div class="listing">
<div class="header">
<span class="name">Name</span>
<span class="size">Size</span>
<span class="last-modified">Last Modified</span>
</div>
</div>
<template id="rowTemplate">
<div class="entry">
<span class="name"><a class="link"></a></span>
<span class="size"></span>
<span class="last-modified"></span>
</div>
</template>
</body>
</html>

View file

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