From 54f131b0669eeebc22d95bfd29fef88bf44fed76 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 15:28:03 +0000 Subject: [PATCH] Refactor: extract lib.rs and use clap for args - Extracted core logic (request handling, config, utilities) to `src/lib.rs`. - Replaced manual argument parsing with `clap` (derive). - Moved `Config` and server logic out of `src/main.rs`. - Preserved existing functionality and tests. - Removed unused `Read` import in `src/lib.rs`. --- Cargo.lock | 177 ++++++++++++++ Cargo.toml | 1 + src/lib.rs | 629 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 665 +--------------------------------------------------- 4 files changed, 814 insertions(+), 658 deletions(-) create mode 100644 src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 827bcca..2dc2b64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "ascii" version = "1.1.0" @@ -14,16 +64,75 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "httpdate" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "lanshare" version = "0.1.0" dependencies = [ + "clap", "tiny_http", ] @@ -33,6 +142,47 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -44,3 +194,30 @@ dependencies = [ "httpdate", "log", ] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index 386bd60..7bb594e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.5.53", features = ["derive"] } tiny_http = "0.12" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c4f27cf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,629 @@ +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use clap::{Parser, ValueEnum}; +use tiny_http::{Header, Method, Response, StatusCode}; + +const DEFAULT_BIND: &str = "127.0.0.1:7878"; +const DEFAULT_ROOT: &str = "./shared"; +const DEFAULT_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; // 100 MiB + +pub type AnyError = Box; + +#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)] +pub enum OverwritePolicy { + Deny, +} + +#[derive(Parser, Clone, Debug)] +#[command(author, version, about, long_about = None)] +#[command(next_line_help = true)] +pub struct Config { + /// Bind address + #[arg(long, default_value = DEFAULT_BIND)] + pub bind: String, + + /// Share root directory + #[arg(long, default_value = DEFAULT_ROOT)] + pub root: PathBuf, + + /// Upload token (default: auto-generated) + #[arg(long)] + pub token: Option, + + /// Disable upload token (NOT recommended) + #[arg(long, default_value_t = false)] + pub no_token: bool, + + /// Max upload size in bytes + #[arg(long, default_value_t = DEFAULT_MAX_FILE_BYTES)] + pub max_file_bytes: u64, + + /// Overwrite policy + #[arg(long, value_enum, default_value_t = OverwritePolicy::Deny)] + pub overwrite: OverwritePolicy, +} + +impl Config { + pub fn finalize(&mut self) { + if self.no_token { + self.token = None; + } else if self.token.is_none() { + self.token = Some(generate_token()); + } + } +} + +pub fn handle_request(request: tiny_http::Request, cfg: &Config) -> Result<(), AnyError> { + let (path_part, query) = split_url(request.url()); + + match (request.method(), path_part.as_str()) { + (&Method::Get, "/") => return redirect_see_other(request, "/browse/"), + (&Method::Get, p) if p == "/browse" || p == "/browse/" || p.starts_with("/browse/") => { + let rel = if p == "/browse" || p == "/browse/" { + String::new() + } else { + // Strip "/browse/" prefix. + normalize_rel_path(percent_decode(&p["/browse/".len()..])) + }; + return handle_browse(request, cfg, rel); + } + (&Method::Get, p) if p == "/files" || p == "/files/" || p.starts_with("/files/") => { + let rel = if p == "/files" || p == "/files/" { + String::new() + } else { + normalize_rel_path(percent_decode(&p["/files/".len()..])) + }; + return handle_download(request, cfg, rel); + } + (&Method::Post, "/upload") => { + let dir = query.get("dir").cloned().unwrap_or_default(); + let name = query.get("name").cloned().unwrap_or_default(); + return handle_upload(request, cfg, dir, name); + } + _ => {} + } + + respond_text(request, 404, "not found") +} + +fn handle_browse(request: tiny_http::Request, cfg: &Config, rel: String) -> Result<(), AnyError> { + let abs = safe_join(&cfg.root, &rel).map_err(|e| format!("invalid path: {e}"))?; + if !abs.is_dir() { + return respond_text(request, 404, "not found"); + } + + let html = render_directory_page(cfg, &rel, &abs)?; + respond_html(request, 200, html) +} + +fn handle_download(request: tiny_http::Request, cfg: &Config, rel: String) -> Result<(), AnyError> { + let abs = safe_join(&cfg.root, &rel).map_err(|e| format!("invalid path: {e}"))?; + if abs.is_dir() { + let target = if rel.is_empty() { + "/browse/".to_string() + } else { + format!("/browse/{}/", url_escape_path(&rel)) + }; + return redirect_see_other(request, &target); + } + + let file = File::open(&abs)?; + let content_type = content_type_for(&abs); + let filename = abs + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("download"); + let filename = header_safe_filename(filename); + + let ct = Header::from_bytes(b"Content-Type" as &[u8], content_type.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Content-Type header"))?; + let disp_value = format!("attachment; filename=\"{filename}\""); + let disp = Header::from_bytes(b"Content-Disposition" as &[u8], disp_value.as_bytes()).map_err( + |_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid Content-Disposition header", + ) + }, + )?; + + let response = Response::from_file(file) + .with_status_code(StatusCode(200)) + .with_header(ct) + .with_header(disp); + + request.respond(response)?; + Ok(()) +} + +fn handle_upload( + mut request: tiny_http::Request, + cfg: &Config, + dir: String, + name: String, +) -> Result<(), AnyError> { + if let Some(expected) = &cfg.token { + if !has_upload_token(&request, expected) { + return respond_text(request, 401, "missing/invalid upload token"); + } + } + + let dir = normalize_rel_path(dir); + let target_dir = safe_join(&cfg.root, &dir).map_err(|e| format!("invalid dir: {e}"))?; + if !target_dir.is_dir() { + return respond_text(request, 400, "dir does not exist"); + } + + let filename = sanitize_filename(&name).ok_or("invalid filename")?; + let dest = target_dir.join(&filename); + + let mut out = match cfg.overwrite { + OverwritePolicy::Deny => { + match OpenOptions::new().write(true).create_new(true).open(&dest) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + return respond_text(request, 409, "file already exists"); + } + Err(e) => return Err(e.into()), + } + } + }; + let mut buf = [0u8; 8192]; + let mut written: u64 = 0; + let reader = request.as_reader(); + + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + written = written.saturating_add(n as u64); + if written > cfg.max_file_bytes { + drop(out); + let _ = fs::remove_file(&dest); + return respond_text(request, 413, "file too large"); + } + out.write_all(&buf[..n])?; + } + + respond_text(request, 200, "ok") +} + +fn render_directory_page(cfg: &Config, rel: &str, abs: &Path) -> io::Result { + let mut entries: Vec<_> = fs::read_dir(abs)?.filter_map(|e| e.ok()).collect(); + + entries.sort_by_key(|e| { + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + let name = e.file_name(); + (!is_dir, name) + }); + + let mut rows = String::new(); + if !rel.is_empty() { + let parent_rel = Path::new(rel) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(""); + let href = format!("/browse/{}/", url_escape_path(parent_rel)); + rows.push_str(&format!("
  • ..
  • \n")); + } + + for e in entries { + let name_os = e.file_name(); + let name = name_os.to_string_lossy(); + let meta = e.metadata().ok(); + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + + let child_rel = if rel.is_empty() { + name.to_string() + } else { + format!("{rel}/{name}") + }; + + let display_name = html_escape(&name); + if is_dir { + let href = format!("/browse/{}/", url_escape_path(&child_rel)); + rows.push_str(&format!( + "
  • [DIR] {display_name}/
  • \n" + )); + } else { + let href = format!("/files/{}", url_escape_path(&child_rel)); + let size = meta.map(|m| m.len()).unwrap_or(0); + rows.push_str(&format!( + "
  • {size} bytes {display_name}
  • \n" + )); + } + } + + let rel_display = if rel.is_empty() { + "/".to_string() + } else { + format!("/{rel}") + }; + let dir_attr = rel; + + let html = format!( + r#" + + + + + File Server + + + +

    File Server

    +
    Directory: {}
    + +

    Upload

    +
    Uploads require a token by default. Start the server and copy the token printed in the terminal.
    +
    Max upload size: {} bytes
    +
    + + + +
    +
    + +

    Files

    +
      + {} +
    + + + + +"#, + html_escape(&rel_display), + cfg.max_file_bytes, + html_escape(dir_attr), + cfg.max_file_bytes, + rows + ); + + Ok(html) +} + +fn respond_html(request: tiny_http::Request, status: u16, body: String) -> Result<(), AnyError> { + let ct = Header::from_bytes( + b"Content-Type" as &[u8], + b"text/html; charset=utf-8" as &[u8], + ) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Content-Type header"))?; + let response = Response::from_string(body) + .with_status_code(StatusCode(status)) + .with_header(ct); + request.respond(response)?; + Ok(()) +} + +fn respond_text(request: tiny_http::Request, status: u16, body: &str) -> Result<(), AnyError> { + let ct = Header::from_bytes( + b"Content-Type" as &[u8], + b"text/plain; charset=utf-8" as &[u8], + ) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Content-Type header"))?; + let response = Response::from_string(body) + .with_status_code(StatusCode(status)) + .with_header(ct); + request.respond(response)?; + Ok(()) +} + +fn redirect_see_other(request: tiny_http::Request, location: &str) -> Result<(), AnyError> { + let loc = Header::from_bytes(b"Location" as &[u8], location.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Location header"))?; + let response = Response::empty(StatusCode(303)).with_header(loc); + request.respond(response)?; + Ok(()) +} + +fn split_url(url: &str) -> (String, HashMap) { + let (path, query) = url.split_once('?').unwrap_or((url, "")); + (path.to_string(), parse_query(query)) +} + +fn parse_query(query: &str) -> HashMap { + let mut out = HashMap::new(); + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + out.insert(percent_decode(k), percent_decode(v)); + } + out +} + +pub fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < b.len() => { + let hi = from_hex(b[i + 1]); + let lo = from_hex(b[i + 2]); + if let (Some(hi), Some(lo)) = (hi, lo) { + out.push((hi << 4) | lo); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + c => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn from_hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +pub fn url_escape_path(rel: &str) -> String { + if rel.is_empty() { + return String::new(); + } + rel.split('/') + .map(percent_encode_segment) + .collect::>() + .join("/") +} + +fn percent_encode_segment(seg: &str) -> String { + let mut out = String::new(); + for b in seg.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +fn normalize_rel_path(s: String) -> String { + s.trim_matches('/').to_string() +} + +pub fn safe_join(root: &Path, rel: &str) -> Result { + let root_can = root + .canonicalize() + .map_err(|e| format!("cannot canonicalize root: {e}"))?; + + let rel = rel.trim_start_matches('/'); + let rel_path = Path::new(rel); + + if rel_path.is_absolute() { + return Err("absolute path not allowed".into()); + } + for comp in rel_path.components() { + match comp { + Component::ParentDir => return Err("parent dir not allowed".into()), + Component::Prefix(_) => return Err("path prefix not allowed".into()), + _ => {} + } + } + + let joined = root.join(rel_path); + if joined.exists() { + let can = joined + .canonicalize() + .map_err(|e| format!("cannot canonicalize path: {e}"))?; + if !can.starts_with(&root_can) { + return Err("path escapes root".into()); + } + Ok(can) + } else { + // For non-existent upload targets, validate the parent directory. + let parent = joined.parent().unwrap_or(root); + let parent_can = parent + .canonicalize() + .map_err(|e| format!("cannot canonicalize parent: {e}"))?; + if !parent_can.starts_with(&root_can) { + return Err("path escapes root".into()); + } + Ok(joined) + } +} + +pub fn sanitize_filename(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + + // Only keep the last path component for both Unix and Windows-style separators. + let raw = raw.rsplit(['/', '\\']).next().unwrap_or(raw); + let raw = raw.trim(); + if raw.is_empty() || raw == "." || raw == ".." { + return None; + } + + // Avoid control chars and separators. + let mut out = String::new(); + for ch in raw.chars() { + if ch.is_control() || ch == '/' || ch == '\\' { + continue; + } + out.push(ch); + } + + if out.is_empty() || out == "." || out == ".." { + None + } else { + Some(out) + } +} + +fn has_upload_token(request: &tiny_http::Request, expected: &str) -> bool { + request + .headers() + .iter() + .any(|h| h.field.equiv("X-Upload-Token") && h.value.as_str() == expected) +} + +fn content_type_for(path: &Path) -> &'static str { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + match ext.to_ascii_lowercase().as_str() { + "html" | "htm" => "text/html; charset=utf-8", + "txt" => "text/plain; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "json" => "application/json; charset=utf-8", + "csv" => "text/csv; charset=utf-8", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "pdf" => "application/pdf", + "zip" => "application/zip", + "gz" => "application/gzip", + _ => "application/octet-stream", + } +} + +fn header_safe_filename(name: &str) -> String { + let mut out = String::new(); + for ch in name.chars() { + match ch { + '"' | '\r' | '\n' => out.push('_'), + _ => out.push(ch), + } + } + if out.is_empty() { + "download".to_string() + } else { + out + } +} + +pub fn html_escape(s: &str) -> String { + let mut out = String::new(); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn generate_token() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!( + "{:x}{:x}{:x}", + now.as_secs(), + now.subsec_nanos(), + std::process::id() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percent_decode_basic() { + assert_eq!(percent_decode("a%20b"), "a b"); + assert_eq!(percent_decode("a+b"), "a b"); + assert_eq!(percent_decode("%2F"), "/"); + } + + #[test] + fn sanitize_filename_rejects_empty_and_dots() { + assert_eq!(sanitize_filename(""), None); + assert_eq!(sanitize_filename("."), None); + assert_eq!(sanitize_filename(".."), None); + } + + #[test] + fn sanitize_filename_strips_paths() { + assert_eq!(sanitize_filename("../x.txt").as_deref(), Some("x.txt")); + assert_eq!(sanitize_filename("a/b/c.bin").as_deref(), Some("c.bin")); + assert_eq!(sanitize_filename("a\\b\\c.bin").as_deref(), Some("c.bin")); + } + + #[test] + fn safe_join_rejects_parent_dir() { + let root = std::env::current_dir().unwrap(); + assert!(safe_join(&root, "../etc").is_err()); + assert!(safe_join(&root, "a/../b").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 877b442..4167aa6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,40 +1,17 @@ -use std::collections::HashMap; -use std::fs::{self, File, OpenOptions}; +use std::fs; use std::io::{self, Write}; -use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::thread; -use std::time::{SystemTime, UNIX_EPOCH}; -use tiny_http::{Header, Method, Response, Server, StatusCode}; +use clap::Parser; +use tiny_http::Server; -const DEFAULT_BIND: &str = "127.0.0.1:7878"; -const DEFAULT_ROOT: &str = "./shared"; -const DEFAULT_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; // 100 MiB +use lanshare::{handle_request, Config}; -type AnyError = Box; +fn main() -> Result<(), Box> { + let mut cfg = Config::parse(); + cfg.finalize(); -#[derive(Clone, Copy, Debug)] -enum OverwritePolicy { - Deny, -} - -#[derive(Clone, Debug)] -struct Config { - bind: String, - root: PathBuf, - token: Option, - max_file_bytes: u64, - overwrite: OverwritePolicy, -} - -fn main() -> Result<(), AnyError> { - let cfg = parse_args().map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("{e}\n\n{}", help_text()), - ) - })?; fs::create_dir_all(&cfg.root)?; let server = Server::http(&cfg.bind)?; @@ -67,631 +44,3 @@ fn main() -> Result<(), AnyError> { Ok(()) } - -fn handle_request(request: tiny_http::Request, cfg: &Config) -> Result<(), AnyError> { - let (path_part, query) = split_url(request.url()); - - match (request.method(), path_part.as_str()) { - (&Method::Get, "/") => return redirect_see_other(request, "/browse/"), - (&Method::Get, p) if p == "/browse" || p == "/browse/" || p.starts_with("/browse/") => { - let rel = if p == "/browse" || p == "/browse/" { - String::new() - } else { - // Strip "/browse/" prefix. - normalize_rel_path(percent_decode(&p["/browse/".len()..])) - }; - return handle_browse(request, cfg, rel); - } - (&Method::Get, p) if p == "/files" || p == "/files/" || p.starts_with("/files/") => { - let rel = if p == "/files" || p == "/files/" { - String::new() - } else { - normalize_rel_path(percent_decode(&p["/files/".len()..])) - }; - return handle_download(request, cfg, rel); - } - (&Method::Post, "/upload") => { - let dir = query.get("dir").cloned().unwrap_or_default(); - let name = query.get("name").cloned().unwrap_or_default(); - return handle_upload(request, cfg, dir, name); - } - _ => {} - } - - respond_text(request, 404, "not found") -} - -fn handle_browse(request: tiny_http::Request, cfg: &Config, rel: String) -> Result<(), AnyError> { - let abs = safe_join(&cfg.root, &rel).map_err(|e| format!("invalid path: {e}"))?; - if !abs.is_dir() { - return respond_text(request, 404, "not found"); - } - - let html = render_directory_page(cfg, &rel, &abs)?; - respond_html(request, 200, html) -} - -fn handle_download(request: tiny_http::Request, cfg: &Config, rel: String) -> Result<(), AnyError> { - let abs = safe_join(&cfg.root, &rel).map_err(|e| format!("invalid path: {e}"))?; - if abs.is_dir() { - let target = if rel.is_empty() { - "/browse/".to_string() - } else { - format!("/browse/{}/", url_escape_path(&rel)) - }; - return redirect_see_other(request, &target); - } - - let file = File::open(&abs)?; - let content_type = content_type_for(&abs); - let filename = abs - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("download"); - let filename = header_safe_filename(filename); - - let ct = Header::from_bytes(b"Content-Type" as &[u8], content_type.as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Content-Type header"))?; - let disp_value = format!("attachment; filename=\"{filename}\""); - let disp = Header::from_bytes(b"Content-Disposition" as &[u8], disp_value.as_bytes()).map_err( - |_| { - io::Error::new( - io::ErrorKind::InvalidInput, - "invalid Content-Disposition header", - ) - }, - )?; - - let response = Response::from_file(file) - .with_status_code(StatusCode(200)) - .with_header(ct) - .with_header(disp); - - request.respond(response)?; - Ok(()) -} - -fn handle_upload( - mut request: tiny_http::Request, - cfg: &Config, - dir: String, - name: String, -) -> Result<(), AnyError> { - if let Some(expected) = &cfg.token { - if !has_upload_token(&request, expected) { - return respond_text(request, 401, "missing/invalid upload token"); - } - } - - let dir = normalize_rel_path(dir); - let target_dir = safe_join(&cfg.root, &dir).map_err(|e| format!("invalid dir: {e}"))?; - if !target_dir.is_dir() { - return respond_text(request, 400, "dir does not exist"); - } - - let filename = sanitize_filename(&name).ok_or("invalid filename")?; - let dest = target_dir.join(&filename); - - let mut out = match cfg.overwrite { - OverwritePolicy::Deny => { - match OpenOptions::new().write(true).create_new(true).open(&dest) { - Ok(f) => f, - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { - return respond_text(request, 409, "file already exists"); - } - Err(e) => return Err(e.into()), - } - } - }; - let mut buf = [0u8; 8192]; - let mut written: u64 = 0; - let reader = request.as_reader(); - - loop { - let n = reader.read(&mut buf)?; - if n == 0 { - break; - } - written = written.saturating_add(n as u64); - if written > cfg.max_file_bytes { - drop(out); - let _ = fs::remove_file(&dest); - return respond_text(request, 413, "file too large"); - } - out.write_all(&buf[..n])?; - } - - respond_text(request, 200, "ok") -} - -fn render_directory_page(cfg: &Config, rel: &str, abs: &Path) -> io::Result { - let mut entries: Vec<_> = fs::read_dir(abs)?.filter_map(|e| e.ok()).collect(); - - entries.sort_by_key(|e| { - let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); - let name = e.file_name(); - (!is_dir, name) - }); - - let mut rows = String::new(); - if !rel.is_empty() { - let parent_rel = Path::new(rel) - .parent() - .and_then(|p| p.to_str()) - .unwrap_or(""); - let href = format!("/browse/{}/", url_escape_path(parent_rel)); - rows.push_str(&format!("
  • ..
  • \n")); - } - - for e in entries { - let name_os = e.file_name(); - let name = name_os.to_string_lossy(); - let meta = e.metadata().ok(); - let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); - - let child_rel = if rel.is_empty() { - name.to_string() - } else { - format!("{rel}/{name}") - }; - - let display_name = html_escape(&name); - if is_dir { - let href = format!("/browse/{}/", url_escape_path(&child_rel)); - rows.push_str(&format!( - "
  • [DIR] {display_name}/
  • \n" - )); - } else { - let href = format!("/files/{}", url_escape_path(&child_rel)); - let size = meta.map(|m| m.len()).unwrap_or(0); - rows.push_str(&format!( - "
  • {size} bytes {display_name}
  • \n" - )); - } - } - - let rel_display = if rel.is_empty() { - "/".to_string() - } else { - format!("/{rel}") - }; - let dir_attr = rel; - - let html = format!( - r#" - - - - - File Server - - - -

    File Server

    -
    Directory: {}
    - -

    Upload

    -
    Uploads require a token by default. Start the server and copy the token printed in the terminal.
    -
    Max upload size: {} bytes
    -
    - - - -
    -
    - -

    Files

    -
      - {} -
    - - - - -"#, - html_escape(&rel_display), - cfg.max_file_bytes, - html_escape(dir_attr), - cfg.max_file_bytes, - rows - ); - - Ok(html) -} - -fn respond_html(request: tiny_http::Request, status: u16, body: String) -> Result<(), AnyError> { - let ct = Header::from_bytes( - b"Content-Type" as &[u8], - b"text/html; charset=utf-8" as &[u8], - ) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Content-Type header"))?; - let response = Response::from_string(body) - .with_status_code(StatusCode(status)) - .with_header(ct); - request.respond(response)?; - Ok(()) -} - -fn respond_text(request: tiny_http::Request, status: u16, body: &str) -> Result<(), AnyError> { - let ct = Header::from_bytes( - b"Content-Type" as &[u8], - b"text/plain; charset=utf-8" as &[u8], - ) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Content-Type header"))?; - let response = Response::from_string(body) - .with_status_code(StatusCode(status)) - .with_header(ct); - request.respond(response)?; - Ok(()) -} - -fn redirect_see_other(request: tiny_http::Request, location: &str) -> Result<(), AnyError> { - let loc = Header::from_bytes(b"Location" as &[u8], location.as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid Location header"))?; - let response = Response::empty(StatusCode(303)).with_header(loc); - request.respond(response)?; - Ok(()) -} - -fn split_url(url: &str) -> (String, HashMap) { - let (path, query) = url.split_once('?').unwrap_or((url, "")); - (path.to_string(), parse_query(query)) -} - -fn parse_query(query: &str) -> HashMap { - let mut out = HashMap::new(); - for pair in query.split('&') { - if pair.is_empty() { - continue; - } - let (k, v) = pair.split_once('=').unwrap_or((pair, "")); - out.insert(percent_decode(k), percent_decode(v)); - } - out -} - -fn percent_decode(s: &str) -> String { - let b = s.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while i < b.len() { - match b[i] { - b'+' => { - out.push(b' '); - i += 1; - } - b'%' if i + 2 < b.len() => { - let hi = from_hex(b[i + 1]); - let lo = from_hex(b[i + 2]); - if let (Some(hi), Some(lo)) = (hi, lo) { - out.push((hi << 4) | lo); - i += 3; - } else { - out.push(b[i]); - i += 1; - } - } - c => { - out.push(c); - i += 1; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - -fn from_hex(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} - -fn url_escape_path(rel: &str) -> String { - if rel.is_empty() { - return String::new(); - } - rel.split('/') - .map(percent_encode_segment) - .collect::>() - .join("/") -} - -fn percent_encode_segment(seg: &str) -> String { - let mut out = String::new(); - for b in seg.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out -} - -fn normalize_rel_path(s: String) -> String { - s.trim_matches('/').to_string() -} - -fn safe_join(root: &Path, rel: &str) -> Result { - let root_can = root - .canonicalize() - .map_err(|e| format!("cannot canonicalize root: {e}"))?; - - let rel = rel.trim_start_matches('/'); - let rel_path = Path::new(rel); - - if rel_path.is_absolute() { - return Err("absolute path not allowed".into()); - } - for comp in rel_path.components() { - match comp { - Component::ParentDir => return Err("parent dir not allowed".into()), - Component::Prefix(_) => return Err("path prefix not allowed".into()), - _ => {} - } - } - - let joined = root.join(rel_path); - if joined.exists() { - let can = joined - .canonicalize() - .map_err(|e| format!("cannot canonicalize path: {e}"))?; - if !can.starts_with(&root_can) { - return Err("path escapes root".into()); - } - Ok(can) - } else { - // For non-existent upload targets, validate the parent directory. - let parent = joined.parent().unwrap_or(root); - let parent_can = parent - .canonicalize() - .map_err(|e| format!("cannot canonicalize parent: {e}"))?; - if !parent_can.starts_with(&root_can) { - return Err("path escapes root".into()); - } - Ok(joined) - } -} - -fn sanitize_filename(raw: &str) -> Option { - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - - // Only keep the last path component for both Unix and Windows-style separators. - let raw = raw.rsplit(['/', '\\']).next().unwrap_or(raw); - let raw = raw.trim(); - if raw.is_empty() || raw == "." || raw == ".." { - return None; - } - - // Avoid control chars and separators. - let mut out = String::new(); - for ch in raw.chars() { - if ch.is_control() || ch == '/' || ch == '\\' { - continue; - } - out.push(ch); - } - - if out.is_empty() || out == "." || out == ".." { - None - } else { - Some(out) - } -} - -fn has_upload_token(request: &tiny_http::Request, expected: &str) -> bool { - request - .headers() - .iter() - .any(|h| h.field.equiv("X-Upload-Token") && h.value.as_str() == expected) -} - -fn content_type_for(path: &Path) -> &'static str { - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - match ext.to_ascii_lowercase().as_str() { - "html" | "htm" => "text/html; charset=utf-8", - "txt" => "text/plain; charset=utf-8", - "css" => "text/css; charset=utf-8", - "js" => "text/javascript; charset=utf-8", - "json" => "application/json; charset=utf-8", - "csv" => "text/csv; charset=utf-8", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "svg" => "image/svg+xml", - "pdf" => "application/pdf", - "zip" => "application/zip", - "gz" => "application/gzip", - _ => "application/octet-stream", - } -} - -fn header_safe_filename(name: &str) -> String { - let mut out = String::new(); - for ch in name.chars() { - match ch { - '"' | '\r' | '\n' => out.push('_'), - _ => out.push(ch), - } - } - if out.is_empty() { - "download".to_string() - } else { - out - } -} - -fn html_escape(s: &str) -> String { - let mut out = String::new(); - for ch in s.chars() { - match ch { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - '\'' => out.push_str("'"), - _ => out.push(ch), - } - } - out -} - -fn parse_args() -> Result { - let mut bind = DEFAULT_BIND.to_string(); - let mut root = PathBuf::from(DEFAULT_ROOT); - let mut token: Option = None; - let mut no_token = false; - let mut max_file_bytes = DEFAULT_MAX_FILE_BYTES; - let mut overwrite = OverwritePolicy::Deny; - - let mut args = std::env::args().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "--bind" => bind = args.next().ok_or("--bind requires a value")?, - "--root" => root = PathBuf::from(args.next().ok_or("--root requires a value")?), - "--token" => token = Some(args.next().ok_or("--token requires a value")?), - "--no-token" => no_token = true, - "--max-file-bytes" => { - let v = args.next().ok_or("--max-file-bytes requires a value")?; - max_file_bytes = v - .parse::() - .map_err(|_| "invalid --max-file-bytes (expected integer bytes)")?; - } - "--overwrite" => { - let v = args.next().ok_or("--overwrite requires a value")?; - if v != "deny" { - return Err("v1 only supports --overwrite deny".into()); - } - overwrite = OverwritePolicy::Deny; - } - "--help" | "-h" => { - print!("{}", help_text()); - std::process::exit(0); - } - _ => return Err(format!("unknown argument: {arg}")), - } - } - - if no_token { - token = None; - } else if token.is_none() { - token = Some(generate_token()); - } - - Ok(Config { - bind, - root, - token, - max_file_bytes, - overwrite, - }) -} - -fn generate_token() -> String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - format!( - "{:x}{:x}{:x}", - now.as_secs(), - now.subsec_nanos(), - std::process::id() - ) -} - -fn help_text() -> String { - format!( - "Usage:\n lanshare [options]\n\nOptions:\n --bind Bind address (default: {DEFAULT_BIND})\n --root Share root directory (default: {DEFAULT_ROOT})\n --token Upload token (default: auto-generated)\n --no-token Disable upload token (NOT recommended)\n --max-file-bytes Max upload size in bytes (default: {DEFAULT_MAX_FILE_BYTES})\n --overwrite deny Overwrite policy (v1: deny only)\n -h, --help Show help\n\nEndpoints:\n GET /browse/ Browse directories\n GET /files/ Download files\n POST /upload?dir=&name= Upload raw bytes (requires JS UI)\n" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn percent_decode_basic() { - assert_eq!(percent_decode("a%20b"), "a b"); - assert_eq!(percent_decode("a+b"), "a b"); - assert_eq!(percent_decode("%2F"), "/"); - } - - #[test] - fn sanitize_filename_rejects_empty_and_dots() { - assert_eq!(sanitize_filename(""), None); - assert_eq!(sanitize_filename("."), None); - assert_eq!(sanitize_filename(".."), None); - } - - #[test] - fn sanitize_filename_strips_paths() { - assert_eq!(sanitize_filename("../x.txt").as_deref(), Some("x.txt")); - assert_eq!(sanitize_filename("a/b/c.bin").as_deref(), Some("c.bin")); - assert_eq!(sanitize_filename("a\\b\\c.bin").as_deref(), Some("c.bin")); - } - - #[test] - fn safe_join_rejects_parent_dir() { - let root = std::env::current_dir().unwrap(); - assert!(safe_join(&root, "../etc").is_err()); - assert!(safe_join(&root, "a/../b").is_err()); - } -}