feat(xtask): serve watched packages over local HTTP

Add watch-only --serve on 127.0.0.1:9932 with directory listings, streamed downloads and HEAD requests. Serve the selected output directory independently of rebuilds and reject paths outside its root. Document usage and test HTTP responses and option parsing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-21 22:42:51 +02:00
parent 6af3df0ecc
commit b0fa5b3ec4
7 changed files with 306 additions and 6 deletions

View File

@ -7,6 +7,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### 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, - `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period,
with automatic rebuilds paused while `dist/NOREBUILD` exists. with automatic rebuilds paused while `dist/NOREBUILD` exists.
- Separate gameplay floor and structure layers, with buildable/removable floors - Separate gameplay floor and structure layers, with buildable/removable floors

32
Cargo.lock generated
View File

@ -297,6 +297,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]] [[package]]
name = "ash" name = "ash"
version = "0.38.0+1.3.281" version = "0.38.0+1.3.281"
@ -2169,6 +2175,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -3525,6 +3537,12 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.15" version = "0.4.15"
@ -6268,6 +6286,18 @@ dependencies = [
"strict-num", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@ -7520,8 +7550,10 @@ name = "xtask"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"flate2", "flate2",
"percent-encoding",
"serde_json", "serde_json",
"tar", "tar",
"tiny_http",
"zip", "zip",
] ]

View File

@ -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 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. 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 <http://127.0.0.1:9932/>.
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 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. 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 An already running build finishes, and subsequent builds never overlap. Build

View File

@ -6,8 +6,10 @@ publish = false
[dependencies] [dependencies]
flate2 = "1" flate2 = "1"
percent-encoding = "2"
serde_json = "1" serde_json = "1"
tar = { version = "0.4", default-features = false } tar = { version = "0.4", default-features = false }
tiny_http = "0.12"
zip = { version = "9", default-features = false, features = ["deflate-flate2"] } zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
[lints] [lints]

View File

@ -2,9 +2,10 @@
//! //!
//! ```text //! ```text
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] //! 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; mod watch;
use std::{ use std::{
@ -28,7 +29,9 @@ fn main() -> ExitCode {
let mut args = env::args().skip(1); let mut args = env::args().skip(1);
let result = match args.next().as_deref() { let result = match args.next().as_deref() {
Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)), 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 => { Some("-h" | "--help") | None => {
print_help(); print_help();
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
@ -54,7 +57,9 @@ fn print_help() {
--target TRIPLE cross-compile for TRIPLE (default: host)\n \ --target TRIPLE cross-compile for TRIPLE (default: host)\n \
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \ --format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
--out-dir DIR output directory (default: dist)\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 { impl PackOptions {
fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> { fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
Self::parse_options(&mut args, false).map(|(opts, _)| opts)
}
fn parse_watch(mut args: impl Iterator<Item = String>) -> Result<(Self, bool)> {
Self::parse_options(&mut args, true)
}
fn parse_options(args: &mut impl Iterator<Item = String>, watch: bool) -> Result<(Self, bool)> {
let mut opts = Self::default(); let mut opts = Self::default();
let mut serve = false;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value")); let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
match arg.as_str() { match arg.as_str() {
@ -107,14 +121,15 @@ impl PackOptions {
"--format" => opts.format = Some(Format::parse(&value("--format")?)?), "--format" => opts.format = Some(Format::parse(&value("--format")?)?),
"--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()), "--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()),
"--debug" => opts.debug = true, "--debug" => opts.debug = true,
"--serve" if watch => serve = true,
"-h" | "--help" => { "-h" | "--help" => {
print_help(); print_help();
std::process::exit(0); 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))
} }
} }

235
xtask/src/serve.rs Normal file
View File

@ -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<JoinHandle<()>> {
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<String> {
let mut entries = fs::read_dir(path)?.collect::<io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
let mut html = String::from(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Station downloads</title>\
</head><body><h1>Station downloads</h1><ul>",
);
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;");
html.push_str(&format!("<li><a href=\"./{href}\">{label}</a></li>"));
}
html.push_str("</ul></body></html>");
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 & <test>.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 &amp; &lt;test&gt;.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();
}
}

View File

@ -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 root = workspace_root().canonicalize()?;
let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist"))); let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist")));
fs::create_dir_all(&output)?; fs::create_dir_all(&output)?;
@ -44,6 +44,11 @@ pub fn run(options: &PackOptions) -> Result<()> {
if root.starts_with(&output) { if root.starts_with(&output) {
return Err("watch output directory must not contain the workspace".into()); 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 excluded = [root.join("dist"), root.join("target"), output];
let guard = root.join("dist/NOREBUILD"); let guard = root.join("dist/NOREBUILD");
let mut previous = snapshot(&root, &excluded)?; let mut previous = snapshot(&root, &excluded)?;
@ -51,6 +56,9 @@ pub fn run(options: &PackOptions) -> Result<()> {
let mut build: Option<Child> = None; let mut build: Option<Child> = None;
println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop."); println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop.");
loop { 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 current = snapshot(&root, &excluded)?;
let changed = current != previous; let changed = current != previous;
previous = current; previous = current;