makepad/libs/zip_file/tests/unzip_interop.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

79 lines
2.7 KiB
Rust

//! Interop: archives we write must satisfy a real zip implementation, not just
//! our own reader. Skips (rather than fails) where `unzip` is absent.
use makepad_zip_file::{ZipMethod, ZipWriter};
use std::process::Command;
fn have_unzip() -> bool {
Command::new("unzip")
.arg("-v")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn write_archive(path: &std::path::Path, method: ZipMethod) {
let mut w = ZipWriter::new();
w.add("game.splash", b"game.box({pos: vec3(0,1,0)})\n", method)
.unwrap();
w.add("manifest.toml", b"name = \"demo\"\nplayers_max = 4\n", method)
.unwrap();
w.add("assets/blob.bin", &vec![7u8; 50_000], method).unwrap();
w.add("nested/dir/deep.txt", b"deep", method).unwrap();
std::fs::write(path, w.finish().unwrap()).unwrap();
}
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("makepad-zip-interop-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn system_unzip_validates_our_archives() {
if !have_unzip() {
eprintln!("skipping: `unzip` not on PATH");
return;
}
for (tag, method) in [("store", ZipMethod::Store), ("deflate", ZipMethod::Deflate)] {
let dir = tmp_dir(tag);
let zip = dir.join("pkg.arcade");
write_archive(&zip, method);
// -t verifies every member's CRC against its decompressed bytes, which
// is what catches a malformed central directory or a wrong crc/size.
let out = Command::new("unzip").arg("-t").arg(&zip).output().unwrap();
assert!(
out.status.success(),
"unzip -t failed for {tag}:\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// And the extracted bytes must actually match.
let dest = dir.join("out");
std::fs::create_dir_all(&dest).unwrap();
let out = Command::new("unzip")
.arg("-q")
.arg(&zip)
.arg("-d")
.arg(&dest)
.output()
.unwrap();
assert!(out.status.success(), "unzip extract failed for {tag}");
assert_eq!(
std::fs::read(dest.join("game.splash")).unwrap(),
b"game.box({pos: vec3(0,1,0)})\n"
);
assert_eq!(
std::fs::read(dest.join("assets/blob.bin")).unwrap(),
vec![7u8; 50_000]
);
assert_eq!(
std::fs::read(dest.join("nested/dir/deep.txt")).unwrap(),
b"deep"
);
let _ = std::fs::remove_dir_all(&dir);
}
}