#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
File,
Dir,
}
#[derive(Debug, Clone)]
pub struct Entry {
name: String,
kind: Kind,
}
impl Entry {
#[must_use]
pub fn file(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: Kind::File,
}
}
#[must_use]
pub fn dir(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: Kind::Dir,
}
}
}
#[must_use]
pub fn render(prefix: &str, entries: &[Entry]) -> String {
let mut out = String::new();
out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">");
out.push_str("<title>Index of ");
out.push_str(&escape_html(prefix));
out.push_str("</title></head><body><h1>Index of ");
out.push_str(&escape_html(prefix));
out.push_str("</h1><ul>");
if prefix != "/" {
out.push_str("<li><a href=\"../\">../</a></li>");
}
for entry in entries {
let suffix = if entry.kind == Kind::Dir { "/" } else { "" };
out.push_str("<li><a href=\"");
out.push_str(&encode_segment(&entry.name));
out.push_str(suffix);
out.push_str("\">");
out.push_str(&escape_html(&entry.name));
out.push_str(suffix);
out.push_str("</a></li>");
}
out.push_str("</ul></body></html>");
out
}
#[must_use]
pub fn encode_segment(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for byte in name.bytes() {
if is_safe_segment_byte(byte) {
out.push(char::from(byte));
} else {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
out
}
fn is_safe_segment_byte(byte: u8) -> bool {
matches!(byte,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' |
b'-' | b'_' | b'.' | b'~' |
b'!' | b'*' | b'\'' | b'(' | b')'
)
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_filename_that_is_html_is_escaped_in_the_text_and_encoded_in_the_href() {
let html = render("/", &[Entry::file("<script>alert(1)</script>")]);
assert!(!html.contains("<script>alert(1)"), "{html}");
assert!(html.contains("<script>alert(1)"), "{html}");
assert!(html.contains("href=\"%3Cscript%3Ealert(1)"), "{html}");
}
#[test]
fn a_filename_with_a_space_or_a_hash_produces_a_link_that_resolves() {
let html = render("/docs/", &[Entry::file("release notes #2.md")]);
assert!(
html.contains("href=\"release%20notes%20%232.md\""),
"{html}"
);
}
#[test]
fn a_non_ascii_name_encodes_to_printable_ascii() {
let encoded = encode_segment("документы");
assert!(
encoded.bytes().all(|b| (0x20..=0x7e).contains(&b)),
"{encoded}"
);
assert!(encoded.starts_with('%'), "{encoded}");
}
#[test]
fn a_directory_entry_gets_a_trailing_slash_on_both_href_and_label() {
let html = render("/", &[Entry::dir("assets")]);
assert!(html.contains("href=\"assets/\">assets/</a>"), "{html}");
}
}