From 11ea8a19d1481b0660e5a2765da6e67d3e8aa72c Mon Sep 17 00:00:00 2001 From: Sven-Hendrik Haase Date: Fri, 7 Mar 2025 11:00:48 +0100 Subject: Add asynchronous directory size counting This is enabled by default and without an option to toggle it off as it's asynchronous and shouldn't block the server thread. --- CHANGELOG.md | 1 + Cargo.lock | 125 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- data/style.scss | 19 ++++++++ src/args.rs | 2 +- src/auth.rs | 7 ++- src/config.rs | 10 +++- src/errors.rs | 3 +- src/file_op.rs | 34 +++++++++++++- src/listing.rs | 16 ++++--- src/main.rs | 45 ++++++++++++++++-- src/renderer.rs | 81 ++++++++++++++++++++++++++++---- tests/api.rs | 53 +++++++++++++++++++++ tests/serve_request.rs | 3 ++ 14 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 tests/api.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index be38ce3..f030e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add upload progress bar and allow for multiple concurrent file uploads [#1431](https://github.com/svenstaro/miniserve/pull/1431) (thanks @AlecDivito) - Add `--size-display` to allow for toggling file size display between `human` and `exact` [#1261](https://github.com/svenstaro/miniserve/pull/1261) (thanks @Lzzzzzt) - Add well-known healthcheck route at `/__miniserve_internal/healthcheck` (of `//__miniserve_internal/healthcheck` when using `--route-prefix`) +- Add asynchronous recursive directory size counting [#1482](https://github.com/svenstaro/miniserve/pull/1482) ## [0.29.0] - 2025-02-06 - Make URL encoding fully WHATWG-compliant [#1454](https://github.com/svenstaro/miniserve/pull/1454) (thanks @cyqsimon) diff --git a/Cargo.lock b/Cargo.lock index 0a96e96..e744911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,6 +463,46 @@ dependencies = [ "tempfile", ] +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.86" @@ -474,6 +514,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "async-walkdir" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37672978ae0febce7516ae0a85b53e6185159a9a28787391eb63fc44ec36037d" +dependencies = [ + "async-fs", + "futures-lite", + "thiserror 2.0.11", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -543,6 +594,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "6.0.0" @@ -759,6 +823,15 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1050,6 +1123,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fake-tty" version = "0.3.1" @@ -1193,6 +1287,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -2059,6 +2166,7 @@ dependencies = [ "anyhow", "assert_cmd", "assert_fs", + "async-walkdir", "bytesize", "chrono", "chrono-humanize", @@ -2246,6 +2354,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -2399,6 +2513,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.31" diff --git a/Cargo.toml b/Cargo.toml index 1525849..1115fef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ actix-web = { version = "4", features = ["macros", "compress-brotli", "compress- actix-web-httpauth = "0.8" alphanumeric-sort = "1" anyhow = "1" +async-walkdir = "2.1.0" bytesize = "2" chrono = "0.4" chrono-humanize = "0.2" @@ -74,7 +75,7 @@ assert_fs = "1" predicates = "3" pretty_assertions = "1.2" regex = "1" -reqwest = { version = "0.12", features = ["blocking", "multipart", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["blocking", "multipart", "json", "rustls-tls"], default-features = false } reqwest_dav = "0.1" rstest = "0.24" select = "0.6" diff --git a/data/style.scss b/data/style.scss index ea80754..44c3752 100644 --- a/data/style.scss +++ b/data/style.scss @@ -428,6 +428,25 @@ td.date-cell { justify-content: space-between; } +.loading-indicator { + width: 65%; + height: 15px; + margin-left: 35%; + animation: shimmer 2s infinite; + background: linear-gradient(to right, #e6e6e655 5%, #77777755 25%, #e6e6e655 35%); + background-size: 500px 100%; +} + +@keyframes shimmer { + from { + background-position: -500px 0; + } + + to { + background-position: 500px 0; + } +} + .history { color: var(--date_text_color); } diff --git a/src/args.rs b/src/args.rs index 6874e25..d078ef2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -367,7 +367,7 @@ pub struct CliArgs { #[arg(long, env = "MINISERVE_ENABLE_WEBDAV", conflicts_with = "no_symlinks")] pub enable_webdav: bool, - /// Show served file size in exact bytes. + /// Show served file size in exact bytes #[arg(long, default_value_t = SizeDisplay::Human, env = "MINISERVE_SIZE_DISPLAY")] pub size_display: SizeDisplay, } diff --git a/src/auth.rs b/src/auth.rs index fa28c4a..3bd9313 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpMessage, dev::ServiceRequest}; +use actix_web::{HttpMessage, dev::ServiceRequest, web}; use actix_web_httpauth::extractors::basic::BasicAuth; use sha2::{Digest, Sha256, Sha512}; @@ -77,7 +77,10 @@ pub async fn handle_auth( req: ServiceRequest, cred: BasicAuth, ) -> actix_web::Result { - let required_auth = &req.app_data::().unwrap().auth; + let required_auth = &req + .app_data::>() + .unwrap() + .auth; req.extensions_mut().insert(CurrentUser { name: cred.user_id().to_string(), diff --git a/src/config.rs b/src/config.rs index efec515..eecc45e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,7 +24,7 @@ const ROUTE_ALPHABET: [char; 16] = [ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', ]; -#[derive(Clone)] +#[derive(Debug, Clone)] /// Configuration of the Miniserve application pub struct MiniserveConfig { /// Enable verbose mode @@ -66,6 +66,9 @@ pub struct MiniserveConfig { /// Well-known healthcheck route (prefixed if route_prefix is provided) pub healthcheck_route: String, + /// Well-known API route (prefixed if route_prefix is provided) + pub api_route: String, + /// Well-known favicon route (prefixed if route_prefix is provided) pub favicon_route: String, @@ -206,15 +209,17 @@ impl MiniserveConfig { // If --random-route is enabled, in order to not leak the random generated route, we must not use it // as static files prefix. // Otherwise, we should apply route_prefix to static files. - let (healthcheck_route, favicon_route, css_route) = if args.random_route { + let (healthcheck_route, api_route, favicon_route, css_route) = if args.random_route { ( "/__miniserve_internal/healthcheck".into(), + "/__miniserve_internal/api".into(), "/__miniserve_internal/favicon.svg".into(), "/__miniserve_internal/style.css".into(), ) } else { ( format!("{}/{}", route_prefix, "__miniserve_internal/healthcheck"), + format!("{}/{}", route_prefix, "__miniserve_internal/api"), format!("{}/{}", route_prefix, "__miniserve_internal/favicon.svg"), format!("{}/{}", route_prefix, "__miniserve_internal/style.css"), ) @@ -305,6 +310,7 @@ impl MiniserveConfig { default_sorting_order: args.default_sorting_order, route_prefix, healthcheck_route, + api_route, favicon_route, css_route, default_color_scheme, diff --git a/src/errors.rs b/src/errors.rs index e35e8a8..3ac7da2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,6 +6,7 @@ use actix_web::{ dev::{ResponseHead, ServiceRequest, ServiceResponse}, http::{StatusCode, header}, middleware::Next, + web, }; use thiserror::Error; @@ -159,7 +160,7 @@ fn map_error_page(req: &HttpRequest, head: &mut ResponseHead, body: BoxBody) -> _ => return BoxBody::new(error_msg), }; - let conf = req.app_data::().unwrap(); + let conf = req.app_data::>().unwrap(); let return_address = req .headers() .get(header::REFERER) diff --git a/src/file_op.rs b/src/file_op.rs index 4319410..149cd2a 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -4,6 +4,7 @@ use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use actix_web::{HttpRequest, HttpResponse, http::header, web}; +use async_walkdir::{Filtering, WalkDir}; use futures::{StreamExt, TryStreamExt}; use log::{info, warn}; use serde::Deserialize; @@ -38,6 +39,37 @@ impl FileHash { } } +/// Get the recursively calculated dir size for a given dir +/// +/// Expects `dir` to be sanitized. This function doesn't do any sanitization itself. +pub async fn recursive_dir_size(dir: &Path) -> Result { + let mut entries = WalkDir::new(dir).filter(|entry| async move { + if let Ok(metadata) = entry.metadata().await { + if metadata.is_file() { + return Filtering::Continue; + } + } + Filtering::Ignore + }); + + let mut total_size = 0; + loop { + match entries.next().await { + Some(Ok(entry)) => { + if let Ok(metadata) = entry.metadata().await { + total_size += metadata.len(); + } + } + Some(Err(e)) => { + warn!("Error trying to read file when calculating dir size: {e}"); + return Err(RuntimeError::InvalidPathError(e.to_string())); + } + None => break, + } + } + Ok(total_size) +} + /// Saves file data from a multipart form field (`field`) to `file_path`. Optionally overwriting /// existing file and comparing the uploaded file checksum to the user provided `file_hash`. /// @@ -329,7 +361,7 @@ pub async fn upload_file( query: web::Query, payload: web::Payload, ) -> Result { - let conf = req.app_data::().unwrap(); + let conf = req.app_data::>().unwrap(); let upload_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| { RuntimeError::InvalidPathError("Invalid value for 'path' parameter".to_string()) })?; diff --git a/src/listing.rs b/src/listing.rs index 025ae86..6e50ba1 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -4,7 +4,7 @@ use std::path::{Component, Path}; use std::time::SystemTime; use actix_web::{ - HttpMessage, HttpRequest, HttpResponse, dev::ServiceResponse, http::Uri, web::Query, + HttpMessage, HttpRequest, HttpResponse, dev::ServiceResponse, http::Uri, web, web::Query, }; use bytesize::ByteSize; use clap::ValueEnum; @@ -51,7 +51,7 @@ pub struct ListingQueryParameters { } /// Available sorting methods -#[derive(Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] +#[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum SortingMethod { @@ -67,7 +67,7 @@ pub enum SortingMethod { } /// Available sorting orders -#[derive(Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] +#[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] pub enum SortingOrder { /// Ascending order #[serde(alias = "asc")] @@ -81,8 +81,9 @@ pub enum SortingOrder { Desc, } -#[derive(PartialEq, Eq)] /// Possible entry types +#[derive(PartialEq, Clone, Display, Eq)] +#[strum(serialize_all = "snake_case")] pub enum EntryType { /// Entry is a directory Directory, @@ -158,7 +159,10 @@ impl Breadcrumb { } pub async fn file_handler(req: HttpRequest) -> actix_web::Result { - let path = &req.app_data::().unwrap().path; + let path = &req + .app_data::>() + .unwrap() + .path; actix_files::NamedFile::open(path).map_err(Into::into) } @@ -171,7 +175,7 @@ pub fn directory_listing( let extensions = req.extensions(); let current_user: Option<&CurrentUser> = extensions.get::(); - let conf = req.app_data::().unwrap(); + let conf = req.app_data::>().unwrap(); if conf.disable_indexing { return Ok(ServiceResponse::new( req.clone(), diff --git a/src/main.rs b/src/main.rs index 856d22d..adda3f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use actix_web::{ }; use actix_web_httpauth::middleware::HttpAuthentication; use anyhow::Result; +use bytesize::ByteSize; use clap::{CommandFactory, Parser, crate_version}; use colored::*; use dav_server::{ @@ -21,7 +22,8 @@ use dav_server::{ actix::{DavRequest, DavResponse}, }; use fast_qr::QRBuilder; -use log::{error, warn}; +use log::{error, info, warn}; +use serde::Deserialize; mod archive; mod args; @@ -38,6 +40,7 @@ mod webdav_fs; use crate::config::MiniserveConfig; use crate::errors::{RuntimeError, StartupError}; +use crate::file_op::recursive_dir_size; use crate::webdav_fs::RestrictedFs; static STYLESHEET: &str = grass::include!("data/style.scss"); @@ -215,7 +218,7 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { let srv = actix_web::HttpServer::new(move || { App::new() .wrap(configure_header(&inside_config.clone())) - .app_data(inside_config.clone()) + .app_data(web::Data::new(inside_config.clone())) .app_data(stylesheet.clone()) .wrap(from_fn(errors::error_page_middleware)) .wrap(middleware::Logger::default()) @@ -224,6 +227,7 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { middleware::Compress::default(), )) .route(&inside_config.healthcheck_route, web::get().to(healthcheck)) + .route(&inside_config.api_route, web::post().to(api)) .route(&inside_config.favicon_route, web::get().to(favicon)) .route(&inside_config.css_route, web::get().to(css)) .service( @@ -353,7 +357,7 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { files = files.default_handler(fn_service(|req: ServiceRequest| async { let (req, _) = req.into_parts(); let conf = req - .app_data::() + .app_data::>() .expect("Could not get miniserve config"); let mut path_base = req.path()[1..].to_string(); if path_base.ends_with('/') { @@ -438,6 +442,41 @@ async fn healthcheck() -> impl Responder { HttpResponse::Ok().body("OK") } +#[derive(Deserialize, Debug)] +enum ApiCommand { + /// Request the size of a particular directory + DirSize(String), +} + +/// This "API" is pretty shitty but frankly miniserve doesn't really need a very fancy API. Or at +/// least I hope so. +async fn api( + command: web::Json, + config: web::Data, +) -> Result { + match command.into_inner() { + ApiCommand::DirSize(dir) => { + // Convert the relative dir to an absolute path on the system + let sanitized_path = + file_utils::sanitize_path(&dir, true).expect("Expected a path to directory"); + let full_path = config + .path + .canonicalize() + .expect("Couldn't canonicalize path") + .join(sanitized_path); + info!("Requested directory listing for {full_path:?}"); + + let dir_size = recursive_dir_size(&full_path).await?; + if config.show_exact_bytes { + Ok(format!("{dir_size} B")) + } else { + let dir_size = ByteSize::b(dir_size); + Ok(dir_size.to_string()) + } + } + } +} + async fn favicon() -> impl Responder { let logo = include_str!("../data/logo.svg"); HttpResponse::Ok() diff --git a/src/renderer.rs b/src/renderer.rs index a59f458..d6b01c8 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -52,7 +52,7 @@ pub fn page( html! { (DOCTYPE) html { - (page_header(&title_path, conf.file_upload, conf.web_upload_concurrency, &conf.favicon_route, &conf.css_route)) + (page_header(&title_path, conf.file_upload, conf.web_upload_concurrency, &conf.api_route, &conf.favicon_route, &conf.css_route)) body #drop-container { @@ -525,11 +525,12 @@ fn entry_row( show_exact_bytes: bool, ) -> Markup { html! { - tr { + @let entry_type = entry.entry_type.clone(); + tr .{ "entry-type-" (entry_type) } { td { p { @if entry.is_dir() { - @if let Some(symlink_dest) = entry.symlink_info { + @if let Some(ref symlink_dest) = entry.symlink_info { a.symlink href=(parametrized_link(&entry.link, sort_method, sort_order, raw)) { (entry.name) "/" span.symlink-symbol { } @@ -541,7 +542,7 @@ fn entry_row( } } } @else if entry.is_file() { - @if let Some(symlink_dest) = entry.symlink_info { + @if let Some(ref symlink_dest) = entry.symlink_info { a.symlink href=(&entry.link) { (entry.name) span.symlink-symbol { } @@ -624,6 +625,7 @@ fn page_header( title: &str, file_upload: bool, web_file_concurrency: usize, + api_route: &str, favicon_route: &str, css_route: &str, ) -> Markup { @@ -639,8 +641,8 @@ fn page_header( title { (title) } - (PreEscaped(r#" - - "#)) + "#)) + } + + script { + (format!("const API_ROUTE = '{api_route}';")) + (PreEscaped(r#" + let dirSizeCache = {}; + + // Query the directory size from the miniserve API + function fetchDirSize(dir) { + return fetch(API_ROUTE, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify({ + DirSize: dir + }) + }).then(resp => resp.text()) + } + + // Initialize shimmer effects for .size-cell elements in .entry-type-directory rows + // + // TODO: Perhaps it'd be better to statically do this during html generation in + // entry_row()? + function initializeLoadingIndicators() { + const directoryCells = document.querySelectorAll('tr.entry-type-directory .size-cell'); + + directoryCells.forEach(cell => { + // Add a loading indicator to each cell + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'loading-indicator'; + cell.appendChild(loadingIndicator); + }); + } + + function updateSizeCells() { + const directoryCells = document.querySelectorAll('tr.entry-type-directory .size-cell'); + + directoryCells.forEach(cell => { + // Get the dir from the sibling anchor tag. + const href = cell.parentNode.querySelector('a').href; + const target = new URL(href).pathname; + + // First check our local cache + if (target in dirSizeCache) { + cell.innerHTML = ''; + cell.textContent = dirSizeCache[target]; + } else { + fetchDirSize(target).then(dir_size => { + cell.innerHTML = ''; + cell.textContent = dir_size; + dirSizeCache[target] = dir_size; + }) + .catch(error => console.error("Error fetching dir size:", error)); + } + }) + } + setInterval(updateSizeCells, 1000); + window.addEventListener('load', initializeLoadingIndicators); + "#)) + } @if file_upload { script { @@ -1003,7 +1066,7 @@ pub fn render_error( html! { (DOCTYPE) html { - (page_header(&error_code.to_string(), false, conf.web_upload_concurrency, &conf.favicon_route, &conf.css_route)) + (page_header(&error_code.to_string(), false, conf.web_upload_concurrency, &conf.api_route, &conf.favicon_route, &conf.css_route)) body { diff --git a/tests/api.rs b/tests/api.rs new file mode 100644 index 0000000..32d6cef --- /dev/null +++ b/tests/api.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; + +use reqwest::{StatusCode, blocking::Client}; +use rstest::rstest; + +mod fixtures; + +use crate::fixtures::{DIRECTORIES, Error, TestServer, server}; + +#[rstest] +fn api_dir_size(server: TestServer) -> Result<(), Error> { + let mut command = HashMap::new(); + command.insert("DirSize", DIRECTORIES[0]); + + let resp = Client::new() + .post(server.url().join(&format!("__miniserve_internal/api"))?) + .json(&command) + .send()? + .error_for_status()?; + + assert_eq!(resp.status(), StatusCode::OK); + assert_ne!(resp.text()?, "0 B"); + + Ok(()) +} + +/// Test for path traversal vulnerability (CWE-22) in DirSize parameter. +#[rstest] +#[case("/tmp")] // Not CWE-22, but `foo` isn't a directory +#[case("/../foo")] +#[case("../foo")] +#[case("../tmp")] +#[case("/tmp")] +#[case("/foo")] +#[case("C:/foo")] +#[case(r"C:\foo")] +#[case(r"\foo")] +fn api_dir_size_prevent_path_transversal_attacks( + server: TestServer, + #[case] path: &str, +) -> Result<(), Error> { + let mut command = HashMap::new(); + command.insert("DirSize", path); + + let resp = Client::new() + .post(server.url().join(&format!("__miniserve_internal/api"))?) + .json(&command) + .send()?; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + Ok(()) +} diff --git a/tests/serve_request.rs b/tests/serve_request.rs index d9d4880..36bdbe5 100644 --- a/tests/serve_request.rs +++ b/tests/serve_request.rs @@ -86,6 +86,9 @@ fn serves_requests_with_non_default_port(server: TestServer) -> Result<(), Error #[case("__miniserve_internal/healthcheck", server(&["--random-route"]))] #[case("__miniserve_internal/favicon.svg", server(&["--random-route"]))] #[case("__miniserve_internal/style.css", server(&["--random-route"]))] +#[case("__miniserve_internal/healthcheck", server(&["--auth", "doesnt:matter"]))] +#[case("__miniserve_internal/favicon.svg", server(&["--auth", "doesnt:matter"]))] +#[case("__miniserve_internal/style.css", server(&["--auth", "doesnt:matter"]))] fn serves_requests_for_special_routes( #[case] route: &str, #[case] server: TestServer, -- cgit v1.2.3