//! 개발/테스트용 최소 WebDAV 서버 — 실제 디렉터리를 서빙한다. //! 사용: cargo run -p archive-vfs --example webdav_server -- <포트> <디렉터리> //! PROPFIND(Depth 0/1) + GET(Range) + MOVE + DELETE 지원. 인증 없음(로컬 전용). use std::path::{Path, PathBuf}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; #[tokio::main] async fn main() { let args: Vec = std::env::args().collect(); let port: u16 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(8080); let root = PathBuf::from(args.get(2).cloned().unwrap_or_else(|| ".".into())); let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); println!("WebDAV 서버: http://127.0.0.1:{port}/ root={}", root.display()); loop { let (sock, _) = listener.accept().await.unwrap(); let root = root.clone(); tokio::spawn(async move { let _ = handle(sock, root).await; }); } } async fn handle(mut sock: TcpStream, root: PathBuf) -> std::io::Result<()> { let mut buf = vec![0u8; 16384]; let n = sock.read(&mut buf).await?; if n == 0 { return Ok(()); } let req = String::from_utf8_lossy(&buf[..n]); let mut lines = req.lines(); let first = lines.next().unwrap_or(""); let mut parts = first.split_whitespace(); let method = parts.next().unwrap_or(""); let raw_path = parts.next().unwrap_or("/"); let path = urldecode(raw_path); let depth = req .lines() .find(|l| l.to_ascii_lowercase().starts_with("depth:")) .and_then(|l| l.split(':').nth(1)) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "1".into()); let fs_path = root.join(path.trim_start_matches('/')); match method { "OPTIONS" => { write_head(&mut sock, "200 OK", "text/plain", 0, None, "DAV: 1\r\nAllow: OPTIONS, GET, PROPFIND, MOVE, DELETE, MKCOL, PUT\r\n").await?; } "PROPFIND" => { let xml = build_propfind(&root, &fs_path, &path, &depth); write_head(&mut sock, "207 Multi-Status", "application/xml; charset=utf-8", xml.len(), None, "").await?; sock.write_all(xml.as_bytes()).await?; } "GET" => { if let Ok(data) = tokio::fs::read(&fs_path).await { let range = req .lines() .find(|l| l.to_ascii_lowercase().starts_with("range:")) .and_then(|l| l.split("bytes=").nth(1)) .map(|s| s.trim().to_string()); match range { Some(spec) => { let (s, e) = spec.split_once('-').unwrap_or(("0", "")); let start: usize = s.trim().parse().unwrap_or(0); let end: usize = e.trim().parse().unwrap_or(data.len().saturating_sub(1)); let end = end.min(data.len().saturating_sub(1)); let slice = &data[start..=end]; let cr = format!("bytes {}-{}/{}", start, end, data.len()); write_head(&mut sock, "206 Partial Content", "application/octet-stream", slice.len(), Some(&cr), "").await?; sock.write_all(slice).await?; } None => { write_head(&mut sock, "200 OK", "application/octet-stream", data.len(), None, "").await?; sock.write_all(&data).await?; } } } else { write_head(&mut sock, "404 Not Found", "text/plain", 0, None, "").await?; } } "MOVE" => { let dest = req .lines() .find(|l| l.to_ascii_lowercase().starts_with("destination:")) .and_then(|l| l.splitn(2, ':').nth(1)) .map(|s| urldecode(s.trim())) .unwrap_or_default(); // Destination은 전체 URL — 경로 부분만 추출 let dest_path = dest.split("//").nth(1).and_then(|s| s.split_once('/')).map(|(_, p)| format!("/{p}")).unwrap_or(dest); let dfs = root.join(dest_path.trim_start_matches('/')); let _ = tokio::fs::rename(&fs_path, &dfs).await; write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?; } "DELETE" => { let _ = tokio::fs::remove_file(&fs_path).await; write_head(&mut sock, "204 No Content", "text/plain", 0, None, "").await?; } "MKCOL" => { let _ = tokio::fs::create_dir_all(&fs_path).await; write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?; } _ => { write_head(&mut sock, "405 Method Not Allowed", "text/plain", 0, None, "").await?; } } sock.flush().await?; Ok(()) } async fn write_head( sock: &mut TcpStream, status: &str, ctype: &str, len: usize, content_range: Option<&str>, extra: &str, ) -> std::io::Result<()> { let mut head = format!( "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {len}\r\nAccept-Ranges: bytes\r\n" ); if let Some(cr) = content_range { head.push_str(&format!("Content-Range: {cr}\r\n")); } head.push_str(extra); head.push_str("Connection: close\r\n\r\n"); sock.write_all(head.as_bytes()).await } fn build_propfind(root: &Path, fs_path: &Path, url_path: &str, depth: &str) -> String { let mut responses = String::new(); let base = url_path.trim_end_matches('/'); // 요청 대상 자신 if fs_path.is_dir() { responses.push_str(&dav_folder(&format!("{base}/"))); if depth != "0" { if let Ok(rd) = std::fs::read_dir(fs_path) { for entry in rd.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); let child_url = format!("{base}/{}", urlencode(&name)); if entry.path().is_dir() { responses.push_str(&dav_folder(&format!("{child_url}/"))); } else { let size = entry.metadata().map(|m| m.len()).unwrap_or(0); responses.push_str(&dav_file(&child_url, size)); } } } } } else { let size = fs_path.metadata().map(|m| m.len()).unwrap_or(0); responses.push_str(&dav_file(base, size)); } let _ = root; format!( r#" {responses}"# ) } fn dav_folder(href: &str) -> String { format!( r#"{href}Wed, 01 Jan 2025 00:00:00 GMTHTTP/1.1 200 OK"# ) } fn dav_file(href: &str, size: u64) -> String { format!( r#"{href}{size}Wed, 01 Jan 2025 00:00:00 GMTHTTP/1.1 200 OK"# ) } fn urldecode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(v); i += 3; continue; } } out.push(bytes[i]); i += 1; } String::from_utf8_lossy(&out).into_owned() } fn urlencode(s: &str) -> String { let mut out = String::new(); for b in s.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!("%{b:02X}")), } } out }