makepad/libs/game/pkg/tests/registry_flow.rs
Admin bdbc946012 Arcade M6+M7: packaging/sharing with sandboxed installs, and the pretty pass
Committed together: both streams landed in libs/game/script, so splitting
them would produce two commits that don't compile.

M6 — packaging and sharing
- libs/zip_file gains a writer (store + deflate); real `unzip -t` validates
  our archives in an interop test. Packing is deterministic (fixed
  timestamps, sorted entries), so a package can be addressed by its own
  sha256 — which is what makes the registry's digest check mean anything
- libs/game/pkg: .arcade format (game.splash + manifest.toml + assets),
  total manifest parsing (attacker bytes always yield a Manifest or an
  error, never a panic; non-finite numbers refused rather than defaulted),
  registry client that verifies sha256 INSIDE download so tampered bytes
  never reach the extractor
- Hardened extraction: absolute paths, drive letters (C:x is absolute on
  Windows), UNC, backslashes, .., NUL/control chars, symlink members (via
  mode bits), duplicate names (the ambiguity IS the attack), declared-size
  caps checked before decompressing plus a post-decompress check, entry/
  total/archive caps, and a post-join re-check that the resolved parent is
  still inside the destination — which catches a pre-existing symlink the
  name test cannot see. 4000-round mutation fuzz with a canary file beside
  the destination; a 320 MB deflate bomb under 1 MB on the wire is refused
- Capability stripping rebinds fs/run/net to FRESH EMPTY OBJECTS rather
  than shadowing known verbs, so there is no hole the day someone adds one.
  Applied before the game handle is registered. Vacuity guard: an unstripped
  isolate genuinely reads a file, so the sandbox tests can't pass for
  unrelated reasons. Browser-installed games load Trust::Downloaded

M7 — pretty pass
- GameSun adopts draw::SceneSun (axis-converted: SceneSun is map-space
  y-south/z-up, games are y-up). Shaders compute hemisphere ambient +
  direct instead of each hardcoding its own split; defaults collapse the
  new formula to the old constants exactly, so unifying did not restyle
  existing games. write_into is the single write path — "one sun" is
  compiler-enforced
- Projected shadow geometry: the caster's silhouette along the sun, fitted
  in the sun's own (u,v) frame, so it stretches as the sun swings. Nearest
  N casters get projection, the rest blobs; one instance in the existing
  alpha batch, no extra pass. 0.6us for 24 casters
- Two pre-existing shadow bugs found via capture: the pipeline blends
  premultiplied, so unpremultiplied dark RGB ADDED light instead of
  removing it; and shadows were fogged, mixing them toward the bright
  horizon so a distant shadow came out lighter than the ground it darkened
- Particles are structurally isolated from the sim: GameWorld has no
  particle field and step_world has no particle code — the renderer owns
  simulation and its own RNG. particles_never_advance_the_world_rng
  interleaves particle verbs with real rand() draws over 32 rounds and
  asserts both the RNG state and the drawn stream are identical
- game.sfx_at with listener-relative gain/pan and a near-field ease so a
  sound at your feet doesn't flip channels; 2D verbs unchanged
- apps/arcade/BUDGETS.md: measured particle/sim costs, Quest columns marked
  as estimates (the real particle limit is fill rate, not CPU)

Tape probe BYTE_IDENTICAL. Not done: arcade has no audio backend, so
positional sound is implemented and tested but not audible there yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 03:27:09 +02:00

309 lines
10 KiB
Rust

//! Download-verify-install and LAN fetch, end to end against a real socket.
//!
//! The server is a std TcpListener in this process: no Cx, no external service,
//! so the whole path a downloaded game takes is exercised headless.
use makepad_game_pkg::{
library::Library, pack::read_package, registry::fetch_lan_package, sha256_hex, HttpError,
IndexEntry, Registry,
};
use makepad_zip_file::{ZipMethod, ZipWriter};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
fn sample_package(name: &str) -> Vec<u8> {
let mut w = ZipWriter::new();
w.add(
"manifest.toml",
format!("name = \"{name}\"\ndescription = \"a test game\"\nplayers_max = 4\n").as_bytes(),
ZipMethod::Deflate,
)
.unwrap();
w.add(
"game.splash",
b"game.sky({})\ngame.box({pos: vec3(0,0,0)})\n",
ZipMethod::Deflate,
)
.unwrap();
w.add("assets/blob.bin", &vec![5u8; 2048], ZipMethod::Deflate)
.unwrap();
w.finish().unwrap()
}
/// Serves a fixed route table, one request per connection, then exits after
/// `requests` have been served so the thread never outlives the test.
fn serve(routes: Vec<(String, Vec<u8>)>, requests: usize) -> (String, mpsc::Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for _ in 0..requests {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
// Read the head, then any declared body.
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let head_end = loop {
let Ok(n) = stream.read(&mut chunk) else { break 0 };
if n == 0 {
break 0;
}
buf.extend_from_slice(&chunk[..n]);
if let Some(p) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break p + 4;
}
if buf.len() > 64 * 1024 {
break 0;
}
};
if head_end == 0 {
continue;
}
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let path = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("/")
.to_string();
let content_length = head
.lines()
.find_map(|l| {
let lower = l.to_ascii_lowercase();
lower
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse::<usize>().ok())
})
.unwrap_or(0);
let mut body = buf[head_end..].to_vec();
while body.len() < content_length {
let Ok(n) = stream.read(&mut chunk) else { break };
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
if !body.is_empty() {
let _ = tx.send(body);
}
let resp = routes.iter().find(|(p, _)| *p == path);
match resp {
Some((_, payload)) => {
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(payload);
}
None => {
let _ = stream.write_all(
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
}
}
let _ = stream.flush();
drop(stream);
}
});
(format!("127.0.0.1:{}", addr.port()), rx)
}
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"makepad-pkg-registry-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn download_verify_install_happy_path() {
let pkg = sample_package("Speedway");
let digest = sha256_hex(&pkg);
let index = format!(
r#"[{{"id":"speedway","name":"Speedway","description":"race","author":"kid","size":{},"sha256":"{}","url":"/games/speedway.arcade"}}]"#,
pkg.len(),
digest
);
let (addr, _rx) = serve(
vec![
("/index.json".into(), index.into_bytes()),
("/games/speedway.arcade".into(), pkg.clone()),
],
2,
);
let reg = Registry::new(&addr);
let entries = reg.index().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "Speedway");
assert_eq!(entries[0].size as usize, pkg.len());
let downloaded = reg.download(&entries[0]).unwrap();
assert_eq!(downloaded, pkg);
let root = tmp_dir("install");
let lib = Library::new(&root);
let entry = lib.install("speedway", &downloaded).unwrap();
assert_eq!(entry.manifest.name, "Speedway");
assert!(entry.dir.join("game.splash").is_file());
assert!(entry.dir.join("assets/blob.bin").is_file());
let listed = lib.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].slug, "speedway");
// And the library can pack it back up for republishing.
let repacked = lib.pack("speedway").unwrap();
let reread = read_package(&repacked).unwrap();
assert_eq!(reread.manifest.name, "Speedway");
assert_eq!(reread.assets.len(), 1);
lib.uninstall("speedway").unwrap();
assert!(lib.list().is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_tampered_download_is_refused_before_it_can_be_installed() {
let pkg = sample_package("Speedway");
let honest_digest = sha256_hex(&pkg);
// The server hands back a DIFFERENT package than the index promised.
let evil = sample_package("NotSpeedway");
assert_ne!(evil, pkg);
let index = format!(
r#"[{{"id":"speedway","name":"Speedway","size":{},"sha256":"{}","url":"/games/speedway.arcade"}}]"#,
pkg.len(),
honest_digest
);
let (addr, _rx) = serve(
vec![
("/index.json".into(), index.into_bytes()),
("/games/speedway.arcade".into(), evil),
],
2,
);
let reg = Registry::new(&addr);
let entries = reg.index().unwrap();
let err = reg.download(&entries[0]).unwrap_err();
match err {
HttpError::DigestMismatch { expected, got } => {
assert_eq!(expected, honest_digest);
assert_ne!(got, honest_digest);
}
other => panic!("expected a digest mismatch, got {other}"),
}
}
#[test]
fn a_missing_game_is_a_status_error_not_a_hang() {
let (addr, _rx) = serve(vec![], 1);
let reg = Registry::new(&addr);
let entry = IndexEntry {
id: "nope".into(),
sha256: "00".into(),
..Default::default()
};
match reg.download(&entry) {
Err(HttpError::Status(404)) => {}
other => panic!("expected 404, got {other:?}"),
}
}
#[test]
fn publish_sends_the_package_body() {
let (addr, rx) = serve(vec![("/publish".into(), b"speedway".to_vec())], 1);
let pkg = sample_package("Speedway");
let reg = Registry::new(&addr);
let id = reg.publish(&pkg).unwrap();
assert_eq!(id, "speedway");
let received = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
assert_eq!(received, pkg, "the server must receive the package verbatim");
}
#[test]
fn lan_fetch_then_install_lets_a_joiner_get_the_running_game() {
// The host serves the game it is running; a joiner installs it before
// entering the room.
let pkg = sample_package("HostGame");
let digest = sha256_hex(&pkg);
let (addr, _rx) = serve(vec![("/game.arcade".into(), pkg.clone())], 2);
let fetched = fetch_lan_package(&addr, Some(&digest)).unwrap();
assert_eq!(fetched, pkg);
let root = tmp_dir("lan");
let lib = Library::new(&root);
let entry = lib.install("hostgame", &fetched).unwrap();
assert_eq!(entry.manifest.name, "HostGame");
// A joiner given the wrong digest refuses rather than installing.
let err = fetch_lan_package(&addr, Some(&sha256_hex(b"something else"))).unwrap_err();
assert!(matches!(err, HttpError::DigestMismatch { .. }));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn library_search_finds_a_game_by_description() {
let root = tmp_dir("search");
let lib = Library::new(&root);
lib.install("speedway", &sample_package("Speedway")).unwrap();
let mut w = ZipWriter::new();
w.add(
"manifest.toml",
b"name = \"Dogfight\"\ndescription = \"planes shooting planes\"\n",
ZipMethod::Deflate,
)
.unwrap();
w.add("game.splash", b"game.plane({})\n", ZipMethod::Deflate)
.unwrap();
lib.install("dogfight", &w.finish().unwrap()).unwrap();
let hits = lib.search("play the one with the planes");
assert_eq!(hits.first().map(|(e, _)| e.slug.as_str()), Some("dogfight"));
// A phrase matching nothing returns nothing rather than a wrong guess.
assert!(lib.search("underwater basket weaving").is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_server_that_lies_about_length_cannot_exhaust_us() {
// Content-Length far beyond the cap must be refused on the header, before
// the body is read.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut chunk = [0u8; 4096];
let _ = stream.read(&mut chunk);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 999999999999\r\nConnection: close\r\n\r\n",
);
let _ = stream.flush();
}
});
// No warm-up connect: bind() is already listening, and an extra connection
// would consume the single accept above.
let reg = Registry::new(format!("127.0.0.1:{}", addr.port()));
let err = reg.index().unwrap_err();
assert!(
matches!(err, HttpError::TooLarge) || matches!(err, HttpError::Io(_)),
"expected a size refusal, got {err:?}"
);
}