Skip to main content

net/
local_directory_listing.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::fmt::Write as _;
6use std::fs::Metadata;
7use std::path::PathBuf;
8
9use chrono::{DateTime, Local};
10use embedder_traits::resources::{Resource, read_string};
11use headers::{ContentType, HeaderMapExt};
12use net_traits::request::Request;
13use net_traits::response::{Response, ResponseBody};
14use net_traits::{NetworkError, ResourceFetchTiming};
15use servo_config::pref;
16use servo_url::ServoUrl;
17use url::Url;
18
19pub(crate) async fn fetch(request: &mut Request, url: ServoUrl, path_buf: PathBuf) -> Response {
20    if !pref!(network_local_directory_listing_enabled) {
21        // If you want to be able to browse local directories, configure Servo prefs so that
22        // "network.local_directory_listing.enabled" is set to true.
23        return Response::network_error(NetworkError::LocalDirectoryError);
24    }
25
26    if !request.origin.is_opaque() {
27        // Checking for an opaque origin as a shorthand for user activation
28        // as opposed to a request originating from a script.
29        // TODO(32534): carefully consider security of this approach.
30        return Response::network_error(NetworkError::LocalDirectoryError);
31    }
32
33    let directory_contents = match tokio::fs::read_dir(&path_buf).await {
34        Ok(directory_contents) => directory_contents,
35        Err(error) => {
36            return Response::network_error(NetworkError::ResourceLoadError(format!(
37                "Unable to access directory: {error}"
38            )));
39        },
40    };
41
42    let output = build_html_directory_listing(url.as_url(), path_buf, directory_contents).await;
43
44    let mut response = Response::new(url, ResourceFetchTiming::new(request.timing_type()));
45    response.headers.typed_insert(ContentType::html());
46    *response.body.lock() = ResponseBody::Done(output.into_bytes());
47
48    response
49}
50
51/// Returns an the string of an JavaScript `<script>` tag calling the `setData` function with the
52/// contents of the given [`ReadDir`] directory listing.
53///
54/// # Arguments
55///
56/// * `url` - the original URL of the request that triggered this directory listing.
57/// * `path` - the full path to the local directory.
58/// * `directory_contents` - a [`ReadDir`] with the contents of the directory.
59pub(crate) async fn build_html_directory_listing(
60    url: &Url,
61    path: PathBuf,
62    mut directory_contents: tokio::fs::ReadDir,
63) -> String {
64    let mut page_html = String::with_capacity(1024);
65    page_html.push_str("<!DOCTYPE html>");
66
67    let mut parent_url_string = String::new();
68    if path.parent().is_some() {
69        let mut parent_url = url.clone();
70        if let Ok(mut path_segments) = parent_url.path_segments_mut() {
71            path_segments.pop();
72        }
73        parent_url.as_str().clone_into(&mut parent_url_string);
74    }
75
76    page_html.push_str(&read_string(Resource::DirectoryListingHTML));
77
78    page_html.push_str("<script>\n");
79    write!(
80        page_html,
81        "setData({:?}, {:?}, [",
82        url.as_str(),
83        parent_url_string
84    )
85    .unwrap();
86
87    while let Ok(Some(directory_entry)) = directory_contents.next_entry().await {
88        let Ok(metadata) = directory_entry.metadata().await else {
89            continue;
90        };
91        write_directory_entry(directory_entry, metadata, url, &mut page_html);
92    }
93
94    page_html.push_str("]);");
95    page_html.push_str("</script>\n");
96
97    page_html
98}
99
100fn write_directory_entry(
101    entry: tokio::fs::DirEntry,
102    metadata: Metadata,
103    url: &Url,
104    output: &mut String,
105) {
106    let Ok(name) = entry.file_name().into_string() else {
107        return;
108    };
109
110    let mut file_url = url.clone();
111    {
112        let Ok(mut path_segments) = file_url.path_segments_mut() else {
113            return;
114        };
115        path_segments.push(&name);
116    }
117
118    let class = if metadata.is_dir() {
119        "directory"
120    } else if metadata.is_symlink() {
121        "symlink"
122    } else {
123        "file"
124    };
125
126    let file_url_string = file_url.as_str();
127    let file_size = metadata_to_file_size_string(&metadata);
128    let last_modified = metadata
129        .modified()
130        .map(DateTime::<Local>::from)
131        .map(|time| time.format("%F %r").to_string())
132        .unwrap_or_default();
133
134    // The file name is the only value here that reaches the page unencoded; the
135    // url crate percent-encodes `<`/`>` in `file_url_string`, and the remaining
136    // fields are produced by us. `{:?}` is not enough on its own because it
137    // leaves `</script>` intact inside the script element below.
138    let name = script_string_literal(&name);
139    write!(
140        output,
141        "[{class:?}, {name}, {file_url_string:?}, {file_size:?}, {last_modified:?}],"
142    )
143    .unwrap();
144}
145
146/// Serialise `value` as a JavaScript string literal that is safe to embed in the
147/// inline `<script>` element of the directory listing. `{:?}` produces a valid
148/// literal but leaves any `<` (and therefore `</script>`) untouched, which lets a
149/// crafted file name close the script element and inject markup into the page.
150fn script_string_literal(value: &str) -> String {
151    format!("{value:?}").replace('<', "\\u003C")
152}
153
154pub fn metadata_to_file_size_string(metadata: &Metadata) -> String {
155    if !metadata.is_file() {
156        return String::new();
157    }
158
159    let mut float_size = metadata.len() as f64;
160    let mut prefix_power = 0;
161    while float_size > 1000.0 && prefix_power < 3 {
162        float_size /= 1000.0;
163        prefix_power += 1;
164    }
165
166    let prefix = match prefix_power {
167        0 => "B",
168        1 => "KB",
169        2 => "MB",
170        _ => "GB",
171    };
172
173    format!("{:.2} {prefix}", float_size)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::script_string_literal;
179
180    #[test]
181    fn script_string_literal_neutralises_script_close() {
182        let literal = script_string_literal("a</script><img src=x onerror=alert(1)>b");
183        assert!(!literal.contains('<'));
184        assert!(!literal.to_lowercase().contains("</script"));
185    }
186
187    #[test]
188    fn script_string_literal_keeps_debug_escaping() {
189        // Quotes and backslashes from the input stay escaped so the result is
190        // still a valid JavaScript string literal.
191        assert_eq!(script_string_literal("a\"b\\c"), r#""a\"b\\c""#);
192        assert_eq!(script_string_literal("plain"), r#""plain""#);
193    }
194}