diff --git a/CHANGELOG.md b/CHANGELOG.md index f201b8a..e3e2b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `cargo xtask watch --serve` serves the packaging output with directory listings + and downloads at `http://127.0.0.1:9932/`, including while rebuilds are paused. - `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period, with automatic rebuilds paused while `dist/NOREBUILD` exists. - Separate gameplay floor and structure layers, with buildable/removable floors diff --git a/Cargo.lock b/Cargo.lock index 2ce960c..a90b663 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,6 +297,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -2169,6 +2175,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -3525,6 +3537,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -6268,6 +6286,18 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -7520,8 +7550,10 @@ name = "xtask" version = "0.1.0" dependencies = [ "flate2", + "percent-encoding", "serde_json", "tar", + "tiny_http", "zip", ] diff --git a/README.md b/README.md index 813cfdf..5e74452 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,12 @@ that timer. It watches Git-tracked and non-ignored untracked files, including assets, manifests and packaging code, and ignores `dist/`, `target/`, Git metadata and the selected output directory. Git must be installed. No build runs on startup. +Add `--serve` to browse and download the output files over HTTP: +`cargo xtask watch --serve` serves `dist/` at . +With `--out-dir DIR`, it serves that directory instead. The server is local-only, +provides directory listings and file downloads, and remains available during +builds and while rebuilds are paused. Stop the watcher to stop serving. + Create `dist/NOREBUILD` to pause automatic builds. Changes remain queued while the file exists; deleting it starts a fresh 10-second wait for pending changes. An already running build finishes, and subsequent builds never overlap. Build diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 3f9040f..01bd0d0 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,8 +6,10 @@ publish = false [dependencies] flate2 = "1" +percent-encoding = "2" serde_json = "1" tar = { version = "0.4", default-features = false } +tiny_http = "0.12" zip = { version = "9", default-features = false, features = ["deflate-flate2"] } [lints] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e892169..3f4d0f6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,9 +2,10 @@ //! //! ```text //! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] -//! cargo xtask watch [pack options] +//! cargo xtask watch [pack options] [--serve] //! ``` +mod serve; mod watch; use std::{ @@ -28,7 +29,9 @@ fn main() -> ExitCode { let mut args = env::args().skip(1); let result = match args.next().as_deref() { Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)), - Some("watch") => PackOptions::parse(args).and_then(|opts| watch::run(&opts)), + Some("watch") => { + PackOptions::parse_watch(args).and_then(|(opts, serve)| watch::run(&opts, serve)) + } Some("-h" | "--help") | None => { print_help(); return ExitCode::SUCCESS; @@ -54,7 +57,9 @@ fn print_help() { --target TRIPLE cross-compile for TRIPLE (default: host)\n \ --format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \ --out-dir DIR output directory (default: dist)\n \ - --debug pack a debug build instead of the stripped `dist` profile" + --debug pack a debug build instead of the stripped `dist` profile\n\n\ + watch options:\n \ + --serve serve the output directory at http://127.0.0.1:9932" ); } @@ -99,7 +104,16 @@ struct PackOptions { impl PackOptions { fn parse(mut args: impl Iterator) -> Result { + Self::parse_options(&mut args, false).map(|(opts, _)| opts) + } + + fn parse_watch(mut args: impl Iterator) -> Result<(Self, bool)> { + Self::parse_options(&mut args, true) + } + + fn parse_options(args: &mut impl Iterator, watch: bool) -> Result<(Self, bool)> { let mut opts = Self::default(); + let mut serve = false; while let Some(arg) = args.next() { let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value")); match arg.as_str() { @@ -107,14 +121,15 @@ impl PackOptions { "--format" => opts.format = Some(Format::parse(&value("--format")?)?), "--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()), "--debug" => opts.debug = true, + "--serve" if watch => serve = true, "-h" | "--help" => { print_help(); std::process::exit(0); } - other => return Err(format!("unknown pack option `{other}`").into()), + other => return Err(format!("unknown option `{other}`").into()), } } - Ok(opts) + Ok((opts, serve)) } } diff --git a/xtask/src/serve.rs b/xtask/src/serve.rs new file mode 100644 index 0000000..e1ff0cb --- /dev/null +++ b/xtask/src/serve.rs @@ -0,0 +1,235 @@ +use std::{ + fs::{self, File}, + io, + path::{Component, Path}, + thread::{self, JoinHandle}, +}; + +use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; +use tiny_http::{Header, Method, Request, Response, Server}; + +use crate::Result; + +pub fn start(root: &Path) -> Result> { + let server = Server::http("127.0.0.1:9932") + .map_err(|error| format!("could not start HTTP server: {error}"))?; + let root = root.to_path_buf(); + println!("Serving {} at http://127.0.0.1:9932/", root.display()); + Ok(thread::spawn(move || { + for request in server.incoming_requests() { + if let Err(error) = respond(request, &root) { + eprintln!("HTTP request failed: {error}"); + } + } + })) +} + +fn header(name: &str, value: &str) -> Header { + Header::from_bytes(name, value).expect("valid HTTP header") +} + +fn text(request: Request, status: u16, body: &str) -> io::Result<()> { + request.respond( + Response::from_string(body) + .with_status_code(status) + .with_header(header("Cache-Control", "no-store")), + ) +} + +fn respond(request: Request, root: &Path) -> io::Result<()> { + if !matches!(request.method(), Method::Get | Method::Head) { + return request.respond( + Response::from_string("Method not allowed") + .with_status_code(405) + .with_header(header("Allow", "GET, HEAD")), + ); + } + let url = request.url().split('?').next().unwrap_or("/"); + let Ok(decoded) = percent_decode_str(url).decode_utf8() else { + return text(request, 400, "Invalid URL encoding"); + }; + if !decoded.starts_with('/') + || decoded.contains(['\\', '\0']) + || Path::new(decoded.trim_start_matches('/')) + .components() + .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) + { + return text(request, 403, "Forbidden"); + } + let path = match root.join(decoded.trim_start_matches('/')).canonicalize() { + Ok(path) if path.starts_with(root) => path, + Ok(_) => return text(request, 403, "Forbidden"), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return text(request, 404, "Not found"); + } + Err(error) => { + eprintln!("Could not resolve HTTP path: {error}"); + return text(request, 500, "Could not read path"); + } + }; + if path.is_dir() { + if !url.ends_with('/') { + let location = format!("{url}/"); + return request + .respond(Response::empty(301).with_header(header("Location", &location))); + } + let listing = match listing(&path) { + Ok(listing) => listing, + Err(error) => { + eprintln!("Could not list {}: {error}", path.display()); + return text(request, 500, "Could not list directory"); + } + }; + return request.respond( + Response::from_string(listing) + .with_header(header("Content-Type", "text/html; charset=utf-8")) + .with_header(header("Cache-Control", "no-store")) + .with_header(header("Content-Security-Policy", "default-src 'none'")), + ); + } + if !path.is_file() { + return text(request, 403, "Forbidden"); + } + match File::open(&path) { + Ok(file) => request.respond( + Response::from_file(file) + .with_header(header("Content-Type", "application/octet-stream")) + .with_header(header("X-Content-Type-Options", "nosniff")) + .with_header(header("Cache-Control", "no-store")), + ), + Err(error) => { + eprintln!("Could not open {}: {error}", path.display()); + text(request, 500, "Could not read file") + } + } +} + +fn listing(path: &Path) -> io::Result { + let mut entries = fs::read_dir(path)?.collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + let mut html = String::from( + "Station downloads\ +

Station downloads

    ", + ); + for entry in entries { + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "filename is not UTF-8"))?; + let href = utf8_percent_encode(name, NON_ALPHANUMERIC); + let label = name + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + html.push_str(&format!("
  • {label}
  • ")); + } + html.push_str("
"); + Ok(html) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpStream, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use crate::PackOptions; + + #[test] + fn serve_is_watch_only_and_does_not_consume_pack_option_values() { + let parse = + |args: &[&str]| PackOptions::parse_watch(args.iter().map(|arg| (*arg).to_owned())); + let (options, serve) = parse(&["--serve", "--debug", "--out-dir", "custom"]).unwrap(); + assert!(serve); + assert!(options.debug); + assert_eq!(options.out_dir.unwrap(), Path::new("custom")); + assert!(!parse(&["--debug"]).unwrap().1); + let (options, serve) = parse(&["--out-dir", "--serve"]).unwrap(); + assert!(!serve); + assert_eq!(options.out_dir.unwrap(), Path::new("--serve")); + assert!(PackOptions::parse(["--serve".to_owned()].into_iter()).is_err()); + } + + #[test] + fn http_lists_downloads_supports_head_and_confines_paths() { + let temp = std::env::temp_dir().join(format!( + "station-http-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let root = temp.join("dist"); + fs::create_dir_all(root.join("nested")).unwrap(); + let root = root.canonicalize().unwrap(); + fs::write(root.join("build & .zip"), [0, 1, 255, 10]).unwrap(); + fs::write(root.join("nested/file.txt"), "nested contents").unwrap(); + fs::write(temp.join("private.txt"), "private").unwrap(); + let server = Server::http("127.0.0.1:0").unwrap(); + let exchange = |method: &str, url: &str| { + let mut stream = TcpStream::connect(server.server_addr().to_ip().unwrap()).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + write!( + stream, + "{method} {url} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let request = server + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .unwrap(); + respond(request, &root).unwrap(); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).unwrap(); + let split = bytes + .windows(4) + .position(|part| part == b"\r\n\r\n") + .unwrap(); + ( + String::from_utf8(bytes[..split].to_vec()).unwrap(), + bytes[split + 4..].to_vec(), + ) + }; + let (headers, body) = exchange("GET", "/"); + assert!(headers.starts_with("HTTP/1.1 200")); + let html = String::from_utf8(body).unwrap(); + assert!(html.contains("build & <test>.zip")); + assert!(html.contains("href=\"./build%20%26%20%3Ctest%3E%2Ezip\"")); + let (headers, body) = exchange("GET", "/build%20%26%20%3Ctest%3E.zip?download=1"); + assert!(headers.starts_with("HTTP/1.1 200")); + assert_eq!(body, [0, 1, 255, 10]); + let (headers, body) = exchange("HEAD", "/build%20%26%20%3Ctest%3E.zip"); + assert!(headers.starts_with("HTTP/1.1 200")); + assert!(headers.to_lowercase().contains("content-length: 4")); + assert!(body.is_empty()); + assert!(exchange("GET", "/nested").0.starts_with("HTTP/1.1 301")); + assert_eq!(exchange("GET", "/nested/file.txt").1, b"nested contents"); + assert!(exchange("GET", "/missing").0.starts_with("HTTP/1.1 404")); + assert!(exchange("POST", "/").0.starts_with("HTTP/1.1 405")); + for url in [ + "/../private.txt", + "/%2e%2e/private.txt", + "/..%5cprivate.txt", + "/%00", + ] { + assert!(exchange("GET", url).0.starts_with("HTTP/1.1 403"), "{url}"); + } + assert!(exchange("GET", "/%FF").0.starts_with("HTTP/1.1 400")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(temp.join("private.txt"), root.join("link")).unwrap(); + assert!(exchange("GET", "/link").0.starts_with("HTTP/1.1 403")); + } + fs::write(root.join("nested/file.txt"), "new contents").unwrap(); + assert_eq!(exchange("GET", "/nested/file.txt").1, b"new contents"); + fs::remove_dir_all(temp).unwrap(); + } +} diff --git a/xtask/src/watch.rs b/xtask/src/watch.rs index cf34dba..7b7209f 100644 --- a/xtask/src/watch.rs +++ b/xtask/src/watch.rs @@ -36,7 +36,7 @@ impl PendingBuild { } } -pub fn run(options: &PackOptions) -> Result<()> { +pub fn run(options: &PackOptions, serve: bool) -> Result<()> { let root = workspace_root().canonicalize()?; let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist"))); fs::create_dir_all(&output)?; @@ -44,6 +44,11 @@ pub fn run(options: &PackOptions) -> Result<()> { if root.starts_with(&output) { return Err("watch output directory must not contain the workspace".into()); } + let server = if serve { + Some(crate::serve::start(&output)?) + } else { + None + }; let excluded = [root.join("dist"), root.join("target"), output]; let guard = root.join("dist/NOREBUILD"); let mut previous = snapshot(&root, &excluded)?; @@ -51,6 +56,9 @@ pub fn run(options: &PackOptions) -> Result<()> { let mut build: Option = None; println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop."); loop { + if server.as_ref().is_some_and(thread::JoinHandle::is_finished) { + return Err("HTTP server stopped unexpectedly".into()); + } let current = snapshot(&root, &excluded)?; let changed = current != previous; previous = current;