feat: path level access control (#52)
BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed
This commit is contained in:
parent
9384cc8587
commit
9c2e9d1503
7 changed files with 288 additions and 94 deletions
49
README.md
49
README.md
|
@ -14,7 +14,7 @@ Duf is a simple file server. Support static serve, search, upload, webdav...
|
|||
- Upload files and folders (Drag & Drop)
|
||||
- Search files
|
||||
- Partial responses (Parallel/Resume download)
|
||||
- Authentication
|
||||
- Path level access control
|
||||
- Support https
|
||||
- Support webdav
|
||||
- Easy to use with curl
|
||||
|
@ -88,12 +88,6 @@ Listen on a specific port
|
|||
duf -p 80
|
||||
```
|
||||
|
||||
Protect with authentication
|
||||
|
||||
```
|
||||
duf -a admin:admin
|
||||
```
|
||||
|
||||
For a single page application (SPA)
|
||||
|
||||
```
|
||||
|
@ -110,27 +104,60 @@ duf --tls-cert my.crt --tls-key my.key
|
|||
|
||||
Download a file
|
||||
```
|
||||
curl http://127.0.0.1:5000/some-file
|
||||
curl http://127.0.0.1:5000/path-to-file
|
||||
```
|
||||
|
||||
Download a folder as zip file
|
||||
|
||||
```
|
||||
curl -o some-folder.zip http://127.0.0.1:5000/some-folder?zip
|
||||
curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
|
||||
```
|
||||
|
||||
Upload a file
|
||||
|
||||
```
|
||||
curl --upload-file some-file http://127.0.0.1:5000/some-file
|
||||
curl --upload-file path-to-file http://127.0.0.1:5000/path-to-file
|
||||
```
|
||||
|
||||
Delete a file/folder
|
||||
|
||||
```
|
||||
curl -X DELETE http://127.0.0.1:5000/some-file
|
||||
curl -X DELETE http://127.0.0.1:5000/path-to-file
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Duf supports path level access control with --auth/-a option.</summary>
|
||||
|
||||
```
|
||||
duf -a <path>@<readwrite>[@<readonly>]
|
||||
```
|
||||
|
||||
- `<path>`: Path to protected
|
||||
- `<readwrite>`: Account with readwrite permission, required
|
||||
- `<readonly>`: Account with readonly permission, optional
|
||||
|
||||
> `*` as `<readonly>` means `<path>` is public, everyone can access/download it.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
duf -a /@admin:pass@* -a /ui@designer:pass1 -A
|
||||
```
|
||||
- All files/folders are public to access/download.
|
||||
- Account `admin:pass` can upload/delete/download any files/folders.
|
||||
- Account `designer:pass1` can upload/delete/download any files/folders in the `ui` folder.
|
||||
|
||||
Curl with auth:
|
||||
|
||||
```
|
||||
curl --digest -u designer:pass1 http://127.0.0.1:5000/ui/path-to-file
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
Copyright (c) 2022 duf-developers.
|
||||
|
|
29
src/args.rs
29
src/args.rs
|
@ -4,7 +4,7 @@ use std::env;
|
|||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::auth::parse_auth;
|
||||
use crate::auth::AccessControl;
|
||||
use crate::tls::{load_certs, load_private_key};
|
||||
use crate::BoxResult;
|
||||
|
||||
|
@ -51,13 +51,10 @@ fn app() -> Command<'static> {
|
|||
Arg::new("auth")
|
||||
.short('a')
|
||||
.long("auth")
|
||||
.help("Use HTTP authentication")
|
||||
.value_name("user:pass"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no-auth-access")
|
||||
.long("no-auth-access")
|
||||
.help("Not required auth when access static files"),
|
||||
.help("Add auth for path")
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.value_name("rule"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("allow-all")
|
||||
|
@ -118,15 +115,14 @@ pub fn matches() -> ArgMatches {
|
|||
app().get_matches()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Args {
|
||||
pub addrs: Vec<IpAddr>,
|
||||
pub port: u16,
|
||||
pub path: PathBuf,
|
||||
pub path_prefix: String,
|
||||
pub uri_prefix: String,
|
||||
pub auth: Option<(String, String)>,
|
||||
pub no_auth_access: bool,
|
||||
pub auth: AccessControl,
|
||||
pub allow_upload: bool,
|
||||
pub allow_delete: bool,
|
||||
pub allow_symlink: bool,
|
||||
|
@ -160,11 +156,11 @@ impl Args {
|
|||
format!("/{}/", &path_prefix)
|
||||
};
|
||||
let cors = matches.is_present("cors");
|
||||
let auth = match matches.value_of("auth") {
|
||||
Some(auth) => Some(parse_auth(auth)?),
|
||||
None => None,
|
||||
};
|
||||
let no_auth_access = matches.is_present("no-auth-access");
|
||||
let auth: Vec<&str> = matches
|
||||
.values_of("auth")
|
||||
.map(|v| v.collect())
|
||||
.unwrap_or_default();
|
||||
let auth = AccessControl::new(&auth, &uri_prefix)?;
|
||||
let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload");
|
||||
let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete");
|
||||
let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink");
|
||||
|
@ -187,7 +183,6 @@ impl Args {
|
|||
path_prefix,
|
||||
uri_prefix,
|
||||
auth,
|
||||
no_auth_access,
|
||||
cors,
|
||||
allow_delete,
|
||||
allow_upload,
|
||||
|
|
164
src/auth.rs
164
src/auth.rs
|
@ -1,4 +1,5 @@
|
|||
use headers::HeaderValue;
|
||||
use hyper::Method;
|
||||
use lazy_static::lazy_static;
|
||||
use md5::Context;
|
||||
use std::{
|
||||
|
@ -7,6 +8,7 @@ use std::{
|
|||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::utils::encode_uri;
|
||||
use crate::BoxResult;
|
||||
|
||||
const REALM: &str = "DUF";
|
||||
|
@ -20,6 +22,151 @@ lazy_static! {
|
|||
};
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessControl {
|
||||
rules: HashMap<String, PathControl>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PathControl {
|
||||
readwrite: Account,
|
||||
readonly: Option<Account>,
|
||||
share: bool,
|
||||
}
|
||||
|
||||
impl AccessControl {
|
||||
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> BoxResult<Self> {
|
||||
let mut rules = HashMap::default();
|
||||
if raw_rules.is_empty() {
|
||||
return Ok(Self { rules });
|
||||
}
|
||||
for rule in raw_rules {
|
||||
let parts: Vec<&str> = rule.split('@').collect();
|
||||
let create_err = || format!("Invalid auth `{}`", rule).into();
|
||||
match parts.as_slice() {
|
||||
[path, readwrite] => {
|
||||
let control = PathControl {
|
||||
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
|
||||
readonly: None,
|
||||
share: false,
|
||||
};
|
||||
rules.insert(sanitize_path(path, uri_prefix), control);
|
||||
}
|
||||
[path, readwrite, readonly] => {
|
||||
let (readonly, share) = if *readonly == "*" {
|
||||
(None, true)
|
||||
} else {
|
||||
(Some(Account::new(readonly).ok_or_else(create_err)?), false)
|
||||
};
|
||||
let control = PathControl {
|
||||
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
|
||||
readonly,
|
||||
share,
|
||||
};
|
||||
rules.insert(sanitize_path(path, uri_prefix), control);
|
||||
}
|
||||
_ => return Err(create_err()),
|
||||
}
|
||||
}
|
||||
Ok(Self { rules })
|
||||
}
|
||||
|
||||
pub fn guard(
|
||||
&self,
|
||||
path: &str,
|
||||
method: &Method,
|
||||
authorization: Option<&HeaderValue>,
|
||||
) -> GuardType {
|
||||
if self.rules.is_empty() {
|
||||
return GuardType::ReadWrite;
|
||||
}
|
||||
let mut controls = vec![];
|
||||
for path in walk_path(path) {
|
||||
if let Some(control) = self.rules.get(path) {
|
||||
controls.push(control);
|
||||
if let Some(authorization) = authorization {
|
||||
let Account { user, pass } = &control.readwrite;
|
||||
if valid_digest(authorization, method.as_str(), user, pass).is_some() {
|
||||
return GuardType::ReadWrite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_readonly_method(method) {
|
||||
for control in controls.into_iter() {
|
||||
if control.share {
|
||||
return GuardType::ReadOnly;
|
||||
}
|
||||
if let Some(authorization) = authorization {
|
||||
if let Some(Account { user, pass }) = &control.readonly {
|
||||
if valid_digest(authorization, method.as_str(), user, pass).is_some() {
|
||||
return GuardType::ReadOnly;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GuardType::Reject
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum GuardType {
|
||||
Reject,
|
||||
ReadWrite,
|
||||
ReadOnly,
|
||||
}
|
||||
|
||||
impl GuardType {
|
||||
pub fn is_reject(&self) -> bool {
|
||||
*self == GuardType::Reject
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_path(path: &str, uri_prefix: &str) -> String {
|
||||
encode_uri(&format!("{}{}", uri_prefix, path.trim_matches('/')))
|
||||
}
|
||||
|
||||
fn walk_path(path: &str) -> impl Iterator<Item = &str> {
|
||||
let mut idx = 0;
|
||||
path.split('/').enumerate().map(move |(i, part)| {
|
||||
let end = if i == 0 { 1 } else { idx + part.len() + i };
|
||||
let value = &path[..end];
|
||||
idx += part.len();
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
fn is_readonly_method(method: &Method) -> bool {
|
||||
method == Method::GET
|
||||
|| method == Method::OPTIONS
|
||||
|| method == Method::HEAD
|
||||
|| method.as_str() == "PROPFIND"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Account {
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
fn new(data: &str) -> Option<Self> {
|
||||
let p: Vec<&str> = data.trim().split(':').collect();
|
||||
if p.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let user = p[0];
|
||||
let pass = p[1];
|
||||
let mut h = Context::new();
|
||||
h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes());
|
||||
Some(Account {
|
||||
user: user.to_owned(),
|
||||
pass: format!("{:x}", h.compute()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_www_auth(stale: bool) -> String {
|
||||
let str_stale = if stale { "stale=true," } else { "" };
|
||||
format!(
|
||||
|
@ -30,26 +177,13 @@ pub fn generate_www_auth(stale: bool) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
pub fn parse_auth(auth: &str) -> BoxResult<(String, String)> {
|
||||
let p: Vec<&str> = auth.trim().split(':').collect();
|
||||
let err = "Invalid auth value";
|
||||
if p.len() != 2 {
|
||||
return Err(err.into());
|
||||
}
|
||||
let user = p[0];
|
||||
let pass = p[1];
|
||||
let mut h = Context::new();
|
||||
h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes());
|
||||
Ok((user.to_owned(), format!("{:x}", h.compute())))
|
||||
}
|
||||
|
||||
pub fn valid_digest(
|
||||
header_value: &HeaderValue,
|
||||
authorization: &HeaderValue,
|
||||
method: &str,
|
||||
auth_user: &str,
|
||||
auth_pass: &str,
|
||||
) -> Option<()> {
|
||||
let digest_value = strip_prefix(header_value.as_bytes(), b"Digest ")?;
|
||||
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
|
||||
let user_vals = to_headermap(digest_value).ok()?;
|
||||
if let (Some(username), Some(nonce), Some(user_response)) = (
|
||||
user_vals
|
||||
|
|
|
@ -3,6 +3,7 @@ mod auth;
|
|||
mod server;
|
||||
mod streamer;
|
||||
mod tls;
|
||||
mod utils;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use crate::auth::{generate_www_auth, valid_digest};
|
||||
use crate::auth::generate_www_auth;
|
||||
use crate::streamer::Streamer;
|
||||
use crate::utils::{decode_uri, encode_uri};
|
||||
use crate::{Args, BoxResult};
|
||||
use xml::escape::escape_str_pcdata;
|
||||
|
||||
|
@ -19,7 +20,6 @@ use hyper::header::{
|
|||
CONTENT_TYPE, ORIGIN, RANGE, WWW_AUTHENTICATE,
|
||||
};
|
||||
use hyper::{Body, Method, StatusCode, Uri};
|
||||
use percent_encoding::percent_decode;
|
||||
use serde::Serialize;
|
||||
use std::fs::Metadata;
|
||||
use std::io::SeekFrom;
|
||||
|
@ -86,16 +86,20 @@ impl Server {
|
|||
pub async fn handle(self: Arc<Self>, req: Request) -> BoxResult<Response> {
|
||||
let mut res = Response::default();
|
||||
|
||||
if !self.auth_guard(&req, &mut res) {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
let req_path = req.uri().path();
|
||||
let headers = req.headers();
|
||||
let method = req.method().clone();
|
||||
|
||||
let authorization = headers.get(AUTHORIZATION);
|
||||
let guard_type = self.args.auth.guard(req_path, &method, authorization);
|
||||
|
||||
if req_path == "/favicon.ico" && method == Method::GET {
|
||||
self.handle_send_favicon(req.headers(), &mut res).await?;
|
||||
self.handle_send_favicon(headers, &mut res).await?;
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
if guard_type.is_reject() {
|
||||
self.auth_reject(&mut res);
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
|
@ -106,6 +110,7 @@ impl Server {
|
|||
return Ok(res);
|
||||
}
|
||||
};
|
||||
|
||||
let path = path.as_path();
|
||||
|
||||
let query = req.uri().query().unwrap_or_default();
|
||||
|
@ -218,7 +223,8 @@ impl Server {
|
|||
"LOCK" => {
|
||||
// Fake lock
|
||||
if is_file {
|
||||
self.handle_lock(req_path, &mut res).await?;
|
||||
let has_auth = authorization.is_some();
|
||||
self.handle_lock(req_path, has_auth, &mut res).await?;
|
||||
} else {
|
||||
status_not_found(&mut res);
|
||||
}
|
||||
|
@ -618,11 +624,11 @@ impl Server {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> {
|
||||
let token = if self.args.auth.is_none() {
|
||||
Utc::now().timestamp().to_string()
|
||||
} else {
|
||||
async fn handle_lock(&self, req_path: &str, auth: bool, res: &mut Response) -> BoxResult<()> {
|
||||
let token = if auth {
|
||||
format!("opaquelocktoken:{}", Uuid::new_v4())
|
||||
} else {
|
||||
Utc::now().timestamp().to_string()
|
||||
};
|
||||
|
||||
res.headers_mut().insert(
|
||||
|
@ -708,34 +714,13 @@ const DATA =
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn auth_guard(&self, req: &Request, res: &mut Response) -> bool {
|
||||
let method = req.method();
|
||||
let pass = {
|
||||
match &self.args.auth {
|
||||
None => true,
|
||||
Some((user, pass)) => match req.headers().get(AUTHORIZATION) {
|
||||
Some(value) => {
|
||||
valid_digest(value, method.as_str(), user.as_str(), pass.as_str()).is_some()
|
||||
}
|
||||
None => {
|
||||
self.args.no_auth_access
|
||||
&& (method == Method::GET
|
||||
|| method == Method::OPTIONS
|
||||
|| method == Method::HEAD
|
||||
|| method.as_str() == "PROPFIND")
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
if !pass {
|
||||
fn auth_reject(&self, res: &mut Response) {
|
||||
let value = generate_www_auth(false);
|
||||
set_webdav_headers(res);
|
||||
*res.status_mut() = StatusCode::UNAUTHORIZED;
|
||||
res.headers_mut().typed_insert(Connection::close());
|
||||
res.headers_mut()
|
||||
.insert(WWW_AUTHENTICATE, value.parse().unwrap());
|
||||
}
|
||||
pass
|
||||
*res.status_mut() = StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
async fn is_root_contained(&self, path: &Path) -> bool {
|
||||
|
@ -753,7 +738,7 @@ const DATA =
|
|||
}
|
||||
|
||||
fn extract_path(&self, path: &str) -> Option<PathBuf> {
|
||||
let decoded_path = percent_decode(path[1..].as_bytes()).decode_utf8().ok()?;
|
||||
let decoded_path = decode_uri(&path[1..])?;
|
||||
let slashes_switched = if cfg!(windows) {
|
||||
decoded_path.replace('/', "\\")
|
||||
} else {
|
||||
|
@ -1023,13 +1008,9 @@ fn parse_range(headers: &HeaderMap<HeaderValue>) -> Option<RangeValue> {
|
|||
}
|
||||
}
|
||||
|
||||
fn encode_uri(v: &str) -> String {
|
||||
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
fn status_forbid(res: &mut Response) {
|
||||
*res.status_mut() = StatusCode::FORBIDDEN;
|
||||
*res.body_mut() = Body::from("Forbidden");
|
||||
}
|
||||
|
||||
fn status_not_found(res: &mut Response) {
|
||||
|
|
12
src/utils.rs
Normal file
12
src/utils.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
pub fn encode_uri(v: &str) -> String {
|
||||
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
pub fn decode_uri(v: &str) -> Option<Cow<str>> {
|
||||
percent_encoding::percent_decode(v.as_bytes())
|
||||
.decode_utf8()
|
||||
.ok()
|
||||
}
|
|
@ -6,7 +6,7 @@ use fixtures::{server, Error, TestServer};
|
|||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
|
||||
fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
|
||||
let resp = reqwest::blocking::get(server.url())?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
assert!(resp.headers().contains_key("www-authenticate"));
|
||||
|
@ -17,7 +17,7 @@ fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result
|
|||
}
|
||||
|
||||
#[rstest]
|
||||
fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
|
||||
fn auth(#[with(&["--auth", "/@user:pass", "-A"])] 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(), 401);
|
||||
|
@ -29,10 +29,54 @@ fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<()
|
|||
}
|
||||
|
||||
#[rstest]
|
||||
fn auth_skip_access(
|
||||
#[with(&["--auth", "user:pass", "--no-auth-access"])] server: TestServer,
|
||||
) -> Result<(), Error> {
|
||||
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
|
||||
let resp = reqwest::blocking::get(server.url())?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn auth_readonly(
|
||||
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
|
||||
) -> Result<(), Error> {
|
||||
let url = format!("{}index.html", server.url());
|
||||
let resp = fetch!(b"GET", &url).send()?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
let resp = fetch!(b"GET", &url).send_with_digest_auth("user2", "pass2")?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let url = format!("{}file1", server.url());
|
||||
let resp = fetch!(b"PUT", &url)
|
||||
.body(b"abc".to_vec())
|
||||
.send_with_digest_auth("user2", "pass2")?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn auth_nest(
|
||||
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dira@user3:pass3", "-A"])]
|
||||
server: TestServer,
|
||||
) -> Result<(), Error> {
|
||||
let url = format!("{}dira/file1", server.url());
|
||||
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
let resp = fetch!(b"PUT", &url)
|
||||
.body(b"abc".to_vec())
|
||||
.send_with_digest_auth("user3", "pass3")?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let resp = fetch!(b"PUT", &url)
|
||||
.body(b"abc".to_vec())
|
||||
.send_with_digest_auth("user", "pass")?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn auth_nest_share(
|
||||
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer,
|
||||
) -> Result<(), Error> {
|
||||
let url = format!("{}index.html", server.url());
|
||||
let resp = fetch!(b"GET", &url).send()?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
Ok(())
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue