use std::collections::BTreeSet;
use std::collections::btree_map::{BTreeMap, Entry as BTreeMapEntry};
use std::env;
use std::ffi::OsString;
use std::net::IpAddr;
use std::num::NonZeroU64;
use std::path::PathBuf;
use std::str::FromStr;
use cidr::IpCidr;
use clap::Parser;
use crate::error::Error;
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
All,
NoServeStatus,
NoStartup,
NoAuth,
}
impl From<u8> for LogLevel {
fn from(raw: u8) -> LogLevel {
match raw {
0 => LogLevel::All,
1 => LogLevel::NoServeStatus,
2 => LogLevel::NoStartup,
_ => LogLevel::NoAuth,
}
}
}
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum WebDavLevel {
No,
MkColMoveOnly,
All,
}
#[derive(Parser, Debug)]
#[command(name = "hpip", version, about)]
struct Cli {
#[arg(default_value = ".")]
dir: String,
#[arg(short, long)]
port: Option<u16>,
#[arg(short = 'a', long = "address")]
address: Option<String>,
#[arg(short = 't', long = "temp-dir")]
temp_dir: Option<String>,
#[arg(long = "404")]
fallback_404: Option<String>,
#[arg(short = 's', long = "no-follow-symlinks")]
no_follow_symlinks: bool,
#[arg(short = 'r', long = "sandbox-symlinks")]
sandbox_symlinks: bool,
#[arg(short = 'w', long = "allow-write")]
allow_write: bool,
#[arg(short = 'l', long = "no-listings")]
no_listings: bool,
#[arg(short = 'i', long = "no-indices")]
no_indices: bool,
#[arg(short = 'e', long = "no-encode")]
no_encode: bool,
#[arg(long = "encoded-filesystem")]
encoded_filesystem: Option<String>,
#[arg(long = "encoded-generated")]
encoded_generated: Option<String>,
#[arg(long = "encoded-prune")]
encoded_prune: Option<String>,
#[arg(short = 'x', long = "strip-extensions")]
strip_extensions: bool,
#[arg(short = 'q', long = "quiet", action = clap::ArgAction::Count)]
quiet: u8,
#[arg(short = 'Q', long = "quiet-time")]
quiet_time: bool,
#[arg(short = 'c', long = "no-colour")]
no_colour: bool,
#[arg(short = 'd', long = "webdav")]
webdav: bool,
#[arg(short = 'D', long = "convenient-webdav")]
convenient_webdav: bool,
#[arg(short = 'A', long = "archives")]
archives: bool,
#[arg(long = "ssl")]
ssl: Option<String>,
#[arg(long = "gen-ssl", conflicts_with = "ssl")]
gen_ssl: bool,
#[arg(long = "auth")]
auth: Option<String>,
#[arg(long = "gen-auth", conflicts_with = "auth")]
gen_auth: bool,
#[arg(long = "path-auth", num_args = 1)]
path_auth: Vec<String>,
#[arg(long = "gen-path-auth", num_args = 1)]
gen_path_auth: Vec<String>,
#[arg(long = "proxy", num_args = 1)]
proxy: Vec<String>,
#[arg(long = "proxy-redir", num_args = 1)]
proxy_redir: Vec<String>,
#[arg(short = 'm', long = "mime-type", num_args = 1)]
mime_type: Vec<String>,
#[arg(long = "request-bandwidth")]
request_bandwidth: Option<String>,
#[arg(short = 'H', long = "header", num_args = 1)]
header: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Options {
pub hosted_directory: (String, PathBuf),
pub port: Option<u16>,
pub bind_address: IpAddr,
pub follow_symlinks: bool,
pub sandbox_symlinks: bool,
pub temp_directory: (String, PathBuf),
pub generate_listings: bool,
pub check_indices: bool,
pub strip_extensions: bool,
pub try_404: Option<PathBuf>,
pub allow_writes: bool,
pub encode_fs: bool,
pub encoded_filesystem_limit: Option<u64>,
pub encoded_generated_limit: Option<u64>,
pub encoded_prune: Option<u64>,
pub loglevel: LogLevel,
pub log_time: bool,
pub log_colour: bool,
pub webdav: WebDavLevel,
pub archives: bool,
pub tls_data: Option<((String, PathBuf), String)>,
pub generate_tls: bool,
pub path_auth_data: BTreeMap<String, Option<String>>,
pub generate_path_auth: BTreeSet<String>,
pub proxies: BTreeMap<IpCidr, String>,
pub proxy_redirs: BTreeMap<IpCidr, String>,
pub mime_type_overrides: BTreeMap<OsString, String>,
pub request_bandwidth: Option<NonZeroU64>,
pub additional_headers: Vec<(String, Vec<u8>)>,
}
impl Options {
pub fn parse() -> Result<Options, Error> {
let cli = Cli::parse();
let dir = &cli.dir;
let dir_pb = std::fs::canonicalize(dir)
.map_err(|_| Error(format!("Directory to host \"{}\" not found", dir)))?;
if !dir_pb.is_dir() {
return Err(Error(format!(
"Directory to host \"{}\" not actually a directory",
dir
)));
}
let follow_symlinks = !cli.no_follow_symlinks;
let bind_address = cli
.address
.as_deref()
.map(IpAddr::from_str)
.transpose()
.map_err(|_| Error("Invalid bind address".into()))?
.unwrap_or_else(|| "0.0.0.0".parse().unwrap());
let mut path_auth_data = BTreeMap::new();
if let Some(ref root_auth) = cli.auth {
Options::validate_credentials(root_auth)?;
path_auth_data.insert(
String::new(),
Some(Options::normalise_credentials(root_auth)),
);
}
for pa in &cli.path_auth {
let (path, auth) = Options::decode_path_credentials(pa)?;
match path_auth_data.entry(path) {
BTreeMapEntry::Occupied(oe) => {
return Err(Error(format!(
"Credentials for path \"/{}\" already present",
oe.key()
)));
}
BTreeMapEntry::Vacant(ve) => {
ve.insert(auth.map(|s| Options::normalise_credentials(&s)));
}
}
}
let mut generate_path_auth = BTreeSet::new();
if cli.gen_auth {
generate_path_auth.insert(String::new());
}
for gpa in &cli.gen_path_auth {
let path = Options::normalise_path(gpa);
if path_auth_data.contains_key(&path) {
return Err(Error(format!(
"Credentials for path \"/{}\" already present",
path
)));
}
if !generate_path_auth.insert(path.clone()) {
return Err(Error(format!(
"Credentials for path \"/{}\" already present",
path
)));
}
}
let proxies: BTreeMap<IpCidr, String> = cli
.proxy
.iter()
.map(|s| Options::proxy_parse(s))
.collect::<Result<_, _>>()?;
let proxy_redirs: BTreeMap<IpCidr, String> = cli
.proxy_redir
.iter()
.map(|s| Options::proxy_parse(s))
.collect::<Result<_, _>>()?;
let mime_type_overrides: BTreeMap<OsString, String> = cli
.mime_type
.iter()
.map(|s| Options::mime_type_override_parse(s))
.collect::<Result<_, _>>()?;
let additional_headers: Vec<(String, Vec<u8>)> = cli
.header
.iter()
.map(|s| Options::header_parse(s))
.collect::<Result<_, _>>()?;
let webdav = std::cmp::max(
if cli.webdav {
WebDavLevel::All
} else {
WebDavLevel::No
},
if cli.convenient_webdav {
WebDavLevel::MkColMoveOnly
} else {
WebDavLevel::No
},
);
Ok(Options {
hosted_directory: (dir.to_string(), dir_pb.clone()),
port: cli.port,
bind_address,
follow_symlinks,
sandbox_symlinks: follow_symlinks && cli.sandbox_symlinks,
temp_directory: {
let (temp_s, temp_pb) = if let Some(ref tmpdir) = cli.temp_dir {
(
tmpdir.to_string(),
std::fs::canonicalize(tmpdir).map_err(|_| {
Error(format!("Temporary directory \"{}\" not found", tmpdir))
})?,
)
} else {
("$TEMP".to_string(), env::temp_dir())
};
let suffix = dir_pb
.to_string_lossy()
.replace(r"\\?\", "")
.replace(':', "")
.replace('\\', "/")
.replace('/', "-");
let suffix = if suffix.len() >= 255 - 5 {
format!("hpip-{}", blake3::hash(suffix.as_bytes()).to_hex())
} else {
format!(
"hpip{}{}",
if suffix.starts_with('-') { "" } else { "-" },
suffix
)
};
(
format!(
"{}{}{}",
temp_s,
if temp_s.ends_with('/') || temp_s.ends_with('\\') {
""
} else {
"/"
},
suffix
),
temp_pb.join(&suffix),
)
},
generate_listings: !cli.no_listings,
check_indices: !cli.no_indices,
strip_extensions: cli.strip_extensions,
try_404: cli.fallback_404.map(PathBuf::from),
allow_writes: cli.allow_write,
encode_fs: !cli.no_encode,
encoded_filesystem_limit: cli
.encoded_filesystem
.as_deref()
.map(Options::size_parse)
.transpose()?,
encoded_generated_limit: cli
.encoded_generated
.as_deref()
.map(Options::size_parse)
.transpose()?,
encoded_prune: cli
.encoded_prune
.as_deref()
.map(Options::age_parse)
.transpose()?,
loglevel: LogLevel::from(cli.quiet),
log_time: !cli.quiet_time,
log_colour: !cli.no_colour,
webdav,
archives: cli.archives,
tls_data: cli.ssl.map(|id| {
let id_pb = std::fs::canonicalize(&id).expect("TLS identity file not found");
((id, id_pb), env::var("HTTP_SSL_PASS").unwrap_or_default())
}),
generate_tls: cli.gen_ssl,
path_auth_data,
generate_path_auth,
proxies,
proxy_redirs,
mime_type_overrides,
request_bandwidth: cli
.request_bandwidth
.as_deref()
.map(Options::bandwidth_parse)
.transpose()?
.flatten(),
additional_headers,
})
}
fn validate_credentials(s: &str) -> Result<(), Error> {
if match s.split_once(':') {
Some((u, p)) => !u.is_empty() && !p.contains(':'),
None => !s.is_empty(),
} {
Ok(())
} else {
Err(Error(format!(
"Global authentication credentials \"{}\" need be in format \"username[:password]\"",
s
)))
}
}
fn decode_path_credentials(s: &str) -> Result<(String, Option<String>), Error> {
let (path, creds) = s.split_once('=').ok_or_else(|| {
Error(format!(
"Per-path authentication credentials \"{}\" need be in format \"path=[username[:password]]\"",
s
))
})?;
let path = Options::normalise_path(path);
if creds.is_empty() {
Ok((path, None))
} else {
if let Some((u, p)) = creds.split_once(':')
&& (u.is_empty() || p.contains(':'))
{
return Err(Error(format!(
"Per-path authentication credentials \"{}\" need be in format \"path=[username[:password]]\"",
s
)));
}
Ok((path, Some(creds.to_string())))
}
}
fn normalise_path(path: &str) -> String {
let mut frags = vec![];
for fragment in path.split(['/', '\\']) {
match fragment {
"" | "." => {}
".." => {
frags.pop();
}
_ => frags.push(fragment),
}
}
let mut ret =
String::with_capacity(frags.iter().map(|s| s.len()).sum::<usize>() + frags.len());
for frag in frags {
ret.push_str(frag);
ret.push('/');
}
ret.pop();
ret
}
fn normalise_credentials(creds: &str) -> String {
if creds.ends_with(':') {
creds[0..creds.len() - 1].to_string()
} else {
creds.to_string()
}
}
pub fn size_parse(s: &str) -> Result<u64, Error> {
let mut s = s;
if matches!(s.as_bytes().last(), Some(b'b' | b'B')) {
s = &s[..s.len() - 1];
}
let mul: u64 = match s.as_bytes().last() {
Some(b'k' | b'K') => 1024,
Some(b'm' | b'M') => 1024 * 1024,
Some(b'g' | b'G') => 1024 * 1024 * 1024,
Some(b't' | b'T') => 1024 * 1024 * 1024 * 1024,
Some(b'p' | b'P') => 1024 * 1024 * 1024 * 1024 * 1024,
_ => 1,
};
if mul != 1 {
s = &s[..s.len() - 1];
}
s.parse::<u64>()
.map(|size| size * mul)
.map_err(|e| Error(format!("{} not a valid size: {}", s, e)))
}
pub fn age_parse(s: &str) -> Result<u64, Error> {
let mut s = s;
let (mul, trim) = match s.as_bytes().last() {
Some(b's') => (1, true),
Some(b'm') => (60, true),
Some(b'h') => (60 * 60, true),
Some(b'd') => (60 * 60 * 24, true),
_ => (1, false),
};
if trim {
s = &s[..s.len() - 1];
}
s.parse::<u64>()
.map(|age| age * mul)
.map_err(|e| Error(format!("{} not a valid age: {}", s, e)))
}
fn proxy_parse(s: &str) -> Result<(IpCidr, String), Error> {
match s.find(':') {
None => Err(Error(format!("{} not in HEADER-NAME:CIDR format", s))),
Some(0) => Err(Error(format!("{} sets invalid zero-length header", s))),
Some(col_idx) => {
let cidr: IpCidr = s[col_idx + 1..]
.parse()
.map_err(|e| Error(format!("{} not a valid CIDR: {}", &s[col_idx + 1..], e)))?;
Ok((cidr, s[..col_idx].to_string()))
}
}
}
fn bandwidth_parse(s: &str) -> Result<Option<NonZeroU64>, Error> {
let s = s.trim();
let multiplier_b = s
.as_bytes()
.last()
.ok_or_else(|| Error(format!("\"{}\" bandwidth specifier empty", s)))?;
let multiplier_order = match multiplier_b {
b'k' | b'K' => 1u32,
b'm' | b'M' => 2,
b'g' | b'G' => 3,
b't' | b'T' => 4,
b'p' | b'P' => 5,
b'e' | b'E' => 6,
_ => 0,
};
let (multiplier, s) = match multiplier_order {
0 => (1u64, s),
mo => {
let base: u64 = if (*multiplier_b as char).is_uppercase() {
1024
} else {
1000
};
(base.pow(mo), &s[..s.len() - 1])
}
};
let number =
u64::from_str(s).map_err(|e| Error(format!("\"{}\" not bandwidth size: {}", s, e)))?;
Ok(NonZeroU64::new(number.checked_mul(multiplier).ok_or_else(
|| Error(format!("{} * {} too big", number, multiplier)),
)?))
}
fn mime_type_override_parse(s: &str) -> Result<(OsString, String), Error> {
match s.find(':') {
None => Err(Error(format!("{} not in EXTENSION:MIME-TYPE format", s))),
Some(col_idx) => {
let mime_s = &s[col_idx + 1..];
if !mime_s.contains('/') {
return Err(Error(format!("{} not a valid MIME type", mime_s)));
}
Ok((OsString::from(&s[..col_idx]), mime_s.to_string()))
}
}
}
fn header_parse(s: &str) -> Result<(String, Vec<u8>), Error> {
s.split_once(':')
.and_then(|(hn, mut hd)| {
hd = hd.trim_start();
if !hn.is_empty() && !hd.is_empty() {
Some((hn, hd))
} else {
None
}
})
.map(|(hn, hd)| (hn.to_string(), hd.as_bytes().to_vec()))
.ok_or_else(|| Error(format!("\"{}\" invalid header format", s)))
}
}