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:
parent
6af3df0ecc
commit
b0fa5b3ec4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
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 & <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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Child> = 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue