makepad/libs/game/pkg/tests/hostile_archives.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

348 lines
12 KiB
Rust

//! Extraction is a security boundary: these archives are built to hurt us.
//!
//! The contract under test is threefold — never panic, never write outside the
//! destination, always terminate. A refusal is a pass; a successful extraction
//! of something benign is a pass; a panic or an escape is not.
use makepad_game_pkg::pack::{
read_package, unpack, MAX_ENTRIES, MAX_ENTRY_BYTES, MAX_TOTAL_BYTES,
};
use makepad_zip_file::{ZipMethod, ZipWriter};
use std::path::{Path, PathBuf};
/// Build an archive whose member names bypass ZipWriter's own validation, by
/// patching the names in after the fact. A real attacker writes the bytes
/// directly, so our writer's refusal to emit them proves nothing about the
/// reader — this is how we get a genuinely hostile input.
fn archive_with_raw_name(name: &str, data: &[u8]) -> Vec<u8> {
let placeholder = "X".repeat(name.len());
let mut w = ZipWriter::new();
w.add(&placeholder, data, ZipMethod::Store).unwrap();
w.add("manifest.toml", b"name = \"evil\"\n", ZipMethod::Store)
.unwrap();
w.add("game.splash", b"", ZipMethod::Store).unwrap();
let mut bytes = w.finish().unwrap();
// Overwrite every occurrence of the placeholder (local header + central
// directory) with the hostile name — same length, so all offsets hold.
let pat = placeholder.as_bytes();
let mut i = 0;
while i + pat.len() <= bytes.len() {
if &bytes[i..i + pat.len()] == pat {
bytes[i..i + pat.len()].copy_from_slice(name.as_bytes());
i += pat.len();
} else {
i += 1;
}
}
bytes
}
fn tmp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"makepad-pkg-hostile-{tag}-{}-{:p}",
std::process::id(),
&tag
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn count_files(dir: &Path) -> usize {
let mut n = 0;
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
n += count_files(&p);
} else {
n += 1;
}
}
}
n
}
#[test]
fn traversal_names_never_escape_the_destination() {
let sandbox = tmp_dir("traversal");
let dest = sandbox.join("dest");
std::fs::create_dir_all(&dest).unwrap();
// A canary next to the destination: if extraction escapes, it dies here.
let canary = sandbox.join("canary.txt");
std::fs::write(&canary, b"original").unwrap();
for name in [
"../canary.txt",
"../../canary.txt",
"a/../../canary.txt",
"/etc/hosts",
"..\\canary.txt",
"C:/canary.txt",
"./../canary.txt",
] {
let archive = archive_with_raw_name(name, b"overwritten");
// Refusal is the expected outcome; success would mean it wrote
// something, and the canary check below proves where.
let _ = unpack(&archive, &dest);
assert_eq!(
std::fs::read(&canary).unwrap(),
b"original",
"archive with name {name:?} escaped the destination"
);
}
let _ = std::fs::remove_dir_all(&sandbox);
}
#[test]
fn a_symlink_member_is_refused() {
// Craft a member whose external attributes mark it S_IFLNK. Writing the
// link target as content would be an escape on extraction.
let mut w = ZipWriter::new();
w.add("manifest.toml", b"name = \"evil\"\n", ZipMethod::Store)
.unwrap();
w.add("game.splash", b"", ZipMethod::Store).unwrap();
w.add("assets/link", b"/etc/passwd", ZipMethod::Store).unwrap();
let mut bytes = w.finish().unwrap();
// Patch the central directory entry for assets/link: external attributes
// sit 38 bytes into the 46-byte header.
let needle = b"assets/link";
let mut positions = Vec::new();
for i in 0..bytes.len().saturating_sub(needle.len()) {
if &bytes[i..i + needle.len()] == needle {
positions.push(i);
}
}
// The last occurrence is the central directory copy.
let name_at = *positions.last().unwrap();
let header_at = name_at - 46;
let attrs = (0xA1FFu32) << 16;
bytes[header_at + 38..header_at + 42].copy_from_slice(&attrs.to_le_bytes());
let err = read_package(&bytes).unwrap_err();
assert!(
format!("{err}").contains("symlink"),
"expected a symlink refusal, got {err}"
);
}
#[test]
fn declared_size_bombs_are_refused_before_decompression() {
// A member that DECLARES more than the per-entry cap, without ever
// containing it: the check must read the header, not the payload.
let mut w = ZipWriter::new();
w.add("manifest.toml", b"name = \"bomb\"\n", ZipMethod::Store)
.unwrap();
w.add("game.splash", b"", ZipMethod::Store).unwrap();
w.add("assets/bomb.bin", b"small", ZipMethod::Store).unwrap();
let mut bytes = w.finish().unwrap();
let needle = b"assets/bomb.bin";
let mut positions = Vec::new();
for i in 0..bytes.len().saturating_sub(needle.len()) {
if &bytes[i..i + needle.len()] == needle {
positions.push(i);
}
}
let header_at = positions.last().unwrap() - 46;
// uncompressed_size is 24 bytes into the central directory header.
let huge = (MAX_ENTRY_BYTES + 1).min(u32::MAX as u64) as u32;
bytes[header_at + 24..header_at + 28].copy_from_slice(&huge.to_le_bytes());
let err = read_package(&bytes).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("declares"), "expected a size refusal, got {msg}");
}
#[test]
fn a_real_deflate_bomb_is_refused() {
// 40 MB of zeros compresses to a few KB. Declared honestly, so this tests
// the total cap rather than a header lie.
let big = vec![0u8; 40 * 1024 * 1024];
let mut w = ZipWriter::new();
w.add("manifest.toml", b"name = \"bomb\"\n", ZipMethod::Store)
.unwrap();
w.add("game.splash", b"", ZipMethod::Store).unwrap();
for i in 0..8 {
w.add(&format!("assets/b{i}.bin"), &big, ZipMethod::Deflate)
.unwrap();
}
let bytes = w.finish().unwrap();
assert!(
bytes.len() < 1024 * 1024,
"the bomb should be small on the wire ({} bytes)",
bytes.len()
);
let err = read_package(&bytes).unwrap_err();
assert!(
format!("{err}").contains("total size cap"),
"expected the total cap to fire, got {err}"
);
assert!(8u64 * big.len() as u64 > MAX_TOTAL_BYTES);
}
#[test]
fn entry_count_is_capped() {
let mut w = ZipWriter::new();
w.add("manifest.toml", b"name = \"many\"\n", ZipMethod::Store)
.unwrap();
w.add("game.splash", b"", ZipMethod::Store).unwrap();
for i in 0..MAX_ENTRIES + 10 {
w.add(&format!("assets/f{i}.bin"), b"x", ZipMethod::Store)
.unwrap();
}
let bytes = w.finish().unwrap();
let err = read_package(&bytes).unwrap_err();
assert!(format!("{err}").contains("too many entries"), "got {err}");
}
#[test]
fn duplicate_members_are_refused() {
// Two members with the same name: which one wins is ambiguous, and the
// ambiguity is the attack (validate one, extract the other).
let mut w = ZipWriter::new();
w.add("manifest.toml", b"name = \"dup\"\n", ZipMethod::Store)
.unwrap();
w.add("game.splash", b"first", ZipMethod::Store).unwrap();
w.add("game.splashX", b"second", ZipMethod::Store).unwrap();
let mut bytes = w.finish().unwrap();
// Rename the second member to collide with the first.
let pat = b"game.splashX";
for i in 0..bytes.len().saturating_sub(pat.len()) {
if &bytes[i..i + pat.len()] == pat {
bytes[i..i + pat.len()].copy_from_slice(b"game.splash\0");
}
}
// The NUL makes it a distinct-but-hostile name; either refusal is correct.
let err = read_package(&bytes).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("duplicate") || msg.contains("NUL"),
"expected duplicate or NUL refusal, got {msg}"
);
}
#[test]
fn truncated_and_garbage_archives_are_refused_not_panics() {
let mut w = ZipWriter::new();
w.add("manifest.toml", b"name = \"ok\"\n", ZipMethod::Deflate)
.unwrap();
w.add("game.splash", b"game.sky({})", ZipMethod::Deflate)
.unwrap();
let good = w.finish().unwrap();
for cut in [0, 1, 5, 21, 22, 30, good.len() / 2, good.len() - 1] {
let _ = read_package(&good[..cut.min(good.len())]);
}
for garbage in [
vec![0u8; 64],
vec![0xffu8; 512],
b"PK\x03\x04 not really a zip".to_vec(),
] {
let _ = read_package(&garbage);
}
}
#[test]
fn mutation_fuzz_never_panics_or_escapes() {
// Seeded LCG: deterministic, so a failure is reproducible.
let mut seed: u64 = 0x5EED_1234_ABCD_0001;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let mut w = ZipWriter::new();
w.add(
"manifest.toml",
b"name = \"fuzz\"\ndescription = \"seed\"\n",
ZipMethod::Deflate,
)
.unwrap();
w.add("game.splash", b"game.box({pos: vec3(0,0,0)})\n", ZipMethod::Deflate)
.unwrap();
w.add("assets/a.bin", &vec![3u8; 4096], ZipMethod::Deflate)
.unwrap();
let base = w.finish().unwrap();
let sandbox = tmp_dir("fuzz");
let dest = sandbox.join("dest");
std::fs::create_dir_all(&dest).unwrap();
let canary = sandbox.join("canary.txt");
std::fs::write(&canary, b"original").unwrap();
let mut accepted = 0usize;
for round in 0..4000 {
let mut m = base.clone();
// 1-6 byte flips per round, biased toward the headers where the
// interesting fields live.
let flips = 1 + (next() % 6) as usize;
for _ in 0..flips {
let pos = if next() % 3 == 0 {
(next() as usize) % m.len()
} else {
// Central directory / EOCD region.
m.len() - 1 - ((next() as usize) % m.len().min(256))
};
m[pos] ^= 1 << (next() % 8);
}
match read_package(&m) {
Ok(_) => {
accepted += 1;
// If it parsed, writing it must also stay inside dest.
let _ = unpack(&m, &dest);
}
Err(_) => {}
}
assert_eq!(
std::fs::read(&canary).unwrap(),
b"original",
"fuzz round {round} escaped the destination"
);
}
// Sanity: the corpus must not be so broken that nothing ever parses, or
// this test would be asserting nothing.
assert!(
accepted > 0,
"no mutated archive ever parsed — the fuzz corpus is not exercising the reader"
);
let _ = std::fs::remove_dir_all(&sandbox);
}
#[test]
fn a_benign_package_still_round_trips() {
// The counterweight to all of the above: hardening that rejects everything
// would pass every test here and be useless.
let mut w = ZipWriter::new();
w.add(
"manifest.toml",
b"name = \"Speedway\"\ndescription = \"race\"\nplayers_max = 4\n",
ZipMethod::Deflate,
)
.unwrap();
w.add("game.splash", b"game.terrain({size: 100})\n", ZipMethod::Deflate)
.unwrap();
w.add("assets/car.bin", &vec![9u8; 1000], ZipMethod::Deflate)
.unwrap();
let bytes = w.finish().unwrap();
let sandbox = tmp_dir("benign");
let dest = sandbox.join("game");
let manifest = unpack(&bytes, &dest).unwrap();
assert_eq!(manifest.name, "Speedway");
assert_eq!(manifest.players_max, 4);
assert_eq!(
std::fs::read_to_string(dest.join("game.splash")).unwrap(),
"game.terrain({size: 100})\n"
);
assert_eq!(
std::fs::read(dest.join("assets/car.bin")).unwrap(),
vec![9u8; 1000]
);
assert_eq!(count_files(&dest), 3);
let _ = std::fs::remove_dir_all(&sandbox);
}