Repository: svenstaro/miniserve Branch: master Commit: 0d70b98ca15b Files: 65 Total size: 375.0 KB Directory structure: gitextract_74o55m93/ ├── .cargo/ │ └── config.toml ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── build-release.yml │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── Containerfile ├── Containerfile.alpine ├── LICENSE ├── Makefile ├── README.md ├── data/ │ ├── style.scss │ └── themes/ │ ├── archlinux.scss │ ├── ayu_dark.scss │ ├── monokai.scss │ ├── squirrel.scss │ └── zenburn.scss ├── packaging/ │ └── miniserve@.service ├── release.toml ├── rustfmt.toml ├── src/ │ ├── archive.rs │ ├── args.rs │ ├── auth.rs │ ├── config.rs │ ├── consts.rs │ ├── errors.rs │ ├── file_op.rs │ ├── file_utils.rs │ ├── listing.rs │ ├── main.rs │ ├── pipe.rs │ ├── renderer.rs │ └── webdav_fs.rs └── tests/ ├── api.rs ├── archive.rs ├── auth.rs ├── auth_file.rs ├── bind.rs ├── cli.rs ├── create_directories.rs ├── data/ │ ├── auth1.txt │ ├── cert.pem │ ├── cert_ec.pem │ ├── cert_rsa.pem │ ├── generate_tls_certs.sh │ ├── key_ec.pem │ ├── key_pkcs1.pem │ └── key_pkcs8.pem ├── fixtures/ │ └── mod.rs ├── header.rs ├── navigation.rs ├── paste.rs ├── qrcode.rs ├── raw.rs ├── readme.rs ├── rm_files.rs ├── serve_request.rs ├── tls.rs ├── upload_files.rs ├── utils/ │ └── mod.rs └── webdav.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [target.x86_64-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] [target.i686-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] ================================================ FILE: .dockerignore ================================================ target ================================================ FILE: .editorconfig ================================================ [*.rs] indent_style = space indent_size = 4 ================================================ FILE: .github/FUNDING.yml ================================================ github: svenstaro ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: cargo directory: "/" schedule: interval: monthly groups: all-dependencies: patterns: - "*" ================================================ FILE: .github/workflows/build-release.yml ================================================ name: Build/publish release on: [push, pull_request] jobs: publish: name: Binary ${{ matrix.target }} (on ${{ matrix.os }}) runs-on: ${{ matrix.os }} outputs: version: ${{ steps.extract_version.outputs.version }} strategy: matrix: include: - os: ubuntu-latest target: x86_64-unknown-linux-musl compress: true cargo_flags: "" - os: ubuntu-latest target: x86_64-unknown-linux-gnu compress: true cargo_flags: "" - os: ubuntu-latest target: aarch64-unknown-linux-musl compress: true cargo_flags: "" - os: ubuntu-latest target: aarch64-unknown-linux-gnu compress: true cargo_flags: "" - os: ubuntu-latest target: armv7-unknown-linux-musleabihf compress: true cargo_flags: "" - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf compress: true cargo_flags: "" - os: ubuntu-latest target: arm-unknown-linux-musleabihf compress: true cargo_flags: "" - os: ubuntu-latest target: riscv64gc-unknown-linux-gnu compress: false cargo_flags: "--no-default-features" - os: windows-latest target: x86_64-pc-windows-msvc compress: true cargo_flags: "" - os: windows-latest target: i686-pc-windows-msvc compress: true cargo_flags: "" - os: macos-latest target: x86_64-apple-darwin compress: false cargo_flags: "" - os: macos-latest target: aarch64-apple-darwin compress: false cargo_flags: "" - os: ubuntu-latest target: x86_64-unknown-freebsd compress: false cargo_flags: "" - os: ubuntu-latest target: x86_64-unknown-illumos compress: false cargo_flags: "" steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable - run: sudo apt install musl-tools if: startsWith(matrix.os, 'ubuntu') - name: cargo build uses: houseabsolute/actions-rust-cross@v0 with: command: build args: --release --locked ${{ matrix.cargo_flags }} target: ${{ matrix.target }} - name: Set exe extension for Windows run: echo "EXE=.exe" >> $env:GITHUB_ENV if: startsWith(matrix.os, 'windows') - name: Compress binaries uses: svenstaro/upx-action@v2 with: files: target/${{ matrix.target }}/release/miniserve${{ env.EXE }} args: --best --lzma strip: false # We're stripping already in Cargo.toml if: ${{ matrix.compress }} - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.target }} path: target/${{ matrix.target }}/release/miniserve${{ env.EXE }} - name: Get version from tag id: extract_version run: | echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" shell: bash - name: Install CHANGELOG parser uses: taiki-e/install-action@parse-changelog - name: Get CHANGELOG entry run: parse-changelog CHANGELOG.md ${{ steps.extract_version.outputs.version }} | tee changelog_entry if: startsWith(github.ref_name, 'v') && github.ref_type == 'tag' shell: bash - name: Read changelog entry from file id: changelog_entry uses: juliangruber/read-file-action@v1 with: path: ./changelog_entry if: startsWith(github.ref_name, 'v') && github.ref_type == 'tag' - name: Release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: target/${{ matrix.target }}/release/miniserve${{ env.EXE }} tag: ${{ github.ref_name }} asset_name: miniserve-${{ steps.extract_version.outputs.version }}-${{ matrix.target }}${{ env.EXE }} body: ${{ steps.changelog_entry.outputs.content }} if: startsWith(github.ref_name, 'v') && github.ref_type == 'tag' container-images: name: Publish images runs-on: ubuntu-latest needs: publish # Run for tags and pushes to the default branch if: (startsWith(github.ref_name, 'v') && github.ref_type == 'tag') || github.event.repository.default_branch == github.ref_name steps: - name: Checkout code uses: actions/checkout@v5 - name: Download artifact aarch64-unknown-linux-gnu uses: actions/download-artifact@v4 with: name: aarch64-unknown-linux-gnu path: target/aarch64-unknown-linux-gnu/release - name: Download artifact x86_64-unknown-linux-gnu uses: actions/download-artifact@v4 with: name: x86_64-unknown-linux-gnu path: target/x86_64-unknown-linux-gnu/release - name: Download artifact armv7-unknown-linux-gnueabihf uses: actions/download-artifact@v4 with: name: armv7-unknown-linux-gnueabihf path: target/armv7-unknown-linux-gnueabihf/release - name: Download artifact aarch64-unknown-linux-musl uses: actions/download-artifact@v4 with: name: aarch64-unknown-linux-musl path: target/aarch64-unknown-linux-musl/release - name: Download artifact x86_64-unknown-linux-musl uses: actions/download-artifact@v4 with: name: x86_64-unknown-linux-musl path: target/x86_64-unknown-linux-musl/release - name: Download artifact armv7-unknown-linux-musleabihf uses: actions/download-artifact@v4 with: name: armv7-unknown-linux-musleabihf path: target/armv7-unknown-linux-musleabihf/release - name: podman login run: podman login --username ${{ secrets.DOCKERHUB_USERNAME }} --password ${{ secrets.DOCKERHUB_TOKEN }} docker.io - name: podman build linux/arm64 run: podman build --format docker --platform linux/arm64/v8 --manifest miniserve -f Containerfile target/aarch64-unknown-linux-gnu/release - name: podman build linux/amd64 run: podman build --format docker --platform linux/amd64 --manifest miniserve -f Containerfile target/x86_64-unknown-linux-gnu/release - name: podman build linux/arm run: podman build --format docker --platform linux/arm/v7 --manifest miniserve -f Containerfile target/armv7-unknown-linux-gnueabihf/release - name: podman manifest push latest run: podman manifest push miniserve docker.io/svenstaro/miniserve:latest - name: podman manifest push tag version run: podman manifest push miniserve docker.io/svenstaro/miniserve:${{ needs.publish.outputs.version }} if: startsWith(github.ref_name, 'v') - name: podman build linux/arm64 (alpine edition) run: podman build --format docker --platform linux/arm64/v8 --manifest miniserve-alpine -f Containerfile.alpine target/aarch64-unknown-linux-musl/release - name: podman build linux/amd64 (alpine edition) run: podman build --format docker --platform linux/amd64 --manifest miniserve-alpine -f Containerfile.alpine target/x86_64-unknown-linux-musl/release - name: podman build linux/arm (alpine edition) run: podman build --format docker --platform linux/arm/v7 --manifest miniserve-alpine -f Containerfile.alpine target/armv7-unknown-linux-musleabihf/release - name: podman manifest push latest (alpine edition) run: podman manifest push miniserve-alpine docker.io/svenstaro/miniserve:alpine - name: podman manifest push tag version (alpine edition) run: podman manifest push miniserve-alpine docker.io/svenstaro/miniserve:${{ needs.publish.outputs.version }}-alpine if: startsWith(github.ref_name, 'v') ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] jobs: ci: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - name: cargo build run: cargo build - name: cargo test run: cargo test -- --test-threads 1 - name: cargo fmt run: cargo fmt --all -- --check - name: cargo clippy run: cargo clippy -- -D warnings ================================================ FILE: .gitignore ================================================ # Generated by Cargo # will have compiled files and executables /target/ # These are backup files generated by rustfmt **/*.rs.bk # Editor-specific ignores .idea/ ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - ReleaseDate - Add Ayu Dark theme [#1551](https://github.com/svenstaro/miniserve/pull/1551) (thanks @rysb-dev) ## [0.33.0] - 2026-02-16 - Add `--log-color` to explicitly control when to print colors [#1529](https://github.com/svenstaro/miniserve/pull/1529) (thanks @MrCroxx) - Fix `--rm-files` not working with `--route-preifx` [#1531](https://github.com/svenstaro/miniserve/pull/1531) (thanks @Jianchi-Chen) - Add illumos builds [#1543](https://github.com/svenstaro/miniserve/pull/1543) - Ignore failure on file hash calculation during upload [#1542](https://github.com/svenstaro/miniserve/pull/1542) (thanks @outloudvi) - Add `--quiet` flag to reduce output and silence warnings [#1539](https://github.com/svenstaro/miniserve/pull/1539) (thanks @joshleeb) - Enable some markdown rendering extensions [#1545](https://github.com/svenstaro/miniserve/pull/1545) (thanks @duskmoon314) - Add `--pastebin` flag to enable pastebin form [#1546](https://github.com/svenstaro/miniserve/pull/1546) (thanks @rktjmp) - Always use forward slashes in zip files [#1534](https://github.com/svenstaro/miniserve/pull/1534) (thanks @pzhlkj6612) ## [0.32.0] - 2025-09-17 - Skip hash calculation when crypto.subtle is not available [#1507](https://github.com/svenstaro/miniserve/pull/1507) (thanks @outloudvi) - Fix hostname:port getting repeated in wget footer if TLS is used [#1515](https://github.com/svenstaro/miniserve/pull/1515) (thanks @xtay573269555) - Add `--chmod` to set file mode after upload on unix [#1506](https://github.com/svenstaro/miniserve/pull/1506) (thanks @lilydjwg) - Add `--rm-files` to allow file deletion [#1518](https://github.com/svenstaro/miniserve/pull/1518) (thanks @NOBLESSE and @cyqsimon) ## [0.31.0] - 2025-06-27 - Fix filtering symlinks when hosting WebDAV [#1502](https://github.com/svenstaro/miniserve/pull/1502) (thanks @ahti) - Enable renaming during file upload if duplicate exists [#1453](https://github.com/svenstaro/miniserve/pull/1453) (thanks @Atreyagaurav) ## [0.30.0] - 2025-06-26 - Add `--file-external-url` to generate links pointing to another server [#1492](https://github.com/svenstaro/miniserve/pull/1492) (thanks @jankeymeulen) - Add date pill and sort links for mobile views [#1473](https://github.com/svenstaro/miniserve/pull/1473) (thanks @Flat) - 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) - Add link to miniserve GitHub page to footer - Add `--directory-size` flag to enable directory size counting - Fix --no-symlinks not filtering files and dirs nested in symlinks [#1495](https://github.com/svenstaro/miniserve/pull/1495) (thanks @ahti) ## [0.29.0] - 2025-02-06 - Make URL encoding fully WHATWG-compliant [#1454](https://github.com/svenstaro/miniserve/pull/1454) (thanks @cyqsimon) - Fix `OVERWRITE_FILES` env var not being prefixed by `MINISERVE_` [#1457](https://github.com/svenstaro/miniserve/issues/1457) - Change `font-weight` of regular files to be `normal` to improve readability [#1471](https://github.com/svenstaro/miniserve/pull/1471) (thanks @shaicoleman) - Add webdav support [#1415](https://github.com/svenstaro/miniserve/pull/1415) (thanks @ahti) - Move favicon and css to stable, non-random routes [#1472](https://github.com/svenstaro/miniserve/pull/1472) (thanks @ahti) ## [0.28.0] - 2024-09-12 - Fix wrapping text in mobile view when the file name too long [#1379](https://github.com/svenstaro/miniserve/pull/1379) (thanks @chaibiq) - Fix missing drag-form when dragging file in to browser [#1390](https://github.com/svenstaro/miniserve/pull/1390) (thanks @chaibiq) - Improve documentation for the --header parameter [#1389](https://github.com/svenstaro/miniserve/pull/1389) (thanks @orwithout) - Don't show mkdir option when the directory is not upload allowed [#1442](https://github.com/svenstaro/miniserve/pull/1442) (thanks @Atreyagaurav) ## [0.27.1] - 2024-03-16 - Add `Add file and folder symbols` [#1365](https://github.com/svenstaro/miniserve/pull/1365) (thanks @chaibiq) ## [0.27.0] - 2024-03-16 - Add `-C/--compress-response` to enable response compression [1315](https://github.com/svenstaro/miniserve/pull/1315) (thanks @zuisong) - Refactor errors [#1331](https://github.com/svenstaro/miniserve/pull/1331) (thanks @cyqsimon) - Add `-I/--disable-inexing` [#1329](https://github.com/svenstaro/miniserve/pull/1329) (thanks @dyc3) ## [0.26.0] - 2024-01-13 - Properly handle read-only errors on Windows [#1310](https://github.com/svenstaro/miniserve/pull/1310) (thanks @ViRb3) - Use `tokio::fs` instead of `std::fs` to enable async file operations [#445](https://github.com/svenstaro/miniserve/issues/445) - Add `-S`/`--default-sorting-method` and `-O`/`--default-sorting-order` flags [#1308](https://github.com/svenstaro/miniserve/pull/1308) (thanks @ElliottLandsborough) ## [0.25.0] - 2024-01-07 - Add `--pretty-urls` [#1193](https://github.com/svenstaro/miniserve/pull/1193) (thanks @nlopes) - Fix single quote display with `--show-wget-footer` [#1191](https://github.com/svenstaro/miniserve/pull/1191) (thanks @d-air1) - Remove header Content-Encoding when archiving [#1290](https://github.com/svenstaro/miniserve/pull/1290) (thanks @5long) - Prevent illegal request path from crashing program [#1285](https://github.com/svenstaro/miniserve/pull/1285) (thanks @cyqsimon) - Fixed issue where serving files with a newline would fail [#1294](https://github.com/svenstaro/miniserve/issues/1294) ## [0.24.0] - 2023-07-06 - Fix ANSI color codes are printed when not a tty [#1095](https://github.com/svenstaro/miniserve/pull/1095) - Allow parameters to be provided via environment variables [#1160](https://github.com/svenstaro/miniserve/pull/1160) ## [0.23.2] - 2023-04-28 - Build Windows build with static CRT [#1107](https://github.com/svenstaro/miniserve/pull/1107) ## [0.23.1] - 2023-04-17 - Add EC key support [#1080](https://github.com/svenstaro/miniserve/issues/1080) ## [0.23.0] - 2023-03-01 - Update to clap v4 - Show localized datetime [#949](https://github.com/svenstaro/miniserve/pull/949) (thanks @IvkinStanislav) - Fix sorting breaks subdir downloading [#991](https://github.com/svenstaro/miniserve/pull/991) (thanks @Vam-Jam) - Fix wget footer [#1043](https://github.com/svenstaro/miniserve/pull/1043) (thanks @Yusuto) ## [0.22.0] - 2022-09-20 - Faster QR code generation [#848](https://github.com/svenstaro/miniserve/pull/848) (thanks @cyqsimon) - Make `--readme` support not only `README.md` but also `README` and `README.txt` rendered as plaintext [#911](https://github.com/svenstaro/miniserve/pull/911) (thanks @Atreyagaurav) - Change `-u/--upload-files` slightly in the sense that it can now either be provided by itself as before or receive a file path to restrict uploading to only that path. Can be provided multiple times for multiple allowed paths [#858](https://github.com/svenstaro/miniserve/pull/858) (thanks @jonasdiemer) ## [0.21.0] - 2022-09-15 - Fix bug where static files would be served incorrectly when using `--random-route` [#835](https://github.com/svenstaro/miniserve/pull/835) (thanks @solarknight) - Add `--readme` to render the README in the current directory after the file listing [#860](https://github.com/svenstaro/miniserve/pull/860) (thanks @Atreyagaurav) - Add more architectures (and also additional container images) ## [0.20.0] - 2022-06-26 - Fixed security issue where it was possible to upload files to locations pointed to by symlinks even when symlinks were disabled [#781](https://github.com/svenstaro/miniserve/pull/781) (thanks @sheepy0125) - Added `--hide-theme-selector` flag to hide the theme selector functionality in the frontend [#805](https://github.com/svenstaro/miniserve/pull/805https://github.com/svenstaro/miniserve/pull/805) (thanks @flamingoodev) - Added `--mkdir` flag to allow for uploading directories [#781](https://github.com/svenstaro/miniserve/pull/781) (thanks @sheepy0125) ## [0.19.5] - 2022-05-18 - Fix security issue where `--no-symlinks` would only hide symlinks from listing but it would still be possible to follow them if the path was known ## [0.19.4] - 2022-04-02 - Fix random route leaking on error pages [#764](https://github.com/svenstaro/miniserve/pull/764) (thanks @steffhip) ## [0.19.3] - 2022-03-15 - Allow to set the accept input attribute to arbitrary values using `-m` and `-M` [#755](https://github.com/svenstaro/miniserve/pull/755) (thanks @mayjs) ## [0.19.2] - 2022-02-21 - Add man page support via `--print-manpage` [#738](https://github.com/svenstaro/miniserve/pull/738) ## [0.19.1] - 2022-02-16 - Better MIME type guessing support due to updated mime_guess ## [0.19.0] - 2022-02-06 - Fix panic when using TLS in some instances [#670](https://github.com/svenstaro/miniserve/issues/670) (thanks @aliemjay) - Add `--route-prefix` to add a fixed route prefix [#728](https://github.com/svenstaro/miniserve/pull/728) (thanks @aliemjay and @Jikstra) - Allow tapping the whole row in mobile view [#729](https://github.com/svenstaro/miniserve/pull/729) ## [0.18.0] - 2021-10-26 - Add raw mode and raw mode footer display [#508](https://github.com/svenstaro/miniserve/pull/508) (thanks @Jikstra) - Add SPA mode [#515](https://github.com/svenstaro/miniserve/pull/515) (thanks @sinking-point) ## [0.17.0] - 2021-09-04 - Print QR codes on terminal [#524](https://github.com/svenstaro/miniserve/pull/524) (thanks @aliemjay) - Fix mobile layout info pills taking whole width [#591](https://github.com/svenstaro/miniserve/issues/591) - Fix security exploit when uploading is enabled [#590](https://github.com/svenstaro/miniserve/pull/590) [#518](https://github.com/svenstaro/miniserve/issues/518) (thanks @aliemjay) - Fix uploading to symlink directories [#590](https://github.com/svenstaro/miniserve/pull/590) [#466](https://github.com/svenstaro/miniserve/issues/466) (thanks @aliemjay) ## [0.16.0] - 2021-08-31 - Fix serving files with backslashes in their names [#578](https://github.com/svenstaro/miniserve/pull/578) (thanks @Jikstra) - Fix behavior of downloading symlinks by upgrading to actix-web 4 [#582](https://github.com/svenstaro/miniserve/pull/582) [#462](https://github.com/svenstaro/miniserve/issues/462) (thanks @aliemjay) - List directory if index file not found [#583](https://github.com/svenstaro/miniserve/pull/583) [#275](https://github.com/svenstaro/miniserve/pull/583) (thanks @aliemjay) - Add special colors for visited links [#521](https://github.com/svenstaro/miniserve/pull/521) (thanks @raffomania) - Switch from structopt to clap v3 [#587](https://github.com/svenstaro/miniserve/pull/587) This enables slightly nicer help output as well as much better completions. - Fix network interface handling [#500](https://github.com/svenstaro/miniserve/pull/500) [#470](https://github.com/svenstaro/miniserve/issues/470) [#405](https://github.com/svenstaro/miniserve/issues/405) [#422](https://github.com/svenstaro/miniserve/issues/422) (thanks @aliemjay) - Implement show symlink destination [#542](https://github.com/svenstaro/miniserve/pull/542) [#499](https://github.com/svenstaro/miniserve/issues/499) (thanks @deantvv) - Fix error page not being correctly themed [#529](https://github.com/svenstaro/miniserve/pull/529) [#588](https://github.com/svenstaro/miniserve/issues/588) (@aliemjay) ## [0.15.0] - 2021-08-27 - Add hardened systemd template unit file to `packaging/miniserve@.service` - Fix qrcodegen dependency problem [#568](https://github.com/svenstaro/miniserve/issues/568) - Remove animation on QR code hover (it was kind of annoying as it makes things less snappy) - Add TLS support [#576](https://github.com/svenstaro/miniserve/pull/576) ## [0.14.0] - 2021-04-18 - Fix breadcrumbs for right-to-left languages [#489](https://github.com/svenstaro/miniserve/pull/489) (thanks @aliemjay) - Fix URL percent encoding for special characters [#485](https://github.com/svenstaro/miniserve/pull/485) (thanks @aliemjay) - Wrap breadcrumbs at any char [#496](https://github.com/svenstaro/miniserve/pull/496) (thanks @aliemjay) - Add separate flags for compressed and uncompressed tar archives [#492](https://github.com/svenstaro/miniserve/pull/492) (thanks @deantvv) - Bump deps - Fix Firefox becoming confused when opening a `.gz` file directly [#160](https://github.com/svenstaro/miniserve/issues/160) - Prefer UTF8 for text responses [#263](https://github.com/svenstaro/miniserve/issues/263) - Resolve symlinks on directory listing [#479](https://github.com/svenstaro/miniserve/pull/479) (thanks @aliemjay) ## [0.13.0] - 2021-03-28 - Change default log level to `Warn` - Change some messages a bit to be more clear - Add `--print-completions` to print shell completions for various supported shells [#482](https://github.com/svenstaro/miniserve/pull/482) (thanks @rouge8) - Don't print some messages if not attached to an interactive terminal - Refuse to start if not attached to interactive terminal and no explicit path is provided This is a security consideration as you wouldn't want to run miniserve without an explicit path as a service. You could end up serving `/` or `/root` in case those working directories are set. ## [0.12.1] - 2021-03-27 - Fix QR code not showing when using both `--random-route` and `--qrcode` [#480](https://github.com/svenstaro/miniserve/pull/480) (thanks @rouge8) - Add FreeBSD binaries ## [0.12.0] - 2021-03-20 - Add option `-H`/`--hidden` to show hidden files - Start instantly in case an explicit index is chosen - Fix DoS issue when deliberately sending unconforming URL paths - Add footer [#456](https://github.com/svenstaro/miniserve/pull/456) (thanks @levaitamas) - Switched from failure to thiserror for error handling ## [0.11.0] - 2021-02-28 - Add binaries for more architectures - Upgrade lockfile which fixes some security issues - Allow multiple file upload [#434](https://github.com/svenstaro/miniserve/pull/434) (thanks @mhuesch) - Allow for setting custom headers via `--header` [#452](https://github.com/svenstaro/miniserve/pull/452) (thanks @deantvv) ## [0.10.4] - 2021-01-05 - Add `--dirs-first`/`-D` option to list directories first [#423](https://github.com/svenstaro/miniserve/pull/423) (thanks @levaitamas) ## [0.10.3] - 2020-11-09 - Actually fix publish workflow ## [0.10.2] - 2020-11-09 - Fix publish workflow ## [0.10.1] - 2020-11-09 - Now compiles on stable! :D ## [0.10.0] - 2020-10-02 - Add embedded favicon [#364](https://github.com/svenstaro/miniserve/issues/364) - Add `--title` option which can be used to set the page title [#378](https://github.com/svenstaro/miniserve/pull/378) (thanks @ahti) - Default title is now the same host received in the request [#378](https://github.com/svenstaro/miniserve/pull/378) (thanks @ahti) - Client-side color-scheme handling [#380](https://github.com/svenstaro/miniserve/pull/380) (thanks @ahti) ## [0.9.0] - 2020-09-16 - Added prebuilt binaries for AARCH64, ARMv7, and ARM [#350](https://github.com/svenstaro/miniserve/pull/350) - Remove percent-encoding in heading and title [#362](https://github.com/svenstaro/miniserve/pull/362) (thanks @ahti) - Make name ordering case-insensitive [#362](https://github.com/svenstaro/miniserve/pull/362) (thanks @ahti) - Give name column more space [#362](https://github.com/svenstaro/miniserve/pull/362) (thanks @ahti) - Fix double-escaping [#354](https://github.com/svenstaro/miniserve/issues/354) - Upgrade to actix-web 3.0 - Fix time display for files created "now" [#373](https://github.com/svenstaro/miniserve/pull/373) (thanks @imp and @KevCui) ## [0.8.0] - 2020-07-22 - Accept port 0 to find a random free port and use that [#327](https://github.com/svenstaro/miniserve/pull/327) (thanks @parrotmac) - Show QR code in interface [#330](https://github.com/svenstaro/miniserve/pull/330) (thanks @wyhaya) - Ported to actix-web 2 and futures 0.3 [#343](https://github.com/svenstaro/miniserve/pull/343) (thanks @equal-l2) ## [0.7.0] - 2020-05-14 - Add zip archiving [#297](https://github.com/svenstaro/miniserve/pull/297) (thanks @marawan31) ## [0.6.0] - 2020-03-14 - Add option to disable archives [#235](https://github.com/svenstaro/miniserve/pull/235) (thanks @DamianX) - Fix minor bug when using `--random-route` [#219](https://github.com/svenstaro/miniserve/pull/219) - Add a default index serving option [#189](https://github.com/svenstaro/miniserve/pull/189) ## [0.5.0] - 2019-06-24 - Add streaming download of tar archives (thanks @gyscos) - Add support for hashed passwords (thanks @KSXGitHub) - Add support for multiple auth flags (thanks @KSXGitHub) - Some theme related bug fixes (thanks @boastful-squirrel) [Unreleased]: https://github.com/svenstaro/miniserve/compare/v0.33.0...HEAD [0.33.0]: https://github.com/svenstaro/miniserve/compare/v0.32.0...v0.33.0 [0.32.0]: https://github.com/svenstaro/miniserve/compare/v0.31.0...v0.32.0 [0.31.0]: https://github.com/svenstaro/miniserve/compare/v0.30.0...v0.31.0 [0.30.0]: https://github.com/svenstaro/miniserve/compare/v0.29.0...v0.30.0 [0.29.0]: https://github.com/svenstaro/miniserve/compare/v0.28.0...v0.29.0 [0.28.0]: https://github.com/svenstaro/miniserve/compare/v0.27.1...v0.28.0 [0.27.1]: https://github.com/svenstaro/miniserve/compare/v0.27.0...v0.27.1 [0.27.0]: https://github.com/svenstaro/miniserve/compare/v0.26.0...v0.27.0 [0.26.0]: https://github.com/svenstaro/miniserve/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/svenstaro/miniserve/compare/v0.24.0...v0.25.0 [0.24.0]: https://github.com/svenstaro/miniserve/compare/v0.23.2...v0.24.0 [0.23.2]: https://github.com/svenstaro/miniserve/compare/v0.23.1...v0.23.2 [0.23.1]: https://github.com/svenstaro/miniserve/compare/v0.23.0...v0.23.1 [0.23.0]: https://github.com/svenstaro/miniserve/compare/v0.22.0...v0.23.0 [0.22.0]: https://github.com/svenstaro/miniserve/compare/v0.21.0...v0.22.0 [0.21.0]: https://github.com/svenstaro/miniserve/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/svenstaro/miniserve/compare/v0.19.5...v0.20.0 [0.19.5]: https://github.com/svenstaro/miniserve/compare/v0.19.4...v0.19.5 [0.19.4]: https://github.com/svenstaro/miniserve/compare/v0.19.3...v0.19.4 [0.19.3]: https://github.com/svenstaro/miniserve/compare/v0.19.2...v0.19.3 [0.19.2]: https://github.com/svenstaro/miniserve/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/svenstaro/miniserve/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/svenstaro/miniserve/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/svenstaro/miniserve/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/svenstaro/miniserve/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/svenstaro/miniserve/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/svenstaro/miniserve/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/svenstaro/miniserve/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/svenstaro/miniserve/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/svenstaro/miniserve/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/svenstaro/miniserve/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/svenstaro/miniserve/compare/v0.10.4...v0.11.0 [0.10.4]: https://github.com/svenstaro/miniserve/compare/v0.10.3...v0.10.4 [0.10.3]: https://github.com/svenstaro/miniserve/compare/v0.10.2...v0.10.3 [0.10.2]: https://github.com/svenstaro/miniserve/compare/v0.10.1...v0.10.2 [0.10.1]: https://github.com/svenstaro/miniserve/compare/v0.10.0...v0.10.1 [0.10.0]: https://github.com/svenstaro/miniserve/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/svenstaro/miniserve/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/svenstaro/miniserve/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/svenstaro/miniserve/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/svenstaro/miniserve/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/svenstaro/miniserve/compare/v0.4.0...v0.5.0 ================================================ FILE: Cargo.toml ================================================ [package] name = "miniserve" version = "0.33.0" description = "For when you really just want to serve some files over HTTP right now!" authors = ["Sven-Hendrik Haase ", "Boastful Squirrel "] repository = "https://github.com/svenstaro/miniserve" license = "MIT" readme = "README.md" keywords = ["serve", "http-server", "static-files", "http", "server"] categories = ["command-line-utilities", "network-programming", "web-programming::http-server"] edition = "2024" [profile.release] codegen-units = 1 lto = true opt-level = 'z' panic = 'abort' strip = true [dependencies] actix-files = "0.6.9" actix-multipart = "0.7" actix-web = { version = "4", features = ["macros", "compress-brotli", "compress-gzip", "compress-zstd"], default-features = false } actix-web-httpauth = "0.8" alphanumeric-sort = "1" anyhow = "1" async-walkdir = "2.1.0" bytesize = "2" chrono = "0.4" chrono-humanize = "0.2" clap = { version = "4", features = ["derive", "cargo", "wrap_help", "deprecated", "env"] } clap_complete = "4" clap_mangen = "0.2" colored = "3" comrak = { version = "0.50", default-features = false } dav-server = { version = "0.11", features = ["actix-compat"] } fast_qr = { version = "0.13", features = ["svg"] } futures = "0.3" grass = { version = "0.13", features = ["macro"], default-features = false } hex = "0.4" httparse = "1" if-addrs = "0.15" libflate = "2" log = "0.4" maud = "0.27" mime = "0.3" nanoid = "0.4" percent-encoding = "2" port_check = "0.3" regex = "1" rustix = { version = "1.1.4", features = ["process", "fs"] } rustls = { version = "0.23", features = ["ring"], optional = true, default-features = false } rustls-pemfile = { version = "2", optional = true } serde = { version = "1", features = ["derive"] } sha2 = "0.10" simplelog = "0.12" socket2 = "0.6" strum = { version = "0.28", features = ["derive"] } tar = "0.4" tempfile = "3.26.0" thiserror = "2" tokio = { version = "1.47.1", features = ["fs", "macros"] } zip = { version = "8", default-features = false } [features] default = ["tls"] # This feature allows us to use rustls only on architectures supported by ring. # See also https://github.com/briansmith/ring/issues/1182 # and https://github.com/briansmith/ring/issues/562 # and https://github.com/briansmith/ring/issues/1367 tls = ["rustls", "rustls-pemfile", "actix-web/rustls-0_23"] [dev-dependencies] assert_cmd = "2" assert_fs = "1" predicates = "3" pretty_assertions = "1.2" regex = "1" reqwest = { version = "0.13", features = ["blocking", "multipart", "json", "rustls-no-provider"], default-features = false } reqwest_dav = { version = "0.3", default-features = false } rstest = "0.26" select = "0.6" url = "2" [target.'cfg(not(windows))'.dev-dependencies] # fake_tty does not support Windows for now fake-tty = "0.3.1" ================================================ FILE: Containerfile ================================================ FROM debian:testing-slim COPY --chmod=755 miniserve /app/ ENTRYPOINT ["/app/miniserve"] ================================================ FILE: Containerfile.alpine ================================================ FROM docker.io/alpine COPY --chmod=755 miniserve /app/ ENTRYPOINT ["/app/miniserve"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Sven-Hendrik Haase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: local local: cargo build --release .PHONY: run run: ifndef ARGS @echo Run "make run" with ARGS set to pass arguments… endif cargo run --release -- $(ARGS) .PHONY: build-linux build-linux: cargo build --target x86_64-unknown-linux-musl --release --locked strip target/x86_64-unknown-linux-musl/release/miniserve upx --lzma target/x86_64-unknown-linux-musl/release/miniserve .PHONY: build-win build-win: RUSTFLAGS="-C linker=x86_64-w64-mingw32-gcc" cargo build --target x86_64-pc-windows-gnu --release --locked strip target/x86_64-pc-windows-gnu/release/miniserve.exe upx --lzma target/x86_64-pc-windows-gnu/release/miniserve.exe .PHONY: build-apple build-apple: cargo build --target x86_64-apple-darwin --release --locked strip target/x86_64-apple-darwin/release/miniserve upx --lzma target/x86_64-apple-darwin/release/miniserve ================================================ FILE: README.md ================================================

miniserve - a CLI tool to serve files and dirs over HTTP

# miniserve - a CLI tool to serve files and dirs over HTTP [![CI](https://github.com/svenstaro/miniserve/workflows/CI/badge.svg)](https://github.com/svenstaro/miniserve/actions) [![Docker Hub](https://img.shields.io/docker/pulls/svenstaro/miniserve)](https://cloud.docker.com/repository/docker/svenstaro/miniserve/) [![Crates.io](https://img.shields.io/crates/v/miniserve.svg)](https://crates.io/crates/miniserve) [![license](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/svenstaro/miniserve/blob/master/LICENSE) [![Stars](https://img.shields.io/github/stars/svenstaro/miniserve.svg)](https://github.com/svenstaro/miniserve/stargazers) [![Downloads](https://img.shields.io/github/downloads/svenstaro/miniserve/total.svg)](https://github.com/svenstaro/miniserve/releases) [![Lines of Code](https://tokei.rs/b1/github/svenstaro/miniserve)](https://github.com/svenstaro/miniserve) **For when you really just want to serve some files over HTTP right now!** **miniserve** is a small, self-contained cross-platform CLI tool that allows you to just grab the binary and serve some file(s) via HTTP. Sometimes this is just a more practical and quick way than doing things properly. ## Screenshot ![Screenshot](screenshot.png) ## How to use ### Serve a directory: miniserve linux-distro-collection/ ### Serve a single file: miniserve linux-distro.iso ### Set a custom index file to serve instead of a file listing: miniserve --index test.html ### Serve an SPA (Single Page Application) so that non-existent paths are forwarded to the SPA's router instead miniserve --spa --index index.html ### Require username/password: miniserve --auth joe:123 unreleased-linux-distros/ ### Require username/password as hash: pw=$(echo -n "123" | sha256sum | cut -f 1 -d ' ') miniserve --auth joe:sha256:$pw unreleased-linux-distros/ ### Require username/password from file (separate logins with new lines): miniserve --auth-file auth.txt unreleased-linux-distros/ ### Generate random 6-hexdigit URL: miniserve -i 192.168.0.1 --random-route /tmp # Serving path /private/tmp at http://192.168.0.1/c789b6 ### Bind to multiple interfaces: miniserve -i 192.168.0.1 -i 10.13.37.10 -i ::1 /tmp/myshare ### Insert custom headers miniserve --header "Cache-Control:no-cache" --header "X-Custom-Header:custom-value" -p 8080 /tmp/myshare # Check headers in another terminal curl -I http://localhost:8080 If a header is already set or previously inserted, it will not be overwritten. ### Start with TLS: miniserve --tls-cert my.cert --tls-key my.key /tmp/myshare # Fullchain TLS and HTTP Strict Transport Security (HSTS) miniserve --tls-cert fullchain.pem --tls-key my.key --header "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload" /tmp/myshare If the parameter value has spaces, be sure to wrap it in quotes. (To achieve an A+ rating at https://www.ssllabs.com/ssltest/, enabling both fullchain TLS and HSTS is necessary.) ### Upload a file using `curl`: # in one terminal miniserve -u -- . # in another terminal curl -F "path=@$FILE" http://localhost:8080/upload\?path\=/ (where `$FILE` is the path to the file. This uses miniserve's default port of 8080) Note that for uploading, we have to use `--` to disambiguate the argument to `-u`. This is because `-u` can also take a path (or multiple). If a path argument to `-u` is given, uploading will only be possible to the provided paths as opposed to every path. Another effect of this is that you can't just combine flags like this `-uv` when `-u` is used. In this example, you'd need to use `-u -v`. ### Create a directory using `curl`: # in one terminal miniserve --upload-files --mkdir . # in another terminal curl -F "mkdir=$DIR_NAME" http://localhost:8080/upload\?path=\/ (where `$DIR_NAME` is the name of the directory. This uses miniserve's default port of 8080.) ### Use the raw renderer for use with simple viewers You can pass `?raw=true` with requests where you only require minimal HTML output for CLI-based browsers such as `lynx` or `w3m`. This is enabled by default without any extra flags: miniserve . curl http://localhost:8080?raw=true You can enable a convenient copy-pastable footer for `wget` using `--show-wget-footer`: miniserve --show-wget-footer . Afterwards, check the bottom of any rendered page. It'll have a neat `wget` command you can easily copy-paste to recursively grab the current directory. ### Take pictures and upload them from smartphones: miniserve -u -m image -q This uses the `--media-type` option, which sends a hint for the expected media type to the browser. Some mobile browsers like Firefox on Android will offer to open the camera app when seeing this. ## Features - Easy to use - Just works: Correct MIME types handling out of the box - Single binary drop-in with no extra dependencies required - Authentication support with username and password (and hashed password) - Mega fast and highly parallel (thanks to [Rust](https://www.rust-lang.org/) and [Actix](https://actix.rs/)) - Folder download (compressed on the fly as `.tar.gz` or `.zip`) - File uploading - Directory creation - Pretty themes (with light and dark theme support) - Scan QR code for quick access - Shell completions - Sane and secure defaults - TLS (for supported architectures) - Supports README.md rendering like on GitHub - Range requests - WebDAV support - Healthcheck route (at `/__miniserve_internal/healthcheck`) ## Usage ``` For when you really just want to serve some files over HTTP right now! Usage: miniserve [OPTIONS] [PATH] Arguments: [PATH] Which path to serve [env: MINISERVE_PATH=] Options: -v, --verbose Be verbose, includes emitting access logs [env: MINISERVE_VERBOSE=] --index The name of a directory index file to serve, like "index.html" Normally, when miniserve serves a directory, it creates a listing for that directory. However, if a directory contains this file, miniserve will serve that file instead. [env: MINISERVE_INDEX=] --spa Activate SPA (Single Page Application) mode This will cause the file given by --index to be served for all non-existing file paths. In effect, this will serve the index file whenever a 404 would otherwise occur in order to allow the SPA router to handle the request instead. [env: MINISERVE_SPA=] --pretty-urls Activate Pretty URLs mode This will cause the server to serve the equivalent `.html` file indicated by the path. `/about` will try to find `about.html` and serve it. [env: MINISERVE_PRETTY_URLS=] -p, --port Port to use [env: MINISERVE_PORT=] [default: 8080] -i, --interfaces Interface to listen on [env: MINISERVE_INTERFACE=] -a, --auth Set authentication Currently supported formats: username:password, username:sha256:hash, username:sha512:hash (e.g. joe:123, joe:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3) [env: MINISERVE_AUTH=] --auth-file Read authentication values from a file Example file content: joe:123 bob:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3 bill: [env: MINISERVE_AUTH_FILE=] --route-prefix Use a specific route prefix [env: MINISERVE_ROUTE_PREFIX=] --random-route Generate a random 6-hexdigit route [env: MINISERVE_RANDOM_ROUTE=] --file-external-url Optional external URL (e.g., 'http://external.example.com:8081') prepended to file links in listings. Allows serving files from a different URL than the browsing instance. Useful for setups like: one authenticated instance for browsing, linking files (via this option) to a second, non-indexed (-I) instance for direct downloads. This obscures the full file list on the download server, while users can still copy direct file URLs for sharing. The external URL is put verbatim in front of the relative location of the file, including the protocol. The user should take care this results in a valid URL, no further checks are being done. [env: MINISERVE_FILE_EXTERNAL_URL=] -P, --no-symlinks Hide symlinks in listing and prevent them from being followed [env: MINISERVE_NO_SYMLINKS=] -H, --hidden Show hidden files [env: MINISERVE_HIDDEN=] -S, --default-sorting-method Default sorting method for file list [env: MINISERVE_DEFAULT_SORTING_METHOD=] [default: name] Possible values: - name: Sort by name - size: Sort by size - date: Sort by last modification date (natural sort: follows alphanumerical order) -O, --default-sorting-order Default sorting order for file list [env: MINISERVE_DEFAULT_SORTING_ORDER=] [default: desc] Possible values: - asc: Ascending order - desc: Descending order -c, --color-scheme Default color scheme [env: MINISERVE_COLOR_SCHEME=] [default: squirrel] [possible values: squirrel, archlinux, zenburn, monokai] -d, --color-scheme-dark Default color scheme [env: MINISERVE_COLOR_SCHEME_DARK=] [default: archlinux] [possible values: squirrel, archlinux, zenburn, monokai] -q, --qrcode Enable QR code display [env: MINISERVE_QRCODE=] -u, --upload-files [] Enable file uploading (and optionally specify for which directory) The provided path is not a physical file system path. Instead, it's relative to the serve dir. For instance, if the serve dir is '/home/hello', set this to '/upload' to allow uploading to '/home/hello/upload'. When specified via environment variable, a path always needs to be specified. [env: MINISERVE_ALLOWED_UPLOAD_DIR=] --web-upload-files-concurrency Configure amount of concurrent uploads when visiting the website. Must have upload-files option enabled for this setting to matter. [env: MINISERVE_WEB_UPLOAD_CONCURRENCY=] [default: 0] -U, --mkdir Enable creating directories [env: MINISERVE_MKDIR_ENABLED=] -m, --media-type Specify uploadable media types [env: MINISERVE_MEDIA_TYPE=] [possible values: image, audio, video] -M, --raw-media-type Directly specify the uploadable media type expression [env: MINISERVE_RAW_MEDIA_TYPE=] -o, --on-duplicate-files What to do if existing files with same name is present during file upload If you enable renaming files, the renaming will occur by adding numerical suffix to the filename before the final extension. For example file.txt will be uploaded as file-1.txt, the number will be increased until an available filename is found. [env: MINISERVE_ON_DUPLICATE_FILES=] [default: error] [possible values: error, overwrite, rename] -R, --rm-files [] Enable file and directory deletion (and optionally specify for which directory) [env: MINISERVE_ALLOWED_RM_DIR=] -r, --enable-tar Enable uncompressed tar archive generation [env: MINISERVE_ENABLE_TAR=] -g, --enable-tar-gz Enable gz-compressed tar archive generation [env: MINISERVE_ENABLE_TAR_GZ=] -z, --enable-zip Enable zip archive generation WARNING: Zipping large directories can result in out-of-memory exception because zip generation is done in memory and cannot be sent on the fly [env: MINISERVE_ENABLE_ZIP=] -C, --compress-response Compress response WARNING: Enabling this option may slow down transfers due to CPU overhead, so it is disabled by default. Only enable this option if you know that your users have slow connections or if you want to minimize your server's bandwidth usage. [env: MINISERVE_COMPRESS_RESPONSE=] -D, --dirs-first List directories first [env: MINISERVE_DIRS_FIRST=] -t, --title Shown instead of host in page title and heading [env: MINISERVE_TITLE=] --header <HEADER> Inserts custom headers into the responses. Specify each header as a 'Header:Value' pair. This parameter can be used multiple times to add multiple headers. Example: --header "Header1:Value1" --header "Header2:Value2" (If a header is already set or previously inserted, it will not be overwritten.) [env: MINISERVE_HEADER=] -l, --show-symlink-info Visualize symlinks in directory listing [env: MINISERVE_SHOW_SYMLINK_INFO=] -F, --hide-version-footer Hide version footer [env: MINISERVE_HIDE_VERSION_FOOTER=] --hide-theme-selector Hide theme selector [env: MINISERVE_HIDE_THEME_SELECTOR=] -W, --show-wget-footer If enabled, display a wget command to recursively download the current directory [env: MINISERVE_SHOW_WGET_FOOTER=] --print-completions <shell> Generate completion file for a shell [possible values: bash, elvish, fish, powershell, zsh] --print-manpage Generate man page --tls-cert <TLS_CERT> TLS certificate to use [env: MINISERVE_TLS_CERT=] --tls-key <TLS_KEY> TLS private key to use [env: MINISERVE_TLS_KEY=] --readme Enable README.md rendering in directories [env: MINISERVE_README=] -I, --disable-indexing Disable indexing This will prevent directory listings from being generated and return an error instead. [env: MINISERVE_DISABLE_INDEXING=] --enable-webdav Enable read-only WebDAV support (PROPFIND requests) Currently incompatible with -P|--no-symlinks (see https://github.com/messense/dav-server-rs/issues/37) [env: MINISERVE_ENABLE_WEBDAV=] --log-color <LOG_COLOR> Set the color style of the log output "auto" (default) enables colors only when the output is a terminal. "always" always enables colors. "never" always disables colors. [env: MINISERVE_LOG_COLOR=] [default: auto] [possible values: auto, always, never] -h, --help Print help (see a summary with '-h') -V, --version Print version ``` ## How to install <a href="https://repology.org/project/miniserve/versions"><img align="right" src="https://repology.org/badge/vertical-allrepos/miniserve.svg" alt="Packaging status"></a> **On Linux**: Download `miniserve-linux` from [the releases page](https://github.com/svenstaro/miniserve/releases) and run chmod +x miniserve-linux ./miniserve-linux Alternatively, if you are on **Arch Linux**, you can do pacman -S miniserve On [Termux](https://termux.com/) pkg install miniserve **On OSX**: Download `miniserve-osx` from [the releases page](https://github.com/svenstaro/miniserve/releases) and run chmod +x miniserve-osx ./miniserve-osx Alternatively install with [Homebrew](https://brew.sh/): brew install miniserve miniserve **On Windows**: Download `miniserve-win.exe` from [the releases page](https://github.com/svenstaro/miniserve/releases) and run miniserve-win.exe Alternatively install with [Scoop](https://scoop.sh/): scoop install miniserve **With Cargo**: Make sure you have a recent version of Rust. Then you can run cargo install --locked miniserve miniserve **With Docker:** Make sure the Docker daemon is running and then run docker run -v /tmp:/tmp -p 8080:8080 --rm -it docker.io/svenstaro/miniserve /tmp **With Podman:** Just run podman run -v /tmp:/tmp -p 8080:8080 --rm -it docker.io/svenstaro/miniserve /tmp **With Helm:** See [this third-party Helm chart](https://codeberg.org/wrenix/helm-charts/src/branch/main/miniserve) by @wrenix. ## Shell completions If you'd like to make use of the built-in shell completion support, you need to run `miniserve --print-completions <your-shell>` and put the completions in the correct place for your shell. A few examples with common paths are provided below: # For bash miniserve --print-completions bash > ~/.local/share/bash-completion/completions/miniserve # For zsh miniserve --print-completions zsh > /usr/local/share/zsh/site-functions/_miniserve # For fish miniserve --print-completions fish > ~/.config/fish/completions/miniserve.fish ## systemd A hardened systemd-compatible unit file can be found in `packaging/miniserve@.service`. You could install this to `/etc/systemd/system/miniserve@.service` and start and enable `miniserve` as a daemon on a specific serve path `/my/serve/path` like this: systemctl enable --now miniserve@-my-serve-path Keep in mind that you'll have to use `systemd-escape` to properly escape a path for this usage. In case you want to customize the particular flags that miniserve launches with, you can use systemctl edit miniserve@-my-serve-path and set the `[Service]` part in the resulting `override.conf` file. For instance: [Service] ExecStart= ExecStart=/usr/bin/miniserve --enable-tar --enable-zip --no-symlinks --verbose -i ::1 -p 1234 --title hello --color-scheme monokai --color-scheme-dark monokai -- %I Make sure to leave the `%I` at the very end in place or the wrong path might be served. Alternatively, you can configure the service via environment variables: [Service] Environment=MINISERVE_ENABLE_TAR=true Environment=MINISERVE_ENABLE_ZIP=true Environment="MINISERVE_TITLE=hello world" ... You might additionally have to override `IPAddressAllow` and `IPAddressDeny` if you plan on making miniserve directly available on a public interface. ## Binding behavior For convenience reasons, miniserve will try to bind on all interfaces by default (if no `-i` is provided). It will also do that if explicitly provided with `-i 0.0.0.0` or `-i ::`. In all of the aforementioned cases, it will bind on both IPv4 and IPv6. If provided with an explicit non-default interface, it will ONLY bind to that interface. You can provide `-i` multiple times to bind to multiple interfaces at the same time. ## Why use this over alternatives? - darkhttpd: Not easily available on Windows and it's not as easy as download-and-go. - Python built-in webserver: Need to have Python installed, it's low performance, and also doesn't do correct MIME type handling in some cases. - netcat: Not as convenient to use and sending directories is [somewhat involved](https://nakkaya.com/2009/04/15/using-netcat-for-file-transfers/). ## Releasing This is mostly a note for me on how to release this thing: - Make sure [CHANGELOG.md](./CHANGELOG.md) is up to date. - `cargo release <version>` - `cargo release --execute <version>` - Releases will automatically be deployed by GitHub Actions. - Update Arch package. ================================================ FILE: data/style.scss ================================================ @use "themes/archlinux" with ($generate_default: false); @use "themes/ayu_dark" with ($generate_default: false); @use "themes/monokai" with ($generate_default: false); @use "themes/squirrel" with ($generate_default: false); @use "themes/zenburn" with ($generate_default: false); // theme colors can be found at the bottom $themes: squirrel, archlinux, ayu_dark, monokai, zenburn; html { font-smoothing: antialiased; text-rendering: optimizeLegibility; width: 100%; height: 100%; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; font-weight: normal; color: var(--text_color); background: var(--background); position: relative; min-height: 100%; } .container { padding: 1.5rem 5rem; } $upload_container_height: 18rem; .upload_area { display: block; position: fixed; bottom: 1rem; right: -105%; color: #ffffff; box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); background: linear-gradient(135deg, #73a5ff, #5477f5); padding: 0px; margin: 0px; opacity: 1; // Change this transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); border-radius: 4px; text-decoration: none; min-width: 400px; max-width: 600px; z-index: 2147483647; max-height: $upload_container_height; overflow: hidden; &.active { right: 1rem; } #upload-toggle { display: none; transition: transform 0.3s ease; cursor: pointer; } } .upload_container { max-height: $upload_container_height; display: flex; flex-direction: column; } .upload_header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; background-color: var(--upload_modal_header_background); color: var(--upload_modal_header_color); } .upload_header svg { width: 24px; height: 24px; } .upload_action { background-color: var(--upload_modal_sub_header_background); color: var(--upload_modal_header_color); padding: 0.25rem 1rem; display: flex; justify-content: space-between; align-items: center; font-size: 0.75em; font-weight: 500; } .upload_cancel { background: none; border: none; font-weight: 500; cursor: pointer; } .upload_files { padding: 0px; margin: 0px; flex: 1; overflow-y: auto; max-height: inherit; } .upload_file_list { background-color: var(--upload_modal_file_item_background); color: var(--upload_modal_file_item_color); padding: 0px; margin: 0px; list-style: none; list-style: none; display: flex; flex-direction: column; align-items: stretch; overflow-y: scroll; } .upload_file_container { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1rem calc(1rem - 2px) 1rem; } .upload_file_action { display: flex; justify-content: right; } .file_progress_bar { width: 0%; border-top: 2px solid var(--progress_bar_background); transition: width 0.25s ease; &.cancelled { border-color: var(--error_color); } &.failed { border-color: var(--error_color); } &.complete { border-color: var(--success_color); } } .upload_file_text { font-size: 0.80em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; &.cancelled { text-decoration: line-through; } &.failed { text-decoration: line-through; } } .file_cancel_upload { padding-left: 0.25rem; font-size: 1em; cursor: pointer; border: none; background: inherit; font-size: 1em; color: var(--error_color); &.complete { color: var(--success_color); } } .title { word-break: break-all; } .title a { font-weight: bold; color: var(--directory_link_color); } .footer { text-align: center; padding-top: 1.5rem; font-size: 0.7em; color: var(--footer_color); .downloadDirectory { display: flex; flex-direction: row; justify-content: center; flex-wrap: wrap; .cmd { margin: 0; padding-left: 5px; line-height: 13px; font-family: monospace; } } } a { text-decoration: none; } a.root, a.root:visited, .root-chevron { font-weight: bold; color: var(--root_link_color); } a:hover { text-decoration: underline; } a.directory { font-weight: bold; color: var(--directory_link_color); &:visited { color: var(--directory_link_color_visited); } } a.file, .error-back { color: var(--file_link_color); &:visited { color: var(--file_link_color_visited); } } a.file::before { content: "📃 "; } a.directory::before { content: "📁 "; } a.directory:hover { color: var(--directory_link_color); } a.file:hover { color: var(--file_link_color); } a.symlink, a.symlink:visited { font-weight: bold; color: var(--symlink_color); } .symlink-symbol::after { content: "⇢"; display: inline-block; border: 1px solid; margin-left: 0.5rem; margin-right: 0.5rem; border-radius: 0.2rem; padding: 0 0.1rem; } nav { padding: 0 5rem; display: flex; justify-content: flex-end; } nav>div { position: relative; margin-left: 0.5rem; } nav p { padding: 0.5rem 1rem; width: 8rem; text-align: center; background: var(--switch_theme_background); color: var(--change_theme_link_color); } nav p+* { display: none; position: absolute; left: 0; right: 0; top: 100%; } @keyframes show { from { opacity: 0; } to { opacity: 1; } } nav>div:hover p { cursor: pointer; color: var(--switch_theme_link_color); } nav>div:hover p+* { display: block; border-top: 1px solid var(--switch_theme_border); } nav .qrcode { padding: 0.5rem; background: var(--switch_theme_background); } nav .qrcode svg { display: block; } nav .theme { margin: 0; padding: 0; list-style-type: none; } nav .theme li { width: 100%; background: var(--switch_theme_background); } nav .theme li a { display: block; width: 100%; padding: 0.5rem 0; text-align: center; color: var(--switch_theme_link_color); } nav .theme li a:visited { color: var(--switch_theme_link_color); } nav .theme li a:hover { text-decoration: underline; color: var(--change_theme_link_color_hover); } p { margin: 0; padding: 0; } h1 { margin-top: 0; font-size: 1.5rem; } table { margin-top: 2rem; width: 100%; border: 0; table-layout: auto; background: var(--table_background); } table thead tr th, table tbody tr td { padding: 0.5625rem 0.625rem; font-size: 0.875rem; color: var(--table_text_color); text-align: left; line-height: 1.125rem; } table tbody tr td p { display: flex; align-items: center; } table thead tr th { padding: 0.5rem 0.625rem 0.625rem; font-weight: bold; color: var(--table_header_text_color); } table thead th.size { width: 6em; } table thead th.date { width: 21em; } table thead th.actions { width: 4em; } table tbody tr:nth-child(odd) { background: var(--odd_row_background); } table tbody tr:nth-child(even) { background: var(--even_row_background); } table thead { background: var(--table_header_background); } table tbody tr:hover { background: var(--active_row_color); } td.size-cell { text-align: right; } td.date-cell { display: flex; justify-content: space-between; } tr.entry-type-directory .size-cell { &:not([data-size])::after { content: "xxxx KiB"; // hidden placeholder to get text-like width and height color: transparent; animation: shimmer 2.5s ease-in-out reverse infinite, bump 1.25s ease-out alternate-reverse infinite; background: linear-gradient(to left, #e6e6e633 0%, #e6e6e633 20%, #e7e7e744 40%, #ececec70 45%, #e7e7e755 60%, #e6e6e633 80%, #e6e6e633 100%), linear-gradient(to bottom, #ffffff00 40%, #ffffff18 60%, #ffffff50 80%); background-size: 500% 160%; border-radius: 4px; } &[data-size]::after { content: attr(data-size); } @keyframes bump { from { background-position-y: 30%; } to { background-position-y: 0; } } @keyframes shimmer { from { background-position-x: 0; } to { background-position-x: 100%; } } } td.actions-cell button { padding: 0.1rem 0.3rem; border-radius: 0.2rem; border: none; } td.actions-cell .rm_form { display: flex; place-content: center; } td.actions-cell .rm_form button { background: var(--rm_button_background); color: var(--rm_button_text_color); } .history { color: var(--date_text_color); } span.size, span.mobile-info.history { white-space: nowrap; border-radius: 1rem; background: var(--size_background_color); padding: 0 0.25rem; margin: 0 0.25rem; font-size: 0.7rem; color: var(--size_text_color); } .mobile-info { display: none; } .mobile-info a, .mobile-info a:visited { color: var(--size_text_color); } th a, th a:visited, .chevron { color: var(--table_header_text_color); } .chevron, .root-chevron { margin-right: 0.5rem; font-size: 1.2em; font-weight: bold; } th span.active a, th span.active span { color: var(--table_header_active_color); } .back { position: fixed; width: 3rem; height: 3rem; align-items: center; justify-content: center; bottom: 3rem; right: 3.75rem; background: var(--back_button_background); border-radius: 100%; box-shadow: 0 0 8px -4px #888888; color: var(--back_button_link_color); display: none; padding: 0; font-size: 2em; } .back:visited { color: var(--back_button_link_color); } .back:hover { color: var(--back_button_link_color_hover); font-weight: bold; text-decoration: none; background: var(--back_button_background_hover); } // // Toolbar & tools inside the bar. // .toolbar { --tool-gap-between: 0.5rem; --tool-spacing-inside: 0.5rem; } .toolbar .tool_row { margin-top: 1rem; display: flex; gap: var(--tool-gap-between); flex-direction: column; @media (min-width: 760px) { flex-direction: row; } } // Upload tools has 4 configurations // // a) Upload enabled, // b) Upload enabled, mkdir enabled // c) Upload enabled, paste enabled // d) Upload enabled, mkdir enabled, paste enabled // // At larger screen sizes, for a and b, we render the tools horizontal, as at // min-content width. For c and d, we render upload (and mkdir) at min-content // width, stacked vertically and let paste fill the remaining space. // // At smaller screen sizes we render any available elememnts in a full-width // stack. // // We render via grid not flex as it affords us better control of the // stack/unstack in b. @media (min-width: 760px) { .toolbar .tool_row.upload_tools { display: grid; grid-template-columns: min-content min-content; .tool[data-tool="upload"] { grid-column: 1 / 2; grid-row: 1 / 2; } .tool[data-tool="mkdir"] { grid-column: 2 / 3; grid-row: 1 / 2; } &:has([data-tool="pastebin"]) { grid-template-columns: min-content auto; .tool[data-tool="upload"] { grid-column: 1 / 2; grid-row: 1 / 2; } .tool[data-tool="mkdir"] { grid-column: 1 / 2; grid-row: 2 / 3; } .tool[data-tool="pastebin"] { grid-column: 2 / 3; grid-row: 1 / 3; } } } } .toolbar form.tool { padding: 1rem; border: 1px solid var(--upload_form_border_color); background: var(--upload_form_background); & > * { margin-bottom: var(--tool-spacing-inside); &:last-child { margin-bottom: 0; } } p { font-size: 0.8rem; color: var(--upload_text_color); } input { padding: 0.5rem; margin-right: 0.2rem; border-radius: 0.2rem; border: 0; display: inline; } button { background: var(--upload_button_background); padding: 0.5rem; border-radius: 0.2rem; color: var(--upload_button_text_color); border: none; min-width: max-content; } div { display: flex; align-items: baseline; justify-content: space-between; } } // // Toolbar tool specific styling // .toolbar .tool[data-tool="download"] { padding: 0.125rem; display: flex; flex-direction: row; align-items: flex-start; flex-wrap: wrap; a { background: var(--download_button_background); padding: 0.5rem; border-radius: 0.2rem; } a, a:visited { color: var(--download_button_link_color); } a:hover { background: var(--download_button_background_hover); color: var(--download_button_link_color_hover); } a:not(:last-of-type) { margin-right: 1rem; } } .toolbar .tool[data-tool="pastebin"] { textarea { width: 100%; resize: vertical; min-height: 4rem; padding: 0.5rem; border-radius: 0.2rem; border: 0; } } .form, .drag-form { display: none; background: var(--drag_background); position: absolute; border: 0.5rem dashed var(--drag_border_color); width: calc(100% - 1rem); height: calc(100% - 1rem); text-align: center; z-index: 2; margin: 0 5px; } .form_title { position: fixed; color: var(--drag_text_color); top: 50%; width: 100%; text-align: center; } .error { margin: 2rem; } .error p { margin: 1rem 0; font-size: 0.9rem; word-break: break-all; } .error p:first-of-type { font-size: 1.25rem; color: var(--error_color); margin-bottom: 2rem; } .error p:nth-of-type(2) { font-weight: bold; } .error-nav { margin-top: 4rem; } @media (max-width: 760px) { nav { padding: 0 2.5rem; } .container { padding: 1.5rem 2.5rem; } h1 { font-size: 1.4em; } td:not(:nth-child(1)), th:not(:nth-child(1)) { display: none; } .mobile-info { display: inline-flex; align-items: center; margin: auto; } table tbody tr td { padding-top: 0; padding-bottom: 0; } a { padding: 0.5625rem 0; } a.directory { flex-grow: 1; } .file-entry { align-items: center; } a.root, a.file { flex-grow: 1; } .back { display: flex; } .back { right: 1.5rem; } $upload_container_height_mobile: 100vh; .upload_area { width: 100%; height: 136px; max-height: $upload_container_height_mobile; max-width: unset; min-width: unset; bottom: 0; transition: height 0.3s ease; &.active { right: 0; left: 0; } #upload-toggle { display: block; transition: transform 0.3s ease; } } .upload_container { max-height: $upload_container_height_mobile; } } @media (max-width: 600px) { h1 { font-size: 1.375em; } nav { padding: 0 1rem; } .container { padding: 1rem; } } @media (max-width: 400px) { nav { padding: 0 0.5rem; } .container { padding: 0.5rem; } h1 { font-size: 1.375em; } .back { right: 1.5rem; } } @mixin theme($name) { @if $name ==squirrel { @include squirrel.theme(); } @else if $name ==archlinux { @include archlinux.theme(); } @else if $name ==ayu_dark { @include ayu_dark.theme(); } @else if $name ==monokai { @include monokai.theme(); } @else if $name ==zenburn { @include zenburn.theme(); } @else { @error "Invalid theme: #{$name}"; } } %active_theme_link { font-weight: bold; color: var(--switch_theme_active); } // when no specific theme is applied, highlight the `default` theme button in // the theme menu body:not([data-theme]) nav .theme li[data-theme="default"] a { @extend %active_theme_link; } @each $theme in $themes { body[data-theme="#{$theme}"] { @include theme($theme); // highlight the currently active theme in the theme selection menu nav .theme li[data-theme="#{$theme}"] a { @extend %active_theme_link; } } } ================================================ FILE: data/themes/archlinux.scss ================================================ $generate_default: true !default; @mixin theme { --background: #383c4a; --text_color: #fefefe; --directory_link_color: #03a9f4; --directory_link_color_visited: #0388f4; --file_link_color: #ea95ff; --file_link_color_visited: #a595ff; --symlink_color: #66d9ef; --table_background: #353946; --table_text_color: #eeeeee; --table_header_background: #5294e2; --table_header_text_color: #eeeeee; --table_header_active_color: #ffffff; --active_row_color: #5194e259; --odd_row_background: #404552; --even_row_background: #4b5162; --root_link_color: #abb2bb; --download_button_background: #ea95ff; --download_button_background_hover: #eea7ff; --download_button_link_color: #ffffff; --download_button_link_color_hover: #ffffff; --back_button_background: #ea95ff; --back_button_background_hover: #ea95ff; --back_button_link_color: #ffffff; --back_button_link_color_hover: #ffffff; --date_text_color: #9ebbdc; --at_color: #9ebbdc; --switch_theme_background: #4b5162; --switch_theme_link_color: #fefefe; --switch_theme_active: #ea95ff; --switch_theme_border: #6a728a; --change_theme_link_color: #fefefe; --change_theme_link_color_hover: #fefefe; --upload_text_color: #fefefe; --upload_form_border_color: #353946; --upload_form_background: #4b5162; --upload_button_background: #ea95ff; --upload_button_text_color: #ffffff; --rm_button_background: #ea95ff; --rm_button_text_color: #ffffff; --drag_background: #3333338f; --drag_border_color: #fefefe; --drag_text_color: #fefefe; --size_background_color: #5294e2; --size_text_color: #fefefe; --error_color: #e44b4b; --footer_color: #8eabcc; --success_color: #52e28a; --upload_modal_header_background: #5294e2; --upload_modal_header_color: #eeeeee; --upload_modal_sub_header_background: #35547a; --upload_modal_file_item_background: #eeeeee; --upload_modal_file_item_color: #111111; --upload_modal_file_upload_complete_background: #cccccc; --progress_bar_background: #5294e2; }; @if $generate_default { body { @include theme; } } ================================================ FILE: data/themes/ayu_dark.scss ================================================ $generate_default: true !default; @mixin theme { --background: #0d1017; --text_color: #bfbdb6; --directory_link_color: #e6b450; --directory_link_color_visited: #c99a3a; --file_link_color: #aad94c; --file_link_color_visited: #7fb032; --symlink_color: #73b8ff; --table_background: #131721; --table_text_color: #bfbdb6; --table_header_background: #1c2130; --table_header_text_color: #bfbdb6; --table_header_active_color: #e6b450; --active_row_color: #e6b45030; --odd_row_background: #111720; --even_row_background: #0f1219; --root_link_color: #39bae6; --download_button_background: #e6b450; --download_button_background_hover: #d9a53e; --download_button_link_color: #0d1017; --download_button_link_color_hover: #0d1017; --back_button_background: #e6b450; --back_button_background_hover: #d9a53e; --back_button_link_color: #0d1017; --back_button_link_color_hover: #0d1017; --date_text_color: #39bae6; --at_color: #39bae6; --switch_theme_background: #131721; --switch_theme_link_color: #bfbdb6; --switch_theme_active: #e6b450; --switch_theme_border: #1c2130; --change_theme_link_color: #bfbdb6; --change_theme_link_color_hover: #e6b450; --upload_text_color: #bfbdb6; --upload_form_border_color: #1c2130; --upload_form_background: #131721; --upload_button_background: #e6b450; --upload_button_text_color: #0d1017; --rm_button_background: #f26d78; --rm_button_text_color: #0d1017; --drag_background: #0d10178f; --drag_border_color: #e6b450; --drag_text_color: #bfbdb6; --size_background_color: #1c2130; --size_text_color: #bfbdb6; --error_color: #d95757; --footer_color: #39bae6; --success_color: #7fd962; --upload_modal_header_background: #1c2130; --upload_modal_header_color: #bfbdb6; --upload_modal_sub_header_background: #0d1017; --upload_modal_file_item_background: #bfbdb6; --upload_modal_file_item_color: #0d1017; --upload_modal_file_upload_complete_background: #636a72; --progress_bar_background: #e6b450; }; @if $generate_default { body { @include theme; } } ================================================ FILE: data/themes/monokai.scss ================================================ $generate_default: true !default; @mixin theme { --background: #272822; --text_color: #f8f8f2; --directory_link_color: #f92672; --directory_link_color_visited: #bc39a7; --file_link_color: #a6e22e; --file_link_color_visited: #4cb936; --symlink_color: #29b8db; --table_background: #3b3a32; --table_text_color: #f8f8f0; --table_header_background: #75715e; --table_header_text_color: #f8f8f2; --table_header_active_color: #e6db74; --active_row_color: #ae81fe3d; --odd_row_background: #3e3d32; --even_row_background: #49483e; --root_link_color: #66d9ef; --download_button_background: #ae81ff; --download_button_background_hover: #c6a6ff; --download_button_link_color: #f8f8f0; --download_button_link_color_hover: #f8f8f0; --back_button_background: #ae81ff; --back_button_background_hover: #ae81ff; --back_button_link_color: #f8f8f0; --back_button_link_color_hover: #f8f8f0; --date_text_color: #66d9ef; --at_color: #66d9ef; --switch_theme_background: #3b3a32; --switch_theme_link_color: #f8f8f2; --switch_theme_active: #a6e22e; --switch_theme_border: #49483e; --change_theme_link_color: #f8f8f2; --change_theme_link_color_hover: #f8f8f2; --upload_text_color: #f8f8f2; --upload_form_border_color: #3b3a32; --upload_form_background: #49483e; --upload_button_background: #ae81ff; --upload_button_text_color: #f8f8f0; --rm_button_background: #ae81ff; --rm_button_text_color: #f8f8f0; --drag_background: #3333338f; --drag_border_color: #f8f8f2; --drag_text_color: #f8f8f2; --size_background_color: #75715e; --size_text_color: #f8f8f2; --error_color: #d02929; --footer_color: #56c9df; --success_color: #52e28a; --upload_modal_header_background: #75715e; --upload_modal_header_color: #eeeeee; --upload_modal_sub_header_background: #323129; --upload_modal_file_item_background: #eeeeee; --upload_modal_file_item_color: #111111; --upload_modal_file_upload_complete_background: #cccccc; --progress_bar_background: #5294e2; }; @if $generate_default { body { @include theme; } } ================================================ FILE: data/themes/squirrel.scss ================================================ $generate_default: true !default; @mixin theme { --background: #ffffff; --text_color: #323232; --directory_link_color: #d02474; --directory_link_color_visited: #9b1985; --file_link_color: #0086b3; --file_link_color_visited: #974ec2; --symlink_color: #ADD8E6; --table_background: #ffffff; --table_text_color: #323232; --table_header_background: #323232; --table_header_text_color: #f5f5f5; --table_header_active_color: #ffffff; --active_row_color: #f6f8fa; --odd_row_background: #fbfbfb; --even_row_background: #f2f2f2; --root_link_color: #323232; --download_button_background: #d02474; --download_button_background_hover: #f52d8a; --download_button_link_color: #ffffff; --download_button_link_color_hover: #ffffff; --back_button_background: #d02474; --back_button_background_hover: #d02474; --back_button_link_color: #ffffff; --back_button_link_color_hover: #ffffff; --date_text_color: #797979; --at_color: #797979; --switch_theme_background: #323232; --switch_theme_link_color: #f5f5f5; --switch_theme_active: #d02474; --switch_theme_border: #49483e; --change_theme_link_color: #f5f5f5; --change_theme_link_color_hover: #f5f5f5; --upload_text_color: #323232; --upload_form_border_color: #d2d2d2; --upload_form_background: #f2f2f2; --upload_button_background: #d02474; --upload_button_text_color: #ffffff; --rm_button_background: #d02474; --rm_button_text_color: #ffffff; --drag_background: #3333338f; --drag_border_color: #ffffff; --drag_text_color: #ffffff; --size_background_color: #323232; --size_text_color: #ffffff; --error_color: #d02424; --footer_color: #898989; --success_color: #52e28a; --upload_modal_header_background: #323232; --upload_modal_header_color: #eeeeee; --upload_modal_sub_header_background: #171616; --upload_modal_file_item_background: #eeeeee; --upload_modal_file_item_color: #111111; --upload_modal_file_upload_complete_background: #cccccc; --progress_bar_background: #5294e2; }; @if $generate_default { body { @include theme; } } ================================================ FILE: data/themes/zenburn.scss ================================================ $generate_default: true !default; @mixin theme { --background: #3f3f3f; --text_color: #efefef; --directory_link_color: #f0dfaf; --directory_link_color_visited: #ebc390; --file_link_color: #87d6d5; --file_link_color_visited: #a7b9ec; --symlink_color: #11a8cd; --table_background: #4a4949; --table_text_color: #efefef; --table_header_background: #7f9f7f; --table_header_text_color: #efefef; --table_header_active_color: #efef8f; --active_row_color: #7e9f7f9c; --odd_row_background: #777777; --even_row_background: #5a5a5a; --root_link_color: #dca3a3; --download_button_background: #cc9393; --download_button_background_hover: #dca3a3; --download_button_link_color: #efefef; --download_button_link_color_hover: #efefef; --back_button_background: #cc9393; --back_button_background_hover: #cc9393; --back_button_link_color: #efefef; --back_button_link_color_hover: #efefef; --date_text_color: #cfbfaf; --at_color: #cfbfaf; --switch_theme_background: #4a4949; --switch_theme_link_color: #efefef; --switch_theme_active: #efef8f; --switch_theme_border: #5a5a5a; --change_theme_link_color: #efefef; --change_theme_link_color_hover: #efefef; --upload_text_color: #efefef; --upload_form_border_color: #4a4949; --upload_form_background: #777777; --upload_button_background: #cc9393; --upload_button_text_color: #efefef; --rm_button_background: #cc9393; --rm_button_text_color: #efefef; --drag_background: #3333338f; --drag_border_color: #efefef; --drag_text_color: #efefef; --size_background_color: #7f9f7f; --size_text_color: #efefef; --error_color: #d06565; --footer_color: #bfaf9f; --success_color: #52e28a; --upload_modal_header_background: #7f9f7f; --upload_modal_header_color: #eeeeee; --upload_modal_sub_header_background: #404e40; --upload_modal_file_item_background: #eeeeee; --upload_modal_file_item_color: #111111; --upload_modal_file_upload_complete_background: #cccccc; --progress_bar_background: #5294e2; }; @if $generate_default { body { @include theme; } } ================================================ FILE: packaging/miniserve@.service ================================================ [Unit] Description=miniserve for %i After=network-online.target Wants=network-online.target [Service] ExecStart=/usr/bin/miniserve -- %I IPAccounting=yes IPAddressAllow=localhost IPAddressDeny=any DynamicUser=yes PrivateTmp=yes PrivateUsers=yes PrivateDevices=yes NoNewPrivileges=true ProtectSystem=strict ProtectHome=yes ProtectClock=yes ProtectControlGroups=yes ProtectKernelLogs=yes ProtectKernelModules=yes ProtectKernelTunables=yes ProtectProc=invisible CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH [Install] WantedBy=multi-user.target ================================================ FILE: release.toml ================================================ sign-commit = true sign-tag = true pre-release-replacements = [ {file="CHANGELOG.md", search="Unreleased", replace="{{version}}"}, {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}"}, {file="CHANGELOG.md", search="<!-- next-header -->", replace="<!-- next-header -->\n\n## [Unreleased] - ReleaseDate"}, {file="CHANGELOG.md", search="<!-- next-url -->", replace="<!-- next-url -->\n[Unreleased]: https://github.com/svenstaro/miniserve/compare/{{tag_name}}...HEAD", exactly=1}, ] # Get rid of the default cargo-release "chore: " prefix in messages as we don't # use semantic commits in this repository. pre-release-commit-message = "Release {{crate_name}} version {{version}}" tag-message = "Release {{crate_name}} version {{version}}" ================================================ FILE: rustfmt.toml ================================================ # This empty config file ensures the default formatter settings are enforced for # all contributors, regardless of their custom global settings. ================================================ FILE: src/archive.rs ================================================ use std::fs::File; use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use libflate::gzip::Encoder; use serde::Deserialize; use strum::{Display, EnumIter, EnumString}; use tar::Builder; use zip::{ZipWriter, write}; use crate::errors::RuntimeError; /// Available archive methods #[derive(Deserialize, Clone, Copy, EnumIter, EnumString, Display)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum ArchiveMethod { /// Gzipped tarball TarGz, /// Regular tarball Tar, /// Regular zip Zip, } impl ArchiveMethod { pub fn extension(self) -> String { match self { Self::TarGz => "tar.gz", Self::Tar => "tar", Self::Zip => "zip", } .to_string() } pub fn content_type(self) -> String { match self { Self::TarGz => "application/gzip", Self::Tar => "application/tar", Self::Zip => "application/zip", } .to_string() } pub fn is_enabled(self, tar_enabled: bool, tar_gz_enabled: bool, zip_enabled: bool) -> bool { match self { Self::TarGz => tar_gz_enabled, Self::Tar => tar_enabled, Self::Zip => zip_enabled, } } /// Make an archive out of the given directory, and write the output to the given writer. /// /// Recursively includes all files and subdirectories. /// /// If `skip_symlinks` is `true`, symlinks fill not be followed and will just be ignored. pub fn create_archive<T, W>( self, dir: T, skip_symlinks: bool, out: W, ) -> Result<(), RuntimeError> where T: AsRef<Path>, W: std::io::Write, { let dir = dir.as_ref(); match self { Self::TarGz => tar_gz(dir, skip_symlinks, out), Self::Tar => tar_dir(dir, skip_symlinks, out), Self::Zip => zip_dir(dir, skip_symlinks, out), } } } /// Write a gzipped tarball of `dir` in `out`. fn tar_gz<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), RuntimeError> where W: std::io::Write, { let mut out = Encoder::new(out).map_err(|e| RuntimeError::IoError("GZIP".to_string(), e))?; tar_dir(dir, skip_symlinks, &mut out)?; out.finish() .into_result() .map_err(|e| RuntimeError::IoError("GZIP finish".to_string(), e))?; Ok(()) } /// Write a tarball of `dir` in `out`. /// /// The target directory will be saved as a top-level directory in the archive. /// /// For example, consider this directory structure: /// /// ```ignore /// a /// └── b /// └── c /// ├── e /// ├── f /// └── g /// ``` /// /// Making a tarball out of `"a/b/c"` will result in this archive content: /// /// ```ignore /// c /// ├── e /// ├── f /// └── g /// ``` fn tar_dir<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), RuntimeError> where W: std::io::Write, { let inner_folder = dir.file_name().ok_or_else(|| { RuntimeError::InvalidPathError("Directory name terminates in \"..\"".to_string()) })?; let directory = inner_folder.to_str().ok_or_else(|| { RuntimeError::InvalidPathError( "Directory name contains invalid UTF-8 characters".to_string(), ) })?; tar(dir, directory.to_string(), skip_symlinks, out) .map_err(|e| RuntimeError::ArchiveCreationError("tarball".to_string(), Box::new(e))) } /// Writes a tarball of `dir` in `out`. /// /// The content of `src_dir` will be saved in the archive as a folder named `inner_folder`. fn tar<W>( src_dir: &Path, inner_folder: String, skip_symlinks: bool, out: W, ) -> Result<(), RuntimeError> where W: std::io::Write, { let mut tar_builder = Builder::new(out); tar_builder.follow_symlinks(!skip_symlinks); // Recursively adds the content of src_dir into the archive stream tar_builder .append_dir_all(inner_folder, src_dir) .map_err(|e| { RuntimeError::IoError( format!( "Failed to append the content of '{}' to the TAR archive", src_dir.to_str().unwrap_or("file") ), e, ) })?; // Finish the archive tar_builder.into_inner().map_err(|e| { RuntimeError::IoError("Failed to finish writing the TAR archive".to_string(), e) })?; Ok(()) } /// Write a zip of `dir` in `out`. /// /// The target directory will be saved as a top-level directory in the archive. /// /// For example, consider this directory structure: /// /// ```ignore /// a /// └── b /// └── c /// ├── e /// ├── f /// └── g /// ``` /// /// Making a zip out of `"a/b/c"` will result in this archive content: /// /// ```ignore /// c /// ├── e /// ├── f /// └── g /// ``` fn create_zip_from_directory<W>( out: W, directory: &Path, skip_symlinks: bool, ) -> Result<(), RuntimeError> where W: std::io::Write + std::io::Seek, { let options = write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let mut paths_queue: Vec<PathBuf> = vec![directory.to_path_buf()]; let zip_root_folder_name = directory.file_name().ok_or_else(|| { RuntimeError::InvalidPathError("Directory name terminates in \"..\"".to_string()) })?; let mut zip_writer = ZipWriter::new(out); let mut buffer = Vec::new(); while !paths_queue.is_empty() { let next = paths_queue.pop().ok_or_else(|| { RuntimeError::ArchiveCreationDetailError("Could not get path from queue".to_string()) })?; let current_dir = next.as_path(); let directory_entry_iterator = std::fs::read_dir(current_dir) .map_err(|e| RuntimeError::IoError("Could not read directory".to_string(), e))?; let zip_directory = Path::new(zip_root_folder_name).join( current_dir.strip_prefix(directory).map_err(|_| { RuntimeError::ArchiveCreationDetailError( "Could not append base directory".to_string(), ) })?, ); for entry in directory_entry_iterator { let entry_path = entry .ok() .ok_or_else(|| { RuntimeError::InvalidPathError( "Directory name terminates in \"..\"".to_string(), ) })? .path(); let entry_metadata = std::fs::metadata(entry_path.clone()).map_err(|e| { RuntimeError::IoError( format!( "Could not get file metadata of '{}'", entry_path.to_string_lossy() ) .to_string(), e, ) })?; if entry_metadata.file_type().is_symlink() && skip_symlinks { continue; } let current_entry_name = entry_path.file_name().ok_or_else(|| { RuntimeError::InvalidPathError("Invalid file or directory name".to_string()) })?; // To let every software correctly parse the file structure in ZIP files that are produced // on any platform (esp. Windows), always use forward slashes. The documentation: // https://users.cs.jmu.edu/buchhofp/forensics/formats/pkzip.html let relative_path = if cfg!(windows) { let branch = zip_directory .as_os_str() .to_string_lossy() .trim_end_matches(r"\") // every branch ends with two backslashes "\\". .replace(r"\", "/"); // every branch uses backslash "\" as path separators. let leaf = current_entry_name.to_string_lossy(); format!("{branch}/{leaf}") // construct a Unix-style path in the simplest way. } else { zip_directory .join(current_entry_name) .into_os_string() .to_string_lossy() .into_owned() }; if entry_metadata.is_file() { let mut f = File::open(&entry_path) .map_err(|e| RuntimeError::IoError("Could not open file".to_string(), e))?; f.read_to_end(&mut buffer).map_err(|e| { RuntimeError::IoError("Could not read from file".to_string(), e) })?; zip_writer.start_file(relative_path, options).map_err(|_| { RuntimeError::ArchiveCreationDetailError( "Could not add file path to ZIP".to_string(), ) })?; zip_writer.write(buffer.as_ref()).map_err(|_| { RuntimeError::ArchiveCreationDetailError( "Could not write file to ZIP".to_string(), ) })?; buffer.clear(); } else if entry_metadata.is_dir() { zip_writer .add_directory(relative_path, options) .map_err(|_| { RuntimeError::ArchiveCreationDetailError( "Could not add directory path to ZIP".to_string(), ) })?; paths_queue.push(entry_path.clone()); } } } zip_writer.finish().map_err(|_| { RuntimeError::ArchiveCreationDetailError("Could not finish writing ZIP archive".to_string()) })?; Ok(()) } /// Writes a zip of `dir` in `out`. /// /// The content of `src_dir` will be saved in the archive as the folder named . fn zip_data<W>(src_dir: &Path, skip_symlinks: bool, mut out: W) -> Result<(), RuntimeError> where W: std::io::Write, { let mut data = Vec::new(); let memory_file = Cursor::new(&mut data); create_zip_from_directory(memory_file, src_dir, skip_symlinks).map_err(|e| { RuntimeError::ArchiveCreationError( "Failed to create the ZIP archive".to_string(), Box::new(e), ) })?; out.write_all(data.as_mut_slice()) .map_err(|e| RuntimeError::IoError("Failed to write the ZIP archive".to_string(), e))?; Ok(()) } fn zip_dir<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), RuntimeError> where W: std::io::Write, { let inner_folder = dir.file_name().ok_or_else(|| { RuntimeError::InvalidPathError("Directory name terminates in \"..\"".to_string()) })?; inner_folder.to_str().ok_or_else(|| { RuntimeError::InvalidPathError( "Directory name contains invalid UTF-8 characters".to_string(), ) })?; zip_data(dir, skip_symlinks, out) .map_err(|e| RuntimeError::ArchiveCreationError("zip".to_string(), Box::new(e))) } ================================================ FILE: src/args.rs ================================================ use std::fmt::Display; use std::net::IpAddr; use std::path::PathBuf; use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue}; use clap::{Parser, ValueEnum, ValueHint}; use crate::auth; use crate::listing::{SortingMethod, SortingOrder}; use crate::renderer::ThemeSlug; #[derive(ValueEnum, Clone)] pub enum MediaType { Image, Audio, Video, } #[derive(Debug, ValueEnum, Clone, Default, Copy)] pub enum DuplicateFile { #[default] Error, Overwrite, Rename, } #[derive(ValueEnum, Clone)] pub enum SizeDisplay { Human, Exact, } impl Display for SizeDisplay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SizeDisplay::Human => write!(f, "human"), SizeDisplay::Exact => write!(f, "exact"), } } } #[derive(Debug, ValueEnum, Clone, Copy, Default)] pub enum LogColor { #[default] Auto, Always, Never, } #[derive(Parser)] #[command(name = "miniserve", author, about, version)] pub struct CliArgs { /// Be verbose, includes emitting access logs #[arg(short = 'v', long = "verbose", env = "MINISERVE_VERBOSE")] pub verbose: bool, /// Which path to serve #[arg(value_hint = ValueHint::AnyPath, env = "MINISERVE_PATH")] pub path: Option<PathBuf>, /// The path to where file uploads will be written to before being moved to their /// correct location. It's wise to make sure that this directory will be written to /// disk and not into memory. /// /// This value will only be used **IF** file uploading is enabled. If this option is /// not set, the operating system default temporary directory will be used. #[arg( long = "temp-directory", value_hint = ValueHint::FilePath, requires = "allowed_upload_dir", value_parser(validate_is_dir_and_exists), env = "MINISERVER_TEMP_UPLOAD_DIRECTORY") ] pub temp_upload_directory: Option<PathBuf>, /// The name of a directory index file to serve, like "index.html" /// /// Normally, when miniserve serves a directory, it creates a listing for that directory. /// However, if a directory contains this file, miniserve will serve that file instead. #[arg(long, value_hint = ValueHint::FilePath, env = "MINISERVE_INDEX")] pub index: Option<PathBuf>, /// Activate SPA (Single Page Application) mode /// /// This will cause the file given by --index to be served for all non-existing file paths. In /// effect, this will serve the index file whenever a 404 would otherwise occur in order to /// allow the SPA router to handle the request instead. #[arg(long, requires = "index", env = "MINISERVE_SPA")] pub spa: bool, /// Reduce output and silence warnings. #[arg(long, env = "MINISERVE_QUIET")] pub quiet: bool, /// Activate Pretty URLs mode /// /// This will cause the server to serve the equivalent `.html` file indicated by the path. /// /// `/about` will try to find `about.html` and serve it. #[arg(long, env = "MINISERVE_PRETTY_URLS")] pub pretty_urls: bool, /// Port to use #[arg( short = 'p', long = "port", default_value = "8080", env = "MINISERVE_PORT" )] pub port: u16, /// Interface to listen on #[arg( short = 'i', long = "interfaces", value_parser(parse_interface), num_args(1), env = "MINISERVE_INTERFACE" )] pub interfaces: Vec<IpAddr>, /// Set authentication /// /// Currently supported formats: /// username:password, username:sha256:hash, username:sha512:hash /// (e.g. joe:123, joe:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3) #[arg( short = 'a', long = "auth", value_parser(parse_auth), num_args(1), env = "MINISERVE_AUTH", verbatim_doc_comment )] pub auth: Vec<auth::RequiredAuth>, /// Read authentication values from a file /// /// Example file content: /// /// joe:123 /// bob:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3 /// bill: #[arg(long, value_hint = ValueHint::FilePath, env = "MINISERVE_AUTH_FILE", verbatim_doc_comment)] pub auth_file: Option<PathBuf>, /// Use a specific route prefix #[arg(long = "route-prefix", env = "MINISERVE_ROUTE_PREFIX")] pub route_prefix: Option<String>, /// Generate a random 6-hexdigit route #[arg( long = "random-route", conflicts_with("route_prefix"), env = "MINISERVE_RANDOM_ROUTE" )] pub random_route: bool, /// Hide symlinks in listing and prevent them from being followed #[arg(short = 'P', long = "no-symlinks", env = "MINISERVE_NO_SYMLINKS")] pub no_symlinks: bool, /// Show hidden files #[arg(short = 'H', long = "hidden", env = "MINISERVE_HIDDEN")] pub hidden: bool, /// Default sorting method for file list #[arg( short = 'S', long = "default-sorting-method", default_value = "name", ignore_case = true, env = "MINISERVE_DEFAULT_SORTING_METHOD" )] pub default_sorting_method: SortingMethod, /// Default sorting order for file list #[arg( short = 'O', long = "default-sorting-order", default_value = "desc", ignore_case = true, env = "MINISERVE_DEFAULT_SORTING_ORDER" )] pub default_sorting_order: SortingOrder, /// Default color scheme #[arg( short = 'c', long = "color-scheme", default_value = "squirrel", ignore_case = true, env = "MINISERVE_COLOR_SCHEME" )] pub color_scheme: ThemeSlug, /// Default color scheme #[arg( short = 'd', long = "color-scheme-dark", default_value = "archlinux", ignore_case = true, env = "MINISERVE_COLOR_SCHEME_DARK" )] pub color_scheme_dark: ThemeSlug, /// Enable QR code display #[arg(short = 'q', long = "qrcode", env = "MINISERVE_QRCODE")] pub qrcode: bool, /// Enable file uploading (and optionally specify for which directory) /// /// The provided path is not a physical file system path. Instead, it's relative to the serve /// dir. For instance, if the serve dir is '/home/hello', set this to '/upload' to allow /// uploading to '/home/hello/upload'. /// When specified via environment variable, a path always needs to be specified. #[arg(short = 'u', long = "upload-files", value_hint = ValueHint::FilePath, num_args(0..=1), value_delimiter(','), env = "MINISERVE_ALLOWED_UPLOAD_DIR")] pub allowed_upload_dir: Option<Vec<PathBuf>>, /// Configure amount of concurrent uploads when visiting the website. Must have /// upload-files option enabled for this setting to matter. /// /// For example, a value of 4 would mean that the web browser will only upload /// 4 files at a time to the web server when using the web browser interface. /// /// When the value is kept at 0, it attempts to resolve all the uploads at once /// in the web browser. /// /// NOTE: Web pages have a limit of how many active HTTP connections that they /// can make at one time, so even though you might set a concurrency limit of /// 100, the browser might only make progress on the max amount of connections /// it allows the web page to have open. #[arg( long = "web-upload-files-concurrency", env = "MINISERVE_WEB_UPLOAD_CONCURRENCY", default_value = "0" )] pub web_upload_concurrency: usize, /// Set unix file permissions of uploaded files /// /// This takes an octal number, for example 0600. By default 0666 & ~umask is used to simulate /// the system's default behavior. #[cfg(unix)] #[arg( long = "chmod", value_parser(parse_file_mode), env = "MINISERVE_CHMOD", requires = "allowed_upload_dir" )] pub chmod: Option<u16>, /// Enable recursive directory size calculation /// /// This is disabled by default because it is a potentially fairly IO intensive operation. #[arg(long = "directory-size", env = "MINISERVE_DIRECTORY_SIZE")] pub directory_size: bool, /// Enable creating directories #[arg( short = 'U', long = "mkdir", requires = "allowed_upload_dir", env = "MINISERVE_MKDIR_ENABLED" )] pub mkdir_enabled: bool, /// Enable creating pastebin 'pastes' /// /// 'pastes' are plaintext files created in the current directory. Creation requires file /// uploads be enabled. #[arg( long = "pastebin", requires = "allowed_upload_dir", env = "MINISERVE_PASTEBIN_ENABLED" )] pub pastebin_enabled: bool, /// Specify uploadable media types #[arg( short = 'm', long = "media-type", requires = "allowed_upload_dir", env = "MINISERVE_MEDIA_TYPE" )] pub media_type: Option<Vec<MediaType>>, /// Directly specify the uploadable media type expression #[arg( short = 'M', long = "raw-media-type", requires = "allowed_upload_dir", conflicts_with = "media_type", env = "MINISERVE_RAW_MEDIA_TYPE" )] pub media_type_raw: Option<String>, /// What to do if existing files with same name is present during file upload /// /// If you enable renaming files, the renaming will occur by /// adding a numerical suffix to the filename before the final /// extension. For example file.txt will be uploaded as /// file-1.txt, the number will be increased until an available /// filename is found. #[arg( short = 'o', long = "on-duplicate-files", env = "MINISERVE_ON_DUPLICATE_FILES", default_value = "error" )] pub on_duplicate_files: DuplicateFile, /// Enable file and directory deletion (and optionally specify for which directory) #[arg( short = 'R', long = "rm-files", value_hint = ValueHint::DirPath, num_args(0..=1), value_delimiter(','), env = "MINISERVE_ALLOWED_RM_DIR" )] pub allowed_rm_dir: Option<Vec<PathBuf>>, /// Enable uncompressed tar archive generation #[arg(short = 'r', long = "enable-tar", env = "MINISERVE_ENABLE_TAR")] pub enable_tar: bool, /// Enable gz-compressed tar archive generation #[arg(short = 'g', long = "enable-tar-gz", env = "MINISERVE_ENABLE_TAR_GZ")] pub enable_tar_gz: bool, /// Enable zip archive generation /// /// WARNING: Zipping large directories can result in out-of-memory exception /// because zip generation is done in memory and cannot be sent on the fly #[arg(short = 'z', long = "enable-zip", env = "MINISERVE_ENABLE_ZIP")] pub enable_zip: bool, /// Compress response /// /// WARNING: Enabling this option may slow down transfers due to CPU overhead, so it is /// disabled by default. /// /// Only enable this option if you know that your users have slow connections or if you want to /// minimize your server's bandwidth usage. #[arg( short = 'C', long = "compress-response", env = "MINISERVE_COMPRESS_RESPONSE" )] pub compress_response: bool, /// List directories first #[arg(short = 'D', long = "dirs-first", env = "MINISERVE_DIRS_FIRST")] pub dirs_first: bool, /// Shown instead of host in page title and heading #[arg(short = 't', long = "title", env = "MINISERVE_TITLE")] pub title: Option<String>, /// Inserts custom headers into the responses. Specify each header as a 'Header:Value' pair. /// This parameter can be used multiple times to add multiple headers. /// /// Example: /// --header "Header1:Value1" --header "Header2:Value2" /// (If a header is already set or previously inserted, it will not be overwritten.) #[arg( long = "header", value_parser(parse_header), num_args(1), env = "MINISERVE_HEADER" )] pub header: Vec<HeaderMap>, /// Visualize symlinks in directory listing #[arg( short = 'l', long = "show-symlink-info", env = "MINISERVE_SHOW_SYMLINK_INFO" )] pub show_symlink_info: bool, /// Hide version footer #[arg( short = 'F', long = "hide-version-footer", env = "MINISERVE_HIDE_VERSION_FOOTER" )] pub hide_version_footer: bool, /// Hide theme selector #[arg(long = "hide-theme-selector", env = "MINISERVE_HIDE_THEME_SELECTOR")] pub hide_theme_selector: bool, /// If enabled, display a wget command to recursively download the current directory #[arg( short = 'W', long = "show-wget-footer", env = "MINISERVE_SHOW_WGET_FOOTER" )] pub show_wget_footer: bool, /// Generate completion file for a shell #[arg(long = "print-completions", value_name = "shell")] pub print_completions: Option<clap_complete::Shell>, /// Generate man page #[arg(long = "print-manpage")] pub print_manpage: bool, /// TLS certificate to use #[cfg(feature = "tls")] #[arg(long = "tls-cert", requires = "tls_key", value_hint = ValueHint::FilePath, env = "MINISERVE_TLS_CERT")] pub tls_cert: Option<PathBuf>, /// TLS private key to use #[cfg(feature = "tls")] #[arg(long = "tls-key", requires = "tls_cert", value_hint = ValueHint::FilePath, env = "MINISERVE_TLS_KEY")] pub tls_key: Option<PathBuf>, /// Enable README.md rendering in directories #[arg(long, env = "MINISERVE_README")] pub readme: bool, /// Disable indexing /// /// This will prevent directory listings from being generated /// and return an error instead. #[arg(short = 'I', long, env = "MINISERVE_DISABLE_INDEXING")] pub disable_indexing: bool, /// Enable read-only WebDAV support (PROPFIND requests) #[arg(long, env = "MINISERVE_ENABLE_WEBDAV")] pub enable_webdav: bool, /// Show served file size in exact bytes #[arg(long, default_value_t = SizeDisplay::Human, env = "MINISERVE_SIZE_DISPLAY")] pub size_display: SizeDisplay, /// Optional external URL (e.g., 'http://external.example.com:8081') prepended to file links in listings. /// /// Allows serving files from a different URL than the browsing instance. Useful for setups like: /// one authenticated instance for browsing, linking files (via this option) to a second, /// non-indexed (-I) instance for direct downloads. This obscures the full file list on /// the download server, while users can still copy direct file URLs for sharing. /// The external URL is put verbatim in front of the relative location of the file, including the protocol. /// The user should take care this results in a valid URL, no further checks are being done. #[arg(long = "file-external-url", env = "MINISERVE_FILE_EXTERNAL_URL")] pub file_external_url: Option<String>, /// Set the color style of the log output /// /// "auto" (default) enables colors only when the output is a terminal. /// "always" always enables colors. /// "never" always disables colors. #[arg( long = "log-color", env = "MINISERVE_LOG_COLOR", default_value = "auto" )] pub log_color: LogColor, } /// Checks whether an interface is valid, i.e. it can be parsed into an IP address fn parse_interface(src: &str) -> Result<IpAddr, std::net::AddrParseError> { src.parse::<IpAddr>() } /// Validate that a path passed in is a directory and it exists. fn validate_is_dir_and_exists(s: &str) -> Result<PathBuf, String> { let path = PathBuf::from(s); if path.exists() && path.is_dir() { Ok(path) } else { Err(format!( "Upload temporary directory must exist and be a directory. \ Validate that path {path:?} meets those requirements." )) } } #[derive(Clone, Debug, thiserror::Error)] pub enum AuthParseError { /// Might occur if the HTTP credential string does not respect the expected format #[error( "Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash" )] InvalidAuthFormat, /// Might occur if the hash method is neither sha256 nor sha512 #[error("{0} is not a valid hashing method. Expected sha256 or sha512")] InvalidHashMethod(String), /// Might occur if the HTTP auth hash password is not a valid hex code #[error("Invalid format for password hash. Expected hex code")] InvalidPasswordHash, /// Might occur if the HTTP auth password exceeds 255 characters #[error("HTTP password length exceeds 255 characters")] PasswordTooLong, } /// Parse authentication requirement pub fn parse_auth(src: &str) -> Result<auth::RequiredAuth, AuthParseError> { use AuthParseError as E; let mut split = src.splitn(3, ':'); let invalid_auth_format = Err(E::InvalidAuthFormat); let username = match split.next() { Some(username) => username, None => return invalid_auth_format, }; // second_part is either password in username:password or method in username:method:hash let second_part = match split.next() { // This allows empty passwords, as the spec does not forbid it Some(password) => password, None => return invalid_auth_format, }; let password = if let Some(hash_hex) = split.next() { let hash_bin = hex::decode(hash_hex).map_err(|_| E::InvalidPasswordHash)?; match second_part { "sha256" => auth::RequiredAuthPassword::Sha256(hash_bin), "sha512" => auth::RequiredAuthPassword::Sha512(hash_bin), _ => return Err(E::InvalidHashMethod(second_part.to_owned())), } } else { // To make it Windows-compatible, the password needs to be shorter than 255 characters. // After 255 characters, Windows will truncate the value. // As for the username, the spec does not mention a limit in length if second_part.len() > 255 { return Err(E::PasswordTooLong); } auth::RequiredAuthPassword::Plain(second_part.to_owned()) }; Ok(auth::RequiredAuth { username: username.to_owned(), password, }) } /// Custom header parser (allow multiple headers input) pub fn parse_header(src: &str) -> Result<HeaderMap, httparse::Error> { let mut headers = [httparse::EMPTY_HEADER; 1]; let header = format!("{src}\n"); httparse::parse_headers(header.as_bytes(), &mut headers)?; let mut header_map = HeaderMap::new(); if let Some(h) = headers.first() && h.name != httparse::EMPTY_HEADER.name { header_map.insert( HeaderName::from_bytes(h.name.as_bytes()).unwrap(), HeaderValue::from_bytes(h.value).unwrap(), ); } Ok(header_map) } #[cfg(unix)] pub fn parse_file_mode(src: &str) -> Result<u16, std::num::ParseIntError> { u16::from_str_radix(src, 8) } #[rustfmt::skip] #[cfg(test)] mod tests { use super::*; use rstest::rstest; use pretty_assertions::assert_eq; /// Helper function that creates a `RequiredAuth` structure fn create_required_auth(username: &str, password: &str, encrypt: &str) -> auth::RequiredAuth { use auth::*; use RequiredAuthPassword::*; let password = match encrypt { "plain" => Plain(password.to_owned()), "sha256" => Sha256(hex::decode(password).unwrap()), "sha512" => Sha512(hex::decode(password).unwrap()), _ => panic!("Unknown encryption type"), }; auth::RequiredAuth { username: username.to_owned(), password, } } #[rstest( auth_string, username, password, encrypt, case("username:password", "username", "password", "plain"), case("username:sha256:abcd", "username", "abcd", "sha256"), case("username:sha512:abcd", "username", "abcd", "sha512") )] fn parse_auth_valid(auth_string: &str, username: &str, password: &str, encrypt: &str) { assert_eq!( parse_auth(auth_string).unwrap(), create_required_auth(username, password, encrypt), ); } #[rstest( auth_string, err_msg, case( "foo", "Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash" ), case( "username:blahblah:abcd", "blahblah is not a valid hashing method. Expected sha256 or sha512" ), case( "username:sha256:invalid", "Invalid format for password hash. Expected hex code" ), case( "username:sha512:invalid", "Invalid format for password hash. Expected hex code" ), )] fn parse_auth_invalid(auth_string: &str, err_msg: &str) { let err = parse_auth(auth_string).unwrap_err(); assert_eq!(format!("{err}"), err_msg.to_owned()); } } ================================================ FILE: src/auth.rs ================================================ use actix_web::{HttpMessage, dev::ServiceRequest, web}; use actix_web_httpauth::extractors::basic::BasicAuth; use sha2::{Digest, Sha256, Sha512}; use crate::errors::RuntimeError; #[derive(Clone, Debug)] /// HTTP Basic authentication parameters pub struct BasicAuthParams { pub username: String, pub password: String, } impl From<BasicAuth> for BasicAuthParams { fn from(auth: BasicAuth) -> Self { Self { username: auth.user_id().to_string(), password: auth.password().unwrap_or_default().to_string(), } } } #[derive(Clone, Debug, PartialEq, Eq)] /// `password` field of `RequiredAuth` pub enum RequiredAuthPassword { Plain(String), Sha256(Vec<u8>), Sha512(Vec<u8>), } #[derive(Clone, Debug, PartialEq, Eq)] /// Authentication structure to match `BasicAuthParams` against pub struct RequiredAuth { pub username: String, pub password: RequiredAuthPassword, } /// Return `true` if `basic_auth` is matches any of `required_auth` pub fn match_auth(basic_auth: &BasicAuthParams, required_auth: &[RequiredAuth]) -> bool { required_auth .iter() .any(|RequiredAuth { username, password }| { basic_auth.username == *username && compare_password(&basic_auth.password, password) }) } /// Return `true` if `basic_auth_pwd` meets `required_auth_pwd`'s requirement pub fn compare_password(basic_auth_pwd: &str, required_auth_pwd: &RequiredAuthPassword) -> bool { match &required_auth_pwd { RequiredAuthPassword::Plain(required_password) => *basic_auth_pwd == *required_password, RequiredAuthPassword::Sha256(password_hash) => { compare_hash::<Sha256>(basic_auth_pwd, password_hash) } RequiredAuthPassword::Sha512(password_hash) => { compare_hash::<Sha512>(basic_auth_pwd, password_hash) } } } /// Return `true` if hashing of `password` by `T` algorithm equals to `hash` pub fn compare_hash<T: Digest>(password: &str, hash: &[u8]) -> bool { get_hash::<T>(password) == hash } /// Get hash of a `text` pub fn get_hash<T: Digest>(text: &str) -> Vec<u8> { let mut hasher = T::new(); hasher.update(text); hasher.finalize().to_vec() } pub struct CurrentUser { pub name: String, } pub async fn handle_auth( req: ServiceRequest, cred: BasicAuth, ) -> actix_web::Result<ServiceRequest, (actix_web::Error, ServiceRequest)> { let required_auth = &req .app_data::<web::Data<crate::MiniserveConfig>>() .unwrap() .auth; req.extensions_mut().insert(CurrentUser { name: cred.user_id().to_string(), }); if match_auth(&cred.into(), required_auth) { Ok(req) } else { Err((RuntimeError::InvalidHttpCredentials.into(), req)) } } #[rustfmt::skip] #[cfg(test)] mod tests { use super::*; use rstest::{rstest, fixture}; use pretty_assertions::assert_eq; /// Return a hashing function corresponds to given name fn get_hash_func(name: &str) -> impl FnOnce(&str) -> Vec<u8> { match name { "sha256" => get_hash::<Sha256>, "sha512" => get_hash::<Sha512>, _ => panic!("Invalid hash method"), } } #[rstest( password, hash_method, hash, case("abc", "sha256", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), case("abc", "sha512", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"), )] fn test_get_hash(password: &str, hash_method: &str, hash: &str) { let hash_func = get_hash_func(hash_method); let expected = hex::decode(hash).expect("Provided hash is not a valid hex code"); let received = hash_func(password); assert_eq!(received, expected); } /// Helper function that creates a `RequiredAuth` structure and encrypt `password` if necessary fn create_required_auth(username: &str, password: &str, encrypt: &str) -> RequiredAuth { use RequiredAuthPassword::*; let password = match encrypt { "plain" => Plain(password.to_owned()), "sha256" => Sha256(get_hash::<sha2::Sha256>(password)), "sha512" => Sha512(get_hash::<sha2::Sha512>(password)), _ => panic!("Unknown encryption type"), }; RequiredAuth { username: username.to_owned(), password, } } #[rstest( should_pass, param_username, param_password, required_username, required_password, encrypt, case(true, "obi", "hello there", "obi", "hello there", "plain"), case(false, "obi", "hello there", "obi", "hi!", "plain"), case(true, "obi", "hello there", "obi", "hello there", "sha256"), case(false, "obi", "hello there", "obi", "hi!", "sha256"), case(true, "obi", "hello there", "obi", "hello there", "sha512"), case(false, "obi", "hello there", "obi", "hi!", "sha512") )] fn test_single_auth( should_pass: bool, param_username: &str, param_password: &str, required_username: &str, required_password: &str, encrypt: &str, ) { assert_eq!( match_auth( &BasicAuthParams { username: param_username.to_owned(), password: param_password.to_owned(), }, &[create_required_auth(required_username, required_password, encrypt)], ), should_pass, ) } /// Helper function that creates a sample of multiple accounts #[fixture] fn account_sample() -> Vec<RequiredAuth> { [ ("usr0", "pwd0", "plain"), ("usr1", "pwd1", "plain"), ("usr2", "pwd2", "sha256"), ("usr3", "pwd3", "sha256"), ("usr4", "pwd4", "sha512"), ("usr5", "pwd5", "sha512"), ] .iter() .map(|(username, password, encrypt)| create_required_auth(username, password, encrypt)) .collect() } #[rstest( username, password, case("usr0", "pwd0"), case("usr1", "pwd1"), case("usr2", "pwd2"), case("usr3", "pwd3"), case("usr4", "pwd4"), case("usr5", "pwd5"), )] fn test_multiple_auth_pass( account_sample: Vec<RequiredAuth>, username: &str, password: &str, ) { assert!(match_auth( &BasicAuthParams { username: username.to_owned(), password: password.to_owned(), }, &account_sample, )); } #[rstest] fn test_multiple_auth_wrong_username(account_sample: Vec<RequiredAuth>) { assert_eq!(match_auth( &BasicAuthParams { username: "unregistered user".to_owned(), password: "pwd0".to_owned(), }, &account_sample, ), false); } #[rstest( username, password, case("usr0", "pwd5"), case("usr1", "pwd4"), case("usr2", "pwd3"), case("usr3", "pwd2"), case("usr4", "pwd1"), case("usr5", "pwd0"), )] fn test_multiple_auth_wrong_password( account_sample: Vec<RequiredAuth>, username: &str, password: &str, ) { assert_eq!(match_auth( &BasicAuthParams { username: username.to_owned(), password: password.to_owned(), }, &account_sample, ), false); } } ================================================ FILE: src/config.rs ================================================ use std::{ fs::File, io::{BufRead, BufReader}, net::{IpAddr, Ipv4Addr, Ipv6Addr}, path::{Path, PathBuf}, }; use actix_web::http::header::HeaderMap; use anyhow::{Context, Result, anyhow}; #[cfg(feature = "tls")] use rustls_pemfile as pemfile; #[cfg(unix)] use crate::file_utils::get_default_filemode; use crate::{ args::{CliArgs, DuplicateFile, LogColor, MediaType, parse_auth}, auth::RequiredAuth, file_utils::sanitize_path, listing::{SortingMethod, SortingOrder}, renderer::ThemeSlug, }; /// Possible characters for random routes const ROUTE_ALPHABET: [char; 16] = [ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', ]; #[derive(Debug, Clone)] /// Configuration of the Miniserve application pub struct MiniserveConfig { /// Enable verbose mode pub verbose: bool, /// Path to be served by miniserve pub path: std::path::PathBuf, /// Temporary directory that should be used when files are uploaded to the server pub temp_upload_directory: Option<std::path::PathBuf>, /// Port on which miniserve will be listening pub port: u16, /// IP address(es) on which miniserve will be available pub interfaces: Vec<IpAddr>, /// Enable HTTP basic authentication pub auth: Vec<RequiredAuth>, /// If false, miniserve will serve the current working directory pub path_explicitly_chosen: bool, /// Enable symlink resolution pub no_symlinks: bool, /// Show hidden files pub show_hidden: bool, /// Default sorting method pub default_sorting_method: SortingMethod, /// Default sorting order pub default_sorting_order: SortingOrder, /// Route prefix; Either empty or prefixed with slash pub route_prefix: String, /// 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, /// Well-known css route (prefixed if route_prefix is provided) pub css_route: String, /// Default color scheme pub default_color_scheme: ThemeSlug, /// Default dark mode color scheme pub default_color_scheme_dark: ThemeSlug, /// The name of a directory index file to serve, like "index.html" /// /// Normally, when miniserve serves a directory, it creates a listing for that directory. /// However, if a directory contains this file, miniserve will serve that file instead. pub index: Option<std::path::PathBuf>, /// Activate SPA (Single Page Application) mode /// /// This will cause the file given by `index` to be served for all non-existing file paths. In /// effect, this will serve the index file whenever a 404 would otherwise occur in order to /// allow the SPA router to handle the request instead. pub spa: bool, /// Reduce output and silence warnings. pub quiet: bool, /// Activate Pretty URLs mode /// /// This will cause the server to serve the equivalent `.html` file indicated by the path. /// /// `/about` will try to find `about.html` and serve it. pub pretty_urls: bool, /// Enable QR code display pub show_qrcode: bool, /// Enable recursive directory size calculation pub directory_size: bool, /// Enable creating directories pub mkdir_enabled: bool, /// Enable file upload pub file_upload: bool, /// Enable pastepin creation pub pastebin_enabled: bool, /// Max amount of concurrency when uploading multiple files pub web_upload_concurrency: usize, /// chmod permissions of uploaded files #[cfg(unix)] pub upload_chmod: u16, /// List of allowed upload directories pub allowed_upload_dir: Vec<String>, /// HTML accept attribute value pub uploadable_media_type: Option<String>, /// What to do on upload if filename already exists pub on_duplicate_files: DuplicateFile, /// Enable file and directory deletion pub rm_enabled: bool, /// List of allowed deletion directories pub allowed_rm_dir: Vec<String>, /// If false, creation of uncompressed tar archives is disabled pub tar_enabled: bool, /// If false, creation of gz-compressed tar archives is disabled pub tar_gz_enabled: bool, /// If false, creation of zip archives is disabled pub zip_enabled: bool, /// Enable compress response pub compress_response: bool, /// If enabled, directories are listed first pub dirs_first: bool, /// Shown instead of host in page title and heading pub title: Option<String>, /// If specified, header will be added pub header: Vec<HeaderMap>, /// If specified, symlink destination will be shown pub show_symlink_info: bool, /// If enabled, version footer is hidden pub hide_version_footer: bool, /// If enabled, theme selector is hidden pub hide_theme_selector: bool, /// If enabled, display a wget command to recursively download the current directory pub show_wget_footer: bool, /// If enabled, render the readme from the current directory pub readme: bool, /// If enabled, indexing is disabled. pub disable_indexing: bool, /// If enabled, respond to WebDAV requests (read-only). pub webdav_enabled: bool, /// If enabled, will show in exact byte size of the file pub show_exact_bytes: bool, /// If set, use provided rustls config for TLS #[cfg(feature = "tls")] pub tls_rustls_config: Option<rustls::ServerConfig>, #[cfg(not(feature = "tls"))] pub tls_rustls_config: Option<()>, /// Optional external URL to prepend to file links in listings pub file_external_url: Option<String>, /// Color choice for the log output pub log_color: LogColor, } impl MiniserveConfig { /// Parses the command line arguments pub fn try_from_args(args: CliArgs) -> Result<Self> { let interfaces = if !args.interfaces.is_empty() { args.interfaces } else { vec![ IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), ] }; let route_prefix = match (args.route_prefix, args.random_route) { (Some(prefix), _) => format!("/{}", prefix.trim_matches('/')), (_, true) => format!("/{}", nanoid::nanoid!(6, &ROUTE_ALPHABET)), _ => "".to_owned(), }; let mut auth = args.auth; if let Some(path) = args.auth_file { let file = File::open(path)?; let lines = BufReader::new(file).lines(); for line in lines { auth.push(parse_auth(line?.as_str())?); } } // Format some well-known routes at paths that are very unlikely to conflict with real // files. // 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, 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"), ) }; let default_color_scheme = args.color_scheme; let default_color_scheme_dark = args.color_scheme_dark; let path_explicitly_chosen = args.path.is_some() || args.index.is_some(); let port = match args.port { 0 => port_check::free_local_port().context("No free ports available")?, _ => args.port, }; #[cfg(feature = "tls")] let tls_rustls_server_config = if let (Some(tls_cert), Some(tls_key)) = (args.tls_cert, args.tls_key) { let cert_file = &mut BufReader::new( File::open(&tls_cert) .context(format!("Couldn't access TLS certificate {tls_cert:?}"))?, ); let key_file = &mut BufReader::new( File::open(&tls_key).context(format!("Couldn't access TLS key {tls_key:?}"))?, ); let cert_chain = pemfile::certs(cert_file) .map(|cert| cert.expect("Invalid certificate in certificate chain")) .collect(); let private_key = pemfile::private_key(key_file) .context("Reading private key file")? .expect("No private key found"); let server_config = rustls::ServerConfig::builder() .with_no_client_auth() .with_single_cert(cert_chain, private_key)?; Some(server_config) } else { None }; #[cfg(not(feature = "tls"))] let tls_rustls_server_config = None; let uploadable_media_type = args.media_type_raw.or_else(|| { args.media_type.map(|types| { types .into_iter() .map(|t| match t { MediaType::Audio => "audio/*", MediaType::Image => "image/*", MediaType::Video => "video/*", }) .collect::<Vec<_>>() .join(",") }) }); let allowed_upload_dir = args .allowed_upload_dir .as_ref() .map(|paths| validate_allowed_paths(paths, args.hidden)) .transpose()? .unwrap_or_default(); let allowed_rm_dir = args .allowed_rm_dir .as_ref() .map(|paths| validate_allowed_paths(paths, args.hidden)) .transpose()? .unwrap_or_default(); let show_exact_bytes = match args.size_display { crate::args::SizeDisplay::Human => false, crate::args::SizeDisplay::Exact => true, }; #[cfg(unix)] let upload_chmod = args.chmod.unwrap_or_else(get_default_filemode); Ok(Self { verbose: args.verbose, path: args.path.unwrap_or_else(|| PathBuf::from(".")), temp_upload_directory: args.temp_upload_directory, port, interfaces, auth, path_explicitly_chosen, no_symlinks: args.no_symlinks, show_hidden: args.hidden, default_sorting_method: args.default_sorting_method, default_sorting_order: args.default_sorting_order, route_prefix, healthcheck_route, api_route, favicon_route, css_route, default_color_scheme, default_color_scheme_dark, index: args.index, spa: args.spa, quiet: args.quiet, pretty_urls: args.pretty_urls, on_duplicate_files: args.on_duplicate_files, show_qrcode: args.qrcode, directory_size: args.directory_size, mkdir_enabled: args.mkdir_enabled, file_upload: args.allowed_upload_dir.is_some(), pastebin_enabled: args.pastebin_enabled, web_upload_concurrency: args.web_upload_concurrency, #[cfg(unix)] upload_chmod, allowed_upload_dir, uploadable_media_type, rm_enabled: args.allowed_rm_dir.is_some(), allowed_rm_dir, tar_enabled: args.enable_tar, tar_gz_enabled: args.enable_tar_gz, zip_enabled: args.enable_zip, dirs_first: args.dirs_first, title: args.title, header: args.header, show_symlink_info: args.show_symlink_info, hide_version_footer: args.hide_version_footer, hide_theme_selector: args.hide_theme_selector, show_wget_footer: args.show_wget_footer, readme: args.readme, disable_indexing: args.disable_indexing, webdav_enabled: args.enable_webdav, tls_rustls_config: tls_rustls_server_config, compress_response: args.compress_response, show_exact_bytes, file_external_url: args.file_external_url, log_color: args.log_color, }) } } fn validate_allowed_paths(paths: &[impl AsRef<Path>], allow_hidden: bool) -> Result<Vec<String>> { paths .iter() .map(|p| { sanitize_path(p, allow_hidden) .map(|p| p.display().to_string().replace('\\', "/")) .ok_or(anyhow!("Illegal path {:?}", p.as_ref())) }) .collect() } ================================================ FILE: src/consts.rs ================================================ use fast_qr::ECL; /// The error correction level to use for all QR code generation. pub const QR_EC_LEVEL: ECL = ECL::L; /// The margin size for the SVG QR code on the webpage. pub const SVG_QR_MARGIN: usize = 1; ================================================ FILE: src/errors.rs ================================================ use std::str::FromStr; use actix_web::{ HttpRequest, HttpResponse, ResponseError, body::{BoxBody, MessageBody}, dev::{ResponseHead, ServiceRequest, ServiceResponse}, http::{StatusCode, header}, middleware::Next, web, }; use thiserror::Error; use crate::{MiniserveConfig, renderer::render_error}; #[derive(Debug, Error)] pub enum StartupError { /// Any kind of IO errors #[error("{0}\ncaused by: {1}")] IoError(String, std::io::Error), /// In case miniserve was invoked without an interactive terminal and without an explicit path #[error("Refusing to start as no explicit serve path was set and no interactive terminal was attached Please set an explicit serve path like: `miniserve /my/path`")] NoExplicitPathAndNoTerminal, /// In case miniserve was invoked with --no-symlinks but the serve path is a symlink #[error("The -P|--no-symlinks option was provided but the serve path '{0}' is a symlink")] NoSymlinksOptionWithSymlinkServePath(String), #[error("The --enable-webdav option was provided, but the serve path '{0}' is a file")] WebdavWithFileServePath(String), } #[derive(Debug, Error)] pub enum RuntimeError { /// Any kind of IO errors #[error("{0}\ncaused by: {1}")] IoError(String, std::io::Error), /// Might occur during file upload, when processing the multipart request fails #[error("Failed to process multipart request\ncaused by: {0}")] MultipartError(String), /// Might occur during file upload #[error("File already exists, and the on_duplicate_files option is set to error out")] DuplicateFileError, /// Uploaded hash not correct #[error("File hash that was provided did not match checksum of uploaded file")] UploadHashMismatchError, /// Upload not allowed #[error("Upload not allowed to this directory")] UploadForbiddenError, /// Remove not allowed #[error("Remove not allowed to this directory")] RmForbiddenError, /// Any error related to an invalid path (failed to retrieve entry name, unexpected entry type, etc) #[error("Invalid path\ncaused by: {0}")] InvalidPathError(String), /// Might occur if the user has insufficient permissions to create an entry in a given directory #[error("Insufficient permissions to create file in {0}")] InsufficientPermissionsError(String), /// Any error related to parsing #[error("Failed to parse {0}\ncaused by: {1}")] ParseError(String, String), /// Might occur when the creation of an archive fails #[error("An error occurred while creating the {0}\ncaused by: {1}")] ArchiveCreationError(String, Box<RuntimeError>), /// More specific archive creation failure reason #[error("{0}")] ArchiveCreationDetailError(String), /// Might occur when the HTTP credentials are not correct #[error("Invalid credentials for HTTP authentication")] InvalidHttpCredentials, /// Might occur when an HTTP request is invalid #[error("Invalid HTTP request\ncaused by: {0}")] InvalidHttpRequestError(String), /// Might occur when trying to access a page that does not exist #[error("Route {0} could not be found")] RouteNotFoundError(String), } impl ResponseError for RuntimeError { fn status_code(&self) -> StatusCode { use RuntimeError as E; use StatusCode as S; match self { E::IoError(_, _) => S::INTERNAL_SERVER_ERROR, E::UploadHashMismatchError => S::BAD_REQUEST, E::MultipartError(_) => S::BAD_REQUEST, E::DuplicateFileError => S::CONFLICT, E::UploadForbiddenError => S::FORBIDDEN, E::RmForbiddenError => S::FORBIDDEN, E::InvalidPathError(_) => S::BAD_REQUEST, E::InsufficientPermissionsError(_) => S::FORBIDDEN, E::ParseError(_, _) => S::BAD_REQUEST, E::ArchiveCreationError(_, err) => err.status_code(), E::ArchiveCreationDetailError(_) => S::INTERNAL_SERVER_ERROR, E::InvalidHttpCredentials => S::UNAUTHORIZED, E::InvalidHttpRequestError(_) => S::BAD_REQUEST, E::RouteNotFoundError(_) => S::NOT_FOUND, } } fn error_response(&self) -> HttpResponse { log_error_chain(self.to_string()); let mut resp = HttpResponse::build(self.status_code()); if let Self::InvalidHttpCredentials = self { resp.append_header(( header::WWW_AUTHENTICATE, header::HeaderValue::from_static("Basic realm=\"miniserve\""), )); } resp.content_type(mime::TEXT_PLAIN_UTF_8) .body(self.to_string()) } } /// Middleware to convert plain-text error responses to user-friendly web pages pub async fn error_page_middleware( req: ServiceRequest, next: Next<impl MessageBody + 'static>, ) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> { let res = next.call(req).await?.map_into_boxed_body(); if (res.status().is_client_error() || res.status().is_server_error()) && res.request().path() != "/upload" && res .headers() .get(header::CONTENT_TYPE) .map(AsRef::as_ref) .and_then(|s| std::str::from_utf8(s).ok()) .and_then(|s| mime::Mime::from_str(s).ok()) .as_ref() .map(mime::Mime::essence_str) == Some(mime::TEXT_PLAIN.as_ref()) { let req = res.request().clone(); Ok(res.map_body(|head, body| map_error_page(&req, head, body))) } else { Ok(res) } } fn map_error_page(req: &HttpRequest, head: &mut ResponseHead, body: BoxBody) -> BoxBody { let error_msg = match body.try_into_bytes() { Ok(bytes) => bytes, Err(body) => return body, }; let error_msg = match std::str::from_utf8(&error_msg) { Ok(msg) => msg, _ => return BoxBody::new(error_msg), }; let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap(); let return_address = req .headers() .get(header::REFERER) .and_then(|h| h.to_str().ok()) .unwrap_or("/"); head.headers.insert( header::CONTENT_TYPE, mime::TEXT_HTML_UTF_8.essence_str().try_into().unwrap(), ); BoxBody::new(render_error(error_msg, head.status, conf, return_address).into_string()) } pub fn log_error_chain(description: String) { for cause in description.lines() { log::error!("{cause}"); } } ================================================ FILE: src/file_op.rs ================================================ //! Handlers for file upload and removal #[cfg(target_family = "unix")] use std::collections::HashSet; use std::io::ErrorKind; #[cfg(target_family = "unix")] use std::os::unix::fs::MetadataExt; use std::path::{Component, Path, PathBuf}; #[cfg(target_family = "unix")] use std::sync::Arc; use actix_web::{HttpRequest, HttpResponse, http::header, web}; use async_walkdir::WalkDir; use futures::{StreamExt, TryStreamExt}; use log::{error, info, warn}; use serde::Deserialize; use sha2::digest::DynDigest; use sha2::{Digest, Sha256, Sha512}; use tempfile::NamedTempFile; use tokio::fs; use tokio::io::AsyncWriteExt; #[cfg(target_family = "unix")] use tokio::sync::RwLock; use crate::{ args::DuplicateFile, config::MiniserveConfig, errors::RuntimeError, file_utils::contains_symlink, file_utils::sanitize_path, }; enum FileHash { SHA256(String), SHA512(String), } impl FileHash { pub fn get_hasher(&self) -> Box<dyn DynDigest> { match self { Self::SHA256(_) => Box::new(Sha256::new()), Self::SHA512(_) => Box::new(Sha512::new()), } } pub fn get_hash(&self) -> &str { match self { Self::SHA256(string) => string, Self::SHA512(string) => string, } } } /// Get the recursively calculated dir size for a given dir /// /// Counts hardlinked files only once if the OS supports hardlinks. /// /// Expects `dir` to be sanitized. This function doesn't do any sanitization itself. pub async fn recursive_dir_size(dir: &Path) -> Result<u64, RuntimeError> { #[cfg(target_family = "unix")] let seen_inodes = Arc::new(RwLock::new(HashSet::new())); let mut entries = WalkDir::new(dir); let mut total_size = 0; loop { match entries.next().await { Some(Ok(entry)) => { if let Ok(metadata) = entry.metadata().await && metadata.is_file() { // On Unix, we want to filter inodes that we've already seen so we get a // more accurate count of real size used on disk. #[cfg(target_family = "unix")] { let (device_id, inode) = (metadata.dev(), metadata.ino()); // Check if this file has been seen before based on its device ID and // inode number if seen_inodes.read().await.contains(&(device_id, inode)) { continue; } else { seen_inodes.write().await.insert((device_id, inode)); } } total_size += metadata.len(); } } Some(Err(e)) => { if let Some(io_err) = e.into_io() { match io_err.kind() { ErrorKind::PermissionDenied => warn!( "Error trying to read file when calculating dir size: {io_err}, ignoring" ), _ => return Err(RuntimeError::InvalidPathError(io_err.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`. /// /// Returns total bytes written to file. async fn save_file( field: &mut actix_multipart::Field, mut file_path: PathBuf, on_duplicate_files: DuplicateFile, file_checksum: Option<&FileHash>, temporary_upload_directory: Option<&PathBuf>, #[cfg(unix)] chmod: u16, ) -> Result<u64, RuntimeError> { if file_path.exists() { match on_duplicate_files { DuplicateFile::Error => return Err(RuntimeError::DuplicateFileError), DuplicateFile::Overwrite => (), DuplicateFile::Rename => { // extract extension of the file and the file stem without extension // file.txt => (file, txt) let file_name = file_path.file_stem().unwrap_or_default().to_string_lossy(); let file_ext = file_path.extension().map(|s| s.to_string_lossy()); for i in 1.. { // increment the number N in {file_name}-{N}.{file_ext} // format until available name is found (e.g. file-1.txt, file-2.txt, etc) let fp = if let Some(ext) = &file_ext { file_path.with_file_name(format!("{file_name}-{i}.{ext}")) } else { file_path.with_file_name(format!("{file_name}-{i}")) }; // If we have a file name that doesn't exist yet then we'll use that. if !fp.exists() { file_path = fp; break; } } } } } let temp_upload_directory = temporary_upload_directory.cloned(); // Tempfile doesn't support async operations, so we'll do it on a background thread. let temp_upload_directory_task = tokio::task::spawn_blocking(move || { // If the user provided a temporary directory path, then use it. if let Some(temp_directory) = temp_upload_directory { NamedTempFile::new_in(temp_directory) } else { NamedTempFile::new() } }); // Validate that the temporary task completed successfully. let named_temp_file_task = match temp_upload_directory_task.await { Ok(named_temp_file) => Ok(named_temp_file), Err(err) => Err(RuntimeError::MultipartError(format!( "Failed to complete spawned task to create named temp file. {err}", ))), }?; // Validate the the temporary file was created successfully. let named_temp_file = match named_temp_file_task { Err(err) if err.kind() == ErrorKind::PermissionDenied => Err( RuntimeError::InsufficientPermissionsError(file_path.display().to_string()), ), Err(err) => Err(RuntimeError::IoError( format!("Failed to create temporary file {}", file_path.display()), err, )), Ok(file) => Ok(file), }?; // Convert the temporary file into a non-temporary file. This allows us // to control the lifecycle of the file. This is useful for us because // we need to convert the temporary file into an async enabled file and // on successful upload, we want to move it to the target directory. let (file, temp_path) = named_temp_file .keep() .map_err(|err| RuntimeError::IoError("Failed to keep temporary file".into(), err.error))?; let mut temp_file = tokio::fs::File::from_std(file); let mut written_len = 0; let mut hasher = file_checksum.as_ref().map(|h| h.get_hasher()); let mut save_upload_file_error: Option<RuntimeError> = None; // This while loop take a stream (in this case `field`) and awaits // new chunks from the websocket connection. The while loop reads // the file from the HTTP connection and writes it to disk or until // the stream from the multipart request is aborted. while let Some(Ok(bytes)) = field.next().await { // If the hasher exists (if the user has also sent a chunksum with the request) // then we want to update the hasher with the new bytes uploaded. if let Some(hasher) = hasher.as_mut() { hasher.update(&bytes) } // Write the bytes from the stream into our temporary file. if let Err(e) = temp_file.write_all(&bytes).await { // Failed to write to file. Drop it and return the error save_upload_file_error = Some(RuntimeError::IoError("Failed to write to file".into(), e)); break; } // record the bytes written to the file. written_len += bytes.len() as u64; } if save_upload_file_error.is_none() { // Flush the changes to disk so that we are sure they are there. if let Err(e) = temp_file.flush().await { save_upload_file_error = Some(RuntimeError::IoError( "Failed to flush all the file writes to disk".into(), e, )); } } // Drop the file expcitly here because IF there is an error when writing to the // temp file, we won't be able to remove as per the comment in `tokio::fs::remove_file` // > Note that there is no guarantee that the file is immediately deleted // > (e.g. depending on platform, other open file descriptors may prevent immediate removal). drop(temp_file); // If there was an error during uploading. if let Some(e) = save_upload_file_error { // If there was an error when writing the file to disk, remove it and return // the error that was encountered. let _ = tokio::fs::remove_file(temp_path).await; return Err(e); } // There isn't a way to get notified when a request is cancelled // by the user in actix it seems. References: // - https://github.com/actix/actix-web/issues/1313 // - https://github.com/actix/actix-web/discussions/3011 // Therefore, we are relying on the fact that the web UI uploads a // hash of the file to determine if it was completed uploaded or not. if let Some(hasher) = hasher && let Some(expected_hash) = file_checksum.as_ref().map(|f| f.get_hash()) { let actual_hash = hex::encode(hasher.finalize()); if actual_hash != expected_hash { warn!( "The expected file hash {expected_hash} did not match the calculated hash of {actual_hash}. This can be caused if a file upload was aborted." ); let _ = tokio::fs::remove_file(&temp_path).await; return Err(RuntimeError::UploadHashMismatchError); } } info!("File upload successful to {temp_path:?}. Moving to {file_path:?}",); if let Err(err) = tokio::fs::rename(&temp_path, &file_path).await { match err.kind() { ErrorKind::CrossesDevices => { warn!( "File writen to {temp_path:?} must be copied to {file_path:?} because it's on a different filesystem" ); let copy_result = tokio::fs::copy(&temp_path, &file_path).await; if let Err(e) = tokio::fs::remove_file(&temp_path).await { error!("Failed to clean up temp file at {temp_path:?} with error {e:?}"); } copy_result.map_err(|e| { RuntimeError::IoError( format!("Failed to copy file from {temp_path:?} to {file_path:?}"), e, ) })?; } _ => { let _ = tokio::fs::remove_file(&temp_path).await; return Err(RuntimeError::IoError( format!("Failed to move temporary file {temp_path:?} to {file_path:?}",), err, )); } } } #[cfg(unix)] { info!("Changing file mode (chmod) to {chmod:o}"); use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(chmod.into()); if let Err(err) = tokio::fs::set_permissions(&file_path, perms).await { return Err(RuntimeError::IoError( format!("Failed to chmod {chmod:o} {file_path:?}"), err, )); } } Ok(written_len) } struct HandleMultipartOpts<'a> { on_duplicate_files: DuplicateFile, allow_mkdir: bool, allow_hidden_paths: bool, allow_symlinks: bool, file_hash: Option<&'a FileHash>, upload_directory: Option<&'a PathBuf>, } /// Handles a single field in a multipart form async fn handle_multipart( mut field: actix_multipart::Field, path: PathBuf, opts: HandleMultipartOpts<'_>, #[cfg(unix)] chmod: u16, ) -> Result<u64, RuntimeError> { let HandleMultipartOpts { on_duplicate_files, allow_mkdir, allow_hidden_paths, allow_symlinks, file_hash, upload_directory, } = opts; let field_name = field.name().expect("No name field found").to_string(); match tokio::fs::metadata(&path).await { Err(_) => Err(RuntimeError::InsufficientPermissionsError( path.display().to_string(), )), Ok(metadata) if !metadata.is_dir() => Err(RuntimeError::InvalidPathError(format!( "cannot upload file to {}, since it's not a directory", &path.display() ))), Ok(_) => Ok(()), }?; if field_name == "mkdir" { if !allow_mkdir { return Err(RuntimeError::InsufficientPermissionsError( path.display().to_string(), )); } let mut user_given_path = PathBuf::new(); let mut absolute_path = path.clone(); // Get the path the user gave let mkdir_path_bytes = field.try_next().await; match mkdir_path_bytes { Ok(Some(mkdir_path_bytes)) => { let mkdir_path = std::str::from_utf8(&mkdir_path_bytes).map_err(|e| { RuntimeError::ParseError( "Failed to parse 'mkdir' path".to_string(), e.to_string(), ) })?; let mkdir_path = mkdir_path.replace('\\', "/"); absolute_path.push(&mkdir_path); user_given_path.push(&mkdir_path); } _ => { return Err(RuntimeError::ParseError( "Failed to parse 'mkdir' path".to_string(), "".to_string(), )); } }; // Disallow using `..` (parent) in mkdir path if user_given_path .components() .any(|c| c == Component::ParentDir) { return Err(RuntimeError::InvalidPathError( "Cannot use '..' in mkdir path".to_string(), )); } // Hidden paths check sanitize_path(&user_given_path, allow_hidden_paths).ok_or_else(|| { RuntimeError::InvalidPathError("Cannot use hidden paths in mkdir path".to_string()) })?; // Ensure there are no illegal symlinks if !allow_symlinks { match contains_symlink(&absolute_path) { Err(err) => Err(RuntimeError::InsufficientPermissionsError(err.to_string()))?, Ok(true) => Err(RuntimeError::InsufficientPermissionsError(format!( "{user_given_path:?} traverses through a symlink" )))?, Ok(false) => (), } } return match tokio::fs::create_dir_all(&absolute_path).await { Err(err) if err.kind() == ErrorKind::PermissionDenied => Err( RuntimeError::InsufficientPermissionsError(path.display().to_string()), ), Err(err) => Err(RuntimeError::IoError( format!("Failed to create {}", user_given_path.display()), err, )), Ok(_) => Ok(0), }; } let filename = field .content_disposition() .expect("No content-disposition field found") .get_filename() .ok_or_else(|| { RuntimeError::ParseError( "HTTP header".to_string(), "Failed to retrieve the name of the file to upload".to_string(), ) })?; let filename_path = sanitize_path(Path::new(&filename), allow_hidden_paths) .ok_or_else(|| RuntimeError::InvalidPathError("Invalid file name to upload".to_string()))?; // Ensure there are no illegal symlinks in the file upload path if !allow_symlinks { match contains_symlink(&path) { Err(err) => Err(RuntimeError::InsufficientPermissionsError(err.to_string()))?, Ok(true) => Err(RuntimeError::InsufficientPermissionsError(format!( "{path:?} traverses through a symlink" )))?, Ok(false) => (), } } save_file( &mut field, path.join(filename_path), on_duplicate_files, file_hash, upload_directory, #[cfg(unix)] chmod, ) .await } /// Query parameters used by upload and rm APIs #[derive(Deserialize, Default)] pub struct FileOpQueryParameters { path: PathBuf, } /// Handle incoming request to upload a file or create a directory. /// Target file path is expected as path parameter in URI and is interpreted as relative from /// server root directory. Any path which will go outside of this directory is considered /// invalid. /// This method returns future. pub async fn upload_file( req: HttpRequest, query: web::Query<FileOpQueryParameters>, payload: web::Payload, ) -> Result<HttpResponse, RuntimeError> { let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap(); let upload_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| { RuntimeError::InvalidPathError("Invalid value for 'path' parameter".to_string()) })?; let app_root_dir = conf.path.canonicalize().map_err(|e| { RuntimeError::IoError("Failed to resolve path served by miniserve".to_string(), e) })?; // Disallow paths outside of allowed directories let upload_allowed = conf.allowed_upload_dir.is_empty() || conf .allowed_upload_dir .iter() .any(|s| upload_path.starts_with(s)); if !upload_allowed { return Err(RuntimeError::UploadForbiddenError); } // Disallow the target path to go outside of the served directory // The target directory shouldn't be canonicalized when it gets passed to // handle_multipart so that it can check for symlinks if needed let non_canonicalized_target_dir = app_root_dir.join(upload_path); match non_canonicalized_target_dir.canonicalize() { Ok(path) if !conf.no_symlinks => Ok(path), Ok(path) if path.starts_with(&app_root_dir) => Ok(path), _ => Err(RuntimeError::InvalidHttpRequestError( "Invalid value for 'path' parameter".to_string(), )), }?; let upload_directory = conf.temp_upload_directory.as_ref(); let file_hash = if let (Some(hash), Some(hash_function)) = ( req.headers() .get("X-File-Hash") .and_then(|h| h.to_str().ok()), req.headers() .get("X-File-Hash-Function") .and_then(|h| h.to_str().ok()), ) { match hash_function.to_ascii_uppercase().as_str() { "SHA256" => Some(FileHash::SHA256(hash.to_string())), "SHA512" => Some(FileHash::SHA512(hash.to_string())), sha => { return Err(RuntimeError::InvalidHttpRequestError(format!( "Invalid header value found for 'X-File-Hash-Function'. Supported values are SHA256 or SHA512. Found {sha}.", ))); } } } else { None }; let hash_ref = file_hash.as_ref(); actix_multipart::Multipart::new(req.headers(), payload) .map_err(|x| RuntimeError::MultipartError(x.to_string())) .and_then(|field| { handle_multipart( field, non_canonicalized_target_dir.clone(), HandleMultipartOpts { on_duplicate_files: conf.on_duplicate_files, allow_mkdir: conf.mkdir_enabled, allow_hidden_paths: conf.show_hidden, allow_symlinks: !conf.no_symlinks, file_hash: hash_ref, upload_directory, }, #[cfg(unix)] conf.upload_chmod, ) }) .try_collect::<Vec<u64>>() .await?; let return_path = req .headers() .get(header::REFERER) .and_then(|h| h.to_str().ok()) .unwrap_or("/"); Ok(HttpResponse::SeeOther() .append_header((header::LOCATION, return_path)) .finish()) } /// Handle incoming request to remove a file or directory. /// /// Target file path is expected as path parameter in URI and is interpreted as relative from /// server root directory. Any path which will go outside of this directory is considered /// invalid. pub async fn rm_file( req: HttpRequest, query: web::Query<FileOpQueryParameters>, ) -> Result<HttpResponse, RuntimeError> { let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap(); let rm_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| { RuntimeError::InvalidPathError("Invalid value for 'path' parameter".to_string()) })?; let app_root_dir = conf.path.canonicalize().map_err(|e| { RuntimeError::IoError("Failed to resolve path served by miniserve".to_string(), e) })?; // Disallow paths outside of allowed directories let rm_allowed = conf.allowed_rm_dir.is_empty() || conf.allowed_rm_dir.iter().any(|s| rm_path.starts_with(s)); if !rm_allowed { return Err(RuntimeError::RmForbiddenError); } // Disallow the target path to go outside of the served directory let canonicalized_rm_path = match app_root_dir.join(&rm_path).canonicalize() { Ok(path) if !conf.no_symlinks => Ok(path), Ok(path) if path.starts_with(&app_root_dir) => Ok(path), _ => Err(RuntimeError::InvalidHttpRequestError( "Invalid value for 'path' parameter".to_string(), )), }?; // Handle non-existent path if !canonicalized_rm_path.exists() { return Err(RuntimeError::RouteNotFoundError(format!( "{rm_path:?} does not exist" ))); } // Remove let rm_res = if canonicalized_rm_path.is_dir() { fs::remove_dir_all(&canonicalized_rm_path).await } else { fs::remove_file(&canonicalized_rm_path).await }; if let Err(err) = rm_res { Err(RuntimeError::IoError( format!("Failed to remove {rm_path:?}"), err, ))?; } let return_path = req .headers() .get(header::REFERER) .and_then(|h| h.to_str().ok()) .unwrap_or("/"); Ok(HttpResponse::SeeOther() .append_header((header::LOCATION, return_path)) .finish()) } ================================================ FILE: src/file_utils.rs ================================================ #[cfg(unix)] use rustix::{fs::Mode, process::umask}; use std::{ io, path::{Component, Path, PathBuf}, }; /// Guarantee that the path is relative and cannot traverse back to parent directories /// and optionally prevent traversing hidden directories. /// /// See the unit tests tests::test_sanitize_path* for examples pub fn sanitize_path(path: impl AsRef<Path>, traverse_hidden: bool) -> Option<PathBuf> { let mut buf = PathBuf::new(); for comp in path.as_ref().components() { match comp { Component::Normal(name) => buf.push(name), Component::ParentDir => { buf.pop(); } _ => (), } } // Double-check that all components are Normal and check for hidden dirs for comp in buf.components() { match comp { Component::Normal(_) if traverse_hidden => (), Component::Normal(name) if !name.to_str()?.starts_with('.') => (), _ => return None, } } Some(buf) } /// Checks if any segment of the path is a symlink. /// /// This function fails if [`std::fs::symlink_metadata`] fails, which usually /// means user has no permission to access the path. pub fn contains_symlink(path: impl AsRef<Path>) -> io::Result<bool> { let contains_symlink = path .as_ref() .ancestors() // On Windows, `\\?\` won't exist even though it's the root, but there's no need to check it // So we filter it out .filter(|p| p.exists()) .map(|p| p.symlink_metadata()) .collect::<Result<Vec<_>, _>>()? .into_iter() .any(|p| p.file_type().is_symlink()); Ok(contains_symlink) } /// Get default file creation permissions by umask #[cfg(unix)] pub fn get_default_filemode() -> u16 { let old = umask(Mode::all()); umask(old); let mode = 0o666 & (!old).as_raw_mode(); mode as u16 } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use rstest::rstest; #[rstest] #[case("/foo", "foo")] #[case("////foo", "foo")] #[case("C:/foo", if cfg!(windows) { "foo" } else { "C:/foo" })] #[case("../foo", "foo")] #[case("../foo/../bar/abc", "bar/abc")] fn test_sanitize_path(#[case] input: &str, #[case] output: &str) { assert_eq!( sanitize_path(Path::new(input), true).unwrap(), Path::new(output) ); assert_eq!( sanitize_path(Path::new(input), false).unwrap(), Path::new(output) ); } #[rstest] #[case(".foo")] #[case("/.foo")] #[case("foo/.bar/foo")] fn test_sanitize_path_no_hidden_files(#[case] input: &str) { assert_eq!(sanitize_path(Path::new(input), false), None); } } ================================================ FILE: src/listing.rs ================================================ #![allow(clippy::format_push_string)] use std::io; use std::path::{Component, Path}; use std::time::SystemTime; use actix_web::{ HttpMessage, HttpRequest, HttpResponse, dev::ServiceResponse, http::Uri, web, web::Query, }; use bytesize::ByteSize; use clap::ValueEnum; use comrak::{Options as ComrakOptions, markdown_to_html}; use percent_encoding::{percent_decode_str, utf8_percent_encode}; use regex::Regex; use serde::Deserialize; use strum::{Display, EnumString}; use self::percent_encode_sets::COMPONENT; use crate::archive::ArchiveMethod; use crate::auth::CurrentUser; use crate::errors::{self, RuntimeError}; use crate::renderer; /// "percent-encode sets" as defined by WHATWG specs: /// https://url.spec.whatwg.org/#percent-encoded-bytes pub mod percent_encode_sets { use percent_encoding::{AsciiSet, CONTROLS}; pub const QUERY: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>'); pub const PATH: &AsciiSet = &QUERY.add(b'?').add(b'`').add(b'{').add(b'}'); pub const USERINFO: &AsciiSet = &PATH .add(b'/') .add(b':') .add(b';') .add(b'=') .add(b'@') .add(b'[') .add(b'\\') .add(b']') .add(b'^') .add(b'|'); pub const COMPONENT: &AsciiSet = &USERINFO.add(b'$').add(b'%').add(b'&').add(b'+').add(b','); } /// Query parameters used by listing APIs #[derive(Deserialize, Default)] pub struct ListingQueryParameters { pub sort: Option<SortingMethod>, pub order: Option<SortingOrder>, pub raw: Option<bool>, download: Option<ArchiveMethod>, } /// Available sorting methods #[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum SortingMethod { #[default] /// Sort by name Name, /// Sort by size Size, /// Sort by last modification date (natural sort: follows alphanumerical order) Date, } /// Available sorting orders #[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] pub enum SortingOrder { /// Ascending order #[serde(alias = "asc")] #[strum(serialize = "asc")] Asc, /// Descending order #[default] #[serde(alias = "desc")] #[strum(serialize = "desc")] Desc, } /// Possible entry types #[derive(PartialEq, Clone, Display, Eq)] #[strum(serialize_all = "snake_case")] pub enum EntryType { /// Entry is a directory Directory, /// Entry is a file File, } /// Entry pub struct Entry { /// Name of the entry pub name: String, /// Type of the entry pub entry_type: EntryType, /// URL of the entry pub link: String, /// Size in byte of the entry. Only available for EntryType::File pub size: Option<bytesize::ByteSize>, /// Last modification date pub last_modification_date: Option<SystemTime>, /// Path of symlink pointed to pub symlink_info: Option<String>, } impl Entry { fn new( name: String, entry_type: EntryType, link: String, size: Option<bytesize::ByteSize>, last_modification_date: Option<SystemTime>, symlink_info: Option<String>, ) -> Self { Self { name, entry_type, link, size, last_modification_date, symlink_info, } } /// Returns whether the entry is a directory pub fn is_dir(&self) -> bool { self.entry_type == EntryType::Directory } /// Returns whether the entry is a file pub fn is_file(&self) -> bool { self.entry_type == EntryType::File } } /// One entry in the path to the listed directory pub struct Breadcrumb { /// Name of directory pub name: String, /// Link to get to directory, relative to listed directory pub link: String, } impl Breadcrumb { fn new(name: String, link: String) -> Self { Self { name, link } } } pub async fn file_handler(req: HttpRequest) -> actix_web::Result<actix_files::NamedFile> { let path = &req .app_data::<web::Data<crate::MiniserveConfig>>() .unwrap() .path; actix_files::NamedFile::open(path).map_err(Into::into) } /// List a directory and renders a HTML file accordingly /// Adapted from https://docs.rs/actix-web/0.7.13/src/actix_web/fs.rs.html#564 pub fn directory_listing( dir: &actix_files::Directory, req: &HttpRequest, ) -> io::Result<ServiceResponse> { let extensions = req.extensions(); let current_user: Option<&CurrentUser> = extensions.get::<CurrentUser>(); let conf = req.app_data::<web::Data<crate::MiniserveConfig>>().unwrap(); if conf.disable_indexing { return Ok(ServiceResponse::new( req.clone(), HttpResponse::NotFound() .content_type(mime::TEXT_PLAIN_UTF_8) .body("File not found."), )); } let serve_path = req.path(); let base = Path::new(serve_path); let random_route_abs = format!("/{}", conf.route_prefix); let abs_uri = { let res = Uri::builder() .scheme(req.connection_info().scheme()) .authority(req.connection_info().host()) .path_and_query(req.path()) .build(); match res { Ok(uri) => uri, Err(err) => return Ok(ServiceResponse::from_err(err, req.clone())), } }; let is_root = base.parent().is_none() || Path::new(&req.path()) == Path::new(&random_route_abs); let encoded_dir = match base.strip_prefix(random_route_abs) { Ok(c_d) => Path::new("/").join(c_d), Err(_) => base.to_path_buf(), } .display() .to_string(); let breadcrumbs = { let title = conf .title .clone() .unwrap_or_else(|| req.connection_info().host().into()); let decoded = percent_decode_str(&encoded_dir).decode_utf8_lossy(); let mut res: Vec<Breadcrumb> = Vec::new(); let mut link_accumulator = format!("{}/", &conf.route_prefix); let mut components = Path::new(&*decoded).components().peekable(); while let Some(c) = components.next() { let name; match c { Component::RootDir => { name = title.clone(); } Component::Normal(s) => { name = s.to_string_lossy().to_string(); link_accumulator .push_str(&(utf8_percent_encode(&name, COMPONENT).to_string() + "/")); } _ => name = "".to_string(), }; res.push(Breadcrumb::new( name, if components.peek().is_some() { link_accumulator.clone() } else { ".".to_string() }, )); } res }; let query_params = extract_query_parameters(req); let mut entries: Vec<Entry> = Vec::new(); let mut readme: Option<(String, String)> = None; let readme_rx: Regex = Regex::new("^readme([.](md|txt))?$").unwrap(); for entry in dir.path.read_dir()? { if dir.is_visible(&entry) || conf.show_hidden { let entry = entry?; // show file url as relative to static path let file_name = entry.file_name().to_string_lossy().to_string(); let (is_symlink, metadata) = match entry.metadata() { Ok(metadata) if metadata.file_type().is_symlink() => { // for symlinks, get the metadata of the original file (true, std::fs::metadata(entry.path())) } res => (false, res), }; let symlink_dest = (is_symlink && conf.show_symlink_info) .then(|| entry.path()) .and_then(|path| std::fs::read_link(path).ok()) .map(|path| path.to_string_lossy().into_owned()); let file_url = base .join(utf8_percent_encode(&file_name, COMPONENT).to_string()) .to_string_lossy() .to_string(); // if file is a directory, add '/' to the end of the name if let Ok(metadata) = metadata { if conf.no_symlinks && is_symlink { continue; } let last_modification_date = metadata.modified().ok(); if metadata.is_dir() { entries.push(Entry::new( file_name, EntryType::Directory, file_url, None, last_modification_date, symlink_dest, )); } else if metadata.is_file() { let file_link = match &conf.file_external_url { Some(external_url) => { // Construct the full relative path including subdirectories // encoded_dir holds the current directory path relative to the prefix (e.g., /subdir1/subdir2) let current_relative_dir = encoded_dir.trim_matches('/'); // Remove leading/trailing slashes if any // Combine the relative directory path and the filename let full_relative_path = if current_relative_dir.is_empty() { // If in the root directory, just use the filename utf8_percent_encode(&file_name, COMPONENT).to_string() } else { // Otherwise, join directory and filename format!( "{}/{}", current_relative_dir, utf8_percent_encode(&file_name, COMPONENT) ) }; // Join the external external URL with the full relative path format!( "{}/{}", external_url.trim_end_matches('/'), // Base URL without trailing slash full_relative_path // Relative path (dir + file) - should not have leading slash here ) } None => file_url, }; entries.push(Entry::new( file_name.clone(), EntryType::File, file_link, Some(ByteSize::b(metadata.len())), last_modification_date, symlink_dest, )); if conf.readme && readme_rx.is_match(&file_name.to_lowercase()) { let ext = file_name.split('.').next_back().unwrap().to_lowercase(); readme = Some(( file_name.to_string(), if ext == "md" { let mut options = ComrakOptions::default(); // Enable some GFM extensions options.extension.strikethrough = true; options.extension.table = true; options.extension.autolink = true; options.extension.tasklist = true; markdown_to_html(&std::fs::read_to_string(entry.path())?, &options) } else { format!("<pre>{}</pre>", &std::fs::read_to_string(entry.path())?) }, )); } } } else { continue; } } } match query_params.sort.unwrap_or(conf.default_sorting_method) { SortingMethod::Name => entries.sort_by(|e1, e2| { alphanumeric_sort::compare_str(e1.name.to_lowercase(), e2.name.to_lowercase()) }), SortingMethod::Size => entries.sort_by(|e1, e2| { // If we can't get the size of the entry (directory for instance) // let's consider it's 0b e2.size .unwrap_or_else(|| ByteSize::b(0)) .cmp(&e1.size.unwrap_or_else(|| ByteSize::b(0))) }), SortingMethod::Date => entries.sort_by(|e1, e2| { // If, for some reason, we can't get the last modification date of an entry // let's consider it was modified on UNIX_EPOCH (01/01/19270 00:00:00) e2.last_modification_date .unwrap_or(SystemTime::UNIX_EPOCH) .cmp(&e1.last_modification_date.unwrap_or(SystemTime::UNIX_EPOCH)) }), }; if let SortingOrder::Asc = query_params.order.unwrap_or(conf.default_sorting_order) { entries.reverse() } // List directories first if conf.dirs_first { entries.sort_by_key(|e| !e.is_dir()); } if let Some(archive_method) = query_params.download { if !archive_method.is_enabled(conf.tar_enabled, conf.tar_gz_enabled, conf.zip_enabled) { return Ok(ServiceResponse::new( req.clone(), HttpResponse::Forbidden() .content_type(mime::TEXT_PLAIN_UTF_8) .body("Archive creation is disabled."), )); } log::info!( "Creating an archive ({extension}) of {path}...", extension = archive_method.extension(), path = &dir.path.display().to_string() ); let file_name = format!( "{}.{}", dir.path.file_name().unwrap().to_str().unwrap(), archive_method.extension() ); // We will create the archive in a separate thread, and stream the content using a pipe. // The pipe is made of a futures channel, and an adapter to implement the `Write` trait. // Include 10 messages of buffer for erratic connection speeds. let (tx, rx) = futures::channel::mpsc::channel::<io::Result<actix_web::web::Bytes>>(10); let pipe = crate::pipe::Pipe::new(tx); // Start the actual archive creation in a separate thread. let dir = dir.path.to_path_buf(); let skip_symlinks = conf.no_symlinks; std::thread::spawn(move || { if let Err(err) = archive_method.create_archive(dir, skip_symlinks, pipe) { log::error!("Error during archive creation: {err:?}"); } }); Ok(ServiceResponse::new( req.clone(), HttpResponse::Ok() .content_type(archive_method.content_type()) .append_header(("Content-Transfer-Encoding", "binary")) .append_header(( "Content-Disposition", format!("attachment; filename={file_name:?}"), )) .body(actix_web::body::BodyStream::new(rx)), )) } else { Ok(ServiceResponse::new( req.clone(), HttpResponse::Ok().content_type(mime::TEXT_HTML_UTF_8).body( renderer::page( entries, readme, &abs_uri, is_root, query_params, &breadcrumbs, &encoded_dir, conf, current_user, ) .into_string(), ), )) } } pub fn extract_query_parameters(req: &HttpRequest) -> ListingQueryParameters { match Query::<ListingQueryParameters>::from_query(req.query_string()) { Ok(Query(query_params)) => query_params, Err(e) => { let err = RuntimeError::ParseError("query parameters".to_string(), e.to_string()); errors::log_error_chain(err.to_string()); ListingQueryParameters::default() } } } ================================================ FILE: src/main.rs ================================================ use std::io::{self, IsTerminal, Write}; use std::net::{IpAddr, SocketAddr, TcpListener}; use std::thread; use std::time::Duration; use actix_files::NamedFile; use actix_web::middleware::from_fn; use actix_web::{ App, HttpRequest, HttpResponse, Responder, dev::{ServiceRequest, ServiceResponse, fn_service}, guard, http::{Method, header::ContentType}, middleware, web, }; use actix_web_httpauth::middleware::HttpAuthentication; use anyhow::Result; use bytesize::ByteSize; use clap::{CommandFactory, Parser, crate_version}; use colored::*; use dav_server::{ DavHandler, DavMethodSet, actix::{DavRequest, DavResponse}, }; use fast_qr::QRBuilder; use log::{error, info, trace, warn}; use percent_encoding::percent_decode_str; use serde::Deserialize; mod archive; mod args; mod auth; mod config; mod consts; mod errors; mod file_op; mod file_utils; mod listing; mod pipe; mod renderer; mod webdav_fs; use crate::args::LogColor; 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"); fn main() -> Result<()> { let args = args::CliArgs::parse(); if let Some(shell) = args.print_completions { let mut clap_app = args::CliArgs::command(); let app_name = clap_app.get_name().to_string(); clap_complete::generate(shell, &mut clap_app, app_name, &mut io::stdout()); return Ok(()); } if args.print_manpage { let clap_app = args::CliArgs::command(); let man = clap_mangen::Man::new(clap_app); man.render(&mut io::stdout())?; return Ok(()); } let miniserve_config = MiniserveConfig::try_from_args(args)?; run(miniserve_config).inspect_err(|e| { errors::log_error_chain(e.to_string()); })?; Ok(()) } #[actix_web::main(miniserve)] async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { let log_level = if miniserve_config.verbose { simplelog::LevelFilter::Info } else { simplelog::LevelFilter::Warn }; let color_choice = match miniserve_config.log_color { LogColor::Auto => { if io::stdout().is_terminal() { simplelog::ColorChoice::Auto } else { simplelog::ColorChoice::Never } } LogColor::Always => { colored::control::SHOULD_COLORIZE.set_override(true); simplelog::ColorChoice::Always } LogColor::Never => { colored::control::SHOULD_COLORIZE.set_override(false); simplelog::ColorChoice::Never } }; trace!( "Set log color, simplelog = {:?}, colored = {:?}", color_choice, colored::control::SHOULD_COLORIZE.should_colorize(), ); simplelog::TermLogger::init( log_level, simplelog::ConfigBuilder::new() .set_time_format_rfc2822() .build(), simplelog::TerminalMode::Mixed, color_choice, ) .or_else(|_| simplelog::SimpleLogger::init(log_level, simplelog::Config::default())) .expect("Couldn't initialize logger"); if miniserve_config.no_symlinks && miniserve_config.path.is_symlink() { return Err(StartupError::NoSymlinksOptionWithSymlinkServePath( miniserve_config.path.to_string_lossy().to_string(), )); } if miniserve_config.webdav_enabled && miniserve_config.path.is_file() { return Err(StartupError::WebdavWithFileServePath( miniserve_config.path.to_string_lossy().to_string(), )); } let inside_config = miniserve_config.clone(); let canon_path = miniserve_config .path .canonicalize() .map_err(|e| StartupError::IoError("Failed to resolve path to be served".to_string(), e))?; // warn if --index is specified but not found if let Some(ref index) = miniserve_config.index && !canon_path.join(index).exists() && !miniserve_config.quiet { warn!( "The file '{}' provided for option --index could not be found.", index.to_string_lossy(), ); } let path_string = canon_path.to_string_lossy(); if !miniserve_config.quiet { println!( "{name} v{version}", name = "miniserve".bold(), version = crate_version!() ); } if !miniserve_config.path_explicitly_chosen { // If the path to serve has NOT been explicitly chosen and if this is NOT an interactive // terminal, we should refuse to start for security reasons. This would be the case when // running miniserve as a service but forgetting to set the path. This could be pretty // dangerous if given with an undesired context path (for instance /root or /). if !io::stdout().is_terminal() { return Err(StartupError::NoExplicitPathAndNoTerminal); } if !miniserve_config.quiet { warn!( "miniserve has been invoked without an explicit path so it will serve the current directory after a short delay." ); warn!( "Invoke with -h|--help to see options or invoke as `miniserve .` to hide this advice." ); print!("Starting server in "); io::stdout() .flush() .map_err(|e| StartupError::IoError("Failed to write data".to_string(), e))?; for c in "3… 2… 1… \n".chars() { print!("{c}"); io::stdout() .flush() .map_err(|e| StartupError::IoError("Failed to write data".to_string(), e))?; thread::sleep(Duration::from_millis(500)); } } } let display_urls = { let (mut ifaces, wildcard): (Vec<_>, Vec<_>) = miniserve_config .interfaces .clone() .into_iter() .partition(|addr| !addr.is_unspecified()); // Replace wildcard addresses with local interface addresses if !wildcard.is_empty() { let all_ipv4 = wildcard.iter().any(|addr| addr.is_ipv4()); let all_ipv6 = wildcard.iter().any(|addr| addr.is_ipv6()); ifaces = if_addrs::get_if_addrs() .unwrap_or_else(|e| { error!("Failed to get local interface addresses: {e}"); Default::default() }) .into_iter() .map(|iface| iface.ip()) .filter(|ip| (all_ipv4 && ip.is_ipv4()) || (all_ipv6 && ip.is_ipv6())) .collect(); ifaces.sort(); } ifaces .into_iter() .map(|addr| match addr { IpAddr::V4(_) => format!("{}:{}", addr, miniserve_config.port), IpAddr::V6(_) => format!("[{}]:{}", addr, miniserve_config.port), }) .map(|addr| match miniserve_config.tls_rustls_config { Some(_) => format!("https://{addr}"), None => format!("http://{addr}"), }) .map(|url| format!("{}{}", url, miniserve_config.route_prefix)) .collect::<Vec<_>>() }; let socket_addresses = miniserve_config .interfaces .iter() .map(|&interface| SocketAddr::new(interface, miniserve_config.port)) .collect::<Vec<_>>(); let display_sockets = socket_addresses .iter() .map(|sock| sock.to_string().green().bold().to_string()) .collect::<Vec<_>>(); let stylesheet = web::Data::new( [ STYLESHEET, inside_config.default_color_scheme.css(), inside_config.default_color_scheme_dark.css_dark().as_str(), ] .join("\n"), ); let srv = actix_web::HttpServer::new(move || { App::new() .wrap(configure_header(&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()) .wrap(middleware::Condition::new( miniserve_config.compress_response, 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( web::scope(&inside_config.route_prefix) .wrap(middleware::Condition::new( !inside_config.auth.is_empty(), actix_web::middleware::Compat::new(HttpAuthentication::basic( auth::handle_auth, )), )) .configure(|c| configure_app(c, &inside_config)), ) .default_service(web::get().to(error_404)) }); let srv = socket_addresses.iter().try_fold(srv, |srv, addr| { let listener = create_tcp_listener(*addr) .map_err(|e| StartupError::IoError(format!("Failed to bind server to {addr}"), e))?; #[cfg(feature = "tls")] let srv = match &miniserve_config.tls_rustls_config { Some(tls_config) => srv.listen_rustls_0_23(listener, tls_config.clone()), None => srv.listen(listener), }; #[cfg(not(feature = "tls"))] let srv = srv.listen(listener); srv.map_err(|e| StartupError::IoError(format!("Failed to bind server to {addr}"), e)) })?; let srv = srv.shutdown_timeout(0).run(); if !miniserve_config.quiet { println!("Bound to {}", display_sockets.join(", ")); println!("Serving path {}", path_string.yellow().bold()); println!( "Available at (non-exhaustive list):\n {}\n", display_urls .iter() .map(|url| url.green().bold().to_string()) .collect::<Vec<_>>() .join("\n "), ); } // print QR code to terminal if miniserve_config.show_qrcode && io::stdout().is_terminal() { for url in display_urls .iter() .filter(|url| !url.contains("//127.0.0.1:") && !url.contains("//[::1]:")) { match QRBuilder::new(url.clone()).ecl(consts::QR_EC_LEVEL).build() { Ok(qr) => { println!("QR code for {}:", url.green().bold()); qr.print(); } Err(e) => { error!("Failed to render QR to terminal: {e:?}"); } }; } } if !miniserve_config.quiet && io::stdout().is_terminal() { println!("Quit by pressing CTRL-C"); } srv.await .map_err(|e| StartupError::IoError("".to_owned(), e)) } /// Allows us to set low-level socket options /// /// This mainly used to set `set_only_v6` socket option /// to get a consistent behavior across platforms. /// see: https://github.com/svenstaro/miniserve/pull/500 fn create_tcp_listener(addr: SocketAddr) -> io::Result<TcpListener> { use socket2::{Domain, Protocol, Socket, Type}; let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?; if addr.is_ipv6() { socket.set_only_v6(true)?; } socket.set_reuse_address(true)?; socket.bind(&addr.into())?; socket.listen(1024 /* Default backlog */)?; Ok(TcpListener::from(socket)) } fn configure_header(conf: &MiniserveConfig) -> middleware::DefaultHeaders { conf.header.iter().flatten().fold( middleware::DefaultHeaders::new(), |headers, (header_name, header_value)| headers.add((header_name, header_value)), ) } /// Configures the Actix application /// /// This is where we configure the app to serve an index file, the file listing, or a single file. fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { let dir_service = || { // use routing guard so propfind and options requests fall through to the webdav handler let mut files = actix_files::Files::new("", &conf.path) .guard(guard::Any(guard::Get()).or(guard::Head())); // Use specific index file if one was provided. if let Some(ref index_file) = conf.index { files = files.index_file(index_file.to_string_lossy()); // Handle SPA option. // // Note: --spa requires --index in clap. if conf.spa { files = files.default_handler( NamedFile::open(conf.path.join(index_file)) .expect("Can't open SPA index file."), ); } } // Handle --pretty-urls options. // // We rewrite the request to append ".html" to the path and serve the file. If the // path ends with a `/`, we remove it before appending ".html". // // This is done to allow for pretty URLs, e.g. "/about" instead of "/about.html". if conf.pretty_urls { files = files.default_handler(fn_service(|req: ServiceRequest| async { let (req, _) = req.into_parts(); let conf = req .app_data::<web::Data<MiniserveConfig>>() .expect("Could not get miniserve config"); let mut path_base = req.path()[1..].to_string(); if path_base.ends_with('/') { path_base.pop(); } if !path_base.ends_with("html") { path_base = format!("{path_base}.html"); } let file = NamedFile::open_async(conf.path.join(path_base)).await?; let res = file.into_response(&req); Ok(ServiceResponse::new(req, res)) })); } if conf.show_hidden { files = files.use_hidden_files(); } let base_path = conf.path.clone(); let no_symlinks = conf.no_symlinks; files .show_files_listing() .files_listing_renderer(listing::directory_listing) .prefer_utf8(true) .redirect_to_slash_directory() .path_filter(move |path, _| { if !no_symlinks { // no_symlinks not enabled => nothing to filter return true; } // append path to base_path component by component and check for symlink at each step let mut full_path = base_path.clone(); for component in path.components() { full_path.push(component); if full_path.is_symlink() { // path contains symlink component while no_symlink is active => filter return false; } } // path didn't include a symlink component => don't filter true }) }; if conf.path.is_file() { // Handle single files app.service(web::resource(["", "/"]).route(web::to(listing::file_handler))); } else { if conf.file_upload { // Allow file upload app.service(web::resource("/upload").route(web::post().to(file_op::upload_file))); } if conf.rm_enabled { // Allow file and directory deletion app.service(web::resource("/rm").route(web::post().to(file_op::rm_file))); } // Handle directories app.service(dir_service()); } if conf.webdav_enabled { let fs = RestrictedFs::new(&conf.path, conf.show_hidden, conf.no_symlinks); let dav_server = DavHandler::builder() .filesystem(fs) .methods(DavMethodSet::WEBDAV_RO) .hide_symlinks(false) // we handle filtering symlinks ourselves in RestrictedFs .strip_prefix(conf.route_prefix.to_owned()) .build_handler(); app.app_data(web::Data::new(dav_server.clone())); app.service( // actix requires tail segment to be named, even if unused web::resource("/{tail}*") .guard( guard::Any(guard::Options()) .or(guard::Method(Method::from_bytes(b"PROPFIND").unwrap())), ) .to(dav_handler), ); } } async fn dav_handler(req: DavRequest, davhandler: web::Data<DavHandler>) -> DavResponse { davhandler.handle(req.request).await.into() } async fn error_404(req: HttpRequest) -> Result<HttpResponse, RuntimeError> { Err(RuntimeError::RouteNotFoundError(req.path().to_string())) } 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<ApiCommand>, config: web::Data<MiniserveConfig>, ) -> Result<impl Responder, RuntimeError> { match command.into_inner() { ApiCommand::DirSize(path) => { if config.directory_size { // The dir argument might be percent-encoded so let's decode it just in case. let decoded_path = percent_decode_str(&path) .decode_utf8() .map_err(|e| RuntimeError::ParseError(path.clone(), e.to_string()))?; // Convert the relative dir to an absolute path on the system. let sanitized_path = file_utils::sanitize_path(&*decoded_path, 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()) } } else { Ok("-".to_string()) } } } } async fn favicon() -> impl Responder { let logo = include_str!("../data/logo.svg"); HttpResponse::Ok() .insert_header(ContentType(mime::IMAGE_SVG)) .body(logo) } async fn css(stylesheet: web::Data<String>) -> impl Responder { HttpResponse::Ok() .insert_header(ContentType(mime::TEXT_CSS)) .body(stylesheet.to_string()) } ================================================ FILE: src/pipe.rs ================================================ //! Define an adapter to implement `std::io::Write` on `Sender<Bytes>`. use std::io::{self, Error, ErrorKind, Write}; use actix_web::web::{Bytes, BytesMut}; use futures::channel::mpsc::Sender; use futures::executor::block_on; use futures::sink::SinkExt; /// Adapter to implement the `std::io::Write` trait on a `Sender<Bytes>` from a futures channel. /// /// It uses an intermediate buffer to transfer packets. pub struct Pipe { dest: Sender<io::Result<Bytes>>, bytes: BytesMut, } impl Pipe { /// Wrap the given sender in a `Pipe`. pub fn new(destination: Sender<io::Result<Bytes>>) -> Self { Self { dest: destination, bytes: BytesMut::new(), } } } impl Drop for Pipe { fn drop(&mut self) { let _ = block_on(self.dest.close()); } } impl Write for Pipe { fn write(&mut self, buf: &[u8]) -> io::Result<usize> { // We are given a slice of bytes we do not own, so we must start by copying it. self.bytes.extend_from_slice(buf); // Then, take the buffer and send it in the channel. block_on(self.dest.send(Ok(self.bytes.split().into()))) .map_err(|e| Error::new(ErrorKind::UnexpectedEof, e))?; // Return how much we sent - all of it. Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { block_on(self.dest.flush()).map_err(|e| Error::new(ErrorKind::UnexpectedEof, e)) } } ================================================ FILE: src/renderer.rs ================================================ use std::time::SystemTime; use actix_web::http::{StatusCode, Uri}; use chrono::{DateTime, Local}; use chrono_humanize::Humanize; use clap::{ValueEnum, crate_name, crate_version}; use fast_qr::{ QRBuilder, convert::{Builder, svg::SvgBuilder}, qr::QRCodeError, }; use maud::{DOCTYPE, Markup, PreEscaped, html}; use strum::{Display, IntoEnumIterator}; use crate::auth::CurrentUser; use crate::consts; use crate::listing::{Breadcrumb, Entry, ListingQueryParameters, SortingMethod, SortingOrder}; use crate::{MiniserveConfig, archive::ArchiveMethod}; #[allow(clippy::too_many_arguments)] /// Renders the file listing pub fn page( entries: Vec<Entry>, readme: Option<(String, String)>, abs_uri: &Uri, is_root: bool, query_params: ListingQueryParameters, breadcrumbs: &[Breadcrumb], encoded_dir: &str, conf: &MiniserveConfig, current_user: Option<&CurrentUser>, ) -> Markup { // If query_params.raw is true, we want render a minimal directory listing if query_params.raw.is_some() && query_params.raw.unwrap() { return raw(entries, is_root, conf); } let upload_route = format!("{}/upload", &conf.route_prefix); let rm_route = format!("{}/rm", &conf.route_prefix); let (sort_method, sort_order) = (query_params.sort, query_params.order); let upload_action = build_upload_action(&upload_route, encoded_dir, sort_method, sort_order); let mkdir_action = build_mkdir_action(&upload_route, encoded_dir); let title_path = breadcrumbs_to_path_string(breadcrumbs); let upload_allowed = conf.allowed_upload_dir.is_empty() || conf .allowed_upload_dir .iter() .any(|x| encoded_dir.starts_with(&format!("/{x}"))); let rm_allowed = conf.allowed_rm_dir.is_empty() || conf .allowed_rm_dir .iter() .any(|x| encoded_dir.starts_with(&format!("/{x}"))); // OR with other conditions in the future if more actions are added let show_actions = conf.rm_enabled && rm_allowed; let actions_conf = show_actions.then(|| ActionsConf { rm_route: &rm_route, }); html! { (DOCTYPE) html { (page_header(&title_path, conf.file_upload, conf.web_upload_concurrency, &conf.api_route, &conf.favicon_route, &conf.css_route)) body #drop-container { div.toolbar_box_group { @if conf.file_upload { div.drag-form { div.form_title { h1 { "Drop your file here to upload it" } } } } @if conf.mkdir_enabled { div.form { div.form_title { h1 { "Create a new directory" } } } } } nav { (qr_spoiler(conf.show_qrcode, abs_uri)) (color_scheme_selector(conf.hide_theme_selector)) } div.container { span #top { } h1.title dir="ltr" { @for el in breadcrumbs { @if el.link == "." { // wrapped in span so the text doesn't shift slightly when it turns into a link span { bdi { (el.name) } } } @else { a href=(parametrized_link(&el.link, sort_method, sort_order, false)) { bdi { (el.name) } } } "/" } } div.toolbar { @if conf.tar_enabled || conf.tar_gz_enabled || conf.zip_enabled { div.tool_row.download_tools { div.tool data-tool="download" { @for archive_method in ArchiveMethod::iter() { @if archive_method.is_enabled(conf.tar_enabled, conf.tar_gz_enabled, conf.zip_enabled) { (archive_button(archive_method, sort_method, sort_order)) } } } } } div.tool_row.upload_tools { @if conf.file_upload && upload_allowed { form.tool id="file_submit" data-tool="upload" action=(upload_action) method="POST" enctype="multipart/form-data" { p { "Select a file to upload or drag it anywhere into the window" } div { @match &conf.uploadable_media_type { Some(accept) => {input #file-input accept=(accept) type="file" name="file_to_upload" required="" multiple {}}, None => {input #file-input type="file" name="file_to_upload" required="" multiple {}} } button type="submit" title="Upload File" { "Upload file" } } } } @if conf.mkdir_enabled && upload_allowed { form.tool id="mkdir" data-tool="mkdir" action=(mkdir_action) method="POST" enctype="multipart/form-data" { p { "Specify a directory name to create" } div { input type="text" name="mkdir" required="" placeholder="Directory name" {} button type="submit" title="Create directory" { "Create directory" } } } } @if conf.pastebin_enabled && upload_allowed { form.tool id="pastebin" data-tool="pastebin" { p { "Create a text file in the current directory, a random filename will be generated, or you may specify one." } div { textarea #pastebin_content name="paste_content" title="Text content" required="" { } } div { input type="text" name="paste_filename" title="Filename" placeholder="Filename (Optional)" autocomplete="off" {} button type="submit" title="Create file" { "Create file" } } } } } } table { thead { th.name { (sortable_title("name", "Name", sort_method, sort_order)) } th.size { (sortable_title("size", "Size", sort_method, sort_order)) } th.date { (sortable_title("date", "Last modification", sort_method, sort_order)) } @if show_actions { th.actions { span { "Actions" } } } } tbody { @if !is_root { tr { td colspan=(3 + show_actions as usize) { p { span.root-chevron { (chevron_left()) } a.root href=(parametrized_link("../", sort_method, sort_order, false)) { "Parent directory" } } } } } @for entry in entries { (entry_row(entry, sort_method, sort_order, false, conf.show_exact_bytes, actions_conf, &conf.route_prefix)) } } } @if let Some(readme) = readme { div id="readme" { h3 id="readme-filename" { (readme.0) } div id="readme-contents" { (PreEscaped (readme.1)) }; } } a.back href="#top" { (arrow_up()) } div.footer { @if conf.show_wget_footer { (wget_footer(abs_uri, conf.title.as_deref(), current_user.map(|x| &*x.name), conf.file_external_url.as_deref())) } @if !conf.hide_version_footer { (version_footer()) } } } div.upload_area id="upload_area" { template id="upload_file_item" { li.upload_file_item { div.upload_file_container { div.upload_file_text { span.file_upload_percent { "" } {" - "} span.file_size { "" } {" - "} span.file_name { "" } } button.file_cancel_upload { "✖" } } div.file_progress_bar {} } } div.upload_container { div.upload_header { h4 style="margin:0px" id="upload_title" {} svg id="upload-toggle" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6" { path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" {} } } div.upload_action { p id="upload_action_text" { "Starting upload..." } button.upload_cancel id="upload_cancel" { "CANCEL" } } div.upload_files { ul.upload_file_list id="upload_file_list" { } } } } } } } } /// Renders the file listing pub fn raw(entries: Vec<Entry>, is_root: bool, conf: &MiniserveConfig) -> Markup { html! { (DOCTYPE) html { body { table { thead { th.name { "Name" } th.size { "Size" } th.date { "Last modification" } } tbody { @if !is_root { tr { td colspan="3" { p { a.root href=(parametrized_link("../", None, None, true)) { ".." } } } } } @for entry in entries { (entry_row(entry, None, None, true, conf.show_exact_bytes, None, &conf.route_prefix)) } } } } } } } /// Renders the QR code SVG fn qr_code_svg(url: &Uri, margin: usize) -> Result<String, QRCodeError> { let qr = QRBuilder::new(url.to_string()) .ecl(consts::QR_EC_LEVEL) .build()?; let svg = SvgBuilder::default().margin(margin).to_str(&qr); Ok(svg) } /// Build a path string from a list of breadcrumbs. fn breadcrumbs_to_path_string(breadcrumbs: &[Breadcrumb]) -> String { breadcrumbs .iter() .map(|el| el.name.clone()) .collect::<Vec<_>>() .join("/") } // Partial: version footer fn version_footer() -> Markup { html! { div.version { a href="https://github.com/svenstaro/miniserve" { (crate_name!()) } (format!("/{}", crate_version!())) } } } fn wget_footer( abs_path: &Uri, root_dir_name: Option<&str>, current_user: Option<&str>, file_external_url: Option<&str>, ) -> Markup { fn escape_apostrophes(x: &str) -> String { x.replace('\'', "'\"'\"'") } // Directory depth, 0 is root directory let cut_dirs = match abs_path.path().matches('/').count() - 1 { // Put all the files in a folder of this name 0 => format!( " -P '{}'", escape_apostrophes( root_dir_name.unwrap_or_else(|| abs_path.authority().unwrap().as_str()) ) ), 1 => String::new(), // Avoids putting the files in excessive directories x => format!(" --cut-dirs={}", x - 1), }; // Ask for password if authentication is required let user_params = match current_user { Some(user) => format!(" --ask-password --user '{}'", escape_apostrophes(user)), None => String::new(), }; // Add the -H option to span hosts when serving files from another instance let span_hosts_option = if file_external_url.is_some() { " -H" } else { " -nH" }; let encoded_abs_path = abs_path.to_string().replace('\'', "%27"); let command = format!( "wget -rcnp -R 'index.html*'{span_hosts_option}{cut_dirs}{user_params} '{encoded_abs_path}?raw=true'" ); let click_to_copy = format!("navigator.clipboard.writeText(\"{command}\")"); html! { div.downloadDirectory { p { "Download folder:" } a.cmd title="Click to copy!" style="cursor: pointer;" onclick=(click_to_copy) { (command) } } } } /// Build the action of the upload form fn build_upload_action( upload_route: &str, encoded_dir: &str, sort_method: Option<SortingMethod>, sort_order: Option<SortingOrder>, ) -> String { let mut upload_action = format!("{upload_route}?path={encoded_dir}"); if let Some(sorting_method) = sort_method { upload_action = format!("{}&sort={}", upload_action, &sorting_method); } if let Some(sorting_order) = sort_order { upload_action = format!("{}&order={}", upload_action, &sorting_order); } upload_action } /// Build the action of the mkdir form fn build_mkdir_action(mkdir_route: &str, encoded_dir: &str) -> String { format!("{mkdir_route}?path={encoded_dir}") } const THEME_PICKER_CHOICES: &[(&str, &str)] = &[ ("Default (light/dark)", "default"), ("Squirrel (light)", "squirrel"), ("Arch Linux (dark)", "archlinux"), ("Ayu Dark (dark)", "ayu_dark"), ("Zenburn (dark)", "zenburn"), ("Monokai (dark)", "monokai"), ]; #[derive(Debug, Clone, ValueEnum, Display)] pub enum ThemeSlug { #[strum(serialize = "squirrel")] Squirrel, #[strum(serialize = "archlinux")] Archlinux, #[strum(serialize = "ayu_dark")] AyuDark, #[strum(serialize = "zenburn")] Zenburn, #[strum(serialize = "monokai")] Monokai, } impl ThemeSlug { pub fn css(&self) -> &str { match self { Self::Squirrel => grass::include!("data/themes/squirrel.scss"), Self::Archlinux => grass::include!("data/themes/archlinux.scss"), Self::AyuDark => grass::include!("data/themes/ayu_dark.scss"), Self::Zenburn => grass::include!("data/themes/zenburn.scss"), Self::Monokai => grass::include!("data/themes/monokai.scss"), } } pub fn css_dark(&self) -> String { format!("@media (prefers-color-scheme: dark) {{\n{}}}", self.css()) } } /// Partial: qr code spoiler fn qr_spoiler(show_qrcode: bool, content: &Uri) -> Markup { html! { @if show_qrcode { div { p { "QR code" } div.qrcode #qrcode title=(PreEscaped(content.to_string())) { @match qr_code_svg(content, consts::SVG_QR_MARGIN) { Ok(svg) => (PreEscaped(svg)), Err(err) => (format!("QR generation error: {err:?}")), } } } } } } /// Partial: color scheme selector fn color_scheme_selector(hide_theme_selector: bool) -> Markup { html! { @if !hide_theme_selector { div { p { "Change theme..." } ul.theme { @for color_scheme in THEME_PICKER_CHOICES { li data-theme=(color_scheme.1) { (color_scheme_link(color_scheme)) } } } } } } } // /// Partial: color scheme link fn color_scheme_link(color_scheme: &(&str, &str)) -> Markup { let title = format!("Switch to {} theme", color_scheme.0); html! { a href=(format!("javascript:updateColorScheme(\"{}\")", color_scheme.1)) title=(title) { (color_scheme.0) } } } /// Partial: archive button fn archive_button( archive_method: ArchiveMethod, sort_method: Option<SortingMethod>, sort_order: Option<SortingOrder>, ) -> Markup { let link = if sort_method.is_none() && sort_order.is_none() { format!("?download={archive_method}") } else { format!( "{}&download={}", parametrized_link("", sort_method, sort_order, false), archive_method ) }; let text = format!("Download .{}", archive_method.extension()); html! { a href=(link) { (text) } } } /// Ensure that there's always a trailing slash behind the `link`. fn make_link_with_trailing_slash(link: &str) -> String { if link.is_empty() || link.ends_with('/') { link.to_string() } else { format!("{link}/") } } /// If they are set, adds query parameters to links to keep them across pages fn parametrized_link( link: &str, sort_method: Option<SortingMethod>, sort_order: Option<SortingOrder>, raw: bool, ) -> String { if raw { return format!("{}?raw=true", make_link_with_trailing_slash(link)); } if let Some(method) = sort_method && let Some(order) = sort_order { let parametrized_link = format!( "{}?sort={}&order={}", make_link_with_trailing_slash(link), method, order, ); return parametrized_link; } make_link_with_trailing_slash(link) } /// Partial: table header link fn sortable_title( name: &str, title: &str, sort_method: Option<SortingMethod>, sort_order: Option<SortingOrder>, ) -> Markup { let mut link = format!("?sort={name}&order=asc"); let mut help = format!("Sort by {name} in ascending order"); let mut chevron = chevron_down(); let mut class = ""; if let Some(method) = sort_method && method.to_string() == name { class = "active"; if let Some(order) = sort_order && order.to_string() == "asc" { link = format!("?sort={name}&order=desc"); help = format!("Sort by {name} in descending order"); chevron = chevron_up(); } }; html! { span class=(class) { span.chevron { (chevron) } a href=(link) title=(help) { (title) } } } } /// Partial: rm form fn rm_form(rm_route: &str, encoded_path: &str, prefix: &str) -> Markup { let stripped_path = encoded_path.strip_prefix(prefix).unwrap_or(encoded_path); let rm_action = format!("{rm_route}?path={stripped_path}"); html! { form class="rm_form" action=(rm_action) method="POST" { button type="submit" title="Delete" { "✗" } } } } #[derive(Copy, Clone, Debug)] struct ActionsConf<'a> { /// Route prefix for file removal POST requests. rm_route: &'a str, } /// Partial: row for an entry fn entry_row( entry: Entry, sort_method: Option<SortingMethod>, sort_order: Option<SortingOrder>, raw: bool, show_exact_bytes: bool, actions_conf: Option<ActionsConf>, route_prefix: &str, ) -> Markup { html! { @let entry_type = entry.entry_type.clone(); tr .{ "entry-type-" (entry_type) } { td { p { @if entry.is_dir() { @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 { } a.directory {(symlink_dest) "/"} } }@else { a.directory href=(parametrized_link(&entry.link, sort_method, sort_order, raw)) { (entry.name) "/" } } } @else if entry.is_file() { @if let Some(ref symlink_dest) = entry.symlink_info { a.symlink href=(&entry.link) { (entry.name) span.symlink-symbol { } a.file {(symlink_dest)} } }@else { a.file href=(&entry.link) { (entry.name) } } @if !raw { @if let Some(size) = entry.size { @if show_exact_bytes { span.mobile-info.size { (maud::display(format!("{} B", size.as_u64()))) } }@else { span.mobile-info.size { (sortable_title("size", &format!("{size}"), sort_method, sort_order)) } } } @if let Some(modification_timer) = humanize_systemtime(entry.last_modification_date) { span.mobile-info.history { (sortable_title("date", &modification_timer, sort_method, sort_order)) } } } } } } td.size-cell { @if let Some(size) = entry.size { @if show_exact_bytes { (maud::display(format!("{} B", size.as_u64()))) }@else { (maud::display(size)) } } } td.date-cell { @if let Some(modification_date) = convert_to_local(entry.last_modification_date) { span { (modification_date) " " } } @if let Some(modification_timer) = humanize_systemtime(entry.last_modification_date) { span.history { (modification_timer) } } } @if let Some(conf) = actions_conf { td.actions-cell { (rm_form(conf.rm_route, &entry.link, route_prefix)) } } } } } /// Partial: up arrow fn arrow_up() -> Markup { PreEscaped("⇪".to_string()) } /// Partial: chevron left fn chevron_left() -> Markup { PreEscaped("◂".to_string()) } /// Partial: chevron up fn chevron_up() -> Markup { PreEscaped("▴".to_string()) } /// Partial: chevron up fn chevron_down() -> Markup { PreEscaped("▾".to_string()) } /// Partial: page header fn page_header( title: &str, file_upload: bool, web_file_concurrency: usize, api_route: &str, favicon_route: &str, css_route: &str, ) -> Markup { html! { head { meta charset="utf-8"; meta http-equiv="X-UA-Compatible" content="IE=edge"; meta name="viewport" content="width=device-width, initial-scale=1"; meta name="color-scheme" content="dark light"; link rel="icon" type="image/svg+xml" href={ (favicon_route) }; link rel="stylesheet" href={ (css_route) }; title { (title) } script { (PreEscaped(r#" // updates the color scheme by setting the theme data attribute // on body and saving the new theme to local storage function updateColorScheme(name) { if (name && name != "default") { localStorage.setItem('theme', name); document.body.setAttribute("data-theme", name) } else { localStorage.removeItem('theme'); document.body.removeAttribute("data-theme") } } // read theme from local storage and apply it to body function loadColorScheme() { var name = localStorage.getItem('theme'); updateColorScheme(name); } // load saved theme on page load addEventListener("load", loadColorScheme); // load saved theme when local storage is changed (synchronize between tabs) addEventListener("storage", loadColorScheme); "#)) } 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.ok ? resp.text() : "~") } 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.dataset.size = dirSizeCache[target]; } else { fetchDirSize(target).then(dir_size => { cell.dataset.size = dir_size; dirSizeCache[target] = dir_size; }) .catch(error => console.error("Error fetching dir size:", error)); } }) } setInterval(updateSizeCells, 1000); "#)) } @if file_upload { script { (format!("const CONCURRENCY = {web_file_concurrency};")) (PreEscaped(r#" window.onload = function() { // Constants const UPLOADING = 'uploading', PENDING = 'pending', COMPLETE = 'complete', CANCELLED = 'cancelled', FAILED = 'failed' const UPLOAD_ITEM_ORDER = { UPLOADING: 0, PENDING: 1, COMPLETE: 2, CANCELLED: 3, FAILED: 4 } let CANCEL_UPLOAD = false; // File Upload dom elements. Used for interacting with the // upload container. const form = document.querySelector('#file_submit'); const uploadArea = document.querySelector('#upload_area'); const uploadTitle = document.querySelector('#upload_title'); const uploadActionText = document.querySelector('#upload_action_text'); const uploadCancelButton = document.querySelector('#upload_cancel'); const uploadList = document.querySelector('#upload_file_list'); const fileUploadItemTemplate = document.querySelector('#upload_file_item'); const uploadWidgetToggle = document.querySelector('#upload-toggle'); const dropContainer = document.querySelector('#drop-container'); const dragForm = document.querySelector('.drag-form'); const fileInput = document.querySelector('#file-input'); const collection = []; dropContainer.ondragover = function(e) { e.preventDefault(); } dropContainer.ondragenter = function(e) { e.preventDefault(); if (collection.length === 0) { dragForm.style.display = 'initial'; } collection.push(e.target); }; dropContainer.ondragleave = function(e) { e.preventDefault(); collection.splice(collection.indexOf(e.target), 1); if (collection.length === 0) { dragForm.style.display = 'none'; } }; dropContainer.ondrop = function(e) { e.preventDefault(); fileInput.files = e.dataTransfer.files; form.requestSubmit(); dragForm.style.display = 'none'; }; // Event listener for toggling the upload widget display on mobile. uploadWidgetToggle.addEventListener('click', function (e) { e.preventDefault(); if (uploadArea.style.height === "100vh") { uploadArea.style = "" document.body.style = "" uploadWidgetToggle.style = "" } else { uploadArea.style.height = "100vh" document.body.style = "overflow: hidden" uploadWidgetToggle.style = "transform: rotate(180deg)" } }) // Cancel all active and pending uploads uploadCancelButton.addEventListener('click', function (e) { e.preventDefault(); CANCEL_UPLOAD = true; }) form.addEventListener('submit', function (e) { e.preventDefault() uploadFiles() }) // When uploads start, finish or are cancelled, the UI needs to reactively shows those // updates of the state. This function updates the text on the upload widget to accurately // show the state of all uploads. function updateUploadTextAndList() { // All state is kept as `data-*` attributed on the HTML node. const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; const total = document.querySelectorAll("[data-state]").length; const uploads = queryLength(UPLOADING); const pending = queryLength(PENDING); const completed = queryLength(COMPLETE); const cancelled = queryLength(CANCELLED); const failed = queryLength(FAILED); const allCompleted = completed + cancelled + failed; // Update header text based on remaining uploads let headerText = `${total - allCompleted} uploads remaining...`; if (total === allCompleted) { headerText = `Complete! Reloading Page!` } // Build a summary of statuses for sub header const statuses = [] if (uploads > 0) { statuses.push(`Uploading ${uploads}`) } if (pending > 0) { statuses.push(`Pending ${pending}`) } if (completed > 0) { statuses.push(`Complete ${completed}`) } if (cancelled > 0) { statuses.push(`Cancelled ${cancelled}`) } if (failed > 0) { statuses.push(`Failed ${failed}`) } uploadTitle.textContent = headerText uploadActionText.textContent = statuses.join(', ') } // Initiates the file upload process by disabling the ability for more files to be // uploaded and creating async callbacks for each file that needs to be uploaded. // Given the concurrency set by the server input arguments, it will try to process // that many uploads at once function uploadFiles() { fileInput.disabled = true; // Map all the files into async callbacks (uploadFile is a function that returns a function) const callbacks = Array.from(fileInput.files).map(uploadFile); // Get a list of all the callbacks const concurrency = CONCURRENCY === 0 ? callbacks.length : CONCURRENCY; // Worker function that continuously pulls tasks from the shared queue. async function worker() { while (callbacks.length > 0) { // Remove a task from the front of the queue. const task = callbacks.shift(); if (task) { await task(); updateUploadTextAndList(); } } } // Create a work stealing shared queue, split up between `concurrency` amount of workers. const workers = Array.from({ length: concurrency }).map(worker); // Wait for all the workers to complete Promise.allSettled(workers) .finally(() => { updateUploadTextAndList(); form.reset(); setTimeout(() => { uploadArea.classList.remove('active'); }, 1000) setTimeout(() => { window.location.reload(); }, 1500) }) updateUploadTextAndList(); uploadArea.classList.add('active') uploadList.scrollTo(0, 0) } function formatBytes(bytes, decimals) { if (bytes == 0) return '0 Bytes'; var k = 1024, dm = decimals || 2, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } document.querySelector('input[type="file"]').addEventListener('change', async (e) => { const file = e.target.files[0]; }); async function get256FileHash(file) { const arrayBuffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } // Upload a file. This function will create a upload item in the upload // widget from an HTML template. It then returns a promise which will // be used to upload the file to the server and control the styles and // interactions on the HTML list item. function uploadFile(file) { const fileUploadItem = fileUploadItemTemplate.content.cloneNode(true) const itemContainer = fileUploadItem.querySelector(".upload_file_item") const itemText = fileUploadItem.querySelector(".upload_file_text") const size = fileUploadItem.querySelector(".file_size") const name = fileUploadItem.querySelector(".file_name") const percentText = fileUploadItem.querySelector(".file_upload_percent") const bar = fileUploadItem.querySelector(".file_progress_bar") const cancel = fileUploadItem.querySelector(".file_cancel_upload") let preCancel = false; itemContainer.dataset.state = PENDING name.textContent = file.name size.textContent = formatBytes(file.size) percentText.textContent = "0%" uploadList.append(fileUploadItem) // Cancel an upload before it even started. function preCancelUpload() { preCancel = true; itemText.classList.add(CANCELLED); bar.classList.add(CANCELLED); itemContainer.dataset.state = CANCELLED; itemContainer.style.background = 'var(--upload_modal_file_upload_complete_background)'; cancel.disabled = true; cancel.removeEventListener("click", preCancelUpload); uploadCancelButton.removeEventListener("click", preCancelUpload); updateUploadTextAndList(); } uploadCancelButton.addEventListener("click", preCancelUpload) cancel.addEventListener("click", preCancelUpload) // A callback function is return so that the upload doesn't start until // we want it to. This is so that we have control over our desired concurrency. return () => { if (preCancel) { return Promise.resolve() } // Upload the single file in a multipart request. return new Promise(async (resolve, reject) => { // File hash calculation may fail at times: // 1. `crypto.subtle` is not available in nonsecure context (e.g. non-HTTPS LAN). // See https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle // 2. For files larger than 2GB, Firefox will refuse to calculate the SHA-256 value, // while Chrome will refuse to create a ArrayBuffer (#1541). const fileHash = await get256FileHash(file).catch(() => ""); const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('file', file); function onReadyStateChange(e) { if (e.target.readyState == 4) { if (e.target.status == 200) { completeSuccess() } else { failedUpload(e.target.status) } } } function onError(e) { failedUpload() } function onAbort(e) { cancelUpload() } function onProgress (e) { update(Math.round((e.loaded / e.total) * 100)); } function update(uploadPercent) { let wholeNumber = Math.floor(uploadPercent) percentText.textContent = `${wholeNumber}%` bar.style.width = `${wholeNumber}%` } function completeSuccess() { cancel.textContent = '✔'; cancel.classList.add(COMPLETE); bar.classList.add(COMPLETE); cleanUp(COMPLETE) } function failedUpload(statusCode) { cancel.textContent = `${statusCode} ⚠`; itemText.classList.add(FAILED); bar.classList.add(FAILED); cleanUp(FAILED); } function cancelUpload() { xhr.abort() itemText.classList.add(CANCELLED); bar.classList.add(CANCELLED); cleanUp(CANCELLED); } function cleanUp(state) { itemContainer.dataset.state = state; itemContainer.style.background = 'var(--upload_modal_file_upload_complete_background)'; cancel.disabled = true; cancel.removeEventListener("click", cancelUpload) uploadCancelButton.removeEventListener("click", cancelUpload) xhr.removeEventListener('readystatechange', onReadyStateChange); xhr.removeEventListener("error", onError); xhr.removeEventListener("abort", onAbort); xhr.upload.removeEventListener('progress', onProgress); resolve() } uploadCancelButton.addEventListener("click", cancelUpload) cancel.addEventListener("click", cancelUpload) if (CANCEL_UPLOAD) { cancelUpload() } else { itemContainer.dataset.state = UPLOADING xhr.addEventListener('readystatechange', onReadyStateChange); xhr.addEventListener("error", onError); xhr.addEventListener("abort", onAbort); xhr.upload.addEventListener('progress', onProgress); xhr.open('post', form.getAttribute("action"), true); if (fileHash) { xhr.setRequestHeader('X-File-Hash', fileHash); xhr.setRequestHeader('X-File-Hash-Function', 'SHA256'); } xhr.send(formData); } }) } } // Bind pastebin submission to create a text/plain blob which is injected // into the upload input then submitted. A title is automatically generated // if none is given. const fileUploadForm = document.querySelector('#file_submit'); const fileUploadInput = document.querySelector('#file_submit input[type=file]'); const pastebinForm = document.querySelector('form#pastebin'); const pastebinFilename = pastebinForm.querySelector('input[name=paste_filename]'); const pastebinContent = pastebinForm.querySelector('textarea'); pastebinContent.addEventListener('keydown', (event) => { // common convenience of ctrl-enter to submit if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.target.form.requestSubmit(); } }); pastebinForm.addEventListener('submit', (event) => { // The pastebin form is "dead" and should not cause any page-submit // events. We capture the pastebin form content, convert it into a // in-memory blob, then pass that blob to the regular fileUpload form // for submission, as if a user and selected a real file. event.preventDefault(); const text = pastebinContent.value; const title = ((inputValue) => { const title = inputValue.trim(); if (title.length === 0) { const suffix = crypto.randomUUID().substring(0,6); return `paste-${suffix}.txt`; } else { // use given extension if one is present, otherwise make it // .txt. We're quite liberal in what we consider an extension, // any number of alpha-numeric after a dot. if (/\.[0-9a-z]+$/i.test(title)) { return title; } else { return `${title}.txt`; } } })(pastebinFilename.value); // Package text as a file and submit const blob = new Blob([text], {type: 'text/plain'}); const file = new File([blob], title, {type: 'text/plain'}); const container = new DataTransfer(); container.items.add(file); fileUploadInput.files = container.files; fileUploadForm.submit(); }); } "#)) } } } } } /// Converts a SystemTime object to a strings tuple (date, time) fn convert_to_local(src_time: Option<SystemTime>) -> Option<String> { src_time .map(DateTime::<Local>::from) .map(|date_time| date_time.format("%Y-%m-%d %H:%M:%S %:z").to_string()) } /// Converts a SystemTime to a string readable by a human, /// and gives a rough approximation of the elapsed time since fn humanize_systemtime(time: Option<SystemTime>) -> Option<String> { time.map(|time| time.humanize()) } /// Renders an error on the webpage pub fn render_error( error_description: &str, error_code: StatusCode, conf: &MiniserveConfig, return_address: &str, ) -> Markup { html! { (DOCTYPE) html { (page_header(&error_code.to_string(), false, conf.web_upload_concurrency, &conf.api_route, &conf.favicon_route, &conf.css_route)) body { div.error { p { (error_code.to_string()) } @for error in error_description.lines() { p { (error) } } // WARN don't expose random route! @if conf.route_prefix.is_empty() && !conf.disable_indexing { div.error-nav { a.error-back href=(return_address) { "Go back to file listing" } } } @if !conf.hide_version_footer { p.footer { (version_footer()) } } } } } } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn to_html(wget_part: &str) -> String { format!( r#"<div class="downloadDirectory"><p>Download folder:</p><a class="cmd" title="Click to copy!" style="cursor: pointer;" onclick="navigator.clipboard.writeText("wget -rcnp -R 'index.html*' {wget_part}/?raw=true'")">wget -rcnp -R 'index.html*' {wget_part}/?raw=true'</a></div>"# ) } fn uri(x: &str) -> Uri { Uri::try_from(x).unwrap() } #[test] fn test_wget_footer_trivial() { let to_be_tested: String = wget_footer(&uri("https://github.com/"), None, None, None).into(); let expected = to_html("-nH -P 'github.com' 'https://github.com"); assert_eq!(to_be_tested, expected); } #[test] fn test_wget_footer_with_root_dir() { let to_be_tested: String = wget_footer( &uri("https://github.com/svenstaro/miniserve/"), Some("Miniserve"), None, None, ) .into(); let expected = to_html("-nH --cut-dirs=1 'https://github.com/svenstaro/miniserve"); assert_eq!(to_be_tested, expected); } #[test] fn test_wget_footer_with_root_dir_and_user() { let to_be_tested: String = wget_footer( &uri("http://1und1.de/"), Some("1&1 - Willkommen!!!"), Some("Marcell D'Avis"), None, ) .into(); let expected = to_html( "-nH -P '1&1 - Willkommen!!!' --ask-password --user 'Marcell D'"'"'Avis' 'http://1und1.de", ); assert_eq!(to_be_tested, expected); } #[test] fn test_wget_footer_escaping() { let to_be_tested: String = wget_footer( &uri("http://127.0.0.1:1234/geheime_dokumente.php/"), Some("Streng Geheim!!!"), Some("uøý`¶'7ÅÛé"), None, ) .into(); let expected = to_html( "-nH --ask-password --user 'uøý`¶'"'"'7ÅÛé' 'http://127.0.0.1:1234/geheime_dokumente.php", ); assert_eq!(to_be_tested, expected); } #[test] fn test_wget_footer_ip() { let to_be_tested: String = wget_footer(&uri("http://127.0.0.1:420/"), None, None, None).into(); let expected = to_html("-nH -P '127.0.0.1:420' 'http://127.0.0.1:420"); assert_eq!(to_be_tested, expected); } #[test] fn test_wget_footer_externalurl() { let to_be_tested: String = wget_footer( &uri("https://github.com/"), None, None, Some("https://gitlab.com"), ) .into(); let expected = to_html("-H -P 'github.com' 'https://github.com"); assert_eq!(to_be_tested, expected); } #[test] fn test_rm_form_strips_prefix() { let rm_route = "/rm"; let prefix = "/prefix"; let encoded_path = "/prefix/some/path/file.txt"; let html = rm_form(rm_route, encoded_path, prefix); let expected_action = r#"action="/rm?path=/some/path/file.txt""#; assert!( html.0.contains(expected_action), "Actual HTML: {}\nExpected to contain: {}", html.0, expected_action ) } } ================================================ FILE: src/webdav_fs.rs ================================================ //! Helper types and functions to allow configuring hidden files visibility //! for WebDAV handlers use dav_server::{ davpath::DavPath, fs::{ DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError as DavFsError, FsFuture as DavFsFuture, FsStream as DavFsStream, OpenOptions as DavOpenOptions, ReadDirMeta as DavReadDirMeta, }, localfs::LocalFs, }; use futures::StreamExt; use std::ffi::OsStr; #[cfg(target_family = "unix")] use std::os::unix::ffi::OsStrExt; use std::path::{Component, Path, PathBuf}; use tokio::fs; /// A dav_server local filesystem backend that can be configured to deny access /// to files and directories with names starting with a dot. #[derive(Clone)] pub struct RestrictedFs { local: Box<LocalFs>, base_path: PathBuf, show_hidden: bool, no_symlinks: bool, } impl RestrictedFs { /// Creates a new RestrictedFs serving the local path at "base". /// If "show_hidden" is false, access to hidden files is prevented. /// If "no_symlinks" is true, access to symlinks is prevented. pub fn new<P: AsRef<Path>>(base: P, show_hidden: bool, no_symlinks: bool) -> Box<RestrictedFs> { let base_path = base.as_ref().to_path_buf(); let local = LocalFs::new(base, false, false, false); Box::new(RestrictedFs { local, base_path, show_hidden, no_symlinks, }) } /// true if the path is allowed to appear in responses (not hidden and/or not a symlink, depending on flags) async fn is_path_allowed(&self, path: &DavPath) -> bool { if self.no_symlinks && path_has_symlink_components(path, &self.base_path).await { return false; } if !self.show_hidden && path_has_hidden_components(path) { return false; } true } } /// true if any normal component of path either starts with dot or can't be turned into a str fn path_has_hidden_components(path: &DavPath) -> bool { path.as_rel_ospath().components().any(|c| match c { Component::Normal(name) => name.to_str().is_none_or(|s| s.starts_with('.')), _ => panic!("dav path should not contain any non-normal components"), }) } /// true if any component in `path` (relative to `base_path`) is a symlink async fn path_has_symlink_components(path: &DavPath, base_path: &Path) -> bool { let mut current_path = base_path.to_path_buf(); for comp in path.as_rel_ospath().components() { match comp { Component::Normal(name) => { current_path.push(name); if let Ok(md) = fs::symlink_metadata(¤t_path).await && md.file_type().is_symlink() { return true; } } _ => { panic!("dav path should not contain any non-normal components") } } } false } impl DavFileSystem for RestrictedFs { fn open<'a>( &'a self, path: &'a DavPath, options: DavOpenOptions, ) -> DavFsFuture<'a, Box<dyn DavFile>> { Box::pin(async move { if !self.is_path_allowed(path).await { Err(DavFsError::NotFound) } else { self.local.open(path, options).await } }) } fn read_dir<'a>( &'a self, path: &'a DavPath, meta: DavReadDirMeta, ) -> DavFsFuture<'a, DavFsStream<Box<dyn DavDirEntry>>> { Box::pin(async move { if !self.is_path_allowed(path).await { return Err(DavFsError::NotFound); } if self.show_hidden && !self.no_symlinks { return self.local.read_dir(path, meta).await; } let dav_path = path.as_rel_ospath(); let base_path = self.base_path.join(dav_path); let show_hidden = self.show_hidden; let no_symlinks = self.no_symlinks; let stream = self.local.read_dir(path, meta).await?; let filtered = stream.filter_map(move |entry_res| { let base_path = base_path.clone(); async move { match entry_res { Ok(e) => { if !show_hidden && e.name().starts_with(b".") { return None; } if no_symlinks { let name = e.name(); #[cfg(not(target_os = "windows"))] let os_string = OsStr::from_bytes(&name); #[cfg(target_os = "windows")] let os_string: &OsStr = std::str::from_utf8(&name).unwrap().as_ref(); let entry_path = base_path.join(os_string); if let Ok(md) = fs::symlink_metadata(&entry_path).await && md.file_type().is_symlink() { return None; } } Some(Ok(e)) } Err(e) => Some(Err(e)), } } }); Ok(Box::pin(filtered) as DavFsStream<Box<dyn DavDirEntry>>) }) } fn metadata<'a>(&'a self, path: &'a DavPath) -> DavFsFuture<'a, Box<dyn DavMetaData>> { Box::pin(async move { if !self.is_path_allowed(path).await { Err(DavFsError::NotFound) } else { self.local.metadata(path).await } }) } fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> DavFsFuture<'a, Box<dyn DavMetaData>> { Box::pin(async move { if !self.is_path_allowed(path).await { Err(DavFsError::NotFound) } else { self.local.symlink_metadata(path).await } }) } } ================================================ FILE: tests/api.rs ================================================ use std::collections::HashMap; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use reqwest::{StatusCode, blocking::Client}; use rstest::rstest; mod fixtures; use crate::fixtures::{DIRECTORIES, Error, TestServer, reqwest_client, server}; /// Test that we can get dir size for plain paths as well as percent-encoded paths #[rstest] #[case(DIRECTORIES[0].to_string())] #[case(DIRECTORIES[1].to_string())] #[case(DIRECTORIES[2].to_string())] #[case(utf8_percent_encode(DIRECTORIES[0], NON_ALPHANUMERIC).to_string())] #[case(utf8_percent_encode(DIRECTORIES[1], NON_ALPHANUMERIC).to_string())] #[case(utf8_percent_encode(DIRECTORIES[2], NON_ALPHANUMERIC).to_string())] fn api_dir_size( #[case] dir: String, #[with(&["--directory-size"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let mut command = HashMap::new(); command.insert("DirSize", dir); let resp = reqwest_client .post(server.url().join("__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( #[case] path: &str, #[with(&["--directory-size"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let mut command = HashMap::new(); command.insert("DirSize", path); let resp = reqwest_client .post(server.url().join("__miniserve_internal/api")?) .json(&command) .send()?; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); Ok(()) } ================================================ FILE: tests/archive.rs ================================================ use std::io::Cursor; use reqwest::{StatusCode, blocking::Client}; use rstest::rstest; use select::{document::Document, predicate::Text}; use zip::ZipArchive; mod fixtures; use crate::fixtures::{Error, TestServer, reqwest_client, server}; enum ArchiveKind { TarGz, Tar, Zip, } impl ArchiveKind { fn server_option(&self) -> &'static str { match self { ArchiveKind::TarGz => "--enable-tar-gz", ArchiveKind::Tar => "--enable-tar", ArchiveKind::Zip => "--enable-zip", } } fn link_text(&self) -> &'static str { match self { ArchiveKind::TarGz => "Download .tar.gz", ArchiveKind::Tar => "Download .tar", ArchiveKind::Zip => "Download .zip", } } fn download_param(&self) -> &'static str { match self { ArchiveKind::TarGz => "?download=tar_gz", ArchiveKind::Tar => "?download=tar", ArchiveKind::Zip => "?download=zip", } } } fn fetch_index_document( reqwest_client: &Client, server: &TestServer, expected: StatusCode, ) -> Result<Document, Error> { let resp = reqwest_client.get(server.url()).send()?; assert_eq!(resp.status(), expected); Ok(Document::from_read(resp)?) } fn download_archive_bytes( reqwest_client: &Client, server: &TestServer, kind: ArchiveKind, ) -> Result<(StatusCode, usize), Error> { let resp = reqwest_client .get(server.url().join(kind.download_param())?) .send()?; Ok((resp.status(), resp.bytes()?.len())) } fn assert_link_presence(document: &Document, present: &[&str], absent: &[&str]) { let contains_text = |document: &Document, text: &str| document.find(Text).any(|x| x.text() == text); for text in present { assert!( contains_text(document, text), "Expected link text '{text}' to be present", ); } for text in absent { assert!( !contains_text(document, text), "Expected link text '{text}' to be absent", ); } } /// By default, all archive links are hidden. #[rstest] fn archives_are_disabled_links(server: TestServer, reqwest_client: Client) -> Result<(), Error> { let document = fetch_index_document(&reqwest_client, &server, StatusCode::OK)?; assert_link_presence( &document, &[], &[ ArchiveKind::TarGz.link_text(), ArchiveKind::Tar.link_text(), ArchiveKind::Zip.link_text(), ], ); Ok(()) } /// By default, downloading archives is forbidden. #[rstest] #[case(ArchiveKind::TarGz)] #[case(ArchiveKind::Tar)] #[case(ArchiveKind::Zip)] fn archives_are_disabled_downloads( #[case] kind: ArchiveKind, server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let (status_code, _) = download_archive_bytes(&reqwest_client, &server, kind)?; assert_eq!(status_code, StatusCode::FORBIDDEN); Ok(()) } /// When indexing is disabled, archive links are hidden despite enabled archive options. #[rstest] fn archives_are_disabled_when_indexing_disabled_links( #[with(&["--disable-indexing", "--enable-tar-gz", "--enable-tar", "--enable-zip"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let document = fetch_index_document(&reqwest_client, &server, StatusCode::NOT_FOUND)?; assert_link_presence( &document, &[], &[ ArchiveKind::TarGz.link_text(), ArchiveKind::Tar.link_text(), ArchiveKind::Zip.link_text(), ], ); Ok(()) } /// When indexing is disabled, archive downloads are not found despite enabled archive options. #[rstest] #[case(ArchiveKind::TarGz)] #[case(ArchiveKind::Tar)] #[case(ArchiveKind::Zip)] fn archives_are_disabled_when_indexing_disabled_downloads( #[case] kind: ArchiveKind, #[with(&["--disable-indexing", "--enable-tar-gz", "--enable-tar", "--enable-zip"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let (status_code, _) = download_archive_bytes(&reqwest_client, &server, kind)?; assert_eq!(status_code, StatusCode::NOT_FOUND); Ok(()) } /// Ensure the link and download to the specified archive is available and others are not #[rstest] #[case::tar_gz(ArchiveKind::TarGz)] #[case::tar(ArchiveKind::Tar)] #[case::zip(ArchiveKind::Zip)] fn archives_links_and_downloads( #[case] kind: ArchiveKind, #[with(&[kind.server_option()])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let document = fetch_index_document(&reqwest_client, &server, StatusCode::OK)?; let (link_text, other_links, tar_gz_status, tar_status, zip_status) = match kind { ArchiveKind::TarGz => ( ArchiveKind::TarGz.link_text(), [ArchiveKind::Tar.link_text(), ArchiveKind::Zip.link_text()], StatusCode::OK, StatusCode::FORBIDDEN, StatusCode::FORBIDDEN, ), ArchiveKind::Tar => ( ArchiveKind::Tar.link_text(), [ArchiveKind::TarGz.link_text(), ArchiveKind::Zip.link_text()], StatusCode::FORBIDDEN, StatusCode::OK, StatusCode::FORBIDDEN, ), ArchiveKind::Zip => ( ArchiveKind::Zip.link_text(), [ArchiveKind::TarGz.link_text(), ArchiveKind::Tar.link_text()], StatusCode::FORBIDDEN, StatusCode::FORBIDDEN, StatusCode::OK, ), }; assert_link_presence(&document, &[link_text], &other_links); for (kind, expected) in [ (ArchiveKind::TarGz, tar_gz_status), (ArchiveKind::Tar, tar_status), (ArchiveKind::Zip, zip_status), ] { let (status, _) = download_archive_bytes(&reqwest_client, &server, kind)?; assert_eq!(status, expected); } Ok(()) } enum ExpectedLen { /// Exact byte length expected. Exact(usize), /// Minimum byte length expected. Min(usize), } /// Broken symlinks (from [`fixtures::BROKEN_SYMLINK`]) yield different archive behaviors: /// - tar_gz: a file with only partial header fields. See "rfc1952 § 2.3.1. Member header and trailer". /// - tar: a tarball containing a subset of files. /// - zip: an empty file. #[rstest] #[case::tar_gz(ArchiveKind::TarGz, ExpectedLen::Exact(10))] #[case::tar(ArchiveKind::Tar, ExpectedLen::Min(512 + 512 + 2 * 512))] #[case::zip(ArchiveKind::Zip, ExpectedLen::Exact(0))] fn archive_behave_differently_with_broken_symlinks( #[case] kind: ArchiveKind, #[case] expected: ExpectedLen, #[with(&[ArchiveKind::TarGz.server_option(), ArchiveKind::Tar.server_option(), ArchiveKind::Zip.server_option()])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let (status_code, byte_len) = download_archive_bytes(&reqwest_client, &server, kind)?; assert_eq!(status_code, StatusCode::OK); match expected { ExpectedLen::Exact(len) => assert_eq!(byte_len, len), ExpectedLen::Min(len) => assert!(byte_len >= len), } Ok(()) } /// ZIP archives store entry names using unix-style paths (no backslashes). /// The "someDir" dir is constructed by [`fixtures`] and all items in it can be correctly processed. #[rstest] fn zip_archives_store_entry_name_in_unix_style( #[with(&["--enable-zip"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let resp = reqwest_client .get(server.url().join("someDir/?download=zip")?) .send()? .error_for_status()?; assert_eq!(resp.status(), StatusCode::OK); let mut archive = ZipArchive::new(Cursor::new(resp.bytes()?))?; for i in 0..archive.len() { let entry = archive.by_index(i)?; let name = entry.name(); assert!( !name.contains(r"\"), "ZIP entry '{}' contains a backslash", name ); } Ok(()) } ================================================ FILE: tests/auth.rs ================================================ use pretty_assertions::assert_eq; use reqwest::{StatusCode, blocking::Client}; use rstest::rstest; use select::{document::Document, predicate::Text}; mod fixtures; use crate::fixtures::{Error, FILES, TestServer, reqwest_client, server}; #[rstest] #[case("testuser:testpassword", "testuser", "testpassword")] #[case( "testuser:sha256:9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05", "testuser", "testpassword" )] #[case( "testuser:sha512:e9e633097ab9ceb3e48ec3f70ee2beba41d05d5420efee5da85f97d97005727587fda33ef4ff2322088f4c79e8133cc9cd9f3512f4d3a303cbdb5bc585415a00", "testuser", "testpassword" )] fn auth_accepts( #[case] _cli_auth_arg: &str, #[case] client_username: &str, #[case] client_password: &str, reqwest_client: Client, #[with(&["-a", _cli_auth_arg])] server: TestServer, ) -> Result<(), Error> { let response = reqwest_client .get(server.url()) .basic_auth(client_username, Some(client_password)) .send()?; let status_code = response.status(); assert_eq!(status_code, StatusCode::OK); let body = response.error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES { assert!(parsed.find(Text).any(|x| x.text() == file)); } Ok(()) } #[rstest] #[case("rightuser:rightpassword", "wronguser", "rightpassword")] #[case( "rightuser:sha256:314eee236177a721d0e58d3ca4ff01795cdcad1e8478ba8183a2e58d69c648c0", "wronguser", "rightpassword" )] #[case( "rightuser:sha512:84ec4056571afeec9f5b59453305877e9a66c3f9a1d91733fde759b370c1d540b9dc58bfc88c5980ad2d020c3a8ee84f21314a180856f5a82ba29ecba29e2cab", "wronguser", "rightpassword" )] #[case("rightuser:rightpassword", "rightuser", "wrongpassword")] #[case( "rightuser:sha256:314eee236177a721d0e58d3ca4ff01795cdcad1e8478ba8183a2e58d69c648c0", "rightuser", "wrongpassword" )] #[case( "rightuser:sha512:84ec4056571afeec9f5b59453305877e9a66c3f9a1d91733fde759b370c1d540b9dc58bfc88c5980ad2d020c3a8ee84f21314a180856f5a82ba29ecba29e2cab", "rightuser", "wrongpassword" )] fn auth_rejects( #[case] _cli_auth_arg: &str, #[case] client_username: &str, #[case] client_password: &str, #[with(&["-a", _cli_auth_arg])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let status = reqwest_client .get(server.url()) .basic_auth(client_username, Some(client_password)) .send()? .status(); assert_eq!(status, StatusCode::UNAUTHORIZED); Ok(()) } /// Command line arguments that register multiple accounts static ACCOUNTS: &[&str] = &[ "--auth", "usr0:pwd0", "--auth", "usr1:pwd1", "--auth", "usr2:sha256:149d2937d1bce53fa683ae652291bd54cc8754444216a9e278b45776b76375af", // pwd2 "--auth", "usr3:sha256:ffc169417b4146cebe09a3e9ffbca33db82e3e593b4d04c0959a89c05b87e15d", // pwd3 "--auth", "usr4:sha512:68050a967d061ac480b414bc8f9a6d368ad0082203edcd23860e94c36178aad1a038e061716707d5479e23081a6d920dc6e9f88e5eb789cdd23e211d718d161a", // pwd4 "--auth", "usr5:sha512:be82a7dccd06122f9e232e9730e67e69e30ec61b268fd9b21a5e5d42db770d45586a1ce47816649a0107e9fadf079d9cf0104f0a3aaa0f67bad80289c3ba25a8", // pwd5 ]; #[rstest] #[case("usr0", "pwd0")] #[case("usr1", "pwd1")] #[case("usr2", "pwd2")] #[case("usr3", "pwd3")] #[case("usr4", "pwd4")] #[case("usr5", "pwd5")] fn auth_multiple_accounts_pass( #[case] username: &str, #[case] password: &str, #[with(ACCOUNTS)] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let response = reqwest_client .get(server.url()) .basic_auth(username, Some(password)) .send()?; let status = response.status(); assert_eq!(status, StatusCode::OK); let body = response.error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES { assert!(parsed.find(Text).any(|x| x.text() == file)); } Ok(()) } #[rstest] fn auth_multiple_accounts_wrong_username( #[with(ACCOUNTS)] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let status = reqwest_client .get(server.url()) .basic_auth("unregistered user", Some("pwd0")) .send()? .status(); assert_eq!(status, StatusCode::UNAUTHORIZED); Ok(()) } #[rstest] #[case("usr0", "pwd5")] #[case("usr1", "pwd4")] #[case("usr2", "pwd3")] #[case("usr3", "pwd2")] #[case("usr4", "pwd1")] #[case("usr5", "pwd0")] fn auth_multiple_accounts_wrong_password( #[case] username: &str, #[case] password: &str, #[with(ACCOUNTS)] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let status = reqwest_client .get(server.url()) .basic_auth(username, Some(password)) .send()? .status(); assert_eq!(status, StatusCode::UNAUTHORIZED); Ok(()) } ================================================ FILE: tests/auth_file.rs ================================================ use reqwest::{StatusCode, blocking::Client}; use rstest::rstest; use select::{document::Document, predicate::Text}; mod fixtures; use crate::fixtures::{Error, FILES, TestServer, reqwest_client, server}; #[rstest] #[case("joe", "123")] #[case("bob", "123")] #[case("bill", "")] fn auth_file_accepts( #[with(&["--auth-file", "tests/data/auth1.txt"])] server: TestServer, reqwest_client: Client, #[case] client_username: &str, #[case] client_password: &str, ) -> Result<(), Error> { let response = reqwest_client .get(server.url()) .basic_auth(client_username, Some(client_password)) .send()?; let status_code = response.status(); assert_eq!(status_code, StatusCode::OK); let body = response.error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES { assert!(parsed.find(Text).any(|x| x.text() == file)); } Ok(()) } #[rstest] #[case("joe", "wrongpassword")] #[case("bob", "")] #[case("nonexistentuser", "wrongpassword")] fn auth_file_rejects( #[with(&["--auth-file", "tests/data/auth1.txt"])] server: TestServer, reqwest_client: Client, #[case] client_username: &str, #[case] client_password: &str, ) -> Result<(), Error> { let status = reqwest_client .get(server.url()) .basic_auth(client_username, Some(client_password)) .send()? .status(); assert_eq!(status, StatusCode::UNAUTHORIZED); Ok(()) } ================================================ FILE: tests/bind.rs ================================================ use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use assert_cmd::{cargo, prelude::*}; use assert_fs::fixture::TempDir; use regex::Regex; use reqwest::blocking::Client; use rstest::rstest; mod fixtures; use crate::fixtures::{Error, TestServer, port, reqwest_client, server, tmpdir}; #[rstest] #[case(&["-i", "12.123.234.12"])] #[case(&["-i", "::", "-i", "12.123.234.12"])] fn bind_fails(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> { Command::new(cargo::cargo_bin!("miniserve")) .arg(tmpdir.path()) .arg("-p") .arg(port.to_string()) .args(args) .assert() .stderr(predicates::str::contains("Failed to bind server to")) .failure(); Ok(()) } #[rstest] #[case(server(&[] as &[&str]), true, true)] #[case(server(&["-i", "::"]), false, true)] #[case(server(&["-i", "0.0.0.0"]), true, false)] #[case(server(&["-i", "::", "-i", "0.0.0.0"]), true, true)] fn bind_ipv4_ipv6( #[case] server: TestServer, reqwest_client: Client, #[case] bind_ipv4: bool, #[case] bind_ipv6: bool, ) -> Result<(), Error> { assert_eq!( reqwest_client .get(format!("http://127.0.0.1:{}", server.port()).as_str()) .send() .is_ok(), bind_ipv4 ); assert_eq!( reqwest_client .get(format!("http://[::1]:{}", server.port()).as_str()) .send() .is_ok(), bind_ipv6 ); Ok(()) } #[rstest] #[case(&[] as &[&str])] #[case(&["-i", "::"])] #[case(&["-i", "127.0.0.1"])] #[case(&["-i", "0.0.0.0"])] #[case(&["-i", "::", "-i", "0.0.0.0"])] #[case(&["--random-route"])] #[case(&["--route-prefix", "/prefix"])] fn validate_printed_urls( reqwest_client: Client, tmpdir: TempDir, port: u16, #[case] args: &[&str], ) -> Result<(), Error> { let mut child = Command::new(cargo::cargo_bin!("miniserve")) .arg(tmpdir.path()) .arg("-p") .arg(port.to_string()) .args(args) .stdout(Stdio::piped()) .spawn()?; // WARN assumes urls list is terminated by an empty line let url_lines = BufReader::new(child.stdout.take().unwrap()) .lines() .map(|line| line.expect("Error reading stdout")) .take_while(|line| !line.is_empty()) /* non-empty lines */ .collect::<Vec<_>>(); let url_lines = url_lines.join("\n"); let urls = Regex::new(r"http://[a-zA-Z0-9\.\[\]:/]+") .unwrap() .captures_iter(url_lines.as_str()) .map(|caps| caps.get(0).unwrap().as_str()) .collect::<Vec<_>>(); assert!(!urls.is_empty()); for url in urls { reqwest_client.get(url).send()?.error_for_status()?; } child.kill()?; Ok(()) } ================================================ FILE: tests/cli.rs ================================================ use std::process::Command; use assert_cmd::{cargo, prelude::*}; use clap::{ValueEnum, crate_name, crate_version}; use clap_complete::Shell; mod fixtures; use crate::fixtures::Error; #[test] /// Show help and exit. fn help_shows() -> Result<(), Error> { Command::new(cargo::cargo_bin!("miniserve")) .arg("-h") .assert() .success(); Ok(()) } #[test] /// Show version and exit. fn version_shows() -> Result<(), Error> { Command::new(cargo::cargo_bin!("miniserve")) .arg("-V") .assert() .success() .stdout(format!("{} {}\n", crate_name!(), crate_version!())); Ok(()) } #[test] /// Print completions and exit. fn print_completions() -> Result<(), Error> { for shell in Shell::value_variants() { Command::new(cargo::cargo_bin!("miniserve")) .arg("--print-completions") .arg(shell.to_string()) .assert() .success(); } Ok(()) } #[test] /// Print completions rejects invalid shells. fn print_completions_invalid_shell() -> Result<(), Error> { Command::new(cargo::cargo_bin!("miniserve")) .arg("--print-completions") .arg("fakeshell") .assert() .failure(); Ok(()) } ================================================ FILE: tests/create_directories.rs ================================================ use reqwest::blocking::{Client, multipart}; use rstest::rstest; use select::{ document::Document, predicate::{Attr, Text}, }; mod fixtures; use crate::fixtures::{DIRECTORY_SYMLINK, Error, TestServer, reqwest_client, server}; /// This should work because the flags for uploading files and creating directories /// are set, and the directory name and path are valid. #[rstest] fn creating_directories_works( #[with(&["--upload-files", "--mkdir"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let test_directory_name = "hello"; // Before creating, check whether the directory does not yet exist. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != test_directory_name)); // Perform the actual creation. let create_action = parsed .find(Attr("id", "mkdir")) .next() .expect("Couldn't find element with id=mkdir") .attr("action") .expect("Directory form doesn't have action attribute"); let form = multipart::Form::new(); let part = multipart::Part::text(test_directory_name); let form = form.part("mkdir", part); reqwest_client .post(server.url().join(create_action)?) .multipart(form) .send()? .error_for_status()?; // After creating, check whether the directory is now getting listed. let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!( parsed .find(Text) .any(|x| x.text() == test_directory_name.to_owned() + "/") ); Ok(()) } /// This should fail because the server does not allow for creating directories /// as the flags for uploading files and creating directories are not set. /// The directory name and path are valid. #[rstest] fn creating_directories_is_prevented( server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let test_directory_name = "hello"; // Before creating, check whether the directory does not yet exist. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != test_directory_name)); // Ensure the directory creation form is not present assert!(parsed.find(Attr("id", "mkdir")).next().is_none()); // Then try to create anyway let form = multipart::Form::new(); let part = multipart::Part::text(test_directory_name); let form = form.part("mkdir", part); // This should fail assert!( reqwest_client .post(server.url().join("/upload?path=/")?) .multipart(form) .send()? .error_for_status() .is_err() ); // After creating, check whether the directory is now getting listed (shouldn't). let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!( parsed .find(Text) .all(|x| x.text() != test_directory_name.to_owned() + "/") ); Ok(()) } /// This should fail because directory creation through symlinks should not be possible /// when the the no symlinks flag is set. #[rstest] fn creating_directories_through_symlinks_is_prevented( #[with(&["--upload-files", "--mkdir", "--no-symlinks"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { // Before attempting to create, ensure the symlink does not exist. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != DIRECTORY_SYMLINK)); // Attempt to perform directory creation. let form = multipart::Form::new(); let part = multipart::Part::text(DIRECTORY_SYMLINK); let form = form.part("mkdir", part); // This should fail assert!( reqwest_client .post( server .url() .join(format!("/upload?path=/{DIRECTORY_SYMLINK}").as_str())? ) .multipart(form) .send()? .error_for_status() .is_err() ); Ok(()) } /// Test for path traversal vulnerability (CWE-22) in both path parameter of query string and in /// mkdir name (Content-Disposition) /// /// see: https://github.com/svenstaro/miniserve/issues/518 #[rstest] #[case("foo", "bar", "foo/bar")] // Not CWE-22, but `foo` isn't a directory #[case("/../foo", "bar", "foo/bar")] #[case("/foo", "/../bar", "foo/bar")] #[case("C:/foo", "C:/bar", if cfg!(windows) { "foo/bar" } else { "C:/foo/C:/bar" })] #[case(r"C:\foo", r"C:\bar", if cfg!(windows) { "foo/bar" } else { r"C:\foo/C:\bar" })] #[case(r"\foo", r"\..\bar", if cfg!(windows) { "foo/bar" } else { r"\foo/\..\bar" })] fn prevent_path_transversal_attacks( #[with(&["--upload-files", "--mkdir"])] server: TestServer, reqwest_client: Client, #[case] path: &str, #[case] dir_name: &'static str, #[case] expected: &str, ) -> Result<(), Error> { let expected_path = server.path().join(expected); assert!(!expected_path.exists()); let form = multipart::Form::new(); let part = multipart::Part::text(dir_name); let form = form.part("mkdir", part); // This should fail assert!( reqwest_client .post(server.url().join(&format!("/upload/path={path}"))?) .multipart(form) .send()? .error_for_status() .is_err() ); Ok(()) } ================================================ FILE: tests/data/auth1.txt ================================================ joe:123 bob:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3 bill: ================================================ FILE: tests/data/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIFCTCCAvGgAwIBAgIUJUf2QS/pOdHEW4EHTfdXxeTvtM8wDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIxMDgyNzAwMzEyOFoXDTMxMDgy NTAwMzEyOFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEAwmYOqToI0R30lPyYtF9bSuhIOCp9cp0jl2nuHaO8mpr1 gMiJKKN4HjAdgac+3hYkTRFqK2mKKpV9QdVKR24Ib7mC45Ek7BlLw3VbxPRKrK/j rKW3M3ui+453B24yf6K8dH36x9gZo4glzghFxuodFakIX2zNKo6tEx0XVkbhsu/w vj2s+0L3oToPAYZaiOB/7xYU6Yu9n7Tn6rE9/orDfK1DlrZDP3hzyxLzuf6tqXCh 66cgaPQTh+xyyWZcvl60kbB4H3bdhqbYGMMQO8bUxXTQXjwvUsvl0yn9qCpMIn99 Pm9xhfDQSF3zawM3CQ/lmn9uFQzdOEfYlO6oaidTqxLtBhVUcEutIcmoW9nmmv2g Ei49/3OmvWQcEdMWt8xwxSrMvKDSeUdF3rbalTHBFQHJlJiKRX9wTNtSZ5T8FTU7 4Ip4EzAtP8wY5NDv253mddANoyKsVRGytS35LDFkCS/TxuVDZrjluc86yqUId/jf HZAzQ7ifpC890aG0JOq/0mmVDvbn7MzdTsTWwhE8UaOiFljTiNQX3QjX3TaEu32M XHKo5nebNqDVRGnFMFmfXw2ZP8lgQCWk1HxLr0qhRxIy8XmIK1ZUz7Uc4Cba73XB pSxcIPytpDuuKotslBjoIYu9DY07n1Hu4zYPvpP9DnaunEW6zmANEtjSyrE/TQ0C AwEAAaNTMFEwHQYDVR0OBBYEFH0VzGnFqGVB+11uyvqea2qXYxQKMB8GA1UdIwQY MBaAFH0VzGnFqGVB+11uyvqea2qXYxQKMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI hvcNAQELBQADggIBAA4FqzX34FCU1WYzmBRcq7QHSrc7LcuTbxhESB7mYbI8IPFt cgrtXL1mTP+nz5nN+E6fyA8Y9zIyXm/6svYpJzXgUTtbdgDW22v5iN+YZvOaQ3Jt /0eEtkx7wdNjLsN0aM6OjPXDw0mAVFDdevE7wgnra6x6/VHOt6pksNJa76ZVPX5X dlLj+OU4eQPPMVxhL7p3xdSPFDZzXY7mNfVycO3tK5Fzrwko7OQKqEBMtc0oZxLd m/FvqcJveHYHfXZl5XKMcsCNO8bG0XXDhwg0CLTf1p0hmp1oLieqplekOWs54Alo FF4EBNdDaIFdQ4FAYaAU+9KLoPstorTl+3Owj/k3xhDB+0sGwGeX/e88nhs/ppEy bxOt0j4AruwapkcvkwhQeMpQJRYyOrcvlbUEZqFABozZ9gbGRQvnConDNg7tz5zc nVUupszA7zs0Vn9b1zVLOcOcS2ziQvoCyh687MsVbjw65Y6tkhvLI35G68zrFKsl MS5mqnK4DZYFc1gGGI/rjsFUf3dD4ww6PTnwv3Ga2yBvXi7EckEeEqB+dRlVdvob cH/grVUum3s5Y4PTnxyNAUFZlFNZ8jlOcgXtAFuTnJ/jcvboZdE7Oja2OIMJo53d rbkqAPNGhQ98QDuTwWjHUq/Th1CQK4ALI/wqoc22TJpSh/mme5Dj4HhB7LWl -----END CERTIFICATE----- ================================================ FILE: tests/data/cert_ec.pem ================================================ -----BEGIN CERTIFICATE----- MIIBujCCAUCgAwIBAgIUd5MqZqnOPFxMKaYipL6S6B3D3cswCgYIKoZIzj0EAwIw FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDQxNjEzNDUxNVoXDTMzMDQxMzEz NDUxNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MHYwEAYHKoZIzj0CAQYFK4EEACID YgAEPcNbYA4vrYfVtR1EZgRZ9/iRvRXtpx9ww3avV9echQj80xZ3KpCTA0T1gQ35 0pCHpd9gMTnDZdHqWfSpBYrFBll26q8rRvSwSTqRQv5YdgrI/yAgZb+CXmjxAZWf GoHso1MwUTAdBgNVHQ4EFgQU301zfi7+pUOUzs1ObVTwxNHM908wHwYDVR0jBBgw FoAU301zfi7+pUOUzs1ObVTwxNHM908wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjO PQQDAgNoADBlAjEAxoOsn/1Kj1sfc3DhoA/UGetA+porz5dSHJOuuHVl5jS5nuAO IrQmVvINVuVV3oQyAjBd8HoemembnGCz78Y40v7vLbd2yqWZt8sKX90f/Mj2hGsn KjraTX2m52X4W8KLhMU= -----END CERTIFICATE----- ================================================ FILE: tests/data/cert_rsa.pem ================================================ -----BEGIN CERTIFICATE----- MIIFCTCCAvGgAwIBAgIUTUIU8j6S7RXbFFhW/yFftSQvUCYwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDQxNjEzNDUxNVoXDTMzMDQx MzEzNDUxNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEArVsX/kqzqJAvuTiXiJZUAyYy3rxo1krwBY4KejzKw3JY UFP63utA8EgGwXjnABua8bA1WKbl+MZwSOz2QOytuR6kkqwKWWdFeCzhOZDAAudd LTqkkAxPX56Iv7y/v0emYfc0XNYjSaoq6yhvELVfeWabZ+WuZF9fisJ+Up1NvnLQ 6K5c+NB++gVHS+URgy0Z4jIaDnm5ofETDZSS9cjDLBMKK9X0bC+JFIXa9x5pQU2u NTnYWtvRuaqAtt0fozT3hqzjAdBf0jhmKOySy/tK0W9n2FHJrqlo68gN0uP1PhQR 9EUCB4ZkElOXpBdoDub4iKVVjtdpBfppOuq+I2hSxtosP+9N0vBSBPrmDPGpdUP+ lhxWEo1wKVW1AhPcOSDhKQM50SLxTk6ZmSKd0Z/obgjttVxqNlHw9GUoXZbztwZb VPDVVEGJ6OZYSm2KENb1VxhLg/6dS/WXN7J240At9yj8PDEEaIa5YTJhGa2j4fDi qsGiEx4AQEgliDUPhXhzmROMBdvynITU55au7bnadxPiGPOWj//qZL0URhCNDl2d Habm7mlgEgaP90R+il5QTe9R+Lg4bTR4f01lXVdB/GE7oent5m82zqpV1TDnCM0Q UneD6nfZjt9oqtTxr/ldcDFFtQhDD0SMtX82+wll+tzVU6lAUCgRC7+4Ob2CEVcC AwEAAaNTMFEwHQYDVR0OBBYEFExLPelY6s7HpSVKWjUL6QFbQUeKMB8GA1UdIwQY MBaAFExLPelY6s7HpSVKWjUL6QFbQUeKMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI hvcNAQELBQADggIBAFzYxVXZTtsT8QAn6lCTcQ59sFRuhPKeuINHlcQRVsYDBukD /ErkamNNycXrpRvQaYj8/cmk+mPaoleOf0e0iTlOs/IIzWKNy2drlmBuRi8FU5Gg lsWt4FvEG3RRsDfTo7gjrqKpwnfkGxx7RZ74ZWg5pVXrzFV7N2XoA1Ln49+2oMJV iZYYSdWdgGxHgivUfEhjliG2CDw6Z5NlEtbu/dXA4sBrSMtP2uW04F/8N61NEhSf KNMQ2/qXkYHyYcwtsnxAjmgpQJK/yR95vLyTnuC5IT33icvXNLKCIa5gnyok2Lxj qIx1SCPp7o6onB3otKkrniZEBpTqW4y5ELW1SHLGkSXqm3cwbxK0Fm2bphF0mhIJ air31ZiENy1ICYGDPv/EetG/bkzy2Cqc9pa+/N5N/9nKh6C9Wi/OsGCn72DvW9OV 6GW/W3HQwiZdTuLYvk0dpsfuFZvEaZrKsvLwkJGA1cS1IHv2V+mPxaHAMPPd+SyA naZj/1vBWxHAenQoUBRxW2qpK708ITJ/PGodi6l0Kv6NSMlSq+JB/gU/fmBJXgL8 Nmc7pn5UJX74CSaBHnxjEfa3F6RPz00nNUStNl0ZjVZW3kKwQfbsCTiSXIvS/whM iQxO+l9Ae7ebWcQ4Qd38ZCMjA6e5CUaCj3oJA4VhNh4zNva8fkQ4mfKWleVv -----END CERTIFICATE----- ================================================ FILE: tests/data/generate_tls_certs.sh ================================================ #!/usr/bin/env bash openssl req -subj '/CN=localhost' -x509 -newkey rsa:4096 -keyout key_pkcs8.pem -out cert_rsa.pem -nodes -days 3650 openssl rsa -in key_pkcs8.pem -out key_pkcs1.pem openssl req -subj '/CN=localhost' -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -keyout key_ec.pem -out cert_ec.pem -days 3650 ================================================ FILE: tests/data/key_ec.pem ================================================ -----BEGIN PRIVATE KEY----- MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBZiwh8yRBpDgAx+Oa4 qgvw0OOBiDnOHgY9+WuIA74dfGbPQDPwAVIVXfUX0a8YHa6hZANiAAQ9w1tgDi+t h9W1HURmBFn3+JG9Fe2nH3DDdq9X15yFCPzTFncqkJMDRPWBDfnSkIel32AxOcNl 0epZ9KkFisUGWXbqrytG9LBJOpFC/lh2Csj/ICBlv4JeaPEBlZ8agew= -----END PRIVATE KEY----- ================================================ FILE: tests/data/key_pkcs1.pem ================================================ -----BEGIN PRIVATE KEY----- MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCtWxf+SrOokC+5 OJeIllQDJjLevGjWSvAFjgp6PMrDclhQU/re60DwSAbBeOcAG5rxsDVYpuX4xnBI 7PZA7K25HqSSrApZZ0V4LOE5kMAC510tOqSQDE9fnoi/vL+/R6Zh9zRc1iNJqirr KG8QtV95Zptn5a5kX1+Kwn5SnU2+ctDorlz40H76BUdL5RGDLRniMhoOebmh8RMN lJL1yMMsEwor1fRsL4kUhdr3HmlBTa41Odha29G5qoC23R+jNPeGrOMB0F/SOGYo 7JLL+0rRb2fYUcmuqWjryA3S4/U+FBH0RQIHhmQSU5ekF2gO5viIpVWO12kF+mk6 6r4jaFLG2iw/703S8FIE+uYM8al1Q/6WHFYSjXApVbUCE9w5IOEpAznRIvFOTpmZ Ip3Rn+huCO21XGo2UfD0ZShdlvO3BltU8NVUQYno5lhKbYoQ1vVXGEuD/p1L9Zc3 snbjQC33KPw8MQRohrlhMmEZraPh8OKqwaITHgBASCWINQ+FeHOZE4wF2/KchNTn lq7tudp3E+IY85aP/+pkvRRGEI0OXZ0dpubuaWASBo/3RH6KXlBN71H4uDhtNHh/ TWVdV0H8YTuh6e3mbzbOqlXVMOcIzRBSd4Pqd9mO32iq1PGv+V1wMUW1CEMPRIy1 fzb7CWX63NVTqUBQKBELv7g5vYIRVwIDAQABAoICAByL65+MXZlcZP9zOkDbwGnk WGwlSn4/SNchVMhcSmd05OYVbjJXOxJWSgaCCkgSQ6mZAq/ei/AzfToFC2gVkWXy jdc5TVr7jo0DlvMLyxKvVsCj74VpAYkVah9ozYqKGfP36T+AY781rmua9O8jbt1m 8CBjyhvtOKZ48KRaEvtRnOU0EUtHyiERzXPJ/OBFBQYiiffoQ5FPSXvrA2hF7x3K 5NnjGaTXDxO6FxyqfVqrmAxbwiz0Fc0lLpzuPM97YWdkAN3DmoPblbcXffTpJKDo X4lXroZ8jzKEdwJLV48pbutykar7jm8WJNp4oEIT9slJsJUdE8ZQPhPdpAHgpACl bZZsPU5n6LVFaeudm100889eIM36WVu2gYO0xewVunMRLhnqPBjlNNA45RIj+XLo CSHSkAP+wzrr5tEScIZKAEArQt0WjZq7/+UIAaE0niwtbQivpwBvlmDV+4EygUSU +PQmDDcQysinQtD9gOFivZ5Kg784i+zwssqNleTzHeg7+CN5eZ+MRHl3S/STF5cI Kph23Ml5r/PApke8O7qeGnEHi1NhtGUmofdhAD9PJ1Z/VS52E61Re9cY87Vo0pM+ +KEi63XDZ+tpYPuL6z3AJ9xTVHxVmqPK7nKvKeO8HXKVbBJRPY6fH85ALDUs+aqq X438ACeNRbNuwrX9PPjBAoIBAQDbDiQNcy1uUestQHNYCFhMsw6krCwwiySPyvYJ /WhiQu9L7PZsjCxcSl/rrZUZyNFm9fKCYXNHV8CHZ1l3hCBjpCbXkWr6Ui0wE8Ye lOBkTiB33FU5LE1TFlMwJ4UuE6npSls7PuIgjfRzfhu+U+3kPR1FgIFllzE6vj59 zYwB+Ord2WnKlI4Zc2AZyNtaTihmXiV9xSsAAxWibRIj9sNpDfjRwBHvbT9rJEer 2SokCBLkECjq2HH51UN8lSeHxK7Mk61FKbfJSFGKzXJb8p42x2xQxLgIRcWCJ98E cOqFBv5zL111IKkrIZS2dUp3KpmP+gL6wItzJQ5b4rL9H88DAoIBAQDKl9mb7iFN 1ai+rJjaLOgzzr9nVt900kcBkV8y0nXCEZY83udrSw9RcE30NBomlXxWJ1lxDDZC ye8AD6cbCIaij3d02jNx2iVgPUf2vxN0YDhwZMpFiy5M8fxRDnxKmIVF03EE8c9X g3GhlGvN3k6sdPJwgb3VwAh9kGQVwK5KMetFcJUypWK6Ot6xViJWJopFe5YPBxdr eCoVq7+JELjJ7j1EEGYhVMVw7t1CsGANVrV6L3TTNQNESdaN8pjz7PsvCX62TlGX wPjSklO5Ru1CFV1w7YJHj2OD6b+JOw6QNLIyQi9oP9EExVTIIQRr7QN1OZ9aGHL6 EDgc53EecIodAoIBADxPvGVnnM6PB21CHX/TbFxRwGpebRxAcySUAQHnH2JOg4wo BgEE5wHSCG7fL/oVbHIorUhwhEjURFIDhoJ9gl1syLT5eLbLAV4HU7j/zHhRemcF 5wECzZdewjCz8Nsq1tFAg7XgLmpAK1nREtpoSUtZ+EE2jGnoIsnFr3b7rNyuKBxE y/fWxvkC5yayQpKuijkFGtVx/9DVCJPb6+6y9kJqcmNtuoJtVdSt/H24IP4iqvDX 8iwWw+rBaP9YIbYj1OzGjCJKxitJGgpZXm8qcZ0rcwsZ3oGIlEStrZ2PaUKPFmeo Vtb00x7o9AT4bjQ5KmaVs1ROxxZA0Z9C330J0PkCggEAd1OTZ6WN1jN3bb9pVHBI 4GLxF+PyP/OuwPyn7t5JX+JN9FJySh7uyc/1ClY55On9Tx1kMBK6TwJzlDyj92dB LbSE7r2quW98vj+6CFqpEc2u0Hx9KxL8VXPeYru+d414ShVtJzVqI6iXIE20ZZCA FFHZjmzMrH6sQZDvcmSIA8l9Quw55JfHG9ua2SbbmJSgsqZFT1qk77baSuNbMFc6 EC4TxehGz3EHzinTBvmtyY193JbhH5nE787x4a+3aUz28dCM4sIkitateBGZ4LIn AtpkrCQorQ+G1Oaz2xd+z29KWhHjrGqSKVY1Rp8z5IG4nK4w7rch2an98wBa/0vX /QKCAQEArzbuZOzgG3UGgeExE8mdUk6OLNFUzSeFOT57CEDcYB+YQUQwMgOI4+k+ oJb9tnUxvd8+mIcppHeDP0+fHU8Uz9OTZ8xPkGoBJ912K2FLFAel8g6vqW0Eu3ER pCiz73Ea+0mTZBZZfZPVwL6mGgbsWJWsX2yCzmJ+Noq0bUxu2QE4LBDr7NiE52An sLG2ruWKIfJOl8BgRfqmoncgFrc2UztTV8PDV8gmp9oIBq7LqXyH70Knz8Flh1U4 9A/5hCqd5ovBmHknQUXcp4vnPu4UzQCNcwuvHGj12UcFAywYD1LYXrwmyWQp57lC u4ePzLXxbfS9k7lshmbr2ix5s1Ypjg== -----END PRIVATE KEY----- ================================================ FILE: tests/data/key_pkcs8.pem ================================================ -----BEGIN PRIVATE KEY----- MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCtWxf+SrOokC+5 OJeIllQDJjLevGjWSvAFjgp6PMrDclhQU/re60DwSAbBeOcAG5rxsDVYpuX4xnBI 7PZA7K25HqSSrApZZ0V4LOE5kMAC510tOqSQDE9fnoi/vL+/R6Zh9zRc1iNJqirr KG8QtV95Zptn5a5kX1+Kwn5SnU2+ctDorlz40H76BUdL5RGDLRniMhoOebmh8RMN lJL1yMMsEwor1fRsL4kUhdr3HmlBTa41Odha29G5qoC23R+jNPeGrOMB0F/SOGYo 7JLL+0rRb2fYUcmuqWjryA3S4/U+FBH0RQIHhmQSU5ekF2gO5viIpVWO12kF+mk6 6r4jaFLG2iw/703S8FIE+uYM8al1Q/6WHFYSjXApVbUCE9w5IOEpAznRIvFOTpmZ Ip3Rn+huCO21XGo2UfD0ZShdlvO3BltU8NVUQYno5lhKbYoQ1vVXGEuD/p1L9Zc3 snbjQC33KPw8MQRohrlhMmEZraPh8OKqwaITHgBASCWINQ+FeHOZE4wF2/KchNTn lq7tudp3E+IY85aP/+pkvRRGEI0OXZ0dpubuaWASBo/3RH6KXlBN71H4uDhtNHh/ TWVdV0H8YTuh6e3mbzbOqlXVMOcIzRBSd4Pqd9mO32iq1PGv+V1wMUW1CEMPRIy1 fzb7CWX63NVTqUBQKBELv7g5vYIRVwIDAQABAoICAByL65+MXZlcZP9zOkDbwGnk WGwlSn4/SNchVMhcSmd05OYVbjJXOxJWSgaCCkgSQ6mZAq/ei/AzfToFC2gVkWXy jdc5TVr7jo0DlvMLyxKvVsCj74VpAYkVah9ozYqKGfP36T+AY781rmua9O8jbt1m 8CBjyhvtOKZ48KRaEvtRnOU0EUtHyiERzXPJ/OBFBQYiiffoQ5FPSXvrA2hF7x3K 5NnjGaTXDxO6FxyqfVqrmAxbwiz0Fc0lLpzuPM97YWdkAN3DmoPblbcXffTpJKDo X4lXroZ8jzKEdwJLV48pbutykar7jm8WJNp4oEIT9slJsJUdE8ZQPhPdpAHgpACl bZZsPU5n6LVFaeudm100889eIM36WVu2gYO0xewVunMRLhnqPBjlNNA45RIj+XLo CSHSkAP+wzrr5tEScIZKAEArQt0WjZq7/+UIAaE0niwtbQivpwBvlmDV+4EygUSU +PQmDDcQysinQtD9gOFivZ5Kg784i+zwssqNleTzHeg7+CN5eZ+MRHl3S/STF5cI Kph23Ml5r/PApke8O7qeGnEHi1NhtGUmofdhAD9PJ1Z/VS52E61Re9cY87Vo0pM+ +KEi63XDZ+tpYPuL6z3AJ9xTVHxVmqPK7nKvKeO8HXKVbBJRPY6fH85ALDUs+aqq X438ACeNRbNuwrX9PPjBAoIBAQDbDiQNcy1uUestQHNYCFhMsw6krCwwiySPyvYJ /WhiQu9L7PZsjCxcSl/rrZUZyNFm9fKCYXNHV8CHZ1l3hCBjpCbXkWr6Ui0wE8Ye lOBkTiB33FU5LE1TFlMwJ4UuE6npSls7PuIgjfRzfhu+U+3kPR1FgIFllzE6vj59 zYwB+Ord2WnKlI4Zc2AZyNtaTihmXiV9xSsAAxWibRIj9sNpDfjRwBHvbT9rJEer 2SokCBLkECjq2HH51UN8lSeHxK7Mk61FKbfJSFGKzXJb8p42x2xQxLgIRcWCJ98E cOqFBv5zL111IKkrIZS2dUp3KpmP+gL6wItzJQ5b4rL9H88DAoIBAQDKl9mb7iFN 1ai+rJjaLOgzzr9nVt900kcBkV8y0nXCEZY83udrSw9RcE30NBomlXxWJ1lxDDZC ye8AD6cbCIaij3d02jNx2iVgPUf2vxN0YDhwZMpFiy5M8fxRDnxKmIVF03EE8c9X g3GhlGvN3k6sdPJwgb3VwAh9kGQVwK5KMetFcJUypWK6Ot6xViJWJopFe5YPBxdr eCoVq7+JELjJ7j1EEGYhVMVw7t1CsGANVrV6L3TTNQNESdaN8pjz7PsvCX62TlGX wPjSklO5Ru1CFV1w7YJHj2OD6b+JOw6QNLIyQi9oP9EExVTIIQRr7QN1OZ9aGHL6 EDgc53EecIodAoIBADxPvGVnnM6PB21CHX/TbFxRwGpebRxAcySUAQHnH2JOg4wo BgEE5wHSCG7fL/oVbHIorUhwhEjURFIDhoJ9gl1syLT5eLbLAV4HU7j/zHhRemcF 5wECzZdewjCz8Nsq1tFAg7XgLmpAK1nREtpoSUtZ+EE2jGnoIsnFr3b7rNyuKBxE y/fWxvkC5yayQpKuijkFGtVx/9DVCJPb6+6y9kJqcmNtuoJtVdSt/H24IP4iqvDX 8iwWw+rBaP9YIbYj1OzGjCJKxitJGgpZXm8qcZ0rcwsZ3oGIlEStrZ2PaUKPFmeo Vtb00x7o9AT4bjQ5KmaVs1ROxxZA0Z9C330J0PkCggEAd1OTZ6WN1jN3bb9pVHBI 4GLxF+PyP/OuwPyn7t5JX+JN9FJySh7uyc/1ClY55On9Tx1kMBK6TwJzlDyj92dB LbSE7r2quW98vj+6CFqpEc2u0Hx9KxL8VXPeYru+d414ShVtJzVqI6iXIE20ZZCA FFHZjmzMrH6sQZDvcmSIA8l9Quw55JfHG9ua2SbbmJSgsqZFT1qk77baSuNbMFc6 EC4TxehGz3EHzinTBvmtyY193JbhH5nE787x4a+3aUz28dCM4sIkitateBGZ4LIn AtpkrCQorQ+G1Oaz2xd+z29KWhHjrGqSKVY1Rp8z5IG4nK4w7rch2an98wBa/0vX /QKCAQEArzbuZOzgG3UGgeExE8mdUk6OLNFUzSeFOT57CEDcYB+YQUQwMgOI4+k+ oJb9tnUxvd8+mIcppHeDP0+fHU8Uz9OTZ8xPkGoBJ912K2FLFAel8g6vqW0Eu3ER pCiz73Ea+0mTZBZZfZPVwL6mGgbsWJWsX2yCzmJ+Noq0bUxu2QE4LBDr7NiE52An sLG2ruWKIfJOl8BgRfqmoncgFrc2UztTV8PDV8gmp9oIBq7LqXyH70Knz8Flh1U4 9A/5hCqd5ovBmHknQUXcp4vnPu4UzQCNcwuvHGj12UcFAywYD1LYXrwmyWQp57lC u4ePzLXxbfS9k7lshmbr2ix5s1Ypjg== -----END PRIVATE KEY----- ================================================ FILE: tests/fixtures/mod.rs ================================================ use std::io::{BufRead, BufReader}; use std::process::{Child, Command, Stdio}; use std::thread; use std::thread::sleep; use std::time::{Duration, Instant}; use assert_cmd::cargo; use assert_fs::fixture::TempDir; use assert_fs::prelude::*; use port_check::free_local_port; use reqwest::Url; use reqwest::blocking::{Client, ClientBuilder}; use rstest::fixture; /// Error type used by tests pub type Error = Box<dyn std::error::Error>; /// File names for testing purpose pub static FILES: &[&str] = &[ "test.txt", "test.html", "test.mkv", #[cfg(not(windows))] "test \" \' & < >.csv", #[cfg(not(windows))] "new\nline", "😀.data", "⎙.mp4", "#[]{}()@!$&'`+,;= %20.test", #[cfg(unix)] ":?#[]{}<>()@!$&'`|*+,;= %20.test", #[cfg(not(windows))] "foo\\bar.test", ]; /// Hidden files for testing purpose pub static HIDDEN_FILES: &[&str] = &[".hidden_file1", ".hidden_file2"]; /// Directory names for testing purpose pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dir space/"]; /// Hidden directories for testing purpose pub static HIDDEN_DIRECTORIES: &[&str] = &[".hidden_dir1/", ".hidden space dir/"]; /// Name of a deeply nested file pub static DEEPLY_NESTED_FILE: &str = "very/deeply/nested/test.rs"; /// Name of a symlink pointing to a directory pub static DIRECTORY_SYMLINK: &str = "dir_symlink/"; /// Name of a directory inside a symlinked directory #[allow(unused)] pub static DIR_BEHIND_SYMLINKED_DIR: &str = "dir_symlink/nested"; /// Name of a file inside a directory inside a symlinked directory pub static FILE_IN_DIR_BEHIND_SYMLINKED_DIR: &str = "dir_symlink/nested/file"; /// Name of a symlink pointing to a file pub static FILE_SYMLINK: &str = "file_symlink"; /// Name of a symlink pointing to a path that doesn't exist pub static BROKEN_SYMLINK: &str = "broken_symlink"; /// Default reqwest client with some defaults set. #[fixture] pub fn reqwest_client() -> Client { if rustls::crypto::CryptoProvider::get_default().is_none() { let _ = rustls::crypto::ring::default_provider().install_default(); } let reqwest_client = ClientBuilder::new().tls_danger_accept_invalid_certs(true); reqwest_client.build().unwrap() } /// Test fixture which creates a temporary directory with a few files and directories inside. /// The directories also contain files. #[fixture] pub fn tmpdir() -> TempDir { let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests"); let mut files = FILES.to_vec(); files.extend_from_slice(HIDDEN_FILES); for file in &files { tmpdir .child(file) .write_str("Test Hello Yes") .expect("Couldn't write to file"); } let mut directories = DIRECTORIES.to_vec(); directories.extend_from_slice(HIDDEN_DIRECTORIES); for directory in directories { for file in &files { tmpdir .child(format!("{directory}{file}")) .write_str(&format!("This is {directory}{file}")) .expect("Couldn't write to file"); } } tmpdir .child(DEEPLY_NESTED_FILE) .write_str("File in a deeply nested directory.") .expect("Couldn't write to file"); // someDir structure that rm_files tests expect tmpdir .child("someDir/alpha") .write_str("alpha file content") .expect("Couldn't write to someDir/alpha"); tmpdir .child("someDir/some_sub_dir/bravo") .write_str("bravo file content") .expect("Couldn't write to someDir/some_sub_dir/bravo"); tmpdir .child(DIRECTORY_SYMLINK.strip_suffix("/").unwrap()) .symlink_to_dir(DIRECTORIES[0].strip_suffix("/").unwrap()) .expect("Couldn't create symlink to dir"); tmpdir .child(FILE_SYMLINK) .symlink_to_file(FILES[0]) .expect("Couldn't create symlink to file"); tmpdir .child(BROKEN_SYMLINK) .symlink_to_file("broken_symlink") .expect("Couldn't create broken symlink"); tmpdir .child(FILE_IN_DIR_BEHIND_SYMLINKED_DIR) .write_str("something") .expect("Couldn't write symlink nexted file"); tmpdir } /// Get a free port. #[fixture] pub fn port() -> u16 { free_local_port().expect("Couldn't find a free local port") } /// Run miniserve as a server; Start with a temporary directory, a free port and some /// optional arguments then wait for a while for the server setup to complete. #[fixture] pub fn server<I>(#[default(&[] as &[&str])] args: I) -> TestServer where I: IntoIterator + Clone, I::Item: AsRef<std::ffi::OsStr>, { let port = port(); let tmpdir = tmpdir(); let mut child = Command::new(cargo::cargo_bin!("miniserve")) .arg(tmpdir.path()) .arg("-v") .arg("-p") .arg(port.to_string()) .args(args.clone()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("Couldn't run test binary"); let is_tls = args .into_iter() .any(|x| x.as_ref().to_str().unwrap().contains("tls")); // Read from stdout/stderr in the background and print/eprint everything read. // This dance is required to allow test output capturing to work as expected. // See https://github.com/rust-lang/rust/issues/92370 and https://github.com/rust-lang/rust/issues/90785 let stdout = child.stdout.take().expect("Child process stdout is None"); thread::spawn(move || { BufReader::new(stdout) .lines() .map_while(Result::ok) .for_each(|line| println!("[miniserve stdout] {line}")); }); let stderr = child.stderr.take().expect("Child process stderr is None"); thread::spawn(move || { BufReader::new(stderr) .lines() .map_while(Result::ok) .for_each(|line| eprintln!("[miniserve stderr] {line}")); }); wait_for_port(port); TestServer::new(port, tmpdir, child, is_tls) } /// Wait a max of 1s for the port to become available. fn wait_for_port(port: u16) { let start_wait = Instant::now(); while !port_check::is_port_reachable(format!("localhost:{port}")) { sleep(Duration::from_millis(100)); if start_wait.elapsed().as_secs() > 5 { panic!("timeout waiting for port {port}"); } } } pub struct TestServer { port: u16, tmpdir: TempDir, child: Child, is_tls: bool, } #[allow(dead_code)] impl TestServer { pub fn new(port: u16, tmpdir: TempDir, child: Child, is_tls: bool) -> Self { Self { port, tmpdir, child, is_tls, } } pub fn url(&self) -> Url { let protocol = if self.is_tls { "https" } else { "http" }; Url::parse(&format!("{}://localhost:{}", protocol, self.port)).unwrap() } pub fn path(&self) -> &std::path::Path { self.tmpdir.path() } pub fn port(&self) -> u16 { self.port } } impl Drop for TestServer { fn drop(&mut self) { self.child.kill().expect("Couldn't kill test server"); self.child.wait().unwrap(); } } ================================================ FILE: tests/header.rs ================================================ use reqwest::blocking::Client; use rstest::rstest; mod fixtures; use crate::fixtures::{Error, reqwest_client, server}; #[rstest] #[case(vec!["x-info: 123".to_string()])] #[case(vec!["x-info1: 123".to_string(), "x-info2: 345".to_string()])] fn custom_header_set(#[case] headers: Vec<String>, reqwest_client: Client) -> Result<(), Error> { let server = server(headers.iter().flat_map(|h| vec!["--header", h])); let resp = reqwest_client.get(server.url()).send()?; for header in headers { let mut header_split = header.splitn(2, ':'); let header_name = header_split.next().unwrap(); let header_value = header_split.next().unwrap().trim(); assert_eq!(resp.headers().get(header_name).unwrap(), header_value); } Ok(()) } ================================================ FILE: tests/navigation.rs ================================================ use std::path::{Component, Path}; use std::process::{Command, Stdio}; use pretty_assertions::{assert_eq, assert_ne}; use reqwest::blocking::Client; use rstest::rstest; use select::document::Document; mod fixtures; mod utils; use crate::fixtures::{DEEPLY_NESTED_FILE, DIRECTORIES, Error, TestServer, reqwest_client, server}; use crate::utils::{get_link_from_text, get_link_hrefs_with_prefix}; #[rstest] #[case("", "/")] #[case("/dira", "/dira/")] #[case("/dirb/", "/dirb/")] #[case("/very/deeply/nested", "/very/deeply/nested/")] /// Directories get a trailing slash. fn index_gets_trailing_slash( server: TestServer, reqwest_client: Client, #[case] input: &str, #[case] expected: &str, ) -> Result<(), Error> { let resp = reqwest_client.get(server.url().join(input)?).send()?; assert!(resp.url().as_str().ends_with(expected)); Ok(()) } #[rstest] /// Can't navigate up the root. fn cant_navigate_up_the_root(server: TestServer) -> Result<(), Error> { // We're using curl for this as it has the option `--path-as-is` which doesn't normalize // invalid urls. A useful feature in this particular case. let base_url = server.url(); let curl_successful = Command::new("curl") .arg("-s") .arg("--fail") .arg("--path-as-is") .arg(format!("{base_url}/../")) .stdout(Stdio::null()) .status()? .success(); assert!(curl_successful); Ok(()) } #[rstest] /// We can navigate into directories and back using shown links. fn can_navigate_into_dirs_and_back( server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let base_url = server.url(); let initial_body = reqwest_client .get(base_url.as_str()) .send()? .error_for_status()?; let initial_parsed = Document::from_read(initial_body)?; for &directory in DIRECTORIES { let dir_elem = get_link_from_text(&initial_parsed, directory).expect("Dir not found."); let body = reqwest_client .get(format!("{base_url}{dir_elem}")) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; let back_link = get_link_from_text(&parsed, "Parent directory").expect("Back link not found."); let resp = reqwest_client .get(format!("{base_url}{back_link}")) .send()?; // Now check that we can actually get back to the original location we came from using the // link. assert_eq!(resp.url().as_str(), base_url.as_str()); } Ok(()) } #[rstest] /// We can navigate deep into the file tree and back using shown links. fn can_navigate_deep_into_dirs_and_back( server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { // Create a vector of parent directory names. let dir_names = Path::new(DEEPLY_NESTED_FILE) .parent() .unwrap() .components() .map(|comp| { let Component::Normal(dir) = comp else { unreachable!() }; dir.to_str().unwrap() }) .collect::<Vec<_>>(); let base_url = server.url(); // First we'll go forwards through the directory tree and then we'll go backwards. // In the end, we'll have to end up where we came from. let mut next_url = base_url.clone(); for dir_name in dir_names.iter() { let resp = reqwest_client.get(next_url.as_str()).send()?; let body = resp.error_for_status()?; let parsed = Document::from_read(body)?; let dir_elem = get_link_from_text(&parsed, &format!("{dir_name}/")).expect("Dir not found."); next_url = next_url.join(&dir_elem)?; } assert_ne!(base_url, next_url); // Now try to get out the tree again using links only. while next_url != base_url { let resp = reqwest_client.get(next_url.as_str()).send()?; let body = resp.error_for_status()?; let parsed = Document::from_read(body)?; let dir_elem = get_link_from_text(&parsed, "Parent directory").expect("Back link not found."); next_url = next_url.join(&dir_elem)?; } assert_eq!(base_url, next_url); Ok(()) } #[rstest] #[case(server(&["--title", "some title"]), "some title")] #[case(server(None::<&str>), format!("localhost:{}", server.port()))] /// We can use breadcrumbs to navigate. fn can_navigate_using_breadcrumbs( #[case] server: TestServer, reqwest_client: Client, #[case] title_name: String, ) -> Result<(), Error> { let dir = Path::new(DEEPLY_NESTED_FILE) .parent() .unwrap() .to_str() .unwrap(); let base_url = server.url(); let nested_url = base_url.join(dir)?; let resp = reqwest_client.get(nested_url.as_str()).send()?; let body = resp.error_for_status()?; let parsed = Document::from_read(body)?; // can go back to root dir by clicking title let title_link = get_link_from_text(&parsed, &title_name).expect("Root dir link not found."); assert_eq!("/", title_link); // can go to intermediate dir let intermediate_dir_link = get_link_from_text(&parsed, "very").expect("Intermediate dir link not found."); assert_eq!("/very/", intermediate_dir_link); // current dir is not linked let current_dir_link = get_link_from_text(&parsed, "nested"); assert_eq!(None, current_dir_link); Ok(()) } #[rstest] #[case(server(&["--default-sorting-method", "name", "--default-sorting-order", "asc"]), "name", "asc")] #[case(server(&["--default-sorting-method", "name", "--default-sorting-order", "desc"]), "name", "desc")] /// We can specify the default sorting order fn can_specify_default_sorting_order( #[case] server: TestServer, reqwest_client: Client, #[case] method: String, #[case] order: String, ) -> Result<(), Error> { let resp = reqwest_client.get(server.url()).send()?; let body = resp.error_for_status()?; let parsed = Document::from_read(body)?; let links = get_link_hrefs_with_prefix(&parsed, "/"); let dir_iter = server.path(); let mut dir_entries = dir_iter .read_dir() .unwrap() .map(|x| x.unwrap().file_name().into_string().unwrap()) .map(|x| format!("/{x}")) .collect::<Vec<_>>(); dir_entries.sort(); if method == "name" && order == "asc" { assert_eq!( *dir_entries.last().unwrap(), *percent_encoding::percent_decode_str(links.first().unwrap()).decode_utf8_lossy() ); } else if method == "name" && order == "desc" { assert_eq!( *dir_entries.first().unwrap(), *percent_encoding::percent_decode_str(links.first().unwrap()).decode_utf8_lossy() ); } Ok(()) } ================================================ FILE: tests/paste.rs ================================================ use reqwest::blocking::Client; use rstest::rstest; use select::{document::Document, predicate::Attr}; mod fixtures; use crate::fixtures::{Error, TestServer, reqwest_client, server}; // There are few tests here because the pastebin is implemented by converting a textareas content // into an in-memory blob/file, and adding that file to the existing file upload form. We can't // test the JS here, and any testing the actual "upload" would just be retesting the existing // uploader. #[rstest] #[case::without_flag(&["--upload-files"], false)] #[case::with_flag(&["--upload-files", "--pastebin"], true)] fn paste_entry_only_appears_with_flag( #[case] _flags: &[&str], #[case] should_exist: bool, #[with(_flags)] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; let exists = parsed.find(Attr("id", "pastebin")).next().is_some(); assert_eq!( exists, should_exist, "Expected exists(#pastebin) to return {}, but got {}", should_exist, exists ); Ok(()) } ================================================ FILE: tests/qrcode.rs ================================================ use std::process::{Command, Stdio}; use std::thread::sleep; use std::time::Duration; use assert_cmd::cargo; use assert_fs::TempDir; use reqwest::blocking::Client; use rstest::rstest; use select::{document::Document, predicate::Attr}; mod fixtures; use crate::fixtures::{Error, TestServer, port, reqwest_client, server, tmpdir}; #[rstest] fn webpage_hides_qrcode_when_disabled( server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Attr("id", "qrcode")).next().is_none()); Ok(()) } #[rstest] fn webpage_shows_qrcode_when_enabled( #[with(&["-q"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; let qr_container = parsed .find(Attr("id", "qrcode")) .next() .ok_or("QR container not found")?; let tooltip = qr_container .attr("title") .ok_or("QR container has no title")?; assert_eq!(tooltip, server.url().as_str()); Ok(()) } #[cfg(not(windows))] fn run_in_faketty_kill_and_get_stdout(template: &Command) -> Result<String, Error> { use fake_tty::{bash_command, get_stdout}; let cmd = { let bin = template.get_program().to_str().expect("not UTF8"); let args = template .get_args() .map(|s| s.to_str().expect("not UTF8")) .collect::<Vec<_>>() .join(" "); format!("{bin} {args}") }; let mut child = bash_command(&cmd)?.stdin(Stdio::null()).spawn()?; sleep(Duration::from_secs(1)); child.kill()?; let output = child.wait_with_output().expect("Failed to read stdout"); let all_text = get_stdout(output.stdout)?; Ok(all_text) } #[rstest] // Disabled for Windows because `fake_tty` does not currently support it. #[cfg(not(windows))] fn qrcode_hidden_in_tty_when_disabled(tmpdir: TempDir, port: u16) -> Result<(), Error> { let mut template = Command::new(cargo::cargo_bin!("miniserve")); template.arg("-p").arg(port.to_string()).arg(tmpdir.path()); let output = run_in_faketty_kill_and_get_stdout(&template)?; assert!(!output.contains("QR code for ")); Ok(()) } #[rstest] // Disabled for Windows because `fake_tty` does not currently support it. #[cfg(not(windows))] fn qrcode_shown_in_tty_when_enabled(tmpdir: TempDir, port: u16) -> Result<(), Error> { let mut template = Command::new(cargo::cargo_bin!("miniserve")); template .arg("-p") .arg(port.to_string()) .arg("-q") .arg(tmpdir.path()); let output = run_in_faketty_kill_and_get_stdout(&template)?; assert!(output.contains("QR code for ")); Ok(()) } #[rstest] fn qrcode_hidden_in_non_tty_when_enabled(tmpdir: TempDir, port: u16) -> Result<(), Error> { let mut child = Command::new(cargo::cargo_bin!("miniserve")) .arg("-p") .arg(port.to_string()) .arg("-q") .arg(tmpdir.path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; sleep(Duration::from_secs(1)); child.kill()?; let output = child.wait_with_output().expect("Failed to read stdout"); let stdout = String::from_utf8(output.stdout)?; assert!(!stdout.contains("QR code for ")); Ok(()) } ================================================ FILE: tests/raw.rs ================================================ use pretty_assertions::assert_eq; use reqwest::blocking::Client; use rstest::rstest; use select::{ document::Document, predicate::{Class, Name}, }; mod fixtures; use crate::fixtures::{Error, TestServer, reqwest_client, server}; /// The footer displays the correct wget command to download the folder recursively // This test can't test all aspects of the wget footer, // a more detailed unit test is available #[rstest] #[case(0, "")] #[case(1, "dira/")] #[case(2, "very/deeply/")] #[case(3, "very/deeply/nested/")] fn ui_displays_wget_element( #[case] depth: u8, #[case] dir: &str, #[with(&["--show-wget-footer"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(format!("{}{}", server.url(), dir)) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; let wget_url = parsed .find(Class("downloadDirectory")) .next() .unwrap() .find(Class("cmd")) .next() .unwrap() .text(); let cut_dirs = match depth { // Put all the files in a folder of this name 0 => format!(" -P 'localhost:{}'", server.port()), 1 => String::new(), // Avoids putting the files in excessive directories x => format!(" --cut-dirs={}", x - 1), }; assert_eq!( wget_url, format!( "wget -rcnp -R 'index.html*' -nH{} '{}{}?raw=true'", cut_dirs, server.url(), dir ) ); Ok(()) } /// All hrefs in raw mode are links to directories or files & directories end with ?raw=true #[rstest] #[case("")] #[case("very/")] #[case("very/deeply/")] #[case("very/deeply/nested/")] fn raw_mode_links_to_directories_end_with_raw_true( #[case] dir: &str, #[with(&["--show-wget-footer"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { fn verify_a_tags(parsed: Document) { // Ensure all links end with ?raw=true or are files for node in parsed.find(Name("a")) { if let Some(class) = node.attr("class") { if class == "root" || class == "directory" { assert!( node.attr("href").unwrap().ends_with("?raw=true"), "doesn't end with ?raw=true" ); } else if class == "file" { return; } else { panic!( "This node is a link and neither of class directory, root or file: {node:?}" ); } } } } // Ensure the links to the archives are not present let body = reqwest_client .get(format!("{}{}?raw=true", server.url(), dir)) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; verify_a_tags(parsed); Ok(()) } ================================================ FILE: tests/readme.rs ================================================ use std::fs::{File, remove_file}; use std::io::Write; use std::path::PathBuf; use reqwest::blocking::Client; use rstest::rstest; use select::predicate::Attr; use select::{document::Document, node::Node}; mod fixtures; use fixtures::{DIRECTORIES, Error, FILES, TestServer, server}; use crate::fixtures::reqwest_client; fn write_readme_contents(path: PathBuf, filename: &str) -> PathBuf { let readme_path = path.join(filename); let mut readme_file = File::create(&readme_path).unwrap(); readme_file .write_all(format!("Contents of {filename}").as_bytes()) .expect("Couldn't write readme"); readme_path } fn assert_readme_contents(parsed_dom: &Document, filename: &str) { assert!(parsed_dom.find(Attr("id", "readme")).next().is_some()); assert!( parsed_dom .find(Attr("id", "readme-filename")) .next() .is_some() ); assert!( parsed_dom .find(Attr("id", "readme-filename")) .next() .unwrap() .text() == filename ); assert!( parsed_dom .find(Attr("id", "readme-contents")) .next() .is_some() ); assert!( parsed_dom .find(Attr("id", "readme-contents")) .next() .unwrap() .text() .trim() .contains(&format!("Contents of {filename}")) ); } /// Do not show readme contents by default #[rstest] fn no_readme_contents(server: TestServer, reqwest_client: Client) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; // Check that the regular file listing still works. for &file in FILES { assert!(parsed.find(|x: &Node| x.text() == file).next().is_some()); } for &dir in DIRECTORIES { assert!(parsed.find(|x: &Node| x.text() == dir).next().is_some()); } // Check that there is no readme stuff here. assert!(parsed.find(Attr("id", "readme")).next().is_none()); assert!(parsed.find(Attr("id", "readme-filename")).next().is_none()); assert!(parsed.find(Attr("id", "readme-contents")).next().is_none()); Ok(()) } /// Show readme contents when told to if there is a readme file in the root #[rstest] #[case("Readme.md")] #[case("readme.md")] #[case("README.md")] #[case("README.MD")] #[case("ReAdMe.Md")] fn show_root_readme_contents( #[with(&["--readme"])] server: TestServer, reqwest_client: Client, #[case] readme_name: &str, ) -> Result<(), Error> { let readme_path = write_readme_contents(server.path().to_path_buf(), readme_name); let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; // All the files are still getting listed... for &file in FILES { assert!(parsed.find(|x: &Node| x.text() == file).next().is_some()); } // ...in addition to the readme contents below the file listing. assert_readme_contents(&parsed, readme_name); remove_file(readme_path).unwrap(); Ok(()) } /// Show readme contents when told to if there is a readme file in any of the directories #[rstest] #[case("Readme.md")] #[case("readme.md")] #[case("README.md")] #[case("README.MD")] #[case("ReAdMe.Md")] #[case("Readme.txt")] #[case("README.txt")] #[case("README")] #[case("ReAdMe")] fn show_nested_readme_contents( #[with(&["--readme"])] server: TestServer, reqwest_client: Client, #[case] readme_name: &str, ) -> Result<(), Error> { for dir in DIRECTORIES { let readme_path = write_readme_contents(server.path().join(dir), readme_name); let body = reqwest_client .get(server.url().join(dir)?) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; // All the files are still getting listed... for &file in FILES { assert!(parsed.find(|x: &Node| x.text() == file).next().is_some()); } // ...in addition to the readme contents below the file listing. assert_readme_contents(&parsed, readme_name); remove_file(readme_path).unwrap(); } Ok(()) } ================================================ FILE: tests/rm_files.rs ================================================ mod fixtures; use assert_fs::fixture::TempDir; use fixtures::{Error, TestServer, server, tmpdir}; use percent_encoding::utf8_percent_encode; use reqwest::StatusCode; use reqwest::blocking::Client; use rstest::rstest; use std::path::{Component, Path}; use url::Url; use crate::fixtures::{ DEEPLY_NESTED_FILE, DIRECTORIES, FILES, HIDDEN_DIRECTORIES, HIDDEN_FILES, reqwest_client, }; const NESTED_FILES_UNDER_SINGLE_ROOT: &[&str] = &["someDir/alpha", "someDir/some_sub_dir/bravo"]; /// Construct a path for a GET request, /// with each path component being separately encoded. fn make_get_path(unencoded_path: impl AsRef<Path>) -> String { unencoded_path .as_ref() .components() .map(|comp| match comp { Component::Prefix(_) | Component::RootDir => unreachable!("Not currently used"), Component::CurDir => ".", Component::ParentDir => "..", Component::Normal(comp) => comp.to_str().unwrap(), }) .map(|comp| utf8_percent_encode(comp, percent_encoding::NON_ALPHANUMERIC).to_string()) .collect::<Vec<_>>() .join("/") } /// Construct a path for a deletion POST request without any further encoding. /// /// This should be kept consistent with implementation. fn make_del_path(unencoded_path: impl AsRef<Path>) -> String { format!("rm?path=/{}", make_get_path(unencoded_path)) } /// Tests that deletion requests succeed as expected. /// Verifies that the path exists, can be deleted, and is no longer accessible after deletion. fn assert_rm_ok( reqwest_client: &Client, base_url: Url, unencoded_path: impl AsRef<Path>, ) -> Result<(), Error> { let file_path = unencoded_path.as_ref(); // encode let get_url = base_url.join(&make_get_path(file_path))?; let del_url = base_url.join(&make_del_path(file_path))?; // check path exists let _get_res = reqwest_client .get(get_url.clone()) .send()? .error_for_status()?; // delete let _del_res = reqwest_client.post(del_url).send()?.error_for_status()?; // check path is gone let get_res = reqwest_client.get(get_url).send()?; if get_res.status() != StatusCode::NOT_FOUND { return Err(format!("Unexpected status code: {}", get_res.status()).into()); } Ok(()) } /// Tests that deletion requests fail as expected. /// The `check_path_exists` parameter allows skipping this check before and after /// the deletion attempt in case the path should be inaccessible via GET. fn assert_rm_err( reqwest_client: &Client, base_url: Url, unencoded_path: impl AsRef<Path>, check_path_exists: bool, ) -> Result<(), Error> { let file_path = unencoded_path.as_ref(); // encode let get_url = base_url.join(&make_get_path(file_path))?; let del_url = base_url.join(&make_del_path(file_path))?; // check path exists if check_path_exists { let _get_res = reqwest_client .get(get_url.clone()) .send()? .error_for_status()?; } // delete let del_res = reqwest_client.post(del_url).send()?; if !del_res.status().is_client_error() { return Err(format!("Unexpected status code: {}", del_res.status()).into()); } // check path still exists if check_path_exists { let _get_res = reqwest_client.get(get_url).send()?.error_for_status()?; } Ok(()) } #[rstest] #[case(FILES[0])] #[case(FILES[1])] #[case(FILES[2])] #[case(DIRECTORIES[0])] #[case(DIRECTORIES[1])] #[case(DIRECTORIES[2])] #[case(DEEPLY_NESTED_FILE)] fn rm_disabled_by_default( server: TestServer, reqwest_client: Client, #[case] path: &str, ) -> Result<(), Error> { assert_rm_err(&reqwest_client, server.url(), path, true) } #[rstest] #[case(FILES[0])] #[case(FILES[1])] #[case(FILES[2])] #[case(HIDDEN_FILES[0])] #[case(HIDDEN_FILES[1])] #[case(DIRECTORIES[0])] #[case(DIRECTORIES[1])] #[case(DIRECTORIES[2])] #[case(HIDDEN_DIRECTORIES[0])] #[case(HIDDEN_DIRECTORIES[1])] #[case(DEEPLY_NESTED_FILE)] fn rm_disabled_by_default_with_hidden( reqwest_client: Client, #[with(&["-H"])] server: TestServer, #[case] path: &str, ) -> Result<(), Error> { assert_rm_err(&reqwest_client, server.url(), path, true) } #[rstest] #[case(FILES[0])] #[case(FILES[1])] #[case(FILES[2])] #[case(DIRECTORIES[0])] #[case(DIRECTORIES[1])] #[case(DIRECTORIES[2])] #[case(DEEPLY_NESTED_FILE)] fn rm_works( #[with(&["-R"])] server: TestServer, reqwest_client: Client, #[case] path: &str, ) -> Result<(), Error> { assert_rm_ok(&reqwest_client, server.url(), path) } #[rstest] #[case(HIDDEN_FILES[0])] #[case(HIDDEN_FILES[1])] #[case(HIDDEN_DIRECTORIES[0])] #[case(HIDDEN_DIRECTORIES[1])] fn cannot_rm_hidden_when_disallowed( #[with(&["-R"])] server: TestServer, reqwest_client: Client, #[case] path: &str, ) -> Result<(), Error> { assert_rm_err(&reqwest_client, server.url(), path, false) } #[rstest] #[case(HIDDEN_FILES[0])] #[case(HIDDEN_FILES[1])] #[case(HIDDEN_DIRECTORIES[0])] #[case(HIDDEN_DIRECTORIES[1])] fn can_rm_hidden_when_allowed( #[with(&["-R", "-H"])] server: TestServer, reqwest_client: Client, #[case] path: &str, ) -> Result<(), Error> { assert_rm_ok(&reqwest_client, server.url(), path) } /// This test runs the server with --allowed-rm-dir argument and checks that /// deletions in a different directory are actually prevented. #[rstest] #[case(server(&["-R", "someOtherDir"]), NESTED_FILES_UNDER_SINGLE_ROOT[0])] #[case(server(&["-R", "someOtherDir"]), NESTED_FILES_UNDER_SINGLE_ROOT[1])] #[case(server(&["-R", "someDir/some_other_sub_dir"]), NESTED_FILES_UNDER_SINGLE_ROOT[0])] #[case(server(&["-R", "someDir/some_other_sub_dir"]), NESTED_FILES_UNDER_SINGLE_ROOT[1])] fn rm_is_restricted( #[case] server: TestServer, reqwest_client: Client, #[case] path: &str, ) -> Result<(), Error> { assert_rm_err(&reqwest_client, server.url(), path, true) } /// This test runs the server with --allowed-rm-dir argument and checks that /// deletions of the specified directories themselves are allowed. /// /// Both ways of specifying multiple directories are tested. #[rstest] #[case(server(&["-R", "dira,dirb,dir space"]), DIRECTORIES[0])] #[case(server(&["-R", "dira,dirb,dir space"]), DIRECTORIES[1])] #[case(server(&["-R", "dira,dirb,dir space"]), DIRECTORIES[2])] #[case(server(&["-R", "dira", "-R", "dirb", "-R", "dir space"]), DIRECTORIES[0])] #[case(server(&["-R", "dira", "-R", "dirb", "-R", "dir space"]), DIRECTORIES[1])] #[case(server(&["-R", "dira", "-R", "dirb", "-R", "dir space"]), DIRECTORIES[2])] fn can_rm_allowed_dir( #[case] server: TestServer, reqwest_client: Client, #[case] path: &str, ) -> Result<(), Error> { assert_rm_ok(&reqwest_client, server.url(), path) } /// This tests that we can delete from directories specified by --allow-rm-dir. #[rstest] #[case(server(&["-R", "someDir"]), "someDir/alpha")] #[case(server(&["-R", "someDir"]), "someDir//alpha")] #[case(server(&["-R", "someDir"]), "someDir/././alpha")] #[case(server(&["-R", "someDir"]), "someDir/some_sub_dir")] #[case(server(&["-R", "someDir"]), "someDir/some_sub_dir/")] #[case(server(&["-R", "someDir"]), "someDir//some_sub_dir")] #[case(server(&["-R", "someDir"]), "someDir/./some_sub_dir")] #[case(server(&["-R", "someDir"]), "someDir/some_sub_dir/bravo")] #[case(server(&["-R", "someDir"]), "someDir//some_sub_dir//bravo")] #[case(server(&["-R", "someDir"]), "someDir/./some_sub_dir/../some_sub_dir/bravo")] #[case(server(&["-R", "someDir/some_sub_dir"]), "someDir/some_sub_dir/bravo")] #[case(server(&["-R", Path::new("someDir/some_sub_dir").to_str().unwrap()]), "someDir/some_sub_dir/bravo")] fn can_rm_from_allowed_dir( #[case] server: TestServer, reqwest_client: Client, #[case] file: &str, ) -> Result<(), Error> { assert_rm_ok(&reqwest_client, server.url(), file) } /// Test deleting from symlinked directories that point to outside the server root. #[rstest] #[case(server(&["-R"]), true)] #[case(server(&["-R", "--no-symlinks"]), false)] fn rm_from_symlinked_dir( #[case] server: TestServer, #[case] should_succeed: bool, #[from(tmpdir)] target: TempDir, reqwest_client: Client, ) -> Result<(), Error> { #[cfg(unix)] use std::os::unix::fs::symlink as symlink_dir; #[cfg(windows)] use std::os::windows::fs::symlink_dir; // create symlink let link: &Path = Path::new("linked"); symlink_dir(target.path(), server.path().join(link))?; let files_through_link = [FILES, DIRECTORIES] .concat() .iter() .map(|name| link.join(name)) .collect::<Vec<_>>(); if should_succeed { for file_path in &files_through_link { assert_rm_ok(&reqwest_client, server.url(), file_path)?; } } else { for file_path in &files_through_link { assert_rm_err(&reqwest_client, server.url(), file_path, false)?; } } Ok(()) } ================================================ FILE: tests/serve_request.rs ================================================ use std::process::{Command, Stdio}; use std::thread::sleep; use std::time::Duration; use assert_cmd::cargo; use assert_fs::fixture::TempDir; use fixtures::BROKEN_SYMLINK; use regex::Regex; use reqwest::StatusCode; use reqwest::blocking::Client; use rstest::rstest; use select::{document::Document, node::Node, predicate::Attr}; mod fixtures; use crate::fixtures::{ DIR_BEHIND_SYMLINKED_DIR, DIRECTORIES, DIRECTORY_SYMLINK, Error, FILE_IN_DIR_BEHIND_SYMLINKED_DIR, FILE_SYMLINK, FILES, HIDDEN_DIRECTORIES, HIDDEN_FILES, TestServer, port, reqwest_client, server, tmpdir, }; #[rstest] fn serves_requests_with_no_options(reqwest_client: Client, tmpdir: TempDir) -> Result<(), Error> { let mut child = Command::new(cargo::cargo_bin!("miniserve")) .arg(tmpdir.path()) .stdout(Stdio::null()) .spawn()?; sleep(Duration::from_secs(1)); let body = reqwest_client .get("http://localhost:8080") .send()? .error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES { assert!(parsed.find(|x: &Node| x.text() == file).next().is_some()); } for &dir in DIRECTORIES { assert!(parsed.find(|x: &Node| x.text() == dir).next().is_some()); } child.kill()?; Ok(()) } #[rstest] fn serves_requests_with_non_default_port( server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES { let f = parsed.find(|x: &Node| x.text() == file).next().unwrap(); reqwest_client .get(server.url().join(f.attr("href").unwrap())?) .send()? .error_for_status()?; assert_eq!( format!("/{file}"), percent_encoding::percent_decode_str(f.attr("href").unwrap()).decode_utf8_lossy(), ); } for &directory in DIRECTORIES { assert!( parsed .find(|x: &Node| x.text() == directory) .next() .is_some() ); let dir_body = reqwest_client .get(server.url().join(directory)?) .send()? .error_for_status()?; let dir_body_parsed = Document::from_read(dir_body)?; for &file in FILES { assert!( dir_body_parsed .find(|x: &Node| x.text() == file) .next() .is_some() ); } } Ok(()) } #[rstest] #[case("__miniserve_internal/healthcheck", server(None::<&str>))] #[case("__miniserve_internal/favicon.svg", server(None::<&str>))] #[case("__miniserve_internal/style.css", server(None::<&str>))] #[case("testlol/__miniserve_internal/healthcheck", server(&["--route-prefix", "testlol"]))] #[case("testlol/__miniserve_internal/favicon.svg", server(&["--route-prefix", "testlol"]))] #[case("testlol/__miniserve_internal/style.css", server(&["--route-prefix", "testlol"]))] #[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( reqwest_client: Client, #[case] route: &str, #[case] server: TestServer, ) -> Result<(), Error> { reqwest_client .get(format!("{}{}", server.url(), route)) .send()? .error_for_status()?; Ok(()) } #[rstest] fn serves_requests_hidden_files( #[with(&["--hidden"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES.iter().chain(HIDDEN_FILES) { let f = parsed.find(|x: &Node| x.text() == file).next().unwrap(); assert_eq!( format!("/{file}"), percent_encoding::percent_decode_str(f.attr("href").unwrap()).decode_utf8_lossy(), ); } for &directory in DIRECTORIES.iter().chain(HIDDEN_DIRECTORIES) { assert!( parsed .find(|x: &Node| x.text() == directory) .next() .is_some() ); let dir_body = reqwest_client .get(server.url().join(directory)?) .send()? .error_for_status()?; let dir_body_parsed = Document::from_read(dir_body)?; for &file in FILES.iter().chain(HIDDEN_FILES) { assert!( dir_body_parsed .find(|x: &Node| x.text() == file) .next() .is_some() ); } } Ok(()) } #[rstest] fn serves_requests_no_hidden_files_without_flag( server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; for &hidden_item in HIDDEN_FILES.iter().chain(HIDDEN_DIRECTORIES) { assert!( parsed .find(|x: &Node| x.text() == hidden_item) .next() .is_none() ); let resp = reqwest_client.get(server.url().join(hidden_item)?).send()?; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } Ok(()) } #[rstest] #[case(server(None::<&str>), StatusCode::OK)] #[case(server(&["--no-symlinks"]), StatusCode::NOT_FOUND)] fn serves_requests_nested_in_symlinks( #[case] server: TestServer, reqwest_client: Client, #[case] expected_status: StatusCode, ) -> Result<(), Error> { let file_status = reqwest_client .get(server.url().join(DIRECTORY_SYMLINK)?.join(FILES[0])?) .send()? .status(); assert_eq!(file_status, expected_status); let dir_status = reqwest_client .get(server.url().join(DIR_BEHIND_SYMLINKED_DIR)?) .send()? .status(); assert_eq!(dir_status, expected_status); let nested_file_status = reqwest_client .get(server.url().join(FILE_IN_DIR_BEHIND_SYMLINKED_DIR)?) .send()? .status(); assert_eq!(nested_file_status, expected_status); Ok(()) } #[rstest] #[case(true, false, server(&["--no-symlinks"]))] #[case(true, true, server(&["--no-symlinks", "--show-symlink-info"]))] #[case(false, false, server(None::<&str>))] fn serves_requests_symlinks( #[case] no_symlinks: bool, #[case] show_symlink_info: bool, #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; for &entry in &[FILE_SYMLINK, DIRECTORY_SYMLINK] { let status = reqwest_client .get(server.url().join(entry)?) .send()? .status(); // We expect a 404 here for when `no_symlinks` is `true`. if no_symlinks { assert_eq!(status, StatusCode::NOT_FOUND); } else { assert_eq!(status, StatusCode::OK); } let node = parsed .find(|x: &Node| x.name().unwrap_or_default() == "a" && x.text() == entry) .next(); // If symlinks are deactivated, none should be shown in the listing. dbg!(&node); assert_eq!(node.is_none(), no_symlinks); if node.is_some() && show_symlink_info { assert_eq!(node.unwrap().attr("class").unwrap(), "symlink"); } // If following symlinks is deactivated, we can just skip this iteration as we assorted // above the no entries in the listing can be found for symlinks in that case. if no_symlinks { continue; } let node = node.unwrap(); assert_eq!(node.attr("href").unwrap().strip_prefix('/').unwrap(), entry); if entry.ends_with('/') { let node = parsed .find(|x: &Node| x.name().unwrap_or_default() == "a" && x.text() == DIRECTORIES[0]) .next(); assert_eq!(node.unwrap().attr("class").unwrap(), "directory"); } else { let node = parsed .find(|x: &Node| x.name().unwrap_or_default() == "a" && x.text() == FILES[0]) .next(); assert_eq!(node.unwrap().attr("class").unwrap(), "file"); } } assert!( parsed .find(|x: &Node| x.text() == BROKEN_SYMLINK) .next() .is_none() ); Ok(()) } #[rstest] fn serves_requests_with_randomly_assigned_port(tmpdir: TempDir) -> Result<(), Error> { let mut child = Command::new(cargo::cargo_bin!("miniserve")) .arg(tmpdir.path()) .arg("-p") .arg("0") .stdout(Stdio::piped()) .spawn()?; sleep(Duration::from_secs(1)); child.kill()?; let output = child.wait_with_output().expect("Failed to read stdout"); let all_text = String::from_utf8(output.stdout)?; let re = Regex::new(r"http://127.0.0.1:(\d+)").unwrap(); let caps = re.captures(all_text.as_str()).unwrap(); let port_num = caps.get(1).unwrap().as_str().parse::<u16>().unwrap(); assert!(port_num > 0); Ok(()) } #[rstest] fn serves_requests_custom_index_notice(tmpdir: TempDir, port: u16) -> Result<(), Error> { let mut child = Command::new(cargo::cargo_bin!("miniserve")) .arg("--index=not.html") .arg("-p") .arg(port.to_string()) .arg(tmpdir.path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; sleep(Duration::from_secs(1)); child.kill()?; let output = child.wait_with_output().expect("Failed to read stdout"); let all_text = String::from_utf8(output.stdout); assert!( all_text?.contains("The file 'not.html' provided for option --index could not be found.") ); Ok(()) } #[rstest] #[case(server(&["--index", FILES[0]]))] #[case(server(&["--index", "does-not-exist.html"]))] fn index_fallback_to_listing( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { // If index file is not found, show directory listing instead both cases should return `Ok` reqwest_client .get(server.url()) .send()? .error_for_status()?; Ok(()) } #[rstest] #[case(server(&["--spa", "--index", FILES[0]]), "/")] #[case(server(&["--spa", "--index", FILES[0]]), "/spa-route")] #[case(server(&["--index", FILES[0]]), "/")] fn serve_index_instead_of_404_in_spa_mode( #[case] server: TestServer, reqwest_client: Client, #[case] url: &str, ) -> Result<(), Error> { let body = reqwest_client .get(format!("{}{}", server.url(), url)) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!( parsed .find(|x: &Node| x.text() == "Test Hello Yes") .next() .is_some() ); Ok(()) } #[rstest] #[case(server(&["--pretty-urls", "--index", FILES[1]]), "/")] #[case(server(&["--pretty-urls", "--index", FILES[1]]), "test.html")] #[case(server(&["--pretty-urls", "--index", FILES[1]]), "test")] fn serve_file_instead_of_404_in_pretty_urls_mode( #[case] server: TestServer, reqwest_client: Client, #[case] url: &str, ) -> Result<(), Error> { let body = reqwest_client .get(format!("{}{}", server.url(), url)) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!( parsed .find(|x: &Node| x.text() == "Test Hello Yes") .next() .is_some() ); Ok(()) } #[rstest] #[case(server(&["--route-prefix", "foobar"]))] #[case(server(&["--route-prefix", "/foobar/"]))] fn serves_requests_with_route_prefix( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let url_without_route = server.url(); let status = reqwest_client.get(url_without_route).send()?.status(); assert_eq!(status, StatusCode::NOT_FOUND); let url_with_route = server.url().join("foobar")?; let status = reqwest_client.get(url_with_route).send()?.status(); assert_eq!(status, StatusCode::OK); Ok(()) } #[rstest] #[case(server(&[] as &[&str]), "/__miniserve_internal/[a-z.]+")] #[case(server(&["--random-route"]), "/__miniserve_internal/[a-z.]+")] #[case(server(&["--route-prefix", "foobar"]), "/foobar/__miniserve_internal/[a-z.]+")] fn serves_requests_static_file_check( #[case] server: TestServer, reqwest_client: Client, #[case] static_file_pattern: String, ) -> Result<(), Error> { let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; let re = Regex::new(&static_file_pattern).unwrap(); assert!( parsed .find(Attr("rel", "stylesheet")) .all(|x| re.is_match(x.attr("href").unwrap())) ); assert!( parsed .find(Attr("rel", "icon")) .all(|x| re.is_match(x.attr("href").unwrap())) ); Ok(()) } #[rstest] #[case(server(&["--disable-indexing"]))] fn serves_no_directory_if_indexing_disabled( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let body = reqwest_client.get(server.url()).send()?; assert_eq!(body.status(), StatusCode::NOT_FOUND); let parsed = Document::from_read(body)?; assert!( parsed .find(|x: &Node| x.text() == FILES[0]) .next() .is_none() ); assert!( parsed .find(|x: &Node| x.text() == DIRECTORIES[0]) .next() .is_none() ); assert!( parsed .find(|x: &Node| x.text() == "404 Not Found") .next() .is_some() ); assert!( parsed .find(|x: &Node| x.text() == "File not found.") .next() .is_some() ); Ok(()) } #[rstest] #[case(server(&["--disable-indexing"]))] fn serves_file_requests_when_indexing_disabled( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { reqwest_client .get(format!("{}{}", server.url(), FILES[0])) .send()? .error_for_status()?; Ok(()) } ================================================ FILE: tests/tls.rs ================================================ use assert_cmd::{Command, cargo}; use predicates::str::contains; use reqwest::blocking::Client; use rstest::rstest; use select::{document::Document, node::Node}; mod fixtures; use crate::fixtures::{Error, FILES, TestServer, reqwest_client, server}; /// Can start the server with TLS and receive encrypted responses. #[rstest] #[case(server(&[ "--tls-cert", "tests/data/cert_rsa.pem", "--tls-key", "tests/data/key_pkcs8.pem", ]))] #[case(server(&[ "--tls-cert", "tests/data/cert_rsa.pem", "--tls-key", "tests/data/key_pkcs1.pem", ]))] #[case(server(&[ "--tls-cert", "tests/data/cert_ec.pem", "--tls-key", "tests/data/key_ec.pem", ]))] fn tls_works(#[case] server: TestServer, reqwest_client: Client) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; for &file in FILES { assert!(parsed.find(|x: &Node| x.text() == file).next().is_some()); } Ok(()) } /// Wrong path for cert throws error. #[rstest] fn wrong_path_cert() -> Result<(), Error> { Command::new(cargo::cargo_bin!("miniserve")) .args(["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"]) .assert() .failure() .stderr(contains("Error: Couldn't access TLS certificate \"wrong\"")); Ok(()) } /// Wrong paths for key throws errors. #[rstest] fn wrong_path_key() -> Result<(), Error> { Command::new(cargo::cargo_bin!("miniserve")) .args(["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"]) .assert() .failure() .stderr(contains("Error: Couldn't access TLS key \"wrong\"")); Ok(()) } ================================================ FILE: tests/upload_files.rs ================================================ use std::fs::create_dir_all; use std::path::Path; use assert_fs::fixture::TempDir; use reqwest::blocking::{Client, multipart}; use reqwest::header::HeaderMap; use rstest::rstest; use select::document::Document; use select::predicate::{Attr, Text}; mod fixtures; use crate::fixtures::{Error, TestServer, reqwest_client, server, tmpdir}; // Generate the hashes using the following // ```bash // $ sha256 -s 'this should be uploaded' // $ sha512 -s 'this should be uploaded' // ``` #[rstest] #[case::no_hash(None, None)] #[case::only_hash(None, Some("test"))] #[case::partial_sha256_hash(Some("SHA256"), None)] #[case::partial_sha512_hash(Some("SHA512"), None)] #[case::sha256_hash( Some("SHA256"), Some("e37b14e22e7b3f50dadaf821c189af80f79b1f39fd5a8b3b4f536103735d4620") )] #[case::sha512_hash( Some("SHA512"), Some( "03bcfc52c53904e34e06b95e8c3ee1275c66960c441417892e977d52687e28afae85b6039509060ee07da739e4e7fc3137acd142162c1456f723604f8365e154" ) )] fn uploading_files_works( #[with(&["-u"])] server: TestServer, reqwest_client: Client, #[case] sha_func: Option<&str>, #[case] sha: Option<&str>, ) -> Result<(), Error> { let test_file_name = "uploaded test file.txt"; // Before uploading, check whether the uploaded file does not yet exist. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != test_file_name)); // Perform the actual upload. let upload_action = parsed .find(Attr("id", "file_submit")) .next() .expect("Couldn't find element with id=file_submit") .attr("action") .expect("Upload form doesn't have action attribute"); let form = multipart::Form::new(); let part = multipart::Part::text("this should be uploaded") .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); let mut headers = HeaderMap::new(); if let Some(sha_func) = sha_func.as_ref() { headers.insert("X-File-Hash-Function", sha_func.parse()?); } if let Some(sha) = sha.as_ref() { headers.insert("X-File-Hash", sha.parse()?); } reqwest_client .post(server.url().join(upload_action)?) .headers(headers) .multipart(form) .send()? .error_for_status()?; // After uploading, check whether the uploaded file is now getting listed. let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); Ok(()) } #[rstest] fn uploading_files_is_prevented(server: TestServer, reqwest_client: Client) -> Result<(), Error> { let test_file_name = "uploaded test file.txt"; // Before uploading, check whether the uploaded file does not yet exist. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != test_file_name)); // Ensure the file upload form is not present assert!(parsed.find(Attr("id", "file_submit")).next().is_none()); // Then try to upload anyway let form = multipart::Form::new(); let part = multipart::Part::text("this should not be uploaded") .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); // Ensure uploading fails and returns an error assert!( reqwest_client .post(server.url().join("/upload?path=/")?) .multipart(form) .send()? .error_for_status() .is_err() ); // After uploading, check whether the uploaded file is NOT getting listed. let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(!parsed.find(Text).any(|x| x.text() == test_file_name)); Ok(()) } // Generated hashs with the following // ```bash // echo "invalid" | base64 | sha256 // echo "invalid" | base64 | sha512 // ``` #[rstest] #[case::sha256_hash( Some("SHA256"), Some("f4ddf641a44e8fe8248cc086532cafaa8a914a21a937e40be67926ea074b955a") )] #[case::sha512_hash( Some("SHA512"), Some( "d3fe39ab560dd7ba91e6e2f8c948066d696f2afcfc90bf9df32946512f6934079807f301235b88b72bf746b6a88bf111bc5abe5c711514ed0731d286985297ba" ) )] #[case::sha128_hash(Some("SHA128"), Some("invalid"))] fn uploading_files_with_invalid_sha_func_is_prevented( #[with(&["-u"])] server: TestServer, reqwest_client: Client, #[case] sha_func: Option<&str>, #[case] sha: Option<&str>, ) -> Result<(), Error> { let test_file_name = "uploaded test file.txt"; // Before uploading, check whether the uploaded file does not yet exist. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != test_file_name)); // Perform the actual upload. let form = multipart::Form::new(); let part = multipart::Part::text("this should be uploaded") .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); let mut headers = HeaderMap::new(); if let Some(sha_func) = sha_func.as_ref() { headers.insert("X-File-Hash-Function", sha_func.parse()?); } if let Some(sha) = sha.as_ref() { headers.insert("X-File-Hash", sha.parse()?); } assert!( reqwest_client .post(server.url().join("/upload?path=/")?) .headers(headers) .multipart(form) .send()? .error_for_status() .is_err() ); // After uploading, check whether the uploaded file is NOT getting listed. let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(!parsed.find(Text).any(|x| x.text() == test_file_name)); Ok(()) } /// This test runs the server with --allowed-upload-dir argument and /// checks that file upload to a different directory is actually prevented. #[rstest] #[case(server(&["-u", "someDir"]))] #[case(server(&["-u", "someDir/some_sub_dir"]))] fn uploading_files_is_restricted( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let test_file_name = "uploaded test file.txt"; // Then try to upload file to root directory (which is not the --allowed-upload-dir) let form = multipart::Form::new(); let part = multipart::Part::text("this should not be uploaded") .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); // Ensure uploading fails and returns an error assert_eq!( 403, reqwest_client .post(server.url().join("/upload?path=/")?) .multipart(form) .send()? .status() ); // After uploading, check whether the uploaded file is NOT getting listed. let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(!parsed.find(Text).any(|x| x.text() == test_file_name)); Ok(()) } /// This tests that we can upload files to the directory specified by --allow-upload-dir #[rstest] #[case(server(&["-u", "someDir"]), vec!["someDir"])] #[case(server(&["-u", "./-someDir"]), vec!["./-someDir"])] #[case(server(&["-u", Path::new("someDir/some_sub_dir").to_str().unwrap()]), vec!["someDir/some_sub_dir"])] #[case(server(&["-u", Path::new("someDir/some_sub_dir").to_str().unwrap(), "-u", Path::new("someDir/some_other_dir").to_str().unwrap()]), vec!["someDir/some_sub_dir", "someDir/some_other_dir"])] fn uploading_files_to_allowed_dir_works( #[case] server: TestServer, reqwest_client: Client, #[case] upload_dirs: Vec<&str>, ) -> Result<(), Error> { let test_file_name = "uploaded test file.txt"; for upload_dir in upload_dirs { // Create test directory create_dir_all(server.path().join(Path::new(upload_dir))).unwrap(); // Before uploading, check whether the uploaded file does not yet exist. let body = reqwest_client .get(server.url().join(upload_dir)?) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).all(|x| x.text() != test_file_name)); // Perform the actual upload. let upload_action = parsed .find(Attr("id", "file_submit")) .next() .expect("Couldn't find element with id=file_submit") .attr("action") .expect("Upload form doesn't have action attribute"); let form = multipart::Form::new(); let part = multipart::Part::text("this should be uploaded") .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); reqwest_client .post(server.url().join(upload_action)?) .multipart(form) .send()? .error_for_status()?; // After uploading, check whether the uploaded file is now getting listed. let body = reqwest_client.get(server.url().join(upload_dir)?).send()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); } Ok(()) } #[rstest] #[case(server(&["-u"]))] #[case(server(&["-u", "-o", "error"]))] #[case(server(&["-u", "--on-duplicate-files", "error"]))] fn uploading_duplicate_file_is_prevented( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let test_file_name = "duplicate test file.txt"; let test_file_contents = "Test File Contents"; let test_file_contents_new = "New Uploaded Test File Contents"; // create the file let test_file_path = server.path().join(test_file_name); std::fs::write(&test_file_path, test_file_contents)?; // Before uploading, make sure the file is there. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); // Perform the actual upload. let upload_action = parsed .find(Attr("id", "file_submit")) .next() .expect("Couldn't find element with id=file_submit") .attr("action") .expect("Upload form doesn't have action attribute"); // Then try to upload anyway let form = multipart::Form::new(); let part = multipart::Part::text(test_file_contents_new) .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); // Ensure uploading fails and returns an error assert!( reqwest_client .post(server.url().join(upload_action)?) .multipart(form) .send()? .error_for_status() .is_err() ); // After uploading, uploaded file is still getting listed. let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); // and assert the contents is the same as before assert_file_contents(&test_file_path, test_file_contents); Ok(()) } #[rstest] #[case(server(&["-u", "-o", "overwrite"]))] #[case(server(&["-u", "--on-duplicate-files", "overwrite"]))] fn overwrite_duplicate_file( #[case] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let test_file_name = "duplicate test file.txt"; let test_file_contents = "Test File Contents"; let test_file_contents_new = "New Uploaded Test File Contents"; // create the file let test_file_path = server.path().join(test_file_name); let _ = std::fs::write(&test_file_path, test_file_contents); // Before uploading, make sure the file is there. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); // Perform the actual upload. let upload_action = parsed .find(Attr("id", "file_submit")) .next() .expect("Couldn't find element with id=file_submit") .attr("action") .expect("Upload form doesn't have action attribute"); // Then try to upload anyway let form = multipart::Form::new(); let part = multipart::Part::text(test_file_contents_new) .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); reqwest_client .post(server.url().join(upload_action)?) .multipart(form) .send()? .error_for_status()?; // After uploading, verify the listing has the file let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); // and assert the contents is from recently uploaded file assert_file_contents(&test_file_path, test_file_contents_new); Ok(()) } #[rstest] #[case(server(&["-u", "-o", "rename"]))] #[case(server(&["-u", "--on-duplicate-files", "rename"]))] fn rename_duplicate_file(#[case] server: TestServer, reqwest_client: Client) -> Result<(), Error> { let test_file_name = "duplicate test file.txt"; let test_file_contents = "Test File Contents"; let test_file_name_new = "duplicate test file-1.txt"; let test_file_contents_new = "New Uploaded Test File Contents"; // create the file let test_file_path = server.path().join(test_file_name); let _ = std::fs::write(&test_file_path, test_file_contents); // Before uploading, make sure the file is there. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); // Perform the actual upload. let upload_action = parsed .find(Attr("id", "file_submit")) .next() .expect("Couldn't find element with id=file_submit") .attr("action") .expect("Upload form doesn't have action attribute"); // Then try to upload anyway let form = multipart::Form::new(); let part = multipart::Part::text(test_file_contents_new) .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); reqwest_client .post(server.url().join(upload_action)?) .multipart(form) .send()? .error_for_status()?; // After uploading, assert the old file is still getting listed, and the new file is also in listing let body = reqwest_client.get(server.url()).send()?; let parsed = Document::from_read(body)?; assert!(parsed.find(Text).any(|x| x.text() == test_file_name)); assert!(parsed.find(Text).any(|x| x.text() == test_file_name_new)); // and assert the contents is the same as before for old file, and new contents for new file assert_file_contents(&test_file_path, test_file_contents); assert_file_contents( &server.path().join(test_file_name_new), test_file_contents_new, ); Ok(()) } /// Test for path traversal vulnerability (CWE-22) in both path parameter of query string and in /// file name (Content-Disposition) /// /// see: https://github.com/svenstaro/miniserve/issues/518 #[rstest] #[case("foo", "bar", "foo/bar")] #[case("/../foo", "bar", "foo/bar")] #[case("/foo", "/../bar", "foo/bar")] #[case("C:/foo", "C:/bar", if cfg!(windows) { "foo/bar" } else { "C:/foo/C:/bar" })] #[case(r"C:\foo", r"C:\bar", if cfg!(windows) { "foo/bar" } else { r"C:\foo/C:\bar" })] #[case(r"\foo", r"\..\bar", if cfg!(windows) { "foo/bar" } else { r"\foo/\..\bar" })] fn prevent_path_traversal_attacks( #[with(&["-u"])] server: TestServer, reqwest_client: Client, #[case] path: &str, #[case] filename: &'static str, #[case] expected: &str, ) -> Result<(), Error> { // Create test directories create_dir_all(server.path().join("foo")).unwrap(); if !cfg!(windows) { for dir in &["C:/foo/C:", r"C:\foo", r"\foo"] { create_dir_all(server.path().join(dir)) .unwrap_or_else(|_| panic!("failed to create: {dir:?}")); } } let expected_path = server.path().join(expected); assert!(!expected_path.exists()); // Perform the actual upload. let part = multipart::Part::text("this should be uploaded") .file_name(filename) .mime_str("text/plain")?; let form = multipart::Form::new().part("file_to_upload", part); reqwest_client .post(server.url().join(&format!("/upload?path={path}"))?) .multipart(form) .send()? .error_for_status()?; // Make sure that the file was uploaded to the expected path assert!(expected_path.exists()); Ok(()) } /// Test uploading to symlink directories that point outside the server root. /// See https://github.com/svenstaro/miniserve/issues/466 #[rstest] #[case(server(&["-u"]), true)] #[case(server(&["-u", "--no-symlinks"]), false)] fn upload_to_symlink_directory( #[case] server: TestServer, reqwest_client: Client, #[case] ok: bool, tmpdir: TempDir, ) -> Result<(), Error> { #[cfg(unix)] use std::os::unix::fs::symlink as symlink_dir; #[cfg(windows)] use std::os::windows::fs::symlink_dir; // Create symlink directory "foo" to point outside the root let (dir, filename) = ("foo", "bar"); symlink_dir(tmpdir.path(), server.path().join(dir)).unwrap(); let full_path = server.path().join(dir).join(filename); assert!(!full_path.exists()); // Try to upload let part = multipart::Part::text("this should be uploaded") .file_name(filename) .mime_str("text/plain")?; let form = multipart::Form::new().part("file_to_upload", part); let status = reqwest_client .post(server.url().join(&format!("/upload?path={dir}"))?) .multipart(form) .send()? .error_for_status(); // Make sure upload behave as expected assert_eq!(status.is_ok(), ok); assert_eq!(full_path.exists(), ok); Ok(()) } /// Test setting the HTML accept attribute using -m and -M. #[rstest] #[case(server(&["-u"]), None)] #[case(server(&["-u", "-m", "image"]), Some("image/*"))] #[case(server(&["-u", "-m", "image", "-m", "audio", "-m", "video"]), Some("image/*,audio/*,video/*"))] #[case(server(&["-u", "-m", "audio", "-m", "image", "-m", "video"]), Some("audio/*,image/*,video/*"))] #[case(server(&["-u", "-M", "test_value"]), Some("test_value"))] fn set_media_type( #[case] server: TestServer, reqwest_client: Client, #[case] expected_accept_value: Option<&str>, ) -> Result<(), Error> { let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; let input = parsed.find(Attr("id", "file-input")).next().unwrap(); assert_eq!(input.attr("accept"), expected_accept_value); Ok(()) } fn assert_file_contents(file_path: &Path, contents: &str) { let file_contents = std::fs::read_to_string(file_path).unwrap(); assert!(file_contents == contents) } /// Test --chmod change file permissions as intended #[cfg(unix)] #[rstest] #[case(server(&["-u", "--chmod", "660"]), 0o660)] #[case(server(&["-u", "--chmod", "644"]), 0o644)] #[case(server(&["-u", "--chmod", "0600"]), 0o600)] fn chmod( #[case] server: TestServer, reqwest_client: Client, #[case] expected_mode: u16, ) -> Result<(), Error> { let test_file_name = "chmod-file.txt"; let test_file_contents = "Test File Contents"; // Perform the actual upload. let body = reqwest_client .get(server.url()) .send()? .error_for_status()?; let parsed = Document::from_read(body)?; let upload_action = parsed .find(Attr("id", "file_submit")) .next() .expect("Couldn't find element with id=file_submit") .attr("action") .expect("Upload form doesn't have action attribute"); let form = multipart::Form::new(); let part = multipart::Part::text(test_file_contents) .file_name(test_file_name) .mime_str("text/plain")?; let form = form.part("file_to_upload", part); reqwest_client .post(server.url().join(upload_action)?) .multipart(form) .send()? .error_for_status()?; // assert the mode of file use std::os::unix::fs::MetadataExt; let test_file_path = server.path().join(test_file_name); let meta = std::fs::metadata(&test_file_path)?; // the returned mode has filetype in it let mode = meta.mode() & 0o7777; assert_eq!(mode as u16, expected_mode); Ok(()) } ================================================ FILE: tests/utils/mod.rs ================================================ use select::document::Document; use select::node::Node; use select::predicate::Name; use select::predicate::Predicate; /// Return the href attribute content for the closest anchor found by `text`. pub fn get_link_from_text(document: &Document, text: &str) -> Option<String> { let a_elem = document .find(Name("a").and(|x: &Node| x.children().any(|x| x.text() == text))) .next()?; Some(a_elem.attr("href")?.to_string()) } /// Return the href attributes of all links that start with the specified `prefix`. pub fn get_link_hrefs_with_prefix(document: &Document, prefix: &str) -> Vec<String> { let mut vec: Vec<String> = Vec::new(); let a_elements = document.find(Name("a")); for element in a_elements { let s = element.attr("href").unwrap_or(""); if s.to_string().starts_with(prefix) { vec.push(s.to_string()); } } vec } ================================================ FILE: tests/webdav.rs ================================================ use std::process::Command; use assert_cmd::{cargo, prelude::*}; use assert_fs::TempDir; use predicates::str::contains; use reqwest::{Method, blocking::Client}; use reqwest_dav::{ ClientBuilder as DavClientBuilder, list_cmd::{ListEntity, ListFile, ListFolder}, }; use rstest::rstest; mod fixtures; use crate::fixtures::{ DIR_BEHIND_SYMLINKED_DIR, DIRECTORIES, DIRECTORY_SYMLINK, Error, FILE_IN_DIR_BEHIND_SYMLINKED_DIR, FILE_SYMLINK, FILES, HIDDEN_DIRECTORIES, HIDDEN_FILES, TestServer, reqwest_client, server, tmpdir, }; #[rstest] #[case(server(&["--enable-webdav"]), true)] #[case(server(&[] as &[&str]), false)] fn webdav_flag_works( #[case] server: TestServer, reqwest_client: Client, #[case] should_respond: bool, ) -> Result<(), Error> { let response = reqwest_client .request(Method::from_bytes(b"PROPFIND").unwrap(), server.url()) .header("Depth", "1") .send()?; assert_eq!(should_respond, response.status().is_success()); Ok(()) } #[rstest] fn webdav_advertised_in_options( #[with(&["--enable-webdav"])] server: TestServer, reqwest_client: Client, ) -> Result<(), Error> { let response = reqwest_client .request(Method::OPTIONS, server.url()) .send()? .error_for_status()?; let headers = response.headers(); let allow = headers.get("allow").unwrap().to_str()?; assert!(allow.contains("OPTIONS") && allow.contains("PROPFIND")); assert!(headers.get("dav").is_some()); Ok(()) } fn list_webdav(url: url::Url, path: &str) -> Result<Vec<ListEntity>, reqwest_dav::Error> { // Make sure that tests using this can run in isolation. For this, we need to make sure // that the crypto provider for rustls is initialized. if rustls::crypto::CryptoProvider::get_default().is_none() { let _ = rustls::crypto::ring::default_provider().install_default(); } let client = DavClientBuilder::new().set_host(url.to_string()).build()?; let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { client.list(path, reqwest_dav::Depth::Number(1)).await }) } #[rstest] #[case(server(&["--enable-webdav"]), false)] #[case(server(&["--enable-webdav", "--hidden"]), true)] fn webdav_respects_hidden_flag( #[case] server: TestServer, #[case] hidden_should_show: bool, ) -> Result<(), Error> { let list = list_webdav(server.url(), "/")?; assert_eq!( hidden_should_show, list.iter().any(|el| matches!(el, ListEntity::File(ListFile { href, .. }) if href.contains(HIDDEN_FILES[0])) ) ); assert_eq!( hidden_should_show, list.iter().any(|el| matches!(el, ListEntity::Folder(ListFolder { href, .. }) if href.contains(HIDDEN_DIRECTORIES[0])) ) ); Ok(()) } #[rstest] #[case(server(&["--enable-webdav"]), true)] #[case(server(&["--enable-webdav", "--no-symlinks"]), false)] fn webdav_respects_no_symlink_flag(#[case] server: TestServer, #[case] symlinks_should_show: bool) { let list = list_webdav(server.url(), "/").unwrap(); assert_eq!( symlinks_should_show, list.iter().any(|el| matches!(el, ListEntity::File(ListFile { href, .. }) if href.contains(FILE_SYMLINK)) ), ); assert_eq!( symlinks_should_show, list.iter().any(|el| matches!(el, ListEntity::Folder(ListFolder { href, .. }) if href.contains(DIRECTORY_SYMLINK)) ), ); let list_linked = list_webdav(server.url(), &format!("/{DIRECTORY_SYMLINK}")); assert_eq!(symlinks_should_show, list_linked.is_ok()); let list_nested_dir = list_webdav(server.url(), &format!("/{DIR_BEHIND_SYMLINKED_DIR}")); assert_eq!(symlinks_should_show, list_nested_dir.is_ok()); let list_nested_file = list_webdav( server.url(), &format!("/{FILE_IN_DIR_BEHIND_SYMLINKED_DIR}"), ); assert_eq!(symlinks_should_show, list_nested_file.is_ok()); } #[rstest] fn webdav_works_with_route_prefix( #[with(&["--enable-webdav", "--route-prefix", "test-prefix"])] server: TestServer, ) -> Result<(), Error> { let prefixed_list = list_webdav(server.url().join("test-prefix")?, "/")?; assert!( prefixed_list.iter().any(|el| matches!(el, ListEntity::Folder(ListFolder { href, .. }) if href.contains(DIRECTORIES[0])) ) ); let root_list = list_webdav(server.url(), "/"); assert!(root_list.is_err()); Ok(()) } // timeout is used in case the binary does not exit as expected and starts waiting for requests #[rstest] #[timeout(std::time::Duration::from_secs(1))] fn webdav_single_file_refuses_starting(tmpdir: TempDir) { Command::new(cargo::cargo_bin!("miniserve")) .current_dir(tmpdir.path()) .arg(FILES[0]) .arg("--enable-webdav") .assert() .failure() .stderr(contains(format!( "Error: The --enable-webdav option was provided, but the serve path '{}' is a file", FILES[0] ))); }