Compare commits

...

31 Commits

Author SHA1 Message Date
sigoden
22cf74e3c0 update deps 2024-02-13 03:16:57 +00:00
sigoden
6a6ac37be4 chore: release v0.40.0 2024-02-13 01:19:40 +00:00
sigoden
920b70abc4 refactor: improve resolve_path and handle_assets, abandon guard_path (#360) 2024-02-07 16:27:22 +08:00
sigoden
015713bc6d chore: update deps 2024-02-06 09:32:31 +00:00
sigoden
3c75a9c4cc fix: guard req and destination path (#359) 2024-02-06 17:23:18 +08:00
sigoden
871e8276ff chore: add SECURITY.md 2024-02-05 00:09:25 +00:00
sigoden
f92c8ee91d refactor: improve invalid auth (#356) 2024-01-19 10:25:11 +08:00
sigoden
95eb648411 feat: revert supporting for forbidden permission (#352) 2024-01-17 11:31:26 +08:00
sigoden
3354b1face refactor: do not try to bind ipv6 if no ipv6 (#348) 2024-01-16 09:03:27 +08:00
sigoden
9b348fc945 chore: fix typos 2024-01-15 12:53:59 +00:00
sigoden
e1fabc7349 chore: update readme 2024-01-11 09:07:40 +00:00
sigoden
58a46f7c3a chore: release v0.39.0 (#345) 2024-01-11 16:50:25 +08:00
sigoden
ef757281b3 chore: release v0.39.0 2024-01-11 08:31:56 +00:00
sigoden
de0614816a refactor: propfind with auth no need to list all (#344) 2024-01-11 16:10:10 +08:00
sigoden
81d2c49e3f chore: update bug_report issue template 2024-01-11 07:04:44 +00:00
sigoden
ee21894452 feat: supports resumable uploads (#343) 2024-01-11 14:56:30 +08:00
sigoden
0ac0c048ec fix: corrupted zip when downloading large folders (#337) 2024-01-07 10:50:15 +08:00
sigoden
17063454d3 chore: update bug_report issue tempalte 2024-01-05 00:37:41 +00:00
sigoden
af347f9cf0 feat: auth supports forbidden permissions (#329) 2023-12-23 18:36:46 +08:00
sigoden
006e03ed30 fix: serve files with names containing newline char (#328) 2023-12-23 15:40:41 +08:00
sigoden
77f86a4c60 fix: auth precedence (#325) 2023-12-21 17:28:13 +08:00
sigoden
a66f95b39f chore: log error during connection 2023-12-21 08:08:15 +00:00
sigoden
52506bc01f refactor: optimize http range parsing and handling (#323) 2023-12-21 15:46:55 +08:00
sigoden
270cc0cba2 feat: upgrade to hyper 1.0 (#321) 2023-12-21 14:24:20 +08:00
sigoden
5988442d5c chore: remove debug print 2023-12-14 11:08:10 +00:00
sigoden
3873f4794a feat: add --compress option (#319) 2023-12-14 18:59:28 +08:00
plantatorbob
cd84dff87f fix: upload more than 100 files in directory (#317) 2023-12-11 18:28:11 +08:00
sigoden
8590f3e841 chore: improve readme 2023-12-09 09:17:36 +00:00
sigoden
44a4ddf973 refactor: change the value name of --config (#313) 2023-12-07 15:14:41 +08:00
sigoden
37800f630d refactor: change the format of www-authenticate (#312) 2023-12-07 15:04:14 +08:00
sigoden
5c850256f4 feat: empty search ?q= list all paths (#311) 2023-12-07 06:55:17 +08:00
26 changed files with 1608 additions and 1024 deletions

View File

@@ -9,9 +9,10 @@ about: Create a report to help us improve
**Log**
If applicable, add logs to help explain your problem.
The dufs log is crucial for locating the problem, so please do not omit it.
**Environment:**
- Dufs version:
- Browser/Webdav Info:
- OS Info:
- Browser/Webdav info:
- OS info:
- Proxy server: e.g. nginx, cloudflare

View File

@@ -2,6 +2,46 @@
All notable changes to this project will be documented in this file.
## [0.40.0] - 2024-02-13
### Bug Fixes
- Guard req and destination path ([#359](https://github.com/sigoden/dufs/issues/359))
### Features
- Revert supporting for forbidden permission ([#352](https://github.com/sigoden/dufs/issues/352))
### Refactor
- Do not try to bind ipv6 if no ipv6 ([#348](https://github.com/sigoden/dufs/issues/348))
- Improve invalid auth ([#356](https://github.com/sigoden/dufs/issues/356))
- Improve resolve_path and handle_assets, abandon guard_path ([#360](https://github.com/sigoden/dufs/issues/360))
## [0.39.0] - 2024-01-11
### Bug Fixes
- Upload more than 100 files in directory ([#317](https://github.com/sigoden/dufs/issues/317))
- Auth precedence ([#325](https://github.com/sigoden/dufs/issues/325))
- Serve files with names containing newline char ([#328](https://github.com/sigoden/dufs/issues/328))
- Corrupted zip when downloading large folders ([#337](https://github.com/sigoden/dufs/issues/337))
### Features
- Empty search `?q=` list all paths ([#311](https://github.com/sigoden/dufs/issues/311))
- Add `--compress` option ([#319](https://github.com/sigoden/dufs/issues/319))
- Upgrade to hyper 1.0 ([#321](https://github.com/sigoden/dufs/issues/321))
- Auth supports forbidden permissions ([#329](https://github.com/sigoden/dufs/issues/329))
- Supports resumable uploads ([#343](https://github.com/sigoden/dufs/issues/343))
### Refactor
- Change the format of www-authenticate ([#312](https://github.com/sigoden/dufs/issues/312))
- Change the value name of `--config` ([#313](https://github.com/sigoden/dufs/issues/313))
- Optimize http range parsing and handling ([#323](https://github.com/sigoden/dufs/issues/323))
- Propfind with auth no need to list all ([#344](https://github.com/sigoden/dufs/issues/344))
## [0.38.0] - 2023-11-28
### Bug Fixes

742
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "dufs"
version = "0.38.0"
version = "0.40.0"
edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server"
@@ -11,26 +11,25 @@ categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies]
clap = { version = "4", features = ["wrap_help", "env"] }
clap_complete = "4"
clap = { version = "~4.4", features = ["wrap_help", "env"] }
clap_complete = "~4.4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-util = { version = "0.7", features = ["io-util", "compat"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
hyper = { version = "1.0", features = ["http1", "server"] }
percent-encoding = "2.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
async_zip = { version = "0.0.15", default-features = false, features = ["deflate", "chrono", "tokio"] }
headers = "0.3"
futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
async_zip = { version = "0.0.16", default-features = false, features = ["deflate", "bzip2", "xz", "chrono", "tokio"] }
headers = "0.4"
mime_guess = "2.0"
if-addrs = "0.10.1"
rustls = { version = "0.21", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = { version = "1", optional = true }
tokio-rustls = { version = "0.24", optional = true }
if-addrs = "0.11"
rustls-pemfile = { version = "2.0", optional = true }
tokio-rustls = { version = "0.25", optional = true }
md5 = "0.7"
lazy_static = "1.4"
uuid = { version = "1.4", features = ["v4", "fast-rng"] }
uuid = { version = "1.7", features = ["v4", "fast-rng"] }
urlencoding = "2.1"
xml-rs = "0.8"
log = "0.4"
@@ -42,16 +41,21 @@ alphanumeric-sort = "1.4"
content_inspector = "0.2"
anyhow = "1.0"
chardetng = "0.1"
glob = "0.3.1"
indexmap = "2.0"
serde_yaml = "0.9.27"
sha-crypt = "0.5.0"
base64 = "0.21.5"
smart-default = "0.7.1"
glob = "0.3"
indexmap = "2.2"
serde_yaml = "0.9"
sha-crypt = "0.5"
base64 = "0.21"
smart-default = "0.7"
rustls-pki-types = "1.2"
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
http-body-util = "0.1"
bytes = "1.5"
pin-project-lite = "0.2"
[features]
default = ["tls"]
tls = ["rustls", "rustls-pemfile", "tokio-rustls"]
tls = ["rustls-pemfile", "tokio-rustls"]
[dev-dependencies]
assert_cmd = "2"
@@ -61,7 +65,7 @@ port_check = "0.1"
rstest = "0.18"
regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking", "rustls-tls"], default-features = false }
diqwest = { version = "2.0", features = ["blocking"], default-features = false }
predicates = "3"
[profile.release]

View File

@@ -13,7 +13,7 @@ Dufs is a distinctive utility file server that supports static serving, uploadin
- Download folder as zip file
- Upload files and folders (Drag & Drop)
- Create/Edit/Search files
- Partial responses (Parallel/Resume download)
- Resumable/partial uploads/downloads
- Access control
- Support https
- Support webdav
@@ -54,7 +54,7 @@ Arguments:
[serve-path] Specific path to serve [default: .]
Options:
-c, --config <config> Specify configuration file
-c, --config <file> Specify configuration file
-b, --bind <addrs> Specify bind address or unix socket
-p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify a path prefix
@@ -72,6 +72,7 @@ Options:
--render-spa Serve SPA(Single Page Application)
--assets <path> Set the path to the assets directory for overriding the built-in assets
--log-format <format> Customize http log format
--compress <level> Set zip compress level [default: low] [possible values: none, low, medium, high]
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
--tls-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
--tls-key <path> Path to the SSL/TLS certificate's private key
@@ -150,52 +151,66 @@ dufs --tls-cert my.crt --tls-key my.key
Upload a file
```
```sh
curl -T path-to-file http://127.0.0.1:5000/new-path/path-to-file
```
Download a file
```
```sh
curl http://127.0.0.1:5000/path-to-file
```
Download a folder as zip file
```
```sh
curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
```
Delete a file/folder
```
```sh
curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder
```
Create a directory
```
```sh
curl -X MKCOL https://127.0.0.1:5000/path-to-folder
```
Move the file/folder to the new path
```
```sh
curl -X MOVE https://127.0.0.1:5000/path -H "Destination: https://127.0.0.1:5000/new-path"
```
List/search directory contents
```
```sh
curl http://127.0.0.1:5000?q=Dockerfile # search for files, similar to `find -name Dockerfile`
curl http://127.0.0.1:5000?simple # output names only, similar to `ls -1`
curl http://127.0.0.1:5000?json # output paths in json format
```
With authorization
With authorization (Both basic or digest auth works)
```sh
curl http://127.0.0.1:5000/file --user user:pass # basic auth
curl http://127.0.0.1:5000/file --user user:pass --digest # digest auth
```
curl http://192.168.8.10:5000/file --user user:pass # basic auth
curl http://192.168.8.10:5000/file --user user:pass --digest # digest auth
Resumable downloads
```sh
curl -C- -o file http://127.0.0.1:5000/file
```
Resumable uploads
```sh
upload_offset=$(curl -I -s http://127.0.0.1:5000/file | tr -d '\r' | sed -n 's/content-length: //p')
dd skip=$upload_offset if=file status=none ibs=1 | \
curl -X PATCH -H "X-Update-Range: append" --data-binary @- http://127.0.0.1:5000/file
```
<details>
@@ -206,20 +221,21 @@ curl http://192.168.8.10:5000/file --user user:pass --digest # digest aut
Dufs supports account based access control. You can control who can do what on which path with `--auth`/`-a`.
```
dufs -a user:pass@/path1:rw,/path2 -a user2:pass2@/path3 -a @/path4
dufs -a admin:admin@/:rw -a guest:guest@/
dufs -a user:pass@/:rw,/dir1 -a @/
```
1. Use `@` to separate the account and paths. No account means anonymous user.
2. Use `:` to separate the username and password of the account.
3. Use `,` to separate paths.
4. Use `:rw` suffix to indicate that the account has read-write permission on the path.
4. Use path suffix `:rw`/`:ro` set permissions: `read-write`/`read-only`. `:ro` can be omitted.
- `-a admin:amdin@/:rw`: `admin` has complete permissions for all paths.
- `-a admin:admin@/:rw`: `admin` has complete permissions for all paths.
- `-a guest:guest@/`: `guest` has read-only permissions for all paths.
- `-a user:pass@/dir1:rw,/dir2`: `user` has complete permissions for `/dir1/*`, has read-only permissions for `/dir2/`.
- `-a user:pass@/:rw,/dir1`: `user` has read-write permissions for `/*`, has read-only permissions for `/dir1/*`.
- `-a @/`: All paths is publicly accessible, everyone can view/download it.
> There are no restrictions on using ':' and '@' characters in a password, `user:pa:ss@1@/:rw` is valid, and the password is `pa:ss@1`.
> There are no restrictions on using ':' and '@' characters in a password. For example, `user:pa:ss@1@/:rw` is valid, the password is `pa:ss@1`.
#### Hashed Password
@@ -308,7 +324,7 @@ All options can be set using environment variables prefixed with `DUFS_`.
```
[serve-path] DUFS_SERVE_PATH="."
--config <path> DUFS_CONFIG=config.yaml
--config <file> DUFS_CONFIG=config.yaml
-b, --bind <addrs> DUFS_BIND=0.0.0.0
-p, --port <port> DUFS_PORT=5000
--path-prefix <path> DUFS_PATH_PREFIX=/static
@@ -326,6 +342,7 @@ All options can be set using environment variables prefixed with `DUFS_`.
--render-spa DUFS_RENDER_SPA=true
--assets <path> DUFS_ASSETS=/assets
--log-format <format> DUFS_LOG_FORMAT=""
--compress <compress> DUFS_COMPRESS="low"
--tls-cert <path> DUFS_TLS_CERT=cert.pem
--tls-key <path> DUFS_TLS_KEY=key.pem
```
@@ -348,6 +365,7 @@ hidden:
auth:
- admin:admin@/:rw
- user:pass@/src:rw,/share
- '@/' # According to the YAML spec, quoting is required.
allow-all: false
allow-upload: true
allow-delete: true
@@ -360,6 +378,7 @@ render-try-index: true
render-spa: true
assets: ./assets/
log-format: '$remote_addr "$request" $status $http_user_agent'
compress: low
tls-cert: tests/data/cert.pem
tls-key: tests/data/key_pkcs1.pem
```
@@ -383,7 +402,7 @@ Your assets folder must contains a `index.html` file.
## License
Copyright (c) 2022 dufs-developers.
Copyright (c) 2022-2024 dufs-developers.
dufs is made available under the terms of either the MIT License or the Apache License 2.0, at your option.

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
The latest release of *dufs* is supported. The fixes for any security issues found will be included
in the next release.
## Reporting a Vulnerability
Please [use *dufs*'s security advisory reporting tool provided by
GitHub](https://github.com/sigoden/dufs/security/advisories/new) to report security issues.
We strive to fix security issues as quickly as possible. Across the industry, often the developers'
slowness in developing and releasing a fix is the biggest delay in the process; we take pride in
minimizing this delay as much as we practically can. We encourage you to also minimize the delay
between when you find an issue and when you contact us. You do not need to convince us to take your
report seriously. You don't need to create a PoC or a patch if that would slow down your reporting.
You don't need an elaborate write-up. A short, informal note about the issue is good. We can always
communicate later to fill in any details we need after that first note is shared with us.

View File

@@ -134,7 +134,6 @@ body {
}
.cell-status span {
width: 80px;
display: inline-block;
}
@@ -239,6 +238,10 @@ body {
font-style: italic;
}
.retry-btn {
cursor: pointer;
}
@media (min-width: 768px) {
.path a {
min-width: 400px;

View File

@@ -59,6 +59,11 @@ const ICONS = {
view: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1"/></svg>`,
}
/**
* @type Map<string, Uploader>
*/
const failUploaders = new Map();
/**
* @type Element
*/
@@ -128,23 +133,24 @@ class Uploader {
/**
*
* @param {File} file
* @param {string[]} dirs
* @param {string[]} pathParts
*/
constructor(file, dirs) {
constructor(file, pathParts) {
/**
* @type Element
*/
this.$uploadStatus = null
this.uploaded = 0;
this.uploadOffset = 0;
this.lastUptime = 0;
this.name = [...dirs, file.name].join("/");
this.name = [...pathParts, file.name].join("/");
this.idx = Uploader.globalIdx++;
this.file = file;
this.url = newUrl(this.name);
}
upload() {
const { idx, name } = this;
const url = newUrl(name);
const { idx, name, url } = this;
const encodedName = encodedStr(name);
$uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader">
@@ -160,13 +166,25 @@ class Uploader {
$emptyFolder.classList.add("hidden");
this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
this.$uploadStatus.innerHTML = '-';
this.$uploadStatus.addEventListener("click", e => {
const nodeId = e.target.id;
const matches = /^retry(\d+)$/.exec(nodeId);
if (matches) {
const id = parseInt(matches[1]);
let uploader = failUploaders.get(id);
if (uploader) uploader.retry();
}
});
Uploader.queues.push(this);
Uploader.runQueue();
}
ajax() {
const url = newUrl(this.name);
const { url } = this;
this.uploaded = 0;
this.lastUptime = Date.now();
const ajax = new XMLHttpRequest();
ajax.upload.addEventListener("progress", e => this.progress(e), false);
ajax.addEventListener("readystatechange", () => {
@@ -174,37 +192,64 @@ class Uploader {
if (ajax.status >= 200 && ajax.status < 300) {
this.complete();
} else {
this.fail();
if (ajax.status != 0) {
this.fail(`${ajax.status} ${ajax.statusText}`);
}
}
}
})
ajax.addEventListener("error", () => this.fail(), false);
ajax.addEventListener("abort", () => this.fail(), false);
if (this.uploadOffset > 0) {
ajax.open("PATCH", url);
ajax.setRequestHeader("X-Update-Range", "append");
ajax.send(this.file.slice(this.uploadOffset));
} else {
ajax.open("PUT", url);
ajax.send(this.file);
// setTimeout(() => ajax.abort(), 3000);
}
}
async retry() {
const { url } = this;
let res = await fetch(url, {
method: "HEAD",
});
let uploadOffset = 0;
if (res.status == 200) {
let value = res.headers.get("content-length");
uploadOffset = parseInt(value) || 0;
}
this.uploadOffset = uploadOffset;
this.ajax()
}
progress(event) {
const now = Date.now();
const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
const [speedValue, speedUnit] = formatSize(speed);
const speedText = `${speedValue} ${speedUnit}/s`;
const progress = formatPercent((event.loaded / event.total) * 100);
const progress = formatPercent(((event.loaded + this.uploadOffset) / this.file.size) * 100);
const duration = formatDuration((event.total - event.loaded) / speed)
this.$uploadStatus.innerHTML = `<span>${speedText}</span><span>${progress} ${duration}</span>`;
this.$uploadStatus.innerHTML = `<span style="width: 80px;">${speedText}</span><span>${progress} ${duration}</span>`;
this.uploaded = event.loaded;
this.lastUptime = now;
}
complete() {
this.$uploadStatus.innerHTML = ``;
const $uploadStatusNew = this.$uploadStatus.cloneNode(true);
$uploadStatusNew.innerHTML = ``;
this.$uploadStatus.parentNode.replaceChild($uploadStatusNew, this.$uploadStatus);
this.$uploadStatus = null;
failUploaders.delete(this.idx);
Uploader.runnings--;
Uploader.runQueue();
}
fail() {
this.$uploadStatus.innerHTML = ``;
fail(reason = "") {
this.$uploadStatus.innerHTML = `<span style="width: 20px;" title="${reason}">✗</span><span class="retry-btn" id="retry${this.idx}" title="Retry">↻</span>`;
failUploaders.set(this.idx, this);
Uploader.runnings--;
Uploader.runQueue();
}
@@ -732,8 +777,16 @@ async function addFileEntries(entries, dirs) {
new Uploader(file, dirs).upload();
});
} else if (entry.isDirectory) {
const dirReader = entry.createReader()
dirReader.readEntries(entries => addFileEntries(entries, [...dirs, entry.name]));
const dirReader = entry.createReader();
const successCallback = entries => {
if (entries.length > 0) {
addFileEntries(entries, [...dirs, entry.name]);
dirReader.readEntries(successCallback);
}
};
dirReader.readEntries(successCallback);
}
}
}
@@ -793,7 +846,7 @@ function padZero(value, size) {
}
function formatSize(size) {
if (size == null) return []
if (size == null) return [0, "B"]
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));

View File

@@ -1,6 +1,7 @@
use anyhow::{bail, Context, Result};
use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use async_zip::Compression;
use clap::builder::{PossibleValue, PossibleValuesParser};
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command, ValueEnum};
use clap_complete::{generate, Generator, Shell};
use serde::{Deserialize, Deserializer};
use smart_default::SmartDefault;
@@ -35,7 +36,8 @@ pub fn build_cli() -> Command {
.short('c')
.long("config")
.value_parser(value_parser!(PathBuf))
.help("Specify configuration file"),
.help("Specify configuration file")
.value_name("file"),
)
.arg(
Arg::new("bind")
@@ -195,6 +197,15 @@ pub fn build_cli() -> Command {
.value_name("format")
.help("Customize http log format"),
)
.arg(
Arg::new("compress")
.env("DUFS_COMPRESS")
.hide_env(true)
.value_parser(clap::builder::EnumValueParser::<Compress>::new())
.long("compress")
.value_name("level")
.help("Set zip compress level [default: low]")
)
.arg(
Arg::new("completions")
.long("completions")
@@ -269,6 +280,7 @@ pub struct Args {
#[serde(deserialize_with = "deserialize_log_http")]
#[serde(rename = "log-format")]
pub http_logger: HttpLogger,
pub compress: Compress,
pub tls_cert: Option<PathBuf>,
pub tls_key: Option<PathBuf>,
}
@@ -368,10 +380,6 @@ impl Args {
args.render_spa = matches.get_flag("render-spa");
}
if let Some(log_format) = matches.get_one::<String>("log-format") {
args.http_logger = log_format.parse()?;
}
if let Some(assets_path) = matches.get_one::<PathBuf>("assets") {
args.assets = Some(assets_path.clone());
}
@@ -380,6 +388,14 @@ impl Args {
args.assets = Some(Args::sanitize_assets_path(assets_path)?);
}
if let Some(log_format) = matches.get_one::<String>("log-format") {
args.http_logger = log_format.parse()?;
}
if let Some(compress) = matches.get_one::<Compress>("compress") {
args.compress = *compress;
}
#[cfg(feature = "tls")]
{
if let Some(tls_cert) = matches.get_one::<PathBuf>("tls-cert") {
@@ -460,6 +476,47 @@ impl BindAddr {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Compress {
None,
Low,
Medium,
High,
}
impl Default for Compress {
fn default() -> Self {
Self::Low
}
}
impl ValueEnum for Compress {
fn value_variants<'a>() -> &'a [Self] {
&[Self::None, Self::Low, Self::Medium, Self::High]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
Some(match self {
Compress::None => PossibleValue::new("none"),
Compress::Low => PossibleValue::new("low"),
Compress::Medium => PossibleValue::new("medium"),
Compress::High => PossibleValue::new("high"),
})
}
}
impl Compress {
pub fn to_compression(self) -> Compression {
match self {
Compress::None => Compression::Stored,
Compress::Low => Compression::Deflate,
Compress::Medium => Compression::Bz,
Compress::High => Compression::Xz,
}
}
}
fn deserialize_bind_addrs<'de, D>(deserializer: D) -> Result<Vec<BindAddr>, D::Error>
where
D: Deserializer<'de>,

View File

@@ -1,7 +1,9 @@
use crate::{args::Args, server::Response, utils::unix_now};
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue;
use hyper::Method;
use hyper::{header::WWW_AUTHENTICATE, Method};
use indexmap::IndexMap;
use lazy_static::lazy_static;
use md5::Context;
@@ -11,8 +13,6 @@ use std::{
};
use uuid::Uuid;
use crate::{args::Args, utils::unix_now};
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days
@@ -29,15 +29,15 @@ lazy_static! {
pub struct AccessControl {
use_hashed_password: bool,
users: IndexMap<String, (String, AccessPaths)>,
anony: Option<AccessPaths>,
anonymous: Option<AccessPaths>,
}
impl Default for AccessControl {
fn default() -> Self {
AccessControl {
use_hashed_password: false,
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
anonymous: Some(AccessPaths::new(AccessPerm::ReadWrite)),
}
}
}
@@ -47,55 +47,47 @@ impl AccessControl {
if raw_rules.is_empty() {
return Ok(Default::default());
}
let new_raw_rules = compact_split_rules(raw_rules);
if new_raw_rules.len() != raw_rules.len() {
eprintln!("Warning: deprecate the use of `|` to separate auth rules.")
}
let new_raw_rules = split_rules(raw_rules);
let mut use_hashed_password = false;
let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
let mut anony = None;
let mut anony_paths = vec![];
let mut users = IndexMap::new();
let mut annoy_paths = None;
let mut account_paths_pairs = vec![];
for rule in &new_raw_rules {
let (account, paths) = split_account_paths(rule).ok_or_else(|| create_err(rule))?;
if account.is_empty() && anony.is_some() {
bail!("Invalid auth, duplicate anonymous rules");
}
let mut access_paths = AccessPaths::default();
for item in paths.trim_matches(',').split(',') {
let (path, perm) = match item.split_once(':') {
None => (item, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return Err(create_err(rule)),
};
let (account, paths) =
split_account_paths(rule).ok_or_else(|| anyhow!("Invalid auth `{rule}`"))?;
if account.is_empty() {
anony_paths.push((path, perm));
if annoy_paths.is_some() {
bail!("Invalid auth, no duplicate anonymous rules");
}
access_paths.add(path, perm);
}
if account.is_empty() {
anony = Some(access_paths);
annoy_paths = Some(paths)
} else if let Some((user, pass)) = account.split_once(':') {
if user.is_empty() || pass.is_empty() {
return Err(create_err(rule));
bail!("Invalid auth `{rule}`");
}
account_paths_pairs.push((user, pass, paths));
}
}
let mut anonymous = None;
if let Some(paths) = annoy_paths {
let mut access_paths = AccessPaths::default();
access_paths.merge(paths);
anonymous = Some(access_paths);
}
let mut users = IndexMap::new();
for (user, pass, paths) in account_paths_pairs.into_iter() {
let mut access_paths = anonymous.clone().unwrap_or_default();
access_paths
.merge(paths)
.ok_or_else(|| anyhow!("Invalid auth `{user}:{pass}@{paths}"))?;
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), access_paths));
} else {
return Err(create_err(rule));
}
}
for (path, perm) in anony_paths {
for (_, (_, paths)) in users.iter_mut() {
paths.add(path, perm)
}
}
Ok(Self {
use_hashed_password,
users,
anony,
anonymous,
})
}
@@ -117,18 +109,18 @@ impl AccessControl {
}
if check_auth(authorization, method.as_str(), &user, pass).is_some() {
return (Some(user), paths.find(path, !is_readonly_method(method)));
} else {
}
}
}
return (None, None);
}
}
}
}
if method == Method::OPTIONS {
return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
if let Some(paths) = self.anony.as_ref() {
if let Some(paths) = self.anonymous.as_ref() {
return (None, paths.find(path, !is_readonly_method(method)));
}
@@ -154,13 +146,26 @@ impl AccessPaths {
self.perm
}
fn set_perm(&mut self, perm: AccessPerm) {
if self.perm < perm {
self.perm = perm
pub fn set_perm(&mut self, perm: AccessPerm) {
if !perm.indexonly() {
self.perm = perm;
}
}
pub fn add(&mut self, path: &str, perm: AccessPerm) {
pub fn merge(&mut self, paths: &str) -> Option<()> {
for item in paths.trim_matches(',').split(',') {
let (path, perm) = match item.split_once(':') {
None => (item, AccessPerm::ReadOnly),
Some((path, "ro")) => (path, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return None,
};
self.add(path, perm);
}
Some(())
}
fn add(&mut self, path: &str, perm: AccessPerm) {
let path = path.trim_matches('/');
if path.is_empty() {
self.set_perm(perm);
@@ -194,7 +199,11 @@ impl AccessPaths {
}
fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
let perm = self.perm.max(perm);
let perm = if !self.perm.indexonly() {
self.perm
} else {
perm
};
if parts.is_empty() {
if perm.indexonly() {
return Some(self.clone());
@@ -215,24 +224,24 @@ impl AccessPaths {
child.find_impl(&parts[1..], perm)
}
pub fn child_paths(&self) -> Vec<&String> {
pub fn child_names(&self) -> Vec<&String> {
self.children.keys().collect()
}
pub fn leaf_paths(&self, base: &Path) -> Vec<PathBuf> {
pub fn child_paths(&self, base: &Path) -> Vec<PathBuf> {
if !self.perm().indexonly() {
return vec![base.to_path_buf()];
}
let mut output = vec![];
self.leaf_paths_impl(&mut output, base);
self.child_paths_impl(&mut output, base);
output
}
fn leaf_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
fn child_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
for (name, child) in self.children.iter() {
let base = base.join(name);
if child.perm().indexonly() {
child.leaf_paths_impl(output, &base);
child.child_paths_impl(output, &base);
} else {
output.push(base)
}
@@ -249,26 +258,30 @@ pub enum AccessPerm {
}
impl AccessPerm {
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
pub fn indexonly(&self) -> bool {
self == &AccessPerm::IndexOnly
}
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
}
pub fn www_authenticate(args: &Args) -> Result<HeaderValue> {
let value = if args.auth.use_hashed_password {
format!("Basic realm=\"{}\"", REALM)
pub fn www_authenticate(res: &mut Response, args: &Args) -> Result<()> {
if args.auth.use_hashed_password {
let basic = HeaderValue::from_str(&format!("Basic realm=\"{}\"", REALM))?;
res.headers_mut().insert(WWW_AUTHENTICATE, basic);
} else {
let nonce = create_nonce()?;
format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\", Basic realm=\"{}\"",
REALM, nonce, REALM
)
};
Ok(HeaderValue::from_str(&value)?)
let digest = HeaderValue::from_str(&format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\"",
REALM, nonce
))?;
let basic = HeaderValue::from_str(&format!("Basic realm=\"{}\"", REALM))?;
res.headers_mut().append(WWW_AUTHENTICATE, digest);
res.headers_mut().append(WWW_AUTHENTICATE, basic);
}
Ok(())
}
pub fn get_auth_user(authorization: &HeaderValue) -> Option<String> {
@@ -485,8 +498,7 @@ fn split_account_paths(s: &str) -> Option<(&str, &str)> {
Some((&s[0..i], &s[i + 1..]))
}
/// Compatible with deprecated usage of `|` for role separation
fn compact_split_rules(rules: &[&str]) -> Vec<String> {
fn split_rules(rules: &[&str]) -> Vec<String> {
let mut output = vec![];
for rule in rules {
let parts: Vec<&str> = rule.split('|').collect();
@@ -536,15 +548,15 @@ mod tests {
#[test]
fn test_compact_split_rules() {
assert_eq!(
compact_split_rules(&["user1:pass1@/:rw|user2:pass2@/:rw"]),
split_rules(&["user1:pass1@/:rw|user2:pass2@/:rw"]),
["user1:pass1@/:rw", "user2:pass2@/:rw"]
);
assert_eq!(
compact_split_rules(&["user1:pa|ss1@/:rw|user2:pa|ss2@/:rw"]),
split_rules(&["user1:pa|ss1@/:rw|user2:pa|ss2@/:rw"]),
["user1:pa|ss1@/:rw", "user2:pa|ss2@/:rw"]
);
assert_eq!(
compact_split_rules(&["user1:pa|ss1@/:rw|@/"]),
split_rules(&["user1:pa|ss1@/:rw|@/"]),
["user1:pa|ss1@/:rw", "@/"]
);
}
@@ -553,16 +565,18 @@ mod tests {
fn test_access_paths() {
let mut paths = AccessPaths::default();
paths.add("/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir2", AccessPerm::ReadOnly);
paths.add("/dir2/dir3/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir21", AccessPerm::ReadWrite);
paths.add("/dir2/dir21/dir211", AccessPerm::ReadOnly);
paths.add("/dir2/dir22", AccessPerm::ReadOnly);
paths.add("/dir2/dir22/dir221", AccessPerm::ReadWrite);
paths.add("/dir2/dir23/dir231", AccessPerm::ReadWrite);
assert_eq!(
paths.leaf_paths(Path::new("/tmp")),
paths.child_paths(Path::new("/tmp")),
[
"/tmp/dir1",
"/tmp/dir2/dir1",
"/tmp/dir2/dir2",
"/tmp/dir2/dir3/dir1"
"/tmp/dir2/dir21",
"/tmp/dir2/dir22",
"/tmp/dir2/dir23/dir231",
]
.iter()
.map(PathBuf::from)
@@ -571,27 +585,31 @@ mod tests {
assert_eq!(
paths
.find("dir2", false)
.map(|v| v.leaf_paths(Path::new("/tmp/dir2"))),
.map(|v| v.child_paths(Path::new("/tmp/dir2"))),
Some(
["/tmp/dir2/dir1", "/tmp/dir2/dir2", "/tmp/dir2/dir3/dir1"]
[
"/tmp/dir2/dir21",
"/tmp/dir2/dir22",
"/tmp/dir2/dir23/dir231"
]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
)
);
assert_eq!(paths.find("dir2", true), None);
assert!(paths.find("dir1/file", true).is_some());
}
#[test]
fn test_access_paths_perm() {
let mut paths = AccessPaths::default();
assert_eq!(paths.perm(), AccessPerm::IndexOnly);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadOnly);
paths.set_perm(AccessPerm::ReadWrite);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
assert_eq!(
paths.find("dir1/file", true),
Some(AccessPaths::new(AccessPerm::ReadWrite))
);
assert_eq!(
paths.find("dir2/dir21/file", true),
Some(AccessPaths::new(AccessPerm::ReadWrite))
);
assert_eq!(
paths.find("dir2/dir21/dir211/file", false),
Some(AccessPaths::new(AccessPerm::ReadOnly))
);
assert_eq!(paths.find("dir2/dir21/dir211/file", true), None);
}
}

105
src/http_utils.rs Normal file
View File

@@ -0,0 +1,105 @@
use bytes::{Bytes, BytesMut};
use futures_util::Stream;
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::body::{Body, Incoming};
use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::io::AsyncRead;
use tokio_util::io::poll_read_buf;
#[derive(Debug)]
pub struct IncomingStream {
inner: Incoming,
}
impl IncomingStream {
pub fn new(inner: Incoming) -> Self {
Self { inner }
}
}
impl Stream for IncomingStream {
type Item = Result<Bytes, anyhow::Error>;
#[inline]
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
match futures_util::ready!(Pin::new(&mut self.inner).poll_frame(cx)?) {
Some(frame) => match frame.into_data() {
Ok(data) => return Poll::Ready(Some(Ok(data))),
Err(_frame) => {}
},
None => return Poll::Ready(None),
}
}
}
}
pin_project_lite::pin_project! {
pub struct LengthLimitedStream<R> {
#[pin]
reader: Option<R>,
remaining: usize,
buf: BytesMut,
capacity: usize,
}
}
impl<R> LengthLimitedStream<R> {
pub fn new(reader: R, limit: usize) -> Self {
Self {
reader: Some(reader),
remaining: limit,
buf: BytesMut::new(),
capacity: 4096,
}
}
}
impl<R: AsyncRead> Stream for LengthLimitedStream<R> {
type Item = std::io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let mut this = self.as_mut().project();
if *this.remaining == 0 {
self.project().reader.set(None);
return Poll::Ready(None);
}
let reader = match this.reader.as_pin_mut() {
Some(r) => r,
None => return Poll::Ready(None),
};
if this.buf.capacity() == 0 {
this.buf.reserve(*this.capacity);
}
match poll_read_buf(reader, cx, &mut this.buf) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(err)) => {
self.project().reader.set(None);
Poll::Ready(Some(Err(err)))
}
Poll::Ready(Ok(0)) => {
self.project().reader.set(None);
Poll::Ready(None)
}
Poll::Ready(Ok(_)) => {
let mut chunk = this.buf.split();
let chunk_size = (*this.remaining).min(chunk.len());
chunk.truncate(chunk_size);
*this.remaining -= chunk_size;
Poll::Ready(Some(Ok(chunk.freeze())))
}
}
}
}
pub fn body_full(content: impl Into<hyper::body::Bytes>) -> BoxBody<Bytes, anyhow::Error> {
Full::new(content.into())
.map_err(anyhow::Error::new)
.boxed()
}

View File

@@ -1,38 +1,37 @@
mod args;
mod auth;
mod http_logger;
mod http_utils;
mod logger;
mod server;
mod streamer;
#[cfg(feature = "tls")]
mod tls;
#[cfg(unix)]
mod unix;
mod utils;
#[macro_use]
extern crate log;
use crate::args::{build_cli, print_completions, Args};
use crate::server::{Request, Server};
use crate::server::Server;
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key, TlsAcceptor, TlsStream};
use crate::utils::{load_certs, load_private_key};
use anyhow::{anyhow, Context, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use args::BindAddr;
use clap_complete::Shell;
use futures::future::join_all;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use futures_util::future::join_all;
use hyper::server::conn::{AddrIncoming, AddrStream};
use hyper::service::{make_service_fn, service_fn};
use hyper::{body::Incoming, service::service_fn, Request};
use hyper_util::{
rt::{TokioExecutor, TokioIo},
server::conn::auto::Builder,
};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tokio::{net::TcpListener, task::JoinHandle};
#[cfg(feature = "tls")]
use rustls::ServerConfig;
use tokio_rustls::{rustls::ServerConfig, TlsAcceptor};
#[tokio::main]
async fn main() -> Result<()> {
@@ -44,11 +43,13 @@ async fn main() -> Result<()> {
print_completions(*generator, &mut cmd);
return Ok(());
}
let args = Args::parse(matches)?;
let args = Arc::new(args);
let mut args = Args::parse(matches)?;
let (new_addrs, print_addrs) = check_addrs(&args)?;
args.addrs = new_addrs;
let running = Arc::new(AtomicBool::new(true));
let handles = serve(args.clone(), running.clone())?;
print_listening(args)?;
let listening = print_listening(&args, &print_addrs)?;
let handles = serve(args, running.clone())?;
println!("{listening}");
tokio::select! {
ret = join_all(handles) => {
@@ -66,56 +67,62 @@ async fn main() -> Result<()> {
}
}
fn serve(
args: Arc<Args>,
running: Arc<AtomicBool>,
) -> Result<Vec<JoinHandle<Result<(), hyper::Error>>>> {
let inner = Arc::new(Server::init(args.clone(), running)?);
let mut handles = vec![];
fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
let addrs = args.addrs.clone();
let port = args.port;
for bind_addr in args.addrs.iter() {
let inner = inner.clone();
let serve_func = move |remote_addr: Option<SocketAddr>| {
let inner = inner.clone();
async move {
Ok::<_, hyper::Error>(service_fn(move |req: Request| {
let inner = inner.clone();
inner.call(req, remote_addr)
}))
}
};
let tls_config = (args.tls_cert.clone(), args.tls_key.clone());
let server_handle = Arc::new(Server::init(args, running)?);
let mut handles = vec![];
for bind_addr in addrs.iter() {
let server_handle = server_handle.clone();
match bind_addr {
BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
let listener = create_listener(SocketAddr::new(*ip, port))
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match (&args.tls_cert, &args.tls_key) {
match &tls_config {
#[cfg(feature = "tls")]
(Some(cert_file), Some(key_file)) => {
let certs = load_certs(cert_file)?;
let key = load_private_key(key_file)?;
let config = ServerConfig::builder()
.with_safe_defaults()
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs.clone(), key.clone())?;
.with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let config = Arc::new(config);
let accepter = TlsAcceptor::new(config.clone(), incoming);
let new_service = make_service_fn(move |socket: &TlsStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
let tls_accepter = TlsAcceptor::from(config);
let handle = tokio::spawn(async move {
loop {
let (cnx, addr) = listener.accept().await.unwrap();
let Ok(stream) = tls_accepter.accept(cnx).await else {
warn!("During cls handshake connection from {}", addr);
continue;
};
let stream = TokioIo::new(stream);
tokio::spawn(handle_stream(
server_handle.clone(),
stream,
Some(addr),
));
}
});
let server =
tokio::spawn(hyper::Server::builder(accepter).serve(new_service));
handles.push(server);
handles.push(handle);
}
(None, None) => {
let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
let handle = tokio::spawn(async move {
loop {
let (cnx, addr) = listener.accept().await.unwrap();
let stream = TokioIo::new(cnx);
tokio::spawn(handle_stream(
server_handle.clone(),
stream,
Some(addr),
));
}
});
let server =
tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server);
handles.push(handle);
}
_ => {
unreachable!()
@@ -130,10 +137,15 @@ fn serve(
{
let listener = tokio::net::UnixListener::bind(path)
.with_context(|| format!("Failed to bind `{}`", path.display()))?;
let acceptor = unix::UnixAcceptor::from_listener(listener);
let new_service = make_service_fn(move |_| serve_func(None));
let server = tokio::spawn(hyper::Server::builder(acceptor).serve(new_service));
handles.push(server);
let handle = tokio::spawn(async move {
loop {
let (cnx, _) = listener.accept().await.unwrap();
let stream = TokioIo::new(cnx);
tokio::spawn(handle_stream(server_handle.clone(), stream, None));
}
});
handles.push(handle);
}
}
}
@@ -141,7 +153,30 @@ fn serve(
Ok(handles)
}
fn create_addr_incoming(addr: SocketAddr) -> Result<AddrIncoming> {
async fn handle_stream<T>(handle: Arc<Server>, stream: TokioIo<T>, addr: Option<SocketAddr>)
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let hyper_service =
service_fn(move |request: Request<Incoming>| handle.clone().call(request, addr));
let ret = Builder::new(TokioExecutor::new())
.serve_connection_with_upgrades(stream, hyper_service)
.await;
if let Err(err) = ret {
let scope = match addr {
Some(addr) => format!(" from {}", addr),
None => String::new(),
};
match err.downcast_ref::<std::io::Error>() {
Some(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {}
_ => warn!("Serving connection{}: {}", scope, err),
}
}
}
fn create_listener(addr: SocketAddr) -> 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() {
@@ -152,45 +187,68 @@ fn create_addr_incoming(addr: SocketAddr) -> Result<AddrIncoming> {
socket.listen(1024 /* Default backlog */)?;
let std_listener = StdTcpListener::from(socket);
std_listener.set_nonblocking(true)?;
let incoming = AddrIncoming::from_listener(TcpListener::from_std(std_listener)?)?;
Ok(incoming)
let listener = TcpListener::from_std(std_listener)?;
Ok(listener)
}
fn print_listening(args: Arc<Args>) -> Result<()> {
let mut bind_addrs = vec![];
let (mut ipv4, mut ipv6) = (false, false);
fn check_addrs(args: &Args) -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
let mut new_addrs = vec![];
let mut print_addrs = vec![];
let (ipv4_addrs, ipv6_addrs) = interface_addrs()?;
for bind_addr in args.addrs.iter() {
match bind_addr {
BindAddr::Address(ip) => {
BindAddr::Address(ip) => match &ip {
IpAddr::V4(_) => {
if !ipv4_addrs.is_empty() {
new_addrs.push(bind_addr.clone());
if ip.is_unspecified() {
if ip.is_ipv6() {
ipv6 = true;
print_addrs.extend(ipv4_addrs.clone());
} else {
ipv4 = true;
print_addrs.push(bind_addr.clone());
}
}
}
IpAddr::V6(_) => {
if !ipv6_addrs.is_empty() {
new_addrs.push(bind_addr.clone());
if ip.is_unspecified() {
print_addrs.extend(ipv6_addrs.clone());
} else {
bind_addrs.push(bind_addr.clone());
print_addrs.push(bind_addr.clone())
}
}
_ => bind_addrs.push(bind_addr.clone()),
}
},
_ => {
new_addrs.push(bind_addr.clone());
print_addrs.push(bind_addr.clone())
}
}
if ipv4 || ipv6 {
}
print_addrs.sort_unstable();
Ok((new_addrs, print_addrs))
}
fn interface_addrs() -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
let (mut ipv4_addrs, mut ipv6_addrs) = (vec![], vec![]);
let ifaces =
if_addrs::get_if_addrs().with_context(|| "Failed to get local interface addresses")?;
for iface in ifaces.into_iter() {
let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() {
bind_addrs.push(BindAddr::Address(local_ip))
let ip = iface.ip();
if ip.is_ipv4() {
ipv4_addrs.push(BindAddr::Address(ip))
}
if ipv6 && local_ip.is_ipv6() {
bind_addrs.push(BindAddr::Address(local_ip))
if ip.is_ipv6() {
ipv6_addrs.push(BindAddr::Address(ip))
}
}
}
bind_addrs.sort_unstable();
let urls = bind_addrs
.into_iter()
Ok((ipv4_addrs, ipv6_addrs))
}
fn print_listening(args: &Args, print_addrs: &[BindAddr]) -> Result<String> {
let mut output = String::new();
let urls = print_addrs
.iter()
.map(|bind_addr| match bind_addr {
BindAddr::Address(addr) => {
let addr = match addr {
@@ -209,17 +267,17 @@ fn print_listening(args: Arc<Args>) -> Result<()> {
.collect::<Vec<_>>();
if urls.len() == 1 {
println!("Listening on {}", urls[0]);
output.push_str(&format!("Listening on {}", urls[0]))
} else {
let info = urls
.iter()
.map(|v| format!(" {v}"))
.collect::<Vec<String>>()
.join("\n");
println!("Listening on:\n{info}\n");
output.push_str(&format!("Listening on:\n{info}\n"))
}
Ok(())
Ok(output)
}
async fn shutdown_signal() {

View File

@@ -1,29 +1,33 @@
#![allow(clippy::too_many_arguments)]
use crate::auth::{www_authenticate, AccessPaths, AccessPerm};
use crate::streamer::Streamer;
use crate::http_utils::{body_full, IncomingStream, LengthLimitedStream};
use crate::utils::{
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, try_get_file_name,
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, parse_range,
try_get_file_name,
};
use crate::Args;
use anyhow::{anyhow, Result};
use walkdir::WalkDir;
use xml::escape::escape_str_pcdata;
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipDateTime, ZipEntryBuilder};
use anyhow::{anyhow, Result};
use async_zip::{tokio::write::ZipFileWriter, Compression, ZipDateTime, ZipEntryBuilder};
use bytes::Bytes;
use chrono::{LocalResult, TimeZone, Utc};
use futures::TryStreamExt;
use futures_util::{pin_mut, TryStreamExt};
use headers::{
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, CacheControl,
ContentLength, ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch,
IfRange, LastModified, Range,
};
use hyper::header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE,
RANGE, WWW_AUTHENTICATE,
use http_body_util::{combinators::BoxBody, BodyExt, StreamBody};
use hyper::body::Frame;
use hyper::{
body::Incoming,
header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE,
CONTENT_TYPE, RANGE,
},
Method, StatusCode, Uri,
};
use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize;
use std::borrow::Cow;
use std::cmp::Ordering;
@@ -31,19 +35,22 @@ use std::collections::HashMap;
use std::fs::Metadata;
use std::io::SeekFrom;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{self, AtomicBool};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite};
use tokio::{fs, io};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::StreamReader;
use uuid::Uuid;
pub type Request = hyper::Request<Body>;
pub type Response = hyper::Response<Body>;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::{ReaderStream, StreamReader};
use uuid::Uuid;
use walkdir::WalkDir;
use xml::escape::escape_str_pcdata;
pub type Request = hyper::Request<Incoming>;
pub type Response = hyper::Response<BoxBody<Bytes, anyhow::Error>>;
const INDEX_HTML: &str = include_str!("../assets/index.html");
const INDEX_CSS: &str = include_str!("../assets/index.css");
@@ -51,10 +58,11 @@ const INDEX_JS: &str = include_str!("../assets/index.js");
const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico");
const INDEX_NAME: &str = "index.html";
const BUF_SIZE: usize = 65536;
const TEXT_MAX_SIZE: u64 = 4194304; // 4M
const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M
const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M
pub struct Server {
args: Arc<Args>,
args: Args,
assets_prefix: String,
html: Cow<'static, str>,
single_file_req_paths: Vec<String>,
@@ -62,8 +70,8 @@ pub struct Server {
}
impl Server {
pub fn init(args: Arc<Args>, running: Arc<AtomicBool>) -> Result<Self> {
let assets_prefix = format!("{}__dufs_v{}_", args.uri_prefix, env!("CARGO_PKG_VERSION"));
pub fn init(args: Args, running: Arc<AtomicBool>) -> Result<Self> {
let assets_prefix = format!("__dufs_v{}__/", env!("CARGO_PKG_VERSION"));
let single_file_req_paths = if args.path_is_file {
vec![
args.uri_prefix.to_string(),
@@ -136,19 +144,23 @@ impl Server {
let headers = req.headers();
let method = req.method().clone();
if method == Method::GET && self.handle_assets(req_path, headers, &mut res).await? {
return Ok(res);
}
let authorization = headers.get(AUTHORIZATION);
let relative_path = match self.resolve_path(req_path) {
Some(v) => v,
None => {
status_forbid(&mut res);
status_bad_request(&mut res, "Invalid Path");
return Ok(res);
}
};
if method == Method::GET
&& self
.handle_assets(&relative_path, headers, &mut res)
.await?
{
return Ok(res);
}
let authorization = headers.get(AUTHORIZATION);
let guard = self.args.auth.guard(&relative_path, &method, authorization);
let (user, access_paths) = match guard {
@@ -290,10 +302,10 @@ impl Server {
}
} else if is_file {
if query_params.contains_key("edit") {
self.handle_deal_file(path, DataKind::Edit, head_only, user, &mut res)
self.handle_edit_file(path, DataKind::Edit, head_only, user, &mut res)
.await?;
} else if query_params.contains_key("view") {
self.handle_deal_file(path, DataKind::View, head_only, user, &mut res)
self.handle_edit_file(path, DataKind::View, head_only, user, &mut res)
.await?;
} else {
self.handle_send_file(path, headers, head_only, &mut res)
@@ -321,10 +333,37 @@ impl Server {
set_webdav_headers(&mut res);
}
Method::PUT => {
if !allow_upload || (!allow_delete && is_file && size > 0) {
if is_dir || !allow_upload || (!allow_delete && size > 0) {
status_forbid(&mut res);
} else {
self.handle_upload(path, req, &mut res).await?;
self.handle_upload(path, None, size, req, &mut res).await?;
}
}
Method::PATCH => {
if is_miss {
status_not_found(&mut res);
} else if !allow_upload {
status_forbid(&mut res);
} else {
let offset = match parse_upload_offset(headers, size) {
Ok(v) => v,
Err(err) => {
status_bad_request(&mut res, &err.to_string());
return Ok(res);
}
};
match offset {
Some(offset) => {
if offset < size && !allow_delete {
status_forbid(&mut res);
}
self.handle_upload(path, Some(offset), size, req, &mut res)
.await?;
}
None => {
*res.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
}
}
}
}
Method::DELETE => {
@@ -339,7 +378,8 @@ impl Server {
method => match method.as_str() {
"PROPFIND" => {
if is_dir {
let access_paths = if access_paths.perm().indexonly() {
let access_paths =
if access_paths.perm().indexonly() && authorization.is_none() {
// see https://github.com/sigoden/dufs/issues/229
AccessPaths::new(AccessPerm::ReadOnly)
} else {
@@ -365,7 +405,7 @@ impl Server {
status_forbid(&mut res);
} else if !is_miss {
*res.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
*res.body_mut() = Body::from("Already exists");
*res.body_mut() = body_full("Already exists");
} else {
self.handle_mkcol(path, &mut res).await?;
}
@@ -411,33 +451,48 @@ impl Server {
Ok(res)
}
async fn handle_upload(&self, path: &Path, mut req: Request, res: &mut Response) -> Result<()> {
async fn handle_upload(
&self,
path: &Path,
upload_offset: Option<u64>,
size: u64,
req: Request,
res: &mut Response,
) -> Result<()> {
ensure_path_parent(path).await?;
let mut file = match fs::File::create(&path).await {
Ok(v) => v,
Err(_) => {
status_forbid(res);
return Ok(());
let (mut file, status) = match upload_offset {
None => (fs::File::create(path).await?, StatusCode::CREATED),
Some(offset) if offset == size => (
fs::OpenOptions::new().append(true).open(path).await?,
StatusCode::NO_CONTENT,
),
Some(offset) => {
let mut file = fs::OpenOptions::new().write(true).open(path).await?;
file.seek(SeekFrom::Start(offset)).await?;
(file, StatusCode::NO_CONTENT)
}
};
let stream = IncomingStream::new(req.into_body());
let body_with_io_error = req
.body_mut()
.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
let body_with_io_error = stream.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
let body_reader = StreamReader::new(body_with_io_error);
futures::pin_mut!(body_reader);
pin_mut!(body_reader);
let ret = io::copy(&mut body_reader, &mut file).await;
let size = fs::metadata(path)
.await
.map(|v| v.len())
.unwrap_or_default();
if ret.is_err() {
tokio::fs::remove_file(&path).await?;
if upload_offset.is_none() && size < RESUMABLE_UPLOAD_MIN_SIZE {
let _ = tokio::fs::remove_file(&path).await;
}
ret?;
}
*res.status_mut() = StatusCode::CREATED;
*res.status_mut() = status;
Ok(())
}
@@ -497,7 +552,11 @@ impl Server {
.get("q")
.ok_or_else(|| anyhow!("invalid q"))?
.to_lowercase();
if !search.is_empty() {
if search.is_empty() {
return self
.handle_ls_dir(path, true, query_params, head_only, user, access_paths, res)
.await;
} else {
let path_buf = path.to_path_buf();
let hidden = Arc::new(self.args.hidden.to_vec());
let hidden = hidden.clone();
@@ -505,7 +564,7 @@ impl Server {
let access_paths = access_paths.clone();
let search_paths = tokio::task::spawn_blocking(move || {
let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&path_buf) {
for dir in access_paths.child_paths(&path_buf) {
let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() {
@@ -568,7 +627,7 @@ impl Server {
) -> Result<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = try_get_file_name(path)?;
set_content_diposition(res, false, &format!("{}.zip", filename))?;
set_content_disposition(res, false, &format!("{}.zip", filename))?;
res.headers_mut()
.insert("content-type", HeaderValue::from_static("application/zip"));
if head_only {
@@ -577,13 +636,29 @@ impl Server {
let path = path.to_owned();
let hidden = self.args.hidden.clone();
let running = self.running.clone();
let compression = self.args.compress.to_compression();
tokio::spawn(async move {
if let Err(e) = zip_dir(&mut writer, &path, access_paths, &hidden, running).await {
if let Err(e) = zip_dir(
&mut writer,
&path,
access_paths,
&hidden,
compression,
running,
)
.await
{
error!("Failed to zip {}, {}", path.display(), e);
}
});
let reader = Streamer::new(reader, BUF_SIZE);
*res.body_mut() = Body::wrap_stream(reader.into_stream());
let reader_stream = ReaderStream::new(reader);
let stream_body = StreamBody::new(
reader_stream
.map_ok(Frame::data)
.map_err(|err| anyhow!("{err}")),
);
let boxed_body = stream_body.boxed();
*res.body_mut() = boxed_body;
Ok(())
}
@@ -642,25 +717,30 @@ impl Server {
match self.args.assets.as_ref() {
Some(assets_path) => {
let path = assets_path.join(name);
if path.exists() {
self.handle_send_file(&path, headers, false, res).await?;
} else {
status_not_found(res);
return Ok(true);
}
}
None => match name {
"index.js" => {
*res.body_mut() = Body::from(INDEX_JS);
*res.body_mut() = body_full(INDEX_JS);
res.headers_mut().insert(
"content-type",
HeaderValue::from_static("application/javascript; charset=UTF-8"),
);
}
"index.css" => {
*res.body_mut() = Body::from(INDEX_CSS);
*res.body_mut() = body_full(INDEX_CSS);
res.headers_mut().insert(
"content-type",
HeaderValue::from_static("text/css; charset=UTF-8"),
);
}
"favicon.ico" => {
*res.body_mut() = Body::from(FAVICON_ICO);
*res.body_mut() = body_full(FAVICON_ICO);
res.headers_mut()
.insert("content-type", HeaderValue::from_static("image/x-icon"));
}
@@ -692,6 +772,7 @@ impl Server {
) -> Result<()> {
let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),);
let (mut file, meta) = (file?, meta?);
let size = meta.len();
let mut use_range = true;
if let Some((etag, last_modified)) = extract_cache_headers(&meta) {
let cached = {
@@ -723,7 +804,12 @@ impl Server {
}
let range = if use_range {
parse_range(headers)
headers.get(RANGE).map(|range| {
range
.to_str()
.ok()
.and_then(|range| parse_range(range, size))
})
} else {
None
};
@@ -734,31 +820,31 @@ impl Server {
);
let filename = try_get_file_name(path)?;
set_content_diposition(res, true, filename)?;
set_content_disposition(res, true, filename)?;
res.headers_mut().typed_insert(AcceptRanges::bytes());
let size = meta.len();
if let Some(range) = range {
if range
.end
.map_or_else(|| range.start < size, |v| v >= range.start)
&& file.seek(SeekFrom::Start(range.start)).await.is_ok()
{
let end = range.end.unwrap_or(size - 1).min(size - 1);
let part_size = end - range.start + 1;
let reader = Streamer::new(file, BUF_SIZE);
if let Some((start, end)) = range {
file.seek(SeekFrom::Start(start)).await?;
let range_size = end - start + 1;
*res.status_mut() = StatusCode::PARTIAL_CONTENT;
let content_range = format!("bytes {}-{}/{}", range.start, end, size);
let content_range = format!("bytes {}-{}/{}", start, end, size);
res.headers_mut()
.insert(CONTENT_RANGE, content_range.parse()?);
res.headers_mut()
.insert(CONTENT_LENGTH, format!("{part_size}").parse()?);
.insert(CONTENT_LENGTH, format!("{range_size}").parse()?);
if head_only {
return Ok(());
}
*res.body_mut() = Body::wrap_stream(reader.into_stream_sized(part_size));
let stream_body = StreamBody::new(
LengthLimitedStream::new(file, range_size as usize)
.map_ok(Frame::data)
.map_err(|err| anyhow!("{err}")),
);
let boxed_body = stream_body.boxed();
*res.body_mut() = boxed_body;
} else {
*res.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
res.headers_mut()
@@ -770,13 +856,20 @@ impl Server {
if head_only {
return Ok(());
}
let reader = Streamer::new(file, BUF_SIZE);
*res.body_mut() = Body::wrap_stream(reader.into_stream());
let reader_stream = ReaderStream::new(file);
let stream_body = StreamBody::new(
reader_stream
.map_ok(Frame::data)
.map_err(|err| anyhow!("{err}")),
);
let boxed_body = stream_body.boxed();
*res.body_mut() = boxed_body;
}
Ok(())
}
async fn handle_deal_file(
async fn handle_edit_file(
&self,
path: &Path,
kind: DataKind,
@@ -792,7 +885,8 @@ impl Server {
);
let mut buffer: Vec<u8> = vec![];
file.take(1024).read_to_end(&mut buffer).await?;
let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
let editable =
meta.len() <= EDITABLE_TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
let data = EditData {
href,
kind,
@@ -807,14 +901,17 @@ impl Server {
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
let output = self
.html
.replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace(
"__ASSETS_PREFIX__",
&format!("{}{}", self.args.uri_prefix, self.assets_prefix),
)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?);
res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64));
if head_only {
return Ok(());
}
*res.body_mut() = output.into();
*res.body_mut() = body_full(output);
Ok(())
}
@@ -829,7 +926,7 @@ impl Server {
Some(v) => match v.to_str().ok().and_then(|v| v.parse().ok()) {
Some(v) => v,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
status_bad_request(res, "");
return Ok(());
}
},
@@ -929,7 +1026,7 @@ impl Server {
res.headers_mut()
.insert("lock-token", format!("<{token}>").parse()?);
*res.body_mut() = Body::from(format!(
*res.body_mut() = body_full(format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>
<D:locktoken><D:href>{token}</D:href></D:locktoken>
@@ -1000,7 +1097,7 @@ impl Server {
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64));
*res.body_mut() = output.into();
*res.body_mut() = body_full(output);
if head_only {
return Ok(());
}
@@ -1032,7 +1129,10 @@ impl Server {
res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
self.html
.replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace(
"__ASSETS_PREFIX__",
&format!("{}{}", self.args.uri_prefix, self.assets_prefix),
)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?)
};
res.headers_mut()
@@ -1046,15 +1146,14 @@ impl Server {
if head_only {
return Ok(());
}
*res.body_mut() = output.into();
*res.body_mut() = body_full(output);
Ok(())
}
fn auth_reject(&self, res: &mut Response) -> Result<()> {
set_webdav_headers(res);
res.headers_mut()
.append(WWW_AUTHENTICATE, www_authenticate(&self.args)?);
// set 401 to make the browser pop up the login box
www_authenticate(res, &self.args)?;
*res.status_mut() = StatusCode::UNAUTHORIZED;
Ok(())
}
@@ -1069,18 +1168,13 @@ impl Server {
fn extract_dest(&self, req: &Request, res: &mut Response) -> Option<PathBuf> {
let headers = req.headers();
let dest_path = match self.extract_destination_header(headers) {
let dest_path = match self
.extract_destination_header(headers)
.and_then(|dest| self.resolve_path(&dest))
{
Some(dest) => dest,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
return None;
}
};
let relative_path = match self.resolve_path(&dest_path) {
Some(v) => v,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
status_bad_request(res, "Invalid Destination");
return None;
}
};
@@ -1089,7 +1183,7 @@ impl Server {
let guard = self
.args
.auth
.guard(&relative_path, req.method(), authorization);
.guard(&dest_path, req.method(), authorization);
match guard {
(_, Some(_)) => {}
@@ -1099,7 +1193,7 @@ impl Server {
}
};
let dest = match self.join_path(&relative_path) {
let dest = match self.join_path(&dest_path) {
Some(dest) => dest,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
@@ -1117,13 +1211,30 @@ impl Server {
}
fn resolve_path(&self, path: &str) -> Option<String> {
let path = path.trim_matches('/');
let path = decode_uri(path)?;
let prefix = self.args.path_prefix.as_str();
if prefix == "/" {
return Some(path.to_string());
let path = path.trim_matches('/');
let mut parts = vec![];
for comp in Path::new(path).components() {
if let Component::Normal(v) = comp {
let v = v.to_string_lossy();
if cfg!(windows) {
let chars: Vec<char> = v.chars().collect();
if chars.len() == 2 && chars[1] == ':' && chars[0].is_ascii_alphabetic() {
return None;
}
path.strip_prefix(prefix.trim_start_matches('/'))
}
parts.push(v);
} else {
return None;
}
}
let new_path = parts.join("/");
let path_prefix = self.args.path_prefix.as_str();
if path_prefix.is_empty() {
return Some(new_path);
}
new_path
.strip_prefix(path_prefix.trim_start_matches('/'))
.map(|v| v.trim_matches('/').to_string())
}
@@ -1147,7 +1258,7 @@ impl Server {
) -> Result<Vec<PathItem>> {
let mut paths: Vec<PathItem> = vec![];
if access_paths.perm().indexonly() {
for name in access_paths.child_paths() {
for name in access_paths.child_names() {
let entry_path = entry_path.join(name);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
}
@@ -1406,7 +1517,7 @@ fn res_multistatus(res: &mut Response, content: &str) {
"content-type",
HeaderValue::from_static("application/xml; charset=utf-8"),
);
*res.body_mut() = Body::from(format!(
*res.body_mut() = body_full(format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:">
{content}
@@ -1419,6 +1530,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
dir: &Path,
access_paths: AccessPaths,
hidden: &[String],
compression: Compression,
running: Arc<AtomicBool>,
) -> Result<()> {
let mut writer = ZipFileWriter::with_tokio(writer);
@@ -1427,7 +1539,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
let dir_clone = dir.to_path_buf();
let zip_paths = tokio::task::spawn_blocking(move || {
let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&dir_clone) {
for dir in access_paths.child_paths(&dir_clone) {
let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() {
@@ -1472,7 +1584,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
None => continue,
};
let (datetime, mode) = get_file_mtime_and_mode(&zip_path).await?;
let builder = ZipEntryBuilder::new(filename.into(), Compression::Deflate)
let builder = ZipEntryBuilder::new(filename.into(), compression)
.unix_permissions(mode)
.last_modification_date(ZipDateTime::from_chrono(&datetime));
let mut file = File::open(&zip_path).await?;
@@ -1493,59 +1605,46 @@ fn extract_cache_headers(meta: &Metadata) -> Option<(ETag, LastModified)> {
Some((etag, last_modified))
}
#[derive(Debug)]
struct RangeValue {
start: u64,
end: Option<u64>,
}
fn parse_range(headers: &HeaderMap<HeaderValue>) -> Option<RangeValue> {
let range_hdr = headers.get(RANGE)?;
let hdr = range_hdr.to_str().ok()?;
let mut sp = hdr.splitn(2, '=');
let units = sp.next()?;
if units == "bytes" {
let range = sp.next()?;
let mut sp_range = range.splitn(2, '-');
let start: u64 = sp_range.next()?.parse().ok()?;
let end: Option<u64> = if let Some(end) = sp_range.next() {
if end.is_empty() {
None
} else {
Some(end.parse().ok()?)
}
} else {
None
};
Some(RangeValue { start, end })
} else {
None
}
}
fn status_forbid(res: &mut Response) {
*res.status_mut() = StatusCode::FORBIDDEN;
*res.body_mut() = Body::from("Forbidden");
*res.body_mut() = body_full("Forbidden");
}
fn status_not_found(res: &mut Response) {
*res.status_mut() = StatusCode::NOT_FOUND;
*res.body_mut() = Body::from("Not Found");
*res.body_mut() = body_full("Not Found");
}
fn status_no_content(res: &mut Response) {
*res.status_mut() = StatusCode::NO_CONTENT;
}
fn set_content_diposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
fn status_bad_request(res: &mut Response, body: &str) {
*res.status_mut() = StatusCode::BAD_REQUEST;
if !body.is_empty() {
*res.body_mut() = body_full(body.to_string());
}
}
fn set_content_disposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
let kind = if inline { "inline" } else { "attachment" };
let filename: String = filename
.chars()
.map(|ch| {
if ch.is_ascii_control() && ch != '\t' {
' '
} else {
ch
}
})
.collect();
let value = if filename.is_ascii() {
HeaderValue::from_str(&format!("{kind}; filename=\"{}\"", filename,))?
} else {
HeaderValue::from_str(&format!(
"{kind}; filename=\"{}\"; filename*=UTF-8''{}",
filename,
encode_uri(filename),
encode_uri(&filename),
))?
};
res.headers_mut().insert(CONTENT_DISPOSITION, value);
@@ -1566,10 +1665,12 @@ fn is_hidden(hidden: &[String], file_name: &str, is_dir_type: bool) -> bool {
fn set_webdav_headers(res: &mut Response) {
res.headers_mut().insert(
"Allow",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"),
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PATCH,PROPFIND,COPY,MOVE"),
);
res.headers_mut().insert(
"DAV",
HeaderValue::from_static("1, 2, 3, sabredav-partialupdate"),
);
res.headers_mut()
.insert("DAV", HeaderValue::from_static("1,2"));
}
async fn get_content_type(path: &Path) -> Result<String> {
@@ -1602,3 +1703,17 @@ async fn get_content_type(path: &Path) -> Result<String> {
};
Ok(content_type)
}
fn parse_upload_offset(headers: &HeaderMap<HeaderValue>, size: u64) -> Result<Option<u64>> {
let value = match headers.get("x-update-range") {
Some(v) => v,
None => return Ok(None),
};
let err = || anyhow!("Invalid X-Update-Range Header");
let value = value.to_str().map_err(|_| err())?;
if value == "append" {
return Ok(Some(size));
}
let (start, _) = parse_range(value, size).ok_or_else(err)?;
Ok(Some(start))
}

View File

@@ -1,68 +0,0 @@
use async_stream::stream;
use futures::{Stream, StreamExt};
use std::io::Error;
use std::pin::Pin;
use tokio::io::{AsyncRead, AsyncReadExt};
pub struct Streamer<R>
where
R: AsyncRead + Unpin + Send + 'static,
{
reader: R,
buf_size: usize,
}
impl<R> Streamer<R>
where
R: AsyncRead + Unpin + Send + 'static,
{
#[inline]
pub fn new(reader: R, buf_size: usize) -> Self {
Self { reader, buf_size }
}
pub fn into_stream(
mut self,
) -> Pin<Box<impl ?Sized + Stream<Item = Result<Vec<u8>, Error>> + 'static>> {
let stream = stream! {
loop {
let mut buf = vec![0; self.buf_size];
let r = self.reader.read(&mut buf).await?;
if r == 0 {
break
}
buf.truncate(r);
yield Ok(buf);
}
};
stream.boxed()
}
// allow truncation as truncated remaining is always less than buf_size: usize
pub fn into_stream_sized(
mut self,
max_length: u64,
) -> Pin<Box<impl ?Sized + Stream<Item = Result<Vec<u8>, Error>> + 'static>> {
let stream = stream! {
let mut remaining = max_length;
loop {
if remaining == 0 {
break;
}
let bs = if remaining >= self.buf_size as u64 {
self.buf_size
} else {
remaining as usize
};
let mut buf = vec![0; bs];
let r = self.reader.read(&mut buf).await?;
if r == 0 {
break;
} else {
buf.truncate(r);
yield Ok(buf);
}
remaining -= r as u64;
}
};
stream.boxed()
}
}

View File

@@ -1,161 +0,0 @@
use anyhow::{anyhow, bail, Context as AnyhowContext, Result};
use core::task::{Context, Poll};
use futures::ready;
use hyper::server::accept::Accept;
use hyper::server::conn::{AddrIncoming, AddrStream};
use rustls::{Certificate, PrivateKey};
use std::future::Future;
use std::net::SocketAddr;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::{fs, io};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_rustls::rustls::ServerConfig;
enum State {
Handshaking(tokio_rustls::Accept<AddrStream>),
Streaming(tokio_rustls::server::TlsStream<AddrStream>),
}
// tokio_rustls::server::TlsStream doesn't expose constructor methods,
// so we have to TlsAcceptor::accept and handshake to have access to it
// TlsStream implements AsyncRead/AsyncWrite handshaking tokio_rustls::Accept first
pub struct TlsStream {
state: State,
remote_addr: SocketAddr,
}
impl TlsStream {
fn new(stream: AddrStream, config: Arc<ServerConfig>) -> TlsStream {
let remote_addr = stream.remote_addr();
let accept = tokio_rustls::TlsAcceptor::from(config).accept(stream);
TlsStream {
state: State::Handshaking(accept),
remote_addr,
}
}
pub fn remote_addr(&self) -> SocketAddr {
self.remote_addr
}
}
impl AsyncRead for TlsStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context,
buf: &mut ReadBuf,
) -> Poll<io::Result<()>> {
let pin = self.get_mut();
match pin.state {
State::Handshaking(ref mut accept) => match ready!(Pin::new(accept).poll(cx)) {
Ok(mut stream) => {
let result = Pin::new(&mut stream).poll_read(cx, buf);
pin.state = State::Streaming(stream);
result
}
Err(err) => Poll::Ready(Err(err)),
},
State::Streaming(ref mut stream) => Pin::new(stream).poll_read(cx, buf),
}
}
}
impl AsyncWrite for TlsStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let pin = self.get_mut();
match pin.state {
State::Handshaking(ref mut accept) => match ready!(Pin::new(accept).poll(cx)) {
Ok(mut stream) => {
let result = Pin::new(&mut stream).poll_write(cx, buf);
pin.state = State::Streaming(stream);
result
}
Err(err) => Poll::Ready(Err(err)),
},
State::Streaming(ref mut stream) => Pin::new(stream).poll_write(cx, buf),
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.state {
State::Handshaking(_) => Poll::Ready(Ok(())),
State::Streaming(ref mut stream) => Pin::new(stream).poll_flush(cx),
}
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.state {
State::Handshaking(_) => Poll::Ready(Ok(())),
State::Streaming(ref mut stream) => Pin::new(stream).poll_shutdown(cx),
}
}
}
pub struct TlsAcceptor {
config: Arc<ServerConfig>,
incoming: AddrIncoming,
}
impl TlsAcceptor {
pub fn new(config: Arc<ServerConfig>, incoming: AddrIncoming) -> TlsAcceptor {
TlsAcceptor { config, incoming }
}
}
impl Accept for TlsAcceptor {
type Conn = TlsStream;
type Error = io::Error;
fn poll_accept(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
let pin = self.get_mut();
match ready!(Pin::new(&mut pin.incoming).poll_accept(cx)) {
Some(Ok(sock)) => Poll::Ready(Some(Ok(TlsStream::new(sock, pin.config.clone())))),
Some(Err(e)) => Poll::Ready(Some(Err(e))),
None => Poll::Ready(None),
}
}
}
// Load public certificate from file.
pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<Certificate>> {
// Open certificate file.
let cert_file = fs::File::open(filename.as_ref())
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = io::BufReader::new(cert_file);
// Load and return certificate.
let certs = rustls_pemfile::certs(&mut reader).with_context(|| "Failed to load certificate")?;
if certs.is_empty() {
bail!("No supported certificate in file");
}
Ok(certs.into_iter().map(Certificate).collect())
}
// Load private key from file.
pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKey> {
let key_file = fs::File::open(filename.as_ref())
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = io::BufReader::new(key_file);
// Load and return a single private key.
let keys = rustls_pemfile::read_all(&mut reader)
.with_context(|| "There was a problem with reading private key")?
.into_iter()
.find_map(|item| match item {
rustls_pemfile::Item::RSAKey(key)
| rustls_pemfile::Item::PKCS8Key(key)
| rustls_pemfile::Item::ECKey(key) => Some(key),
_ => None,
})
.ok_or_else(|| anyhow!("No supported private key in file"))?;
Ok(PrivateKey(keys))
}

View File

@@ -1,31 +0,0 @@
use hyper::server::accept::Accept;
use tokio::net::UnixListener;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct UnixAcceptor {
inner: UnixListener,
}
impl UnixAcceptor {
pub fn from_listener(listener: UnixListener) -> Self {
Self { inner: listener }
}
}
impl Accept for UnixAcceptor {
type Conn = tokio::net::UnixStream;
type Error = std::io::Error;
fn poll_accept(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
match self.inner.poll_accept(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Ok((socket, _addr))) => Poll::Ready(Some(Ok(socket))),
Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))),
}
}
}

View File

@@ -1,5 +1,7 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
#[cfg(feature = "tls")]
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use std::{
borrow::Cow,
path::Path,
@@ -58,8 +60,84 @@ pub fn glob(pattern: &str, target: &str) -> bool {
pat.matches(target)
}
#[test]
fn test_glob_key() {
// Load public certificate from file.
#[cfg(feature = "tls")]
pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<CertificateDer<'static>>> {
// Open certificate file.
let cert_file = std::fs::File::open(filename.as_ref())
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = std::io::BufReader::new(cert_file);
// Load and return certificate.
let mut certs = vec![];
for cert in rustls_pemfile::certs(&mut reader) {
let cert = cert.with_context(|| "Failed to load certificate")?;
certs.push(cert)
}
if certs.is_empty() {
anyhow::bail!("No supported certificate in file");
}
Ok(certs)
}
// Load private key from file.
#[cfg(feature = "tls")]
pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKeyDer<'static>> {
let key_file = std::fs::File::open(filename.as_ref())
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = std::io::BufReader::new(key_file);
// Load and return a single private key.
for key in rustls_pemfile::read_all(&mut reader) {
let key = key.with_context(|| "There was a problem with reading private key")?;
match key {
rustls_pemfile::Item::Pkcs1Key(key) => return Ok(PrivateKeyDer::Pkcs1(key)),
rustls_pemfile::Item::Pkcs8Key(key) => return Ok(PrivateKeyDer::Pkcs8(key)),
rustls_pemfile::Item::Sec1Key(key) => return Ok(PrivateKeyDer::Sec1(key)),
_ => {}
}
}
anyhow::bail!("No supported private key in file");
}
pub fn parse_range(range: &str, size: u64) -> Option<(u64, u64)> {
let (unit, range) = range.split_once('=')?;
if unit != "bytes" || range.contains(',') {
return None;
}
let (start, end) = range.split_once('-')?;
if start.is_empty() {
let offset = end.parse::<u64>().ok()?;
if offset <= size {
Some((size - offset, size - 1))
} else {
None
}
} else {
let start = start.parse::<u64>().ok()?;
if start < size {
if end.is_empty() {
Some((start, size - 1))
} else {
let end = end.parse::<u64>().ok()?;
if end < size {
Some((start, end))
} else {
None
}
}
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_key() {
assert!(glob("", ""));
assert!(glob(".*", ".git"));
assert!(glob("abc", "abc"));
@@ -80,4 +158,17 @@ fn test_glob_key() {
assert!(glob("*.log", "a.log"));
assert!(glob("*/", "abc/"));
assert!(!glob("*/", "abc"));
}
#[test]
fn test_parse_range() {
assert_eq!(parse_range("bytes=0-499", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=0-", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=299-", 500), Some((299, 499)));
assert_eq!(parse_range("bytes=-500", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=-300", 500), Some((200, 499)));
assert_eq!(parse_range("bytes=500-", 500), None);
assert_eq!(parse_range("bytes=-501", 500), None);
assert_eq!(parse_range("bytes=0-500", 500), None);
}
}

View File

@@ -11,10 +11,11 @@ use std::process::{Command, Stdio};
fn assets(server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(server.url())?;
let index_js = format!("/__dufs_v{ver}_index.js");
let index_css = format!("/__dufs_v{ver}_index.css");
let favicon_ico = format!("/__dufs_v{ver}_favicon.ico");
let index_js = format!("/__dufs_v{ver}__/index.js");
let index_css = format!("/__dufs_v{ver}__/index.css");
let favicon_ico = format!("/__dufs_v{ver}__/favicon.ico");
let text = resp.text()?;
println!("{text}");
assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
assert!(text.contains(&format!(r#"src="{index_js}""#)));
@@ -24,7 +25,7 @@ fn assets(server: TestServer) -> Result<(), Error> {
#[rstest]
fn asset_js(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.js",
"{}__dufs_v{}__/index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -40,7 +41,7 @@ fn asset_js(server: TestServer) -> Result<(), Error> {
#[rstest]
fn asset_css(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.css",
"{}__dufs_v{}__/index.css",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -56,7 +57,7 @@ fn asset_css(server: TestServer) -> Result<(), Error> {
#[rstest]
fn asset_ico(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_favicon.ico",
"{}__dufs_v{}__/favicon.ico",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -70,9 +71,9 @@ fn asset_ico(server: TestServer) -> Result<(), Error> {
fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?;
let index_js = format!("/xyz/__dufs_v{ver}_index.js");
let index_css = format!("/xyz/__dufs_v{ver}_index.css");
let favicon_ico = format!("/xyz/__dufs_v{ver}_favicon.ico");
let index_js = format!("/xyz/__dufs_v{ver}__/index.js");
let index_css = format!("/xyz/__dufs_v{ver}__/index.css");
let favicon_ico = format!("/xyz/__dufs_v{ver}__/favicon.ico");
let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
@@ -85,7 +86,7 @@ fn asset_js_with_prefix(
#[with(&["--path-prefix", "xyz"])] server: TestServer,
) -> Result<(), Error> {
let url = format!(
"{}xyz/__dufs_v{}_index.js",
"{}xyz/__dufs_v{}__/index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -114,7 +115,7 @@ fn assets_override(tmpdir: TempDir, port: u16) -> Result<(), Error> {
let url = format!("http://localhost:{port}");
let resp = reqwest::blocking::get(&url)?;
assert!(resp.text()?.starts_with(&format!(
"/__dufs_v{}_index.js;DATA",
"/__dufs_v{}__/index.js;DATA",
env!("CARGO_PKG_VERSION")
)));
let resp = reqwest::blocking::get(&url)?;

View File

@@ -10,7 +10,15 @@ use rstest::rstest;
fn no_auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate"));
let values: Vec<&str> = resp
.headers()
.get_all("www-authenticate")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert!(values[0].starts_with("Digest"));
assert!(values[1].starts_with("Basic"));
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
@@ -31,6 +39,25 @@ fn auth(#[case] server: TestServer, #[case] user: &str, #[case] pass: &str) -> R
Ok(())
}
#[rstest]
fn invalid_auth(
#[with(&["-a", "user:pass@/:rw", "-a", "@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"GET", server.url())
.basic_auth("user", Some("-"))
.send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"GET", server.url())
.basic_auth("-", Some("pass"))
.send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"GET", server.url())
.header("Authorization", "Basic Og==")
.send()?;
assert_eq!(resp.status(), 401);
Ok(())
}
const HASHED_PASSWORD_AUTH: &str = "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw"; // user:pass
#[rstest]
@@ -246,7 +273,7 @@ fn auth_partial_index(
#[rstest]
fn no_auth_propfind_dir(
#[with(&["--auth", "user:pass@/:rw", "--auth", "@/dir-assets", "-A"])] server: TestServer,
#[with(&["--auth", "admin:admin@/:rw", "--auth", "@/dir-assets", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
@@ -256,6 +283,19 @@ fn no_auth_propfind_dir(
Ok(())
}
#[rstest]
fn auth_propfind_dir(
#[with(&["--auth", "admin:admin@/:rw", "--auth", "user:pass@/dir-assets", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/dir-assets/</D:href>"));
assert!(!body.contains("<D:href>/dir1/</D:href>"));
Ok(())
}
#[rstest]
fn auth_data(
#[with(&["-a", "user:pass@/:rw", "-a", "@/", "-A"])] server: TestServer,
@@ -274,3 +314,22 @@ fn auth_data(
assert_eq!(json["allow_upload"], serde_json::Value::Bool(true));
Ok(())
}
#[rstest]
fn auth_precedence(
#[with(&["--auth", "user:pass@/dir1:rw,/dir1/test.txt", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/test.txt", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 403);
let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
Ok(())
}

View File

@@ -16,7 +16,14 @@ pub const BIN_FILE: &str = "😀.bin";
/// File names for testing purpose
#[allow(dead_code)]
pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", BIN_FILE];
pub static FILES: &[&str] = &[
"test.txt",
"test.html",
"index.html",
#[cfg(not(target_os = "windows"))]
"file\n1.txt",
BIN_FILE,
];
/// Directory names for testing directory don't exist
#[allow(dead_code)]

View File

@@ -59,7 +59,7 @@ fn hidden_search_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<
#[rstest]
#[case(server(&["--hidden", "hidden/"]), "dir4/", 1)]
#[case(server(&["--hidden", "hidden"]), "dir4/", 0)]
fn hidden_dir_noly(
fn hidden_dir_only(
#[case] server: TestServer,
#[case] dir: &str,
#[case] count: usize,

View File

@@ -40,7 +40,12 @@ fn head_dir_404(server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn get_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[case(server(&["--allow-archive"] as &[&str]))]
#[case(server(&["--allow-archive", "--compress", "none"]))]
#[case(server(&["--allow-archive", "--compress", "low"]))]
#[case(server(&["--allow-archive", "--compress", "medium"]))]
#[case(server(&["--allow-archive", "--compress", "high"]))]
fn get_dir_zip(#[case] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
@@ -147,9 +152,7 @@ fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[rstest]
fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q=", server.url()))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(paths.is_empty());
assert_resp_paths!(resp);
Ok(())
}
@@ -204,6 +207,18 @@ fn get_file_emoji_path(server: TestServer) -> Result<(), Error> {
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn get_file_newline_path(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}file%0A1.txt", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-disposition").unwrap(),
"inline; filename=\"file 1.txt\""
);
Ok(())
}
#[rstest]
fn get_file_edit(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;
@@ -235,9 +250,12 @@ fn options_dir(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("allow").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
"GET,HEAD,PUT,OPTIONS,DELETE,PATCH,PROPFIND,COPY,MOVE"
);
assert_eq!(
resp.headers().get("dav").unwrap(),
"1, 2, 3, sabredav-partialupdate"
);
assert_eq!(resp.headers().get("dav").unwrap(), "1,2");
Ok(())
}
@@ -315,3 +333,19 @@ fn get_file_content_type(server: TestServer) -> Result<(), Error> {
);
Ok(())
}
#[rstest]
fn resumable_upload(#[with(&["--allow-upload"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
let resp = fetch!(b"PATCH", &url)
.header("X-Update-Range", "append")
.body(b"123".to_vec())
.send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().unwrap(), "abc123");
Ok(())
}

View File

@@ -2,7 +2,7 @@ mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use headers::HeaderValue;
use reqwest::header::HeaderValue;
use rstest::rstest;
#[rstest]
@@ -23,14 +23,10 @@ fn get_file_range_beyond(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html", server.url()))
.header("range", HeaderValue::from_static("bytes=12-20"))
.send()?;
assert_eq!(resp.status(), 206);
assert_eq!(
resp.headers().get("content-range").unwrap(),
"bytes 12-17/18"
);
assert_eq!(resp.status(), 416);
assert_eq!(resp.headers().get("content-range").unwrap(), "bytes */18");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert_eq!(resp.headers().get("content-length").unwrap(), "6");
assert_eq!(resp.text()?, "x.html");
assert_eq!(resp.headers().get("content-length").unwrap(), "0");
Ok(())
}

View File

@@ -53,7 +53,7 @@ fn path_prefix_single_file(tmpdir: TempDir, port: u16, #[case] file: &str) -> Re
let resp = reqwest::blocking::get(format!("http://localhost:{port}/xyz/index.html"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}"))?;
assert_eq!(resp.status(), 403);
assert_eq!(resp.status(), 400);
child.kill()?;
Ok(())

View File

@@ -20,7 +20,7 @@ macro_rules! assert_resp_paths {
#[macro_export]
macro_rules! fetch {
($method:literal, $url:expr) => {
reqwest::blocking::Client::new().request(hyper::Method::from_bytes($method)?, $url)
reqwest::blocking::Client::new().request(reqwest::Method::from_bytes($method)?, $url)
};
}

View File

@@ -49,7 +49,7 @@ fn propfind_404(server: TestServer) -> Result<(), Error> {
#[rstest]
fn propfind_double_slash(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}/", server.url())).send()?;
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
Ok(())
}