diff --git a/Cargo.lock b/Cargo.lock index 69445361..2fe7b362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,29 @@ version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[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-compression" version = "0.4.21" @@ -128,6 +151,155 @@ dependencies = [ "zstd-safe", ] +[[package]] +name = "async-executor" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 0.38.44", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.4.0", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel 2.3.1", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.0", + "futures-lite", + "rustix 0.38.44", + "tracing", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.44", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-std" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-tar" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42f905d4f623faf634bbd1e001e84e0efc24694afa64be9ad239bf6ca49e1f8" +dependencies = [ + "async-std", + "filetime", + "libc", + "pin-project", + "redox_syscall 0.2.16", + "xattr", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -174,6 +346,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.0" @@ -189,6 +367,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 2.3.1", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -460,6 +651,15 @@ dependencies = [ "static_assertions", ] +[[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 = "const_format" version = "0.2.34" @@ -597,12 +797,51 @@ dependencies = [ "version_check", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.0", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "flate2" version = "1.1.0" @@ -643,6 +882,25 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +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" @@ -725,7 +983,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ - "bitflags", + "bitflags 2.9.0", "libc", "libgit2-sys", "log", @@ -752,6 +1010,18 @@ dependencies = [ "serde", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.26" @@ -813,6 +1083,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "http" version = "0.2.12" @@ -1100,6 +1376,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1124,6 +1409,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", + "redox_syscall 0.5.10", +] + [[package]] name = "libz-sys" version = "1.1.22" @@ -1136,6 +1432,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.9.3" @@ -1174,6 +1476,9 @@ name = "log" version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +dependencies = [ + "value-bag", +] [[package]] name = "maud" @@ -1309,6 +1614,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[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" @@ -1327,7 +1638,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.10", "smallvec", "windows-targets", ] @@ -1370,12 +1681,38 @@ 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.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 0.38.44", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1444,7 +1781,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags", + "bitflags 2.9.0", "memchr", "unicase", ] @@ -1464,13 +1801,22 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" dependencies = [ - "bitflags", + "bitflags 2.9.0", ] [[package]] @@ -1528,16 +1874,29 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" dependencies = [ - "bitflags", + "bitflags 2.9.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.9.3", "windows-sys 0.59.0", ] @@ -1816,6 +2175,7 @@ dependencies = [ "aho-corasick", "anyhow", "async-compression", + "async-tar", "bcrypt", "bytes", "chrono", @@ -1912,7 +2272,7 @@ dependencies = [ "fastrand", "getrandom 0.3.2", "once_cell", - "rustix", + "rustix 1.0.3", "windows-sys 0.59.0", ] @@ -2111,6 +2471,7 @@ checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -2317,6 +2678,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2389,6 +2756,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -2421,6 +2801,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "widestring" version = "1.2.0" @@ -2479,7 +2869,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" dependencies = [ - "bitflags", + "bitflags 2.9.0", "widestring", "windows-sys 0.52.0", ] @@ -2581,7 +2971,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.0", ] [[package]] @@ -2596,6 +2986,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index a03284d5..b0d4fede 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ doc = false [features] # All features enabled by default -default = ["compression", "http2", "directory-listing", "basic-auth", "fallback-page"] +default = ["compression", "http2", "directory-listing", "directory-listing-download", "basic-auth", "fallback-page"] # Include all features (used when building SWS binaries) all = ["default", "experimental"] # HTTP2 @@ -51,6 +51,8 @@ compression-gzip = ["async-compression/deflate"] compression-zstd = ["async-compression/zstd"] # Directory listing directory-listing = ["chrono", "maud"] +# Directory listing download +directory-listing-download = ["async-tar", "compression-gzip", "directory-listing"] # Basic HTTP Authorization basic-auth = ["bcrypt"] # Fallback Page @@ -63,6 +65,7 @@ experimental = ["tokio-metrics-collector", "prometheus", "compact_str", "mini-mo aho-corasick = "1.1" anyhow = "1.0" async-compression = { version = "0.4", default-features = false, optional = true, features = ["brotli", "deflate", "gzip", "zstd", "tokio"] } +async-tar = { version = "0.5", optional = true } bcrypt = { version = "0.17", optional = true } bytes = "1.10" chrono = { version = "0.4", default-features = false, features = ["std", "clock"], optional = true } @@ -92,7 +95,7 @@ serde_repr = "0.1" shadow-rs = "1.1.1" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"] } tokio-rustls = { version = "0.26", optional = true, default-features = false, features = ["logging", "tls12", "ring"] } -tokio-util = { version = "0.7", default-features = false, features = ["io"] } +tokio-util = { version = "0.7", default-features = false, features = ["compat", "io"] } toml = "0.8" tracing = { version = "0.1", default-features = false, features = ["std"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["smallvec", "registry", "parking_lot", "fmt", "ansi", "tracing-log"] } diff --git a/docs/content/configuration/command-line-arguments.md b/docs/content/configuration/command-line-arguments.md index eaf93fd3..4abc4443 100644 --- a/docs/content/configuration/command-line-arguments.md +++ b/docs/content/configuration/command-line-arguments.md @@ -72,6 +72,8 @@ Options: Specify a default code number to order directory listing entries per `Name`, `Last modified` or `Size` attributes (columns). Code numbers supported: 0 (Name asc), 1 (Name desc), 2 (Last modified asc), 3 (Last modified desc), 4 (Size asc), 5 (Size desc). Default 6 (unordered) [env: SERVER_DIRECTORY_LISTING_ORDER=] [default: 6] --directory-listing-format Specify a content format for directory listing entries. Formats supported: "html" or "json". Default "html" [env: SERVER_DIRECTORY_LISTING_FORMAT=] [default: html] [possible values: html, json] + --directory-listing-download= + Specify list of enabled format(s) for directory download. Format supported: `targz`. Default to empty list (disabled) [env: SERVER_DIRECTORY_LISTING_DOWNLOAD=] [possible values: targz] --security-headers [] Enable security headers by default when HTTP/2 feature is activated. Headers included: "Strict-Transport-Security: max-age=63072000; includeSubDomains; preload" (2 years max-age), "X-Frame-Options: DENY" and "Content-Security-Policy: frame-ancestors 'self'" [env: SERVER_SECURITY_HEADERS=] [default: false] [possible values: true, false] -e, --cache-control-headers [] diff --git a/docs/content/configuration/config-file.md b/docs/content/configuration/config-file.md index d6058962..0cc698cb 100644 --- a/docs/content/configuration/config-file.md +++ b/docs/content/configuration/config-file.md @@ -57,6 +57,9 @@ directory-listing-order = 1 #### Directory listing content format directory-listing-format = "html" +#### Directory listing download format +directory-listing-download = [] + #### Basic Authentication # basic-auth = "" diff --git a/docs/content/configuration/environment-variables.md b/docs/content/configuration/environment-variables.md index da643fbd..154009ed 100644 --- a/docs/content/configuration/environment-variables.md +++ b/docs/content/configuration/environment-variables.md @@ -107,6 +107,9 @@ Specify a default code number to order directory listing entries per `Name`, `La ### SERVER_DIRECTORY_LISTING_FORMAT Specify a content format for the directory listing entries. Formats supported: `html` or `json`. Default `html`. +### SERVER_DIRECTORY_LISTING_DOWNLOAD +Specify list of enabled format(s) for directory download. Format supported: `targz`. Default to empty list (disabled). + ### SERVER_SECURITY_HEADERS Enable security headers by default when the HTTP/2 feature is activated. Headers included: `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` (2 years max-age), `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'self'`. Default `false` (disabled). diff --git a/docs/content/features/directory-listing.md b/docs/content/features/directory-listing.md index 3f82f907..533282c5 100644 --- a/docs/content/features/directory-listing.md +++ b/docs/content/features/directory-listing.md @@ -126,3 +126,15 @@ curl -iH "content-type: application/json" http://localhost:8787 # [{"name":"spécial directöry","type":"directory","mtime":"2022-10-07T00:53:50Z"},{"name":"index.html.gz","type":"file","mtime":"2022-09-27T22:44:34Z","size":332}]⏎ ``` + +## Directory Download +**`SWS`** supports downloading the content of a directory as a single file when **Directory Listing** feature is enabled. To activate, specify the list of download format to enable using the `--directory-listing-download` flag or the equivalent [SERVER_DIRECTORY_LISTING_DOWNLOAD](./../configuration/environment-variables.md#server_directory_listing_download) env. Currently, `targz` format is supported. + +```sh +static-web-server \ + --directory-listing=true \ + --directory-listing-download=targz +``` + +When **Directory Download** is enabled, append `?download` to a directory URL to download it. A link will also be added to the top part of **HTML** output format. + diff --git a/src/directory_listing.rs b/src/directory_listing.rs index d2f6d489..02d4cef1 100644 --- a/src/directory_listing.rs +++ b/src/directory_listing.rs @@ -17,6 +17,9 @@ use std::ffi::{OsStr, OsString}; use std::io; use std::path::Path; +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::{DirDownloadFmt, DOWNLOAD_PARAM_KEY}; + use crate::{handler::RequestHandlerOpts, http_ext::MethodExt, Context, Result}; /// Non-alphanumeric characters to be percent-encoded @@ -52,6 +55,9 @@ pub struct DirListOpts<'a> { pub dir_listing_order: u8, /// Directory listing format. pub dir_listing_format: &'a DirListFmt, + #[cfg(feature = "directory-listing-download")] + /// Directory listing download. + pub dir_listing_download: &'a [DirDownloadFmt], /// Ignore hidden files (dotfiles). pub ignore_hidden_files: bool, /// Prevent following symlinks for files and directories. @@ -96,6 +102,8 @@ pub fn auto_index(opts: DirListOpts<'_>) -> Result, StatusCode> { content_format: opts.dir_listing_format, ignore_hidden_files: opts.ignore_hidden_files, disable_symlinks: opts.disable_symlinks, + #[cfg(feature = "directory-listing-download")] + download: opts.dir_listing_download, }; match read_dir_entries(dir_opts) { Ok(resp) => Ok(resp), @@ -184,6 +192,8 @@ struct DirEntryOpts<'a> { content_format: &'a DirListFmt, ignore_hidden_files: bool, disable_symlinks: bool, + #[cfg(feature = "directory-listing-download")] + download: &'a [DirDownloadFmt], } /// It reads a list of directory entries and create an index page content. @@ -337,6 +347,8 @@ fn read_dir_entries(mut opt: DirEntryOpts<'_>) -> Result> { files_count, &mut file_entries, opt.order_code, + #[cfg(feature = "directory-listing-download")] + opt.download, ) } }; @@ -388,12 +400,25 @@ fn html_auto_index<'a>( files_count: usize, entries: &'a mut [FileEntry], order_code: u8, + #[cfg(feature = "directory-listing-download")] download: &'a [DirDownloadFmt], ) -> String { use maud::{html, DOCTYPE}; let sort_attrs = sort_file_entries(entries, order_code); let current_path = percent_decode_str(base_path).decode_utf8_lossy(); + #[cfg(feature = "directory-listing-download")] + let download_directory_elem = match download.is_empty() { + true => html! {}, + false => html! { + ", " a href={ "?" (DOWNLOAD_PARAM_KEY) } { + "download tar.gz" + } + }, + }; + #[cfg(not(feature = "directory-listing-download"))] + let download_directory_elem = html! {}; + html! { (DOCTYPE) html { @@ -413,7 +438,7 @@ fn html_auto_index<'a>( } p { small { - "directories: " (dirs_count) ", files: " (files_count) + "directories: " (dirs_count) ", files: " (files_count) (download_directory_elem) } } hr; diff --git a/src/directory_listing_download.rs b/src/directory_listing_download.rs new file mode 100644 index 00000000..cfe25545 --- /dev/null +++ b/src/directory_listing_download.rs @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana + +//! Compress content of a directory into a tarball +//! + +use async_compression::tokio::write::GzipEncoder; +use async_tar::Builder; +use bytes::BytesMut; +use clap::ValueEnum; +use headers::{ContentType, HeaderMapExt}; +use http::{HeaderValue, Method, Response}; +use hyper::{body::Sender, Body}; +use mime_guess::Mime; +use std::fmt::Display; +use std::path::Path; +use std::path::PathBuf; +use std::str::FromStr; +use std::task::Poll::{Pending, Ready}; +use tokio::fs; +use tokio::io; +use tokio::io::AsyncWriteExt; +use tokio_util::compat::TokioAsyncWriteCompatExt; + +use crate::handler::RequestHandlerOpts; +use crate::http_ext::MethodExt; +use crate::Result; + +/// query parameter key to download directory as tar.gz +pub const DOWNLOAD_PARAM_KEY: &str = "download"; + +/// Download format for directory +#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum, Eq, Hash, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum DirDownloadFmt { + /// Gunzip-compressed tarball (.tar.gz) + Targz, +} + +impl Display for DirDownloadFmt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +/// Directory download options. +pub struct DirDownloadOpts<'a> { + /// Request method. + pub method: &'a Method, + /// Prevent following symlinks for files and directories. + pub disable_symlinks: bool, + /// Ignore hidden files (dotfiles). + pub ignore_hidden_files: bool, +} + +/// Initializes directory listing download +pub fn init(formats: &Vec, handler_opts: &mut RequestHandlerOpts) { + for fmt in formats { + // Use naive implementation since the list is not expected to be long + if !handler_opts.dir_listing_download.contains(fmt) { + tracing::info!("directory listing download: enabled format {}", &fmt); + handler_opts.dir_listing_download.push(fmt.to_owned()); + } + } + tracing::info!( + "directory listing download: enabled={}", + !handler_opts.dir_listing_download.is_empty() + ); +} + +/// impl AsyncWrite for hyper::Body::Sender +pub struct ChannelBuffer { + s: Sender, +} + +impl tokio::io::AsyncWrite for ChannelBuffer { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let this = self.get_mut(); + let b = BytesMut::from(buf); + match this.s.poll_ready(cx) { + Ready(r) => match r { + Ok(()) => match this.s.try_send_data(b.freeze()) { + Ok(_) => Ready(Ok(buf.len())), + Err(_) => Pending, + }, + Err(e) => Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, e))), + }, + Pending => Pending, + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +async fn archive( + path: PathBuf, + src_path: PathBuf, + cb: ChannelBuffer, + follow_symlinks: bool, + ignore_hidden: bool, +) -> Result { + let gz = GzipEncoder::with_quality(cb, async_compression::Level::Default); + let mut a = Builder::new(gz.compat_write()); + a.follow_symlinks(follow_symlinks); + + // NOTE: Since it is not possible to handle error gracefully, we will + // just stop writing when error occurs. It is also not possible to call + // sender.abort() as it is protected behind the Builder to ensure + // finish() is successfully called. + + // adapted from async_tar::Builder::append_dir_all + let mut stack = vec![(src_path.to_path_buf(), true, false)]; + while let Some((src, is_dir, is_symlink)) = stack.pop() { + let dest = path.join(src.strip_prefix(&src_path)?); + + // In case of a symlink pointing to a directory, is_dir is false, but src.is_dir() will return true + if is_dir || (is_symlink && follow_symlinks && src.is_dir()) { + let mut entries = fs::read_dir(&src).await?; + while let Some(entry) = entries.next_entry().await? { + // Check and ignore the current hidden file/directory (dotfile) if feature enabled + let name = entry.file_name(); + if ignore_hidden && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') { + continue; + } + + let file_type = entry.file_type().await?; + stack.push((entry.path(), file_type.is_dir(), file_type.is_symlink())); + } + if dest != Path::new("") { + a.append_dir(&dest, &src).await?; + } + } else { + // use append_path_with_name to handle symlink + a.append_path_with_name(src, &dest).await?; + } + } + + a.finish().await?; + // this is required to emit gzip CRC trailer + a.into_inner().await?.into_inner().shutdown().await?; + + Ok(()) +} + +/// Reply with archived directory content in compressed tarball format. +/// The content from `src_path` on server filesystem will be stored to `path` +/// within the tarball. +/// An async task will be spawned to asynchronously write compressed data to the +/// response body. +pub fn archive_reply(path: P, src_path: Q, opts: DirDownloadOpts<'_>) -> Response +where + P: AsRef, + Q: AsRef, +{ + let archive_name = path.as_ref().with_extension("tar.gz"); + let mut resp = Response::new(Body::empty()); + + resp.headers_mut().typed_insert(ContentType::from( + // since this satisfies the required format: `*/*`, it should not fail + Mime::from_str("application/gzip").unwrap(), + )); + let hvals = format!( + "attachment; filename=\"{}\"", + archive_name.to_string_lossy() + ); + match HeaderValue::from_str(hvals.as_str()) { + Ok(hval) => { + resp.headers_mut() + .insert(hyper::header::CONTENT_DISPOSITION, hval); + } + Err(err) => { + // not fatal, most browser is able to handle the download since + // content-type is set + tracing::error!("can't make content disposition from {}: {:?}", hvals, err); + } + } + + // We skip the body for HEAD requests + if opts.method.is_head() { + return resp; + } + + let (tx, body) = Body::channel(); + tokio::task::spawn(archive( + path.as_ref().into(), + src_path.as_ref().into(), + ChannelBuffer { s: tx }, + !opts.disable_symlinks, + opts.ignore_hidden_files, + )); + *resp.body_mut() = body; + + resp +} diff --git a/src/handler.rs b/src/handler.rs index 24aade74..2992b85e 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -47,6 +47,9 @@ use crate::{ #[cfg(feature = "directory-listing")] use crate::directory_listing::DirListFmt; +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::DirDownloadFmt; + /// It defines options for a request handler. pub struct RequestHandlerOpts { // General options @@ -80,6 +83,10 @@ pub struct RequestHandlerOpts { #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))] /// Directory listing format feature. pub dir_listing_format: DirListFmt, + /// Directory listing download feature. + #[cfg(feature = "directory-listing-download")] + #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))] + pub dir_listing_download: Vec, /// CORS feature. pub cors: Option, /// Security headers feature. @@ -150,6 +157,8 @@ impl Default for RequestHandlerOpts { dir_listing_order: 6, // unordered #[cfg(feature = "directory-listing")] dir_listing_format: DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: Vec::new(), cors: None, #[cfg(feature = "experimental")] memory_cache: None, @@ -200,6 +209,8 @@ impl RequestHandler { let dir_listing_order = self.opts.dir_listing_order; #[cfg(feature = "directory-listing")] let dir_listing_format = &self.opts.dir_listing_format; + #[cfg(feature = "directory-listing-download")] + let dir_listing_download = &self.opts.dir_listing_download; let redirect_trailing_slash = self.opts.redirect_trailing_slash; let compression_static = self.opts.compression_static; let ignore_hidden_files = self.opts.ignore_hidden_files; @@ -286,6 +297,8 @@ impl RequestHandler { dir_listing_order, #[cfg(feature = "directory-listing")] dir_listing_format, + #[cfg(feature = "directory-listing-download")] + dir_listing_download, redirect_trailing_slash, compression_static, ignore_hidden_files, diff --git a/src/lib.rs b/src/lib.rs index 921e1218..3cc8f13e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -156,6 +156,9 @@ pub mod custom_headers; #[cfg(feature = "directory-listing")] #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))] pub mod directory_listing; +#[cfg(feature = "directory-listing-download")] +#[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))] +pub mod directory_listing_download; pub mod error_page; #[cfg(feature = "fallback-page")] #[cfg_attr(docsrs, doc(cfg(feature = "fallback-page")))] diff --git a/src/server.rs b/src/server.rs index f65fec43..599c105c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -29,6 +29,10 @@ use { #[cfg(feature = "directory-listing")] use crate::directory_listing; + +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download; + #[cfg(feature = "fallback-page")] use crate::fallback_page; @@ -290,6 +294,10 @@ impl Server { &mut handler_opts, ); + // Directory listing download options + #[cfg(feature = "directory-listing-download")] + directory_listing_download::init(&general.directory_listing_download, &mut handler_opts); + // Fallback page option #[cfg(feature = "fallback-page")] fallback_page::init(&general.page_fallback, &mut handler_opts); diff --git a/src/settings/cli.rs b/src/settings/cli.rs index 1e311075..b2b7f6fe 100644 --- a/src/settings/cli.rs +++ b/src/settings/cli.rs @@ -11,6 +11,10 @@ use std::{net::IpAddr, path::PathBuf}; #[cfg(feature = "directory-listing")] use crate::directory_listing::DirListFmt; + +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::DirDownloadFmt; + use crate::Result; /// General server configuration available in CLI and config file options. @@ -361,6 +365,23 @@ pub struct General { /// Specify a content format for directory listing entries. Formats supported: "html" or "json". Default "html". pub directory_listing_format: DirListFmt, + #[cfg(feature = "directory-listing-download")] + #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))] + #[arg( + long, + value_delimiter(','), + value_enum, + requires_ifs([ + ("targz", "directory_listing"), + ]), + require_equals(true), + action = clap::ArgAction::Set, + env = "SERVER_DIRECTORY_LISTING_DOWNLOAD", + ignore_case(true) + )] + /// Specify list of enabled format(s) for directory download. Format supported: `targz`. Default to empty list (disabled). + pub directory_listing_download: Vec, + #[arg( long, default_value = "false", diff --git a/src/settings/file.rs b/src/settings/file.rs index 276dd85b..75d70d72 100644 --- a/src/settings/file.rs +++ b/src/settings/file.rs @@ -15,6 +15,9 @@ use std::{collections::BTreeSet, path::PathBuf}; #[cfg(feature = "directory-listing")] use crate::directory_listing::DirListFmt; +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::DirDownloadFmt; + use crate::{helpers, Context, Result}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -334,6 +337,11 @@ pub struct General { #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))] pub directory_listing_format: Option, + /// Directory listing download feature. + #[cfg(feature = "directory-listing-download")] + #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))] + pub directory_listing_download: Option>, + /// Basic Authentication feature. #[cfg(feature = "basic-auth")] #[cfg_attr(docsrs, doc(cfg(feature = "basic-auth")))] diff --git a/src/settings/mod.rs b/src/settings/mod.rs index b178b5ab..b6b8c3e8 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -186,6 +186,9 @@ impl Settings { #[cfg(feature = "directory-listing")] let mut directory_listing_format = opts.directory_listing_format; + #[cfg(feature = "directory-listing-download")] + let mut directory_listing_download = opts.directory_listing_download; + #[cfg(feature = "basic-auth")] let mut basic_auth = opts.basic_auth; @@ -347,6 +350,10 @@ impl Settings { if let Some(v) = general.directory_listing_format { directory_listing_format = v } + #[cfg(feature = "directory-listing-download")] + if let Some(v) = general.directory_listing_download { + directory_listing_download = v + } #[cfg(feature = "basic-auth")] if let Some(ref v) = general.basic_auth { v.clone_into(&mut basic_auth) @@ -663,6 +670,8 @@ impl Settings { directory_listing_order, #[cfg(feature = "directory-listing")] directory_listing_format, + #[cfg(feature = "directory-listing-download")] + directory_listing_download, #[cfg(feature = "basic-auth")] basic_auth, fd, diff --git a/src/static_files.rs b/src/static_files.rs index f4ff425f..9aa7b449 100644 --- a/src/static_files.rs +++ b/src/static_files.rs @@ -40,6 +40,11 @@ use crate::{ directory_listing::{DirListFmt, DirListOpts}, }; +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::{ + archive_reply, DirDownloadFmt, DirDownloadOpts, DOWNLOAD_PARAM_KEY, +}; + const DEFAULT_INDEX_FILES: &[&str; 1] = &["index.html"]; /// Defines all options needed by the static-files handler. @@ -71,6 +76,10 @@ pub struct HandleOpts<'a> { #[cfg(feature = "directory-listing")] #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))] pub dir_listing_format: &'a DirListFmt, + /// Directory listing download feature. + #[cfg(feature = "directory-listing-download")] + #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))] + pub dir_listing_download: &'a [DirDownloadFmt], /// Redirect trailing slash feature. pub redirect_trailing_slash: bool, /// Compression static feature. @@ -185,6 +194,40 @@ pub async fn handle(opts: &HandleOpts<'_>) -> Result) -> Result { + let mut res = result.resp; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8"); + + let body = hyper::body::to_bytes(res.body_mut()) + .await + .expect("unexpected bytes error during `body` conversion"); + let body_str = std::str::from_utf8(&body).unwrap(); + + if method == Method::GET { + assert!(body_str.contains("download tar.gz")) + } else { + assert!(body_str.is_empty()); + } + } + Err(status) => { + assert!(method != Method::GET && method != Method::HEAD); + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + } + } + } + } + + #[cfg(feature = "directory-listing-download")] + #[tokio::test] + async fn dir_listing_has_no_download_link_when_disabled() { + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &HeaderMap::new(), + base_path: &root_dir("tests/fixtures/public"), + uri_path: "/", + uri_query: None, + #[cfg(feature = "experimental")] + memory_cache: None, + dir_listing: true, + dir_listing_order: 1, + dir_listing_format: &DirListFmt::Html, + redirect_trailing_slash: true, + compression_static: false, + ignore_hidden_files: true, + disable_symlinks: false, + index_files: &[], + dir_listing_download: &[], + }) + .await + { + Ok(result) => { + let mut res = result.resp; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8"); + + let body = hyper::body::to_bytes(res.body_mut()) + .await + .expect("unexpected bytes error during `body` conversion"); + let body_str = std::str::from_utf8(&body).unwrap(); + + if method == Method::GET { + assert!(!body_str.contains("download tar.gz")) + } else { + assert!(body_str.is_empty()); + } + } + Err(status) => { + assert!(method != Method::GET && method != Method::HEAD); + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + } + } + } + } } diff --git a/tests/dir_listing_download.rs b/tests/dir_listing_download.rs new file mode 100644 index 00000000..9ec12f09 --- /dev/null +++ b/tests/dir_listing_download.rs @@ -0,0 +1,361 @@ +#![forbid(unsafe_code)] +#![deny(warnings)] +#![deny(rust_2018_idioms)] +#![deny(dead_code)] + +#[cfg(feature = "directory-listing-download")] +#[cfg(test)] +mod tests { + use async_compression::tokio::bufread::GzipDecoder; + use async_tar::Archive; + use futures_util::StreamExt; + use headers::HeaderMap; + use http::{Method, StatusCode}; + use std::{ + collections::HashSet, + path::{Path, PathBuf}, + pin::Pin, + }; + use tokio::{fs, io::AsyncReadExt}; + use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; + + use static_web_server::{ + directory_listing::DirListFmt, + directory_listing_download::DirDownloadOpts, + static_files::{self, HandleOpts}, + }; + + use static_web_server::directory_listing_download::{DirDownloadFmt, DOWNLOAD_PARAM_KEY}; + + const METHODS: [Method; 8] = [ + Method::CONNECT, + Method::DELETE, + Method::GET, + Method::HEAD, + Method::PATCH, + Method::POST, + Method::PUT, + Method::TRACE, + ]; + + fn root_dir>(dir: P) -> PathBuf + where + PathBuf: From

, + { + PathBuf::from(dir) + } + + async fn inspect_tarball_content( + prefix: PathBuf, + body: &[u8], + validate: bool, + ) -> HashSet { + let reader = Archive::new(GzipDecoder::new(body).compat()); + + let mut content = HashSet::new(); + // adapted from async_tar::Archive::unpack + let mut entries = reader.entries().unwrap(); + let mut pinned = Pin::new(&mut entries); + while let Some(entry) = pinned.next().await { + let file = entry.unwrap(); + let path: PathBuf = file.header().path().unwrap().to_path_buf().into(); + + // validate content + if validate + && (file.header().entry_type() == async_tar::EntryType::Link + || file.header().entry_type() == async_tar::EntryType::Regular + || file.header().entry_type() == async_tar::EntryType::Symlink) + { + let on_disk_path = prefix.join(&path); + // in case of symlink, skip dir + let meta = std::fs::metadata(&on_disk_path).unwrap(); + if !meta.is_dir() { + let on_disk = std::fs::read(&on_disk_path).unwrap(); + let mut compressed = Vec::new(); + file.compat().read_to_end(&mut compressed).await.unwrap(); + assert_eq!(on_disk, compressed); + } + } + + content.insert(path); + } + content + } + + async fn get_dir_content( + path: PathBuf, + src_path: PathBuf, + opts: DirDownloadOpts<'_>, + ) -> HashSet { + let mut content = HashSet::new(); + let mut stack = vec![(src_path.to_path_buf(), true, false)]; + + while let Some((src, is_dir, is_symlink)) = stack.pop() { + let dest = path.join(src.strip_prefix(&src_path).unwrap()); + + // In case of a symlink pointing to a directory, is_dir is false, but src.is_dir() will return true + if is_dir || (is_symlink && !opts.disable_symlinks && src.is_dir()) { + let mut entries = fs::read_dir(&src).await.unwrap(); + while let Some(entry) = entries.next_entry().await.unwrap() { + // Check and ignore the current hidden file/directory (dotfile) if feature enabled + let name = entry.file_name(); + if opts.ignore_hidden_files + && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') + { + continue; + } + + let file_type = entry.file_type().await.unwrap(); + stack.push((entry.path(), file_type.is_dir(), file_type.is_symlink())); + } + if dest != Path::new("") { + content.insert(dest); + } + } else { + content.insert(dest); + } + } + + content + } + + #[tokio::test] + async fn dir_listing_download_targz() { + let base_path = root_dir("tests/fixtures/public"); + let disable_symlinks = false; + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &HeaderMap::new(), + base_path: &base_path, + uri_path: "/", + uri_query: Some(DOWNLOAD_PARAM_KEY), + #[cfg(feature = "experimental")] + memory_cache: None, + dir_listing: true, + dir_listing_order: 1, + dir_listing_format: &DirListFmt::Html, + redirect_trailing_slash: true, + compression_static: false, + ignore_hidden_files: false, + disable_symlinks, + index_files: &[], + dir_listing_download: &vec![DirDownloadFmt::Targz], + }) + .await + { + Ok(result) => { + let mut res = result.resp; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "application/gzip"); + assert!(res.headers()["content-disposition"] + .to_str() + .unwrap() + .starts_with("attachment")); + + let body = hyper::body::to_bytes(res.body_mut()) + .await + .expect("unexpected bytes error during `body` conversion"); + + if method == Method::GET { + let mut prefix = base_path.clone(); + prefix.pop(); + let left = inspect_tarball_content(prefix, &body, true).await; + let right = get_dir_content( + PathBuf::from(base_path.file_name().unwrap()), + base_path.clone(), + DirDownloadOpts { + method: &method, + disable_symlinks, + ignore_hidden_files: false, + }, + ) + .await; + + if left != right { + eprintln!("left - right {:?}", (left.difference(&right))); + eprintln!("right - left {:?}", (right.difference(&left))); + } + + assert_eq!(left, right); + } else { + assert!(body.len() == 0); + } + } + Err(status) => { + assert!(method != Method::GET && method != Method::HEAD); + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + } + } + } + } + + #[tokio::test] + async fn dir_listing_download_targz_no_hidden() { + let base_path = root_dir("tests/fixtures/public"); + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &HeaderMap::new(), + base_path: &base_path, + uri_path: "/", + uri_query: Some(DOWNLOAD_PARAM_KEY), + #[cfg(feature = "experimental")] + memory_cache: None, + dir_listing: true, + dir_listing_order: 1, + dir_listing_format: &DirListFmt::Html, + redirect_trailing_slash: true, + compression_static: false, + ignore_hidden_files: true, + disable_symlinks: false, + index_files: &[], + dir_listing_download: &vec![DirDownloadFmt::Targz], + }) + .await + { + Ok(result) => { + let mut res = result.resp; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "application/gzip"); + assert!(res.headers()["content-disposition"] + .to_str() + .unwrap() + .starts_with("attachment")); + + let body = hyper::body::to_bytes(res.body_mut()) + .await + .expect("unexpected bytes error during `body` conversion"); + + if method == Method::GET { + let mut prefix = base_path.clone(); + prefix.pop(); + assert!(inspect_tarball_content(prefix, &body, false) + .await + .iter() + .find(|path| path.file_name().unwrap() == ".dotfile") + .is_none()); + } else { + assert!(body.len() == 0); + } + } + Err(status) => { + assert!(method != Method::GET && method != Method::HEAD); + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + } + } + } + } + + #[tokio::test] + async fn dir_listing_download_targz_no_symlinks() { + let base_path = root_dir("tests/fixtures/public"); + let disable_symlinks = true; + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &HeaderMap::new(), + base_path: &base_path, + uri_path: "/", + uri_query: Some(DOWNLOAD_PARAM_KEY), + #[cfg(feature = "experimental")] + memory_cache: None, + dir_listing: true, + dir_listing_order: 1, + dir_listing_format: &DirListFmt::Html, + redirect_trailing_slash: true, + compression_static: false, + ignore_hidden_files: false, + disable_symlinks, + index_files: &[], + dir_listing_download: &vec![DirDownloadFmt::Targz], + }) + .await + { + Ok(result) => { + let mut res = result.resp; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "application/gzip"); + assert!(res.headers()["content-disposition"] + .to_str() + .unwrap() + .starts_with("attachment")); + + let body = hyper::body::to_bytes(res.body_mut()) + .await + .expect("unexpected bytes error during `body` conversion"); + + if method == Method::GET { + let mut prefix = base_path.clone(); + prefix.pop(); + let left = inspect_tarball_content(prefix, &body, false).await; + let right = get_dir_content( + PathBuf::from(base_path.file_name().unwrap()), + base_path.clone(), + DirDownloadOpts { + method: &method, + disable_symlinks, + ignore_hidden_files: false, + }, + ) + .await; + + if left != right { + eprintln!("left - right {:?}", (left.difference(&right))); + eprintln!("right - left {:?}", (right.difference(&left))); + } + + assert_eq!(left, right); + } else { + assert!(body.len() == 0); + } + } + Err(status) => { + assert!(method != Method::GET && method != Method::HEAD); + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + } + } + } + } + + #[tokio::test] + async fn dir_listing_download_when_disabled() { + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &HeaderMap::new(), + base_path: &root_dir("tests/fixtures/public"), + uri_path: "/", + uri_query: Some(DOWNLOAD_PARAM_KEY), + #[cfg(feature = "experimental")] + memory_cache: None, + dir_listing: true, + dir_listing_order: 1, + dir_listing_format: &DirListFmt::Html, + redirect_trailing_slash: true, + compression_static: false, + ignore_hidden_files: false, + disable_symlinks: false, + index_files: &[], + dir_listing_download: &vec![], + }) + .await + { + Ok(result) => { + let res = result.resp; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8"); + assert!(res + .headers() + .iter() + .find(|(k, _v)| *k == "content-disposition") + .is_none()); + } + Err(status) => { + assert!(method != Method::GET && method != Method::HEAD); + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); + } + } + } + } +} diff --git a/tests/static_files.rs b/tests/static_files.rs index 74f4d9ad..efdf5945 100644 --- a/tests/static_files.rs +++ b/tests/static_files.rs @@ -55,6 +55,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -101,6 +103,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -148,6 +152,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -182,6 +188,8 @@ mod tests { dir_listing_order: 0, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -218,6 +226,8 @@ mod tests { dir_listing_order: 0, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -253,6 +263,8 @@ mod tests { dir_listing_order: 0, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: false, compression_static: false, ignore_hidden_files: false, @@ -293,6 +305,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -348,6 +362,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -385,6 +401,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -424,6 +442,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -464,6 +484,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -507,6 +529,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -548,6 +572,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -587,6 +613,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -625,6 +653,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -667,6 +697,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -752,6 +784,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -825,6 +859,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -877,6 +913,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -929,6 +967,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -982,6 +1022,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1027,6 +1069,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1082,6 +1126,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1134,6 +1180,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1186,6 +1234,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1241,6 +1291,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1286,6 +1338,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1330,6 +1384,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1389,6 +1445,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: false, ignore_hidden_files: false, @@ -1440,6 +1498,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: true, ignore_hidden_files: true, @@ -1482,6 +1542,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: true, ignore_hidden_files: true, @@ -1526,6 +1588,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: true, ignore_hidden_files: true, @@ -1560,6 +1624,8 @@ mod tests { dir_listing_order: 6, #[cfg(feature = "directory-listing")] dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], redirect_trailing_slash: true, compression_static: true, ignore_hidden_files: true,