From 6ff8b29b691512e7d6964775e6e0a2385bfa395d Mon Sep 17 00:00:00 2001 From: sigoden Date: Mon, 27 Nov 2023 04:24:25 +0800 Subject: [PATCH] feat: more flexible config values (#299) --- Cargo.lock | 12 +++ Cargo.toml | 1 + README.md | 17 ++-- src/args.rs | 216 ++++++++++++++++++++++++++++++++++++++++++--- src/auth.rs | 2 +- src/http_logger.rs | 4 +- 6 files changed, 226 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 100e7b7..1ab101d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha-crypt", + "smart-default", "socket2 0.5.5", "tokio", "tokio-rustls", @@ -1552,6 +1553,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.4.10" diff --git a/Cargo.toml b/Cargo.toml index 6c868a2..914d42a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ indexmap = "2.0" serde_yaml = "0.9.27" sha-crypt = "0.5.0" base64 = "0.21.5" +smart-default = "0.7.1" [features] default = ["tls"] diff --git a/README.md b/README.md index 18f61d8..79a2f58 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,7 @@ $6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8 Use hashed password ``` -dufs \ - -a 'admin:$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/@/:rw' +dufs -a 'admin:$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/@/:rw' ``` Two important things for hashed passwords: @@ -256,9 +255,10 @@ dufs --hidden .git,.DS_Store,tmp > The glob used in --hidden only matches file and directory names, not paths. So `--hidden dir1/file` is invalid. ```sh -dufs --hidden '.*' # hidden dotfiles -dufs --hidden '*/' # hidden all folders -dufs --hidden '*.log,*.lock' # hidden by exts +dufs --hidden '.*' # hidden dotfiles +dufs --hidden '*/' # hidden all folders +dufs --hidden '*.log,*.lock' # hidden by exts +dufs --hidden '*.log' --hidden '*.lock' ``` ### Log Format @@ -311,8 +311,8 @@ All options can be set using environment variables prefixed with `DUFS_`. --config DUFS_CONFIG=config.yaml -b, --bind DUFS_BIND=0.0.0.0 -p, --port DUFS_PORT=5000 - --path-prefix DUFS_PATH_PREFIX=/path - --hidden DUFS_HIDDEN=*.log + --path-prefix DUFS_PATH_PREFIX=/static + --hidden DUFS_HIDDEN=tmp,*.log,*.lock -a, --auth DUFS_AUTH="admin:admin@/:rw|@/" -A, --allow-all DUFS_ALLOW_ALL=true --allow-upload DUFS_ALLOW_UPLOAD=true @@ -338,8 +338,7 @@ The following are the configuration items: ```yaml serve-path: '.' -bind: - - 192.168.8.10 +bind: 0.0.0.0 port: 5000 path-prefix: /dufs hidden: diff --git a/src/args.rs b/src/args.rs index 79bf0c2..edd05f2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -3,6 +3,7 @@ use clap::builder::PossibleValuesParser; use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use clap_complete::{generate, Generator, Shell}; use serde::{Deserialize, Deserializer}; +use smart_default::SmartDefault; use std::env; use std::net::IpAddr; use std::path::{Path, PathBuf}; @@ -70,6 +71,8 @@ pub fn build_cli() -> Command { .env("DUFS_HIDDEN") .hide_env(true) .long("hidden") + .action(ArgAction::Append) + .value_delimiter(',') .help("Hide paths from directory listings, e.g. tmp,*.log,*.lock") .value_name("value"), ) @@ -228,23 +231,27 @@ pub fn print_completions(gen: G, cmd: &mut Command) { generate(gen, cmd, cmd.get_name().to_string(), &mut std::io::stdout()); } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, SmartDefault, PartialEq)] #[serde(default)] #[serde(rename_all = "kebab-case")] pub struct Args { #[serde(default = "default_serve_path")] + #[default(default_serve_path())] pub serve_path: PathBuf, #[serde(deserialize_with = "deserialize_bind_addrs")] #[serde(rename = "bind")] #[serde(default = "default_addrs")] + #[default(default_addrs())] pub addrs: Vec, #[serde(default = "default_port")] + #[default(default_port())] pub port: u16, #[serde(skip)] pub path_is_file: bool, pub path_prefix: String, #[serde(skip)] pub uri_prefix: String, + #[serde(deserialize_with = "deserialize_string_or_vec")] pub hidden: Vec, #[serde(deserialize_with = "deserialize_access_control")] pub auth: AccessControl, @@ -272,12 +279,7 @@ impl Args { /// If a parsing error occurred, exit the process and print out informative /// error message to user. pub fn parse(matches: ArgMatches) -> Result { - let mut args = Self { - serve_path: default_serve_path(), - addrs: default_addrs(), - port: default_port(), - ..Default::default() - }; + let mut args = Self::default(); if let Some(config_path) = matches.get_one::("config") { let contents = std::fs::read_to_string(config_path) @@ -289,6 +291,7 @@ impl Args { if let Some(path) = matches.get_one::("serve-path") { args.serve_path = path.clone() } + args.serve_path = Self::sanitize_path(args.serve_path)?; if let Some(port) = matches.get_one::("port") { @@ -312,11 +315,15 @@ impl Args { format!("/{}/", &encode_uri(&args.path_prefix)) }; - if let Some(hidden) = matches - .get_one::("hidden") - .map(|v| v.split(',').map(|x| x.to_string()).collect()) - { - args.hidden = hidden; + if let Some(hidden) = matches.get_many::("hidden") { + args.hidden = hidden.cloned().collect(); + } else { + let mut hidden = vec![]; + std::mem::swap(&mut args.hidden, &mut hidden); + args.hidden = hidden + .into_iter() + .flat_map(|v| v.split(',').map(|v| v.to_string()).collect::>()) + .collect(); } if !args.enable_cors { @@ -457,8 +464,64 @@ fn deserialize_bind_addrs<'de, D>(deserializer: D) -> Result, D::E where D: Deserializer<'de>, { - let addrs: Vec<&str> = Vec::deserialize(deserializer)?; - BindAddr::parse_addrs(&addrs).map_err(serde::de::Error::custom) + struct StringOrVec; + + impl<'de> serde::de::Visitor<'de> for StringOrVec { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string or list of strings") + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + BindAddr::parse_addrs(&[s]).map_err(serde::de::Error::custom) + } + + fn visit_seq(self, seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + let addrs: Vec<&'de str> = + Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?; + BindAddr::parse_addrs(&addrs).map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_any(StringOrVec) +} + +fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct StringOrVec; + + impl<'de> serde::de::Visitor<'de> for StringOrVec { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string or list of strings") + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + Ok(vec![s.to_owned()]) + } + + fn visit_seq(self, seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)) + } + } + + deserializer.deserialize_any(StringOrVec) } fn deserialize_access_control<'de, D>(deserializer: D) -> Result @@ -488,3 +551,128 @@ fn default_addrs() -> Vec { fn default_port() -> u16 { 5000 } + +#[cfg(test)] +mod tests { + use super::*; + + use assert_fs::prelude::*; + + #[test] + fn test_default() { + let cli = build_cli(); + let matches = cli.try_get_matches_from(vec![""]).unwrap(); + let args = Args::parse(matches).unwrap(); + let cwd = Args::sanitize_path(std::env::current_dir().unwrap()).unwrap(); + assert_eq!(args.serve_path, cwd); + assert_eq!(args.port, default_port()); + assert_eq!(args.addrs, default_addrs()); + } + + #[test] + fn test_args_from_cli1() { + let tmpdir = assert_fs::TempDir::new().unwrap(); + let cli = build_cli(); + let matches = cli + .try_get_matches_from(vec![ + "", + "--hidden", + "tmp,*.log,*.lock", + &tmpdir.to_string_lossy(), + ]) + .unwrap(); + let args = Args::parse(matches).unwrap(); + assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap()); + assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]); + } + + #[test] + fn test_args_from_cli2() { + let cli = build_cli(); + let matches = cli + .try_get_matches_from(vec![ + "", "--hidden", "tmp", "--hidden", "*.log", "--hidden", "*.lock", + ]) + .unwrap(); + let args = Args::parse(matches).unwrap(); + assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]); + } + + #[test] + fn test_args_from_empty_config_file() { + let tmpdir = assert_fs::TempDir::new().unwrap(); + let config_file = tmpdir.child("config.yaml"); + config_file.write_str("").unwrap(); + + let cli = build_cli(); + let matches = cli + .try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()]) + .unwrap(); + let args = Args::parse(matches).unwrap(); + let cwd = Args::sanitize_path(std::env::current_dir().unwrap()).unwrap(); + assert_eq!(args.serve_path, cwd); + assert_eq!(args.port, default_port()); + assert_eq!(args.addrs, default_addrs()); + } + + #[test] + fn test_args_from_config_file1() { + let tmpdir = assert_fs::TempDir::new().unwrap(); + let config_file = tmpdir.child("config.yaml"); + let contents = format!( + r#" +serve-path: {} +bind: 0.0.0.0 +port: 3000 +allow-upload: true +hidden: tmp,*.log,*.lock +"#, + tmpdir.display() + ); + config_file.write_str(&contents).unwrap(); + + let cli = build_cli(); + let matches = cli + .try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()]) + .unwrap(); + let args = Args::parse(matches).unwrap(); + assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap()); + assert_eq!( + args.addrs, + vec![BindAddr::Address("0.0.0.0".parse().unwrap())] + ); + assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]); + assert_eq!(args.port, 3000); + assert!(args.allow_upload); + } + + #[test] + fn test_args_from_config_file2() { + let tmpdir = assert_fs::TempDir::new().unwrap(); + let config_file = tmpdir.child("config.yaml"); + let contents = r#" +bind: + - 127.0.0.1 + - 192.168.8.10 +hidden: + - tmp + - '*.log' + - '*.lock' +"#; + config_file.write_str(contents).unwrap(); + + let cli = build_cli(); + let matches = cli + .try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()]) + .unwrap(); + let args = Args::parse(matches).unwrap(); + assert_eq!( + args.addrs, + vec![ + BindAddr::Address("127.0.0.1".parse().unwrap()), + BindAddr::Address("192.168.8.10".parse().unwrap()) + ] + ); + assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]); + } +} diff --git a/src/auth.rs b/src/auth.rs index 73f93c2..678079c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -25,7 +25,7 @@ lazy_static! { }; } -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct AccessControl { use_hashed_password: bool, users: IndexMap, diff --git a/src/http_logger.rs b/src/http_logger.rs index 0b56580..9d1d644 100644 --- a/src/http_logger.rs +++ b/src/http_logger.rs @@ -4,7 +4,7 @@ use crate::{auth::get_auth_user, server::Request}; pub const DEFAULT_LOG_FORMAT: &str = r#"$remote_addr "$request" $status"#; -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct HttpLogger { elements: Vec, } @@ -15,7 +15,7 @@ impl Default for HttpLogger { } } -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] enum LogElement { Variable(String), Header(String),