diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 808e1628..4b630f9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,47 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/AGENTS.md b/AGENTS.md index aad29c94..24abee6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon). See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). + Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). diff --git a/CLAUDE.md b/CLAUDE.md index a7c47fe3..e5238694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`). - **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32. - **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`). - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id. - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde. diff --git a/Cargo.lock b/Cargo.lock index 2cd7ed3a..9bed551d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1163,7 +1172,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.14.0" +version = "0.16.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1197,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1209,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.14.0" +version = "0.16.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.14.0" +version = "0.16.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1232,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.14.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1231,8 +1240,9 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.14.0" +version = "0.16.0" dependencies = [ + "chrono", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1248,8 +1258,9 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.14.0" +version = "0.16.0" dependencies = [ + "chrono", "craft-docpath", "effectcraft-color", "effectcraft-effects", @@ -1282,7 +1293,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1323,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.14.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1340,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.14.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1348,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.14.0" +version = "0.16.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1370,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.14.0" +version = "0.16.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1402,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1421,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1433,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.14.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1451,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.14.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1480,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1492,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.14.0" +version = "0.16.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1511,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.14.0" +version = "0.16.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1527,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1539,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1552,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.14.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1568,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1590,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.14.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1596,7 +1607,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.14.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1616,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1628,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1640,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.14.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1648,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,8 +1660,9 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.14.0" +version = "0.16.0" dependencies = [ + "chrono", "craft-ui", "effectcraft-color", "effectcraft-engine", @@ -1681,11 +1693,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.14.0" +version = "0.16.0" [[package]] name = "effectcraft-web" -version = "0.14.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6625,9 +6637,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.16.0" dependencies = [ + "chrono", "serde_json", + "tempfile", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index eecbbc7e..d71d2933 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.14.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -52,6 +52,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" # std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there). web-time = "1" @@ -137,9 +139,12 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +# Panics and backtraces keep file and line; full debug info only made the many test binaries large. +debug = "line-tables-only" # Pixel hot loops: keep debug builds interactive. [profile.dev.package.effectcraft-raster] diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs similarity index 100% rename from apps/effectcraft-cli/tests/cli.rs rename to apps/effectcraft-cli/tests/it/cli.rs diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs new file mode 100644 index 00000000..17149f54 --- /dev/null +++ b/apps/effectcraft-cli/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod cli; +mod qa_template; diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs similarity index 100% rename from apps/effectcraft-cli/tests/qa_template.rs rename to apps/effectcraft-cli/tests/it/qa_template.rs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 5c438550..12cb90bf 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -494,8 +494,12 @@ export async function cacheRead(name) { /// resolves with its size. export async function cacheWrite(fullName, bytes) { const [dir, name] = await entryDir(fullName); - const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype; - const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name; + const canMove = + typeof FileSystemHandle !== "undefined" && + "move" in FileSystemHandle.prototype && + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function"; + const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name; const fh = await dir.getFileHandle(tmp, { create: true }); if (fh.createSyncAccessHandle) { const h = await fh.createSyncAccessHandle(); diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 8c7efc2e..4acbeeee 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -32,13 +32,15 @@ self.addEventListener("fetch", (event) => { if (req.method !== "GET" || url.origin !== self.location.origin) return; event.respondWith( (async () => { - const cache = await caches.open(CACHE); - // The page itself regardless of its query (?empty, ?demo…). - const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req; - const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }); - if (hit) return hit; + const cache = await caches.open(CACHE).catch(() => null); + if (cache) { + // The page itself regardless of its query (?empty, ?demo…). + const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req; + const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null); + if (hit) return hit; + } const res = await fetch(req); - if (res.ok && res.type === "basic") cache.put(req, res.clone()); + if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {}); return res; })(), ); diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md index 6ced20ba..c922c95e 100644 --- a/crates/av1enc/README.md +++ b/crates/av1enc/README.md @@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0, no `unsafe`, builds for `wasm32-unknown-unknown`). Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in -`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived -`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own +`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived +`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own reconstruction, so prediction never drifts. ## What is implemented diff --git a/crates/av1enc/tests/bench.rs b/crates/av1enc/tests/it/bench.rs similarity index 97% rename from crates/av1enc/tests/bench.rs rename to crates/av1enc/tests/it/bench.rs index 6577f8f9..b29817e1 100644 --- a/crates/av1enc/tests/bench.rs +++ b/crates/av1enc/tests/it/bench.rs @@ -1,7 +1,7 @@ //! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default): -//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`. +//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`. -mod common; +use crate::common; use std::process::Command; use std::time::Instant; diff --git a/crates/av1enc/tests/common/mod.rs b/crates/av1enc/tests/it/common/mod.rs similarity index 100% rename from crates/av1enc/tests/common/mod.rs rename to crates/av1enc/tests/it/common/mod.rs diff --git a/crates/av1enc/tests/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs similarity index 99% rename from crates/av1enc/tests/ffmpeg_oracle.rs rename to crates/av1enc/tests/it/ffmpeg_oracle.rs index 66f380ac..b35cbf49 100644 --- a/crates/av1enc/tests/ffmpeg_oracle.rs +++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs @@ -2,7 +2,7 @@ //! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1` //! decoder only drives hardware decoders, so it is not used. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/av1enc/tests/it/main.rs b/crates/av1enc/tests/it/main.rs new file mode 100644 index 00000000..c66bacde --- /dev/null +++ b/crates/av1enc/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod bench; +mod common; +mod ffmpeg_oracle; +mod roundtrip; diff --git a/crates/av1enc/tests/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs similarity index 99% rename from crates/av1enc/tests/roundtrip.rs rename to crates/av1enc/tests/it/roundtrip.rs index 1e777e50..282880e0 100644 --- a/crates/av1enc/tests/roundtrip.rs +++ b/crates/av1enc/tests/it/roundtrip.rs @@ -1,7 +1,7 @@ //! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the //! encoder's own reconstruction bit for bit. -mod common; +use crate::common; use common::*; use effectcraft_av1enc::{EncoderConfig, RateControl}; diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index c37d0994..bb26d312 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -21,4 +21,5 @@ rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 32a196af..d98ec73a 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -284,9 +284,13 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: return None; } let fr = fc.frames.get(&f)?; - let a = rle::decode(&fr.a)?; let [fw, fh] = fc.size; - if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? { + let expected = fw.checked_mul(fh)?; + if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + return None; + } + let a = rle::decode_bounded(&fr.a, expected)?; + if a.len() != expected { return None; } let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect(); diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index a6c80e74..4917cc23 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(V && fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps()) && let Some(fr) = fc.frames.get(&f) { - let a = rle::decode(&fr.a)?; - let bnd = rle::decode(&fr.b)?; let [fw, fh] = fc.size; - if a.len() == fw * fh && bnd.len() == fw * fh { + let expected = fw.checked_mul(fh)?; + if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + return None; + } + let a = rle::decode_bounded(&fr.a, expected)?; + let bnd = rle::decode_bounded(&fr.b, expected)?; + if a.len() == expected && bnd.len() == expected { let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect(); let (alpha, band) = if fw == w && fh == h { (af, bnd) diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index 34282c27..cb172dfc 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -288,20 +288,39 @@ fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; -/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday). +/// `num_days_from_ce` of 2000-01-01: the day count since 2000-01-01 (Numbers' date epoch, a +/// Saturday) rebased so that chrono's `num_days_from_ce` (0001-01-01 is day 1) can convert it. +const CE_FROM_2000: i64 = 730_120; + +/// Days in one 400-year Gregorian cycle; the proleptic calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Proleptic Gregorian date (year, month, day, Saturday-indexed weekday) from a day count since +/// 2000-01-01 (a Saturday), for any `i64` count. +/// +/// chrono's `NaiveDate` stops near ±262143, but Numbers' `value` slider is a plain scalar with +/// no range guard, so a keyframed count can run past that. The calendar repeats every 400 years, +/// so chrono converts the day within one cycle and the whole cycles are added back as years (via +/// i128 so `i64::MIN`/`MAX` can't overflow); the result matches the pre-chrono count for every +/// year. The weekday comes from the same resolved date, so it can never disagree with it. fn date_from_days(days: i64) -> (i64, u32, u32, usize) { - // Days since 0000-03-01 based civil calendar conversion. - let z = days + 730_425; // 2000-01-01 relative to 0000-03-01 - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d, days.rem_euclid(7) as usize) + use chrono::Datelike; + let from_ce = i128::from(days) + i128::from(CE_FROM_2000); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so it is always a valid `num_days_from_ce` day inside chrono's range. + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + // Unreachable fallback: every `rest` in 0..146_097 converts. 2000-01-01 keeps the epoch's date. + let (y, m, d, wd) = match date { + Some(date) => { + let year = i128::from(date.year()) + cycles * 400; + let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX }); + // DAYS is Saturday-indexed; chrono counts from Sunday. + let wd = (date.weekday().num_days_from_sunday() as usize + 1) % 7; + (year, date.month(), date.day(), wd) + } + None => (2000, 1, 1, 0), + }; + (y, m, d, wd) } fn timecode(frames: i64, fps: f64) -> String { @@ -1123,6 +1142,10 @@ mod tests { assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF"); assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00"); assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000"); + // A keyframed date count past chrono's ±262143 range still renders its real date and a + // matching weekday (275790-09-13 is a Monday), not an epoch-derived stand-in. + assert_eq!(txt(&[("value", num(100_000_000.0)), ("type", Value::Enum(8))], 0.0), "Monday, September 13, 275790"); + assert_eq!(txt(&[("value", num(-100_000_000.0)), ("type", Value::Enum(7))], 0.0), "Apr 20, -271791"); assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0"); assert_ne!( txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0), @@ -1130,6 +1153,28 @@ mod tests { ); } + #[test] + fn date_from_days_past_chrono_range() { + // chrono's NaiveDate stops near ±262143, but Numbers' `value` has no range guard, so a + // keyframed day count can run past it. The 400-year cycle keeps the real date (and a + // matching weekday) for every count; values from the pre-chrono formula, both directions, + // both sides of the 2000-01-01 epoch. + for (days, date, day) in [ + (0_i64, (2000, 1, 1), "Saturday"), + (60, (2000, 3, 1), "Wednesday"), + (95_015_644, (262143, 12, 31), "Tuesday"), // chrono's last representable date + (100_000_000, (275790, 9, 13), "Monday"), // past it: used to read as the epoch + (-96_476_616, (-262145, 12, 31), "Monday"), + (-100_000_000, (-271791, 4, 20), "Thursday"), + ] { + let (y, m, d, wd) = super::date_from_days(days); + assert_eq!((y, m, d), date, "days={days}"); + assert_eq!(super::DAYS[wd], day, "days={days}"); + } + // Extremes don't overflow (i128 intermediates). + let _ = (super::date_from_days(i64::MAX), super::date_from_days(i64::MIN)); + } + #[test] fn numbers_draws_glyph_at_position() { let img = Image::new(80, 60); diff --git a/crates/effects/tests/gen_golden.rs b/crates/effects/tests/it/gen_golden.rs similarity index 100% rename from crates/effects/tests/gen_golden.rs rename to crates/effects/tests/it/gen_golden.rs diff --git a/crates/effects/tests/it/main.rs b/crates/effects/tests/it/main.rs new file mode 100644 index 00000000..7b12e25c --- /dev/null +++ b/crates/effects/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod gen_golden; +mod particle_golden; +mod sim_golden; +mod timewarp_golden; diff --git a/crates/effects/tests/particle_golden.rs b/crates/effects/tests/it/particle_golden.rs similarity index 100% rename from crates/effects/tests/particle_golden.rs rename to crates/effects/tests/it/particle_golden.rs diff --git a/crates/effects/tests/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs similarity index 100% rename from crates/effects/tests/sim_golden.rs rename to crates/effects/tests/it/sim_golden.rs diff --git a/crates/effects/tests/timewarp_golden.rs b/crates/effects/tests/it/timewarp_golden.rs similarity index 100% rename from crates/effects/tests/timewarp_golden.rs rename to crates/effects/tests/it/timewarp_golden.rs diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0e26cca5..7104bc1c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs index 097bddc2..38d9e784 100644 --- a/crates/engine/src/commands/align_data.rs +++ b/crates/engine/src/commands/align_data.rs @@ -33,14 +33,16 @@ pub struct Stamp { pub dated: bool, } -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let mp = (m + 9) % 12; - let doy = (153 * mp + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 +/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as +/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over; +/// `None` when the year/month is out of chrono's range. `m` must be 1..=12. +fn days_from_civil(y: i64, m: i64, d: i64) -> Option { + let year = i32::try_from(y).ok()?; + let month = u32::try_from(m).ok()?; + let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?; + // Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01. + use chrono::Datelike; + i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?) } /// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds. @@ -71,7 +73,7 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option { // ISO 8601: date [T| ] time [zone]. if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' { let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?); - let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0; + let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0; let rest = s[10..].trim_start_matches(['T', 't', ' ']); if !rest.is_empty() { // Zone: Z or ±hh:mm / ±hhmm. diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index c7416a8c..1f8e10e8 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -46,13 +46,24 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result + { + NotFollowed::NoProjectFolder + } + why => NotFollowed::Refused(why), + }) +} + +/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows. +fn is_windows_rooted(p: &Path) -> bool { + let s = p.to_string_lossy(); + let b = s.as_bytes(); + (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//") } /// The paths of `f` (its file and every frame of an image sequence) resolved against the document diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 74680df7..d9d97124 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -149,7 +149,23 @@ fn stored_paths_resolve_against_the_project() { let doc = doc.to_str().unwrap(); assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov"))); assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder)); + assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder)); + // A drive path is not a local file on Unix, with or without a project: it is not a missing project. + #[cfg(unix)] + for stored in [r"C:\x.mov", "C:x.mov"] { + assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}"); + } assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network))); + assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url))); + // Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character. + if cfg!(windows) { + assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid))); + } else { + assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov"))); + } + // A Windows drive path names no file on other platforms. + #[cfg(unix)] + assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid))); #[cfg(windows)] assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok()); } diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs index 2c89466a..ec5785f7 100644 --- a/crates/engine/src/logging.rs +++ b/crates/engine/src/logging.rs @@ -93,28 +93,34 @@ fn thread_name() -> String { return "main".to_string(); } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set +/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch. fn timestamp(ms: u64) -> String { - let secs = ms / 1000; - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000) + let (secs, millis) = (ms / 1000, (ms % 1000) as u32); + let (y, t) = utc_parts(secs, millis * 1_000_000); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most -/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// One log line (without the newline): ` [] : `. @@ -358,7 +364,12 @@ mod tests { assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z"); assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z"); - let _ = timestamp(u64::MAX); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z"); + // A clock absurdly far ahead keeps its real year (years far outside chrono's own range are + // preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs). + assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z"); + assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z"); } /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index d22875f1..c0e20be7 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option { std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } -/// Format seconds since the epoch as an ISO 8601 UTC date-time. +/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own +/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn iso_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ")) +} + +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days` +/// always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// File dates and size: (created, modified, size). @@ -506,5 +517,10 @@ mod tests { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z"); assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z"); + assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z"); } } diff --git a/crates/engine/src/tests_timeline_interchange.rs b/crates/engine/src/tests_timeline_interchange.rs index b32e19c4..45be0f7a 100644 --- a/crates/engine/src/tests_timeline_interchange.rs +++ b/crates/engine/src/tests_timeline_interchange.rs @@ -1,5 +1,5 @@ //! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command -//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`). +//! registry, without media decoding (pixels: `crates/host/tests/it/timeline_interop.rs`). use serde_json::json; diff --git a/crates/engine/tests/key_time_post_snap_bounds.rs b/crates/engine/tests/it/key_time_post_snap_bounds.rs similarity index 100% rename from crates/engine/tests/key_time_post_snap_bounds.rs rename to crates/engine/tests/it/key_time_post_snap_bounds.rs diff --git a/crates/engine/tests/key_time_reply_contract.rs b/crates/engine/tests/it/key_time_reply_contract.rs similarity index 100% rename from crates/engine/tests/key_time_reply_contract.rs rename to crates/engine/tests/it/key_time_reply_contract.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 00000000..ac1484cf --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod key_time_post_snap_bounds; +mod key_time_reply_contract; +mod parenting_3d_position; diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs similarity index 100% rename from crates/engine/tests/parenting_3d_position.rs rename to crates/engine/tests/it/parenting_3d_position.rs diff --git a/crates/export/tests/audio_sample_positions.rs b/crates/export/tests/it/audio_sample_positions.rs similarity index 100% rename from crates/export/tests/audio_sample_positions.rs rename to crates/export/tests/it/audio_sample_positions.rs diff --git a/crates/export/tests/hevc_av1_roundtrip.rs b/crates/export/tests/it/hevc_av1_roundtrip.rs similarity index 100% rename from crates/export/tests/hevc_av1_roundtrip.rs rename to crates/export/tests/it/hevc_av1_roundtrip.rs diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs new file mode 100644 index 00000000..0f89a641 --- /dev/null +++ b/crates/export/tests/it/main.rs @@ -0,0 +1,14 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod audio_sample_positions; +mod hevc_av1_roundtrip; +#[cfg(not(target_arch = "wasm32"))] +mod mov_audio_sample_positions; +mod ms_rounded_footage; +#[cfg(not(target_arch = "wasm32"))] +mod native_audio_sample_positions; +mod opus_roundtrip; +mod render_settings; +mod roundtrip; +mod uncompressed_mov; +mod vp9_roundtrip; diff --git a/crates/export/tests/mov_audio_sample_positions.rs b/crates/export/tests/it/mov_audio_sample_positions.rs similarity index 99% rename from crates/export/tests/mov_audio_sample_positions.rs rename to crates/export/tests/it/mov_audio_sample_positions.rs index c58d2b14..a46a4927 100644 --- a/crates/export/tests/mov_audio_sample_positions.rs +++ b/crates/export/tests/it/mov_audio_sample_positions.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - //! Tiny native WAV to ProRes PCM MOV sample-position proof. use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; use effectcraft_export::{Job, JobOptions, Sink, export}; diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/it/ms_rounded_footage.rs similarity index 100% rename from crates/export/tests/ms_rounded_footage.rs rename to crates/export/tests/it/ms_rounded_footage.rs diff --git a/crates/export/tests/native_audio_sample_positions.rs b/crates/export/tests/it/native_audio_sample_positions.rs similarity index 99% rename from crates/export/tests/native_audio_sample_positions.rs rename to crates/export/tests/it/native_audio_sample_positions.rs index 1b59e8c1..9222d0f2 100644 --- a/crates/export/tests/native_audio_sample_positions.rs +++ b/crates/export/tests/it/native_audio_sample_positions.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - //! Native MediaPool decoding and native WAV export preserve selected sample positions. use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; use effectcraft_export::{Job, JobOptions, Sink, export}; diff --git a/crates/export/tests/opus_roundtrip.rs b/crates/export/tests/it/opus_roundtrip.rs similarity index 100% rename from crates/export/tests/opus_roundtrip.rs rename to crates/export/tests/it/opus_roundtrip.rs diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/it/render_settings.rs similarity index 100% rename from crates/export/tests/render_settings.rs rename to crates/export/tests/it/render_settings.rs diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/it/roundtrip.rs similarity index 100% rename from crates/export/tests/roundtrip.rs rename to crates/export/tests/it/roundtrip.rs diff --git a/crates/export/tests/uncompressed_mov.rs b/crates/export/tests/it/uncompressed_mov.rs similarity index 100% rename from crates/export/tests/uncompressed_mov.rs rename to crates/export/tests/it/uncompressed_mov.rs diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs similarity index 100% rename from crates/export/tests/vp9_roundtrip.rs rename to crates/export/tests/it/vp9_roundtrip.rs diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4da7bc63..df6ed3ff 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -461,7 +461,7 @@ Comp.prototype = { var l = this.layer(i); if (l.__info[10] === 'Camera' && l.active) return l; } - __err('Composition "' + this.name + '" has no active camera'); + return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index 55e9800a..6b0ea539 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -528,8 +528,9 @@ fn rewrite_seq(items: &[Item]) -> String { let end = assignment_end(items, j + 1); let rhs = rewrite_seq(&items[j + 1..end]); let h = helper(&op.text[..1]).unwrap_or("__add"); + let target = compound_assignment_target(items, i, j); out.push_str(&p.ws); - out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body)); + out.push_str(&format!("{target} ={}{h}({target}, ({rhs}))", op.ws)); i = end; continue; } @@ -543,6 +544,34 @@ fn rewrite_seq(items: &[Item]) -> String { out } +fn raw_ref(item: &Item) -> String { + match item { + Item::Tok(t) => format!("{}{}", t.ws, t.text), + Item::Group { open, inner, close } => { + let mut out = format!("{}{}", open.ws, open.text); + if has_assign(inner) { + // shortcut: nested assignments stay verbatim (rewriting them would double per level), so arithmetic beside them is not array-aware + for item in inner { + out.push_str(&raw_ref(item)); + } + } else { + out.push_str(&rewrite_seq(inner)); + } + if let Some(close) = close { + out.push_str(&close.text); + } + out + } + } +} + +fn has_assign(items: &[Item]) -> bool { + items.iter().any(|it| match it { + Item::Tok(t) => ASSIGN.contains(&t.text.as_str()), + Item::Group { inner, .. } => has_assign(inner), + }) +} + fn push_piece(out: &mut String, p: Piece) { out.push_str(&p.ws); out.push_str(&p.body); @@ -554,6 +583,18 @@ fn push_piece(out: &mut String, p: Piece) { /// nested brackets, so deeper expressions are rejected with an error instead. Constructs that /// nest without brackets count as part of a level ([`too_nested`]). pub const MAX_NESTING: usize = 32; +/// Hard limit on bytes emitted by the array-math source rewrite. +pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024; + +fn over_rewrite_limit(text: &str) -> bool { + text.len() > MAX_REWRITTEN_BYTES +} + +fn compound_assignment_target(items: &[Item], start: usize, end: usize) -> String { + // Rewriting the whole left side duplicates nested computed assignment targets. Keep the + // original target syntax for both reads/writes; source length is bounded above. + items[start..end].iter().map(raw_ref).collect() +} /// Stack cost of one level of brackets (also of a template substitution, a regex group and a /// prefix operator, which cost the parser about as much or are never chained in real code). @@ -746,12 +787,18 @@ fn too_nested_from(toks: &[Tok], base: usize) -> bool { /// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite). pub fn check_nesting(src: &str) -> Result<(), String> { + if over_rewrite_limit(src) { + return Err(too_large()); + } let (toks, tail) = tokenize(src); if too_nested(&toks) { return Err(too_deep()); } let mut out = rewrite_seq(&build(toks)); out.push_str(&tail); + if over_rewrite_limit(&out) { + return Err(too_large()); + } if too_nested(&tokenize(&out).0) { return Err(too_deep()); } @@ -765,12 +812,18 @@ fn too_deep() -> String { /// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved. /// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error. pub fn rewrite(src: &str) -> String { + if over_rewrite_limit(src) { + return throw_too_large(); + } let (toks, tail) = tokenize(src); if too_nested(&toks) { return throw_too_deep(); } let mut out = rewrite_seq(&build(toks)); out.push_str(&tail); + if over_rewrite_limit(&out) { + return throw_too_large(); + } if too_nested(&tokenize(&out).0) { return throw_too_deep(); } @@ -781,6 +834,14 @@ fn throw_too_deep() -> String { format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: ")) } +fn too_large() -> String { + format!("Error: expression exceeds the {MAX_REWRITTEN_BYTES}-byte rewrite limit") +} + +fn throw_too_large() -> String { + format!("throw new RangeError({:?})", too_large().trim_start_matches("Error: ")) +} + #[cfg(test)] mod tests { use super::{MAX_NESTING, check_nesting, rewrite}; @@ -821,6 +882,8 @@ mod tests { #[test] fn compound_assignment_and_functions() { assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));")); + assert_eq!(n(&rewrite("obj[key] += 1")), n("obj[key] = __add(obj[key], (1))")); + assert_eq!(n(&rewrite("o[a + b] += 1")), n("o[__add(a, b)] = __add(o[__add(a, b)], (1))")); assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }")); assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)")); assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')")); @@ -828,6 +891,20 @@ mod tests { assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)")); } + #[test] + fn source_expansion_is_bounded_before_rewriting() { + let src = "x += 1;".repeat(super::MAX_REWRITTEN_BYTES / 6 + 1); + assert!(check_nesting(&src).unwrap_err().contains("rewrite limit")); + assert!(rewrite(&src).starts_with("throw new RangeError(")); + } + + #[test] + fn output_expansion_is_bounded() { + let src = format!("{} += 1", "a".repeat(super::MAX_REWRITTEN_BYTES / 2)); + assert!(check_nesting(&src).unwrap_err().contains("rewrite limit")); + assert!(rewrite(&src).starts_with("throw new RangeError(")); + } + #[test] fn deep_nesting_is_an_error_not_a_stack_overflow() { // Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB diff --git a/crates/host/tests/background_presets.rs b/crates/host/tests/it/background_presets.rs similarity index 100% rename from crates/host/tests/background_presets.rs rename to crates/host/tests/it/background_presets.rs diff --git a/crates/host/tests/determinism.rs b/crates/host/tests/it/determinism.rs similarity index 100% rename from crates/host/tests/determinism.rs rename to crates/host/tests/it/determinism.rs diff --git a/crates/host/tests/exr_layers.rs b/crates/host/tests/it/exr_layers.rs similarity index 100% rename from crates/host/tests/exr_layers.rs rename to crates/host/tests/it/exr_layers.rs diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/it/footage_reload.rs similarity index 100% rename from crates/host/tests/footage_reload.rs rename to crates/host/tests/it/footage_reload.rs diff --git a/crates/host/tests/gif_animates.rs b/crates/host/tests/it/gif_animates.rs similarity index 100% rename from crates/host/tests/gif_animates.rs rename to crates/host/tests/it/gif_animates.rs diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs new file mode 100644 index 00000000..575ae946 --- /dev/null +++ b/crates/host/tests/it/main.rs @@ -0,0 +1,13 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod background_presets; +mod determinism; +mod exr_layers; +mod footage_reload; +mod gif_animates; +mod plugins; +mod relink; +mod tga_import; +mod timeline_interop; +mod vector_import; +mod webm_metadata; diff --git a/crates/host/tests/plugins.rs b/crates/host/tests/it/plugins.rs similarity index 100% rename from crates/host/tests/plugins.rs rename to crates/host/tests/it/plugins.rs diff --git a/crates/host/tests/relink.rs b/crates/host/tests/it/relink.rs similarity index 100% rename from crates/host/tests/relink.rs rename to crates/host/tests/it/relink.rs diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/it/tga_import.rs similarity index 100% rename from crates/host/tests/tga_import.rs rename to crates/host/tests/it/tga_import.rs diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/it/timeline_interop.rs similarity index 100% rename from crates/host/tests/timeline_interop.rs rename to crates/host/tests/it/timeline_interop.rs diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/it/vector_import.rs similarity index 99% rename from crates/host/tests/vector_import.rs rename to crates/host/tests/it/vector_import.rs index 49f8d950..36d83dd1 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -121,8 +121,8 @@ fn psd_16bit_cmyk_composition_imports() { assert!(d < 2.0 / 255.0, "mean diff {d}"); } -const SVG: &[u8] = include_bytes!("../../svg/tests/fixtures/basic-shapes.svg"); -const SVG2: &[u8] = include_bytes!("../../svg/tests/fixtures/paths-gradients.svg"); +const SVG: &[u8] = include_bytes!("../../../svg/tests/fixtures/basic-shapes.svg"); +const SVG2: &[u8] = include_bytes!("../../../svg/tests/fixtures/paths-gradients.svg"); #[test] fn svg_footage_and_shapes_from_vector_layer() { diff --git a/crates/host/tests/webm_metadata.rs b/crates/host/tests/it/webm_metadata.rs similarity index 100% rename from crates/host/tests/webm_metadata.rs rename to crates/host/tests/it/webm_metadata.rs diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 7730017d..b2eeece4 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -96,9 +96,11 @@ fn external_image(stored: &str, base_dir: &str) -> Result { let doc = std::path::Path::new(base_dir).join("lottie.json"); std::path::absolute(&doc).unwrap_or(doc) }); - match craft_docpath::resolve(stored, doc.as_deref()) { - Ok(path) if path.is_absolute() => Ok(path.to_string_lossy().into_owned()), - Ok(_) => Err(format!("{stored} is relative and the Lottie file's folder is not known")), + match craft_docpath::resolve_absolute(stored, doc.as_deref()) { + Ok(path) => Ok(path.to_string_lossy().into_owned()), + Err(craft_docpath::Refused::Invalid) if doc.is_none() && craft_docpath::resolve(stored, None).is_ok() => { + Err(format!("{stored} is relative and the Lottie file's folder is not known")) + } Err(why) => Err(format!("{stored}: {why}")), } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index d363143d..05adee5a 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -559,7 +559,7 @@ impl Inner { let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { // Files the model names are read only when local or on the model's own network share. let resolve = |uri: &str| -> Option> { - let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?; + let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?; self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) }; effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2793be25..6bba682b 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -27,12 +27,21 @@ pub fn probe(path: impl AsRef) -> Result { } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { - let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); - return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok()); + return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path)); } probe_bytes(&path.to_string_lossy(), bytes.into()) } +/// Reads the files a model file names (buffers, textures, MTL files). Like the pool, it follows only +/// local files and files on the model's own network share: a URI is a path stored in the model. +fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { + let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf()); + move |uri| { + let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?; + std::fs::read(p).ok() + } +} + /// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its /// longest animation. `resolve` reads sibling resources. pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { @@ -316,6 +325,20 @@ mod tests { (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() } + /// A model's side files are read from beside it; a URI to another computer is not followed. + #[test] + fn model_side_files_are_not_read_from_other_computers() { + let dir = std::env::temp_dir().join(format!("ec-probe-side-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("buf.bin"), b"abc").unwrap(); + let read = side_file_reader(&dir.join("model.gltf")); + assert_eq!(read("buf.bin"), Some(b"abc".to_vec())); + assert_eq!(read(r"\\192.0.2.1\share\buf.bin"), None); + assert_eq!(read("file://192.0.2.1/share/buf.bin"), None); + assert_eq!(read(r"C:\buf.bin"), None); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn millisecond_rounded_frame_times_average_to_their_rate() { assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]); diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/it/downmix.rs similarity index 100% rename from crates/media/tests/downmix.rs rename to crates/media/tests/it/downmix.rs diff --git a/crates/media/tests/exif_orientation.rs b/crates/media/tests/it/exif_orientation.rs similarity index 100% rename from crates/media/tests/exif_orientation.rs rename to crates/media/tests/it/exif_orientation.rs diff --git a/crates/media/tests/footage.rs b/crates/media/tests/it/footage.rs similarity index 99% rename from crates/media/tests/footage.rs rename to crates/media/tests/it/footage.rs index 8fbd6e97..9b1baa8b 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -837,7 +837,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { } // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as - // its stereo downmix (tests/downmix.rs checks that matrix channel by channel). + // its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel). let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs new file mode 100644 index 00000000..5f7fb803 --- /dev/null +++ b/crates/media/tests/it/main.rs @@ -0,0 +1,7 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod downmix; +mod exif_orientation; +mod footage; +mod open_in_place; +mod tga; diff --git a/crates/media/tests/open_in_place.rs b/crates/media/tests/it/open_in_place.rs similarity index 100% rename from crates/media/tests/open_in_place.rs rename to crates/media/tests/it/open_in_place.rs diff --git a/crates/media/tests/tga.rs b/crates/media/tests/it/tga.rs similarity index 100% rename from crates/media/tests/tga.rs rename to crates/media/tests/it/tga.rs diff --git a/crates/opusenc/README.md b/crates/opusenc/README.md index 999d8a7e..1ebf6fed 100644 --- a/crates/opusenc/README.md +++ b/crates/opusenc/README.md @@ -130,7 +130,7 @@ weights) and the decoder-side normative arithmetic the encoder must reproduce bi allocation; SILK NLSF dequantisation and stabilisation, NLSF → LPC conversion, `log2lin`, the fixed-point division/inverse helpers and the LPC analysis filter) are mirrored from FilmCraft's first-party, spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0), which is also the -round-trip oracle in `tests/` and `crates/export/tests/opus_roundtrip.rs` (ffmpeg is a second, +round-trip oracle in `tests/` and `crates/export/tests/it/opus_roundtrip.rs` (ffmpeg is a second, external oracle when installed). All encoder-side analysis and search (pitch, LPC, NLSF and LTP search, noise feedback, rate control, stereo prediction, the decimator) is our own design. No third-party encoder or decoder source (libopus or otherwise) was consulted. diff --git a/crates/opusenc/tests/common/mod.rs b/crates/opusenc/tests/it/common/mod.rs similarity index 100% rename from crates/opusenc/tests/common/mod.rs rename to crates/opusenc/tests/it/common/mod.rs diff --git a/crates/opusenc/tests/ffmpeg.rs b/crates/opusenc/tests/it/ffmpeg.rs similarity index 99% rename from crates/opusenc/tests/ffmpeg.rs rename to crates/opusenc/tests/it/ffmpeg.rs index fcbb8c8a..1294ed34 100644 --- a/crates/opusenc/tests/ffmpeg.rs +++ b/crates/opusenc/tests/it/ffmpeg.rs @@ -2,7 +2,7 @@ //! without errors with both ffmpeg's native `opus` decoder and `libopus`, honour the pre-skip //! and end granule, and reproduce the input. Skipped when ffmpeg is not installed. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/opusenc/tests/it/main.rs b/crates/opusenc/tests/it/main.rs new file mode 100644 index 00000000..ba6a7895 --- /dev/null +++ b/crates/opusenc/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod common; +mod ffmpeg; +mod modes; diff --git a/crates/opusenc/tests/modes.rs b/crates/opusenc/tests/it/modes.rs similarity index 99% rename from crates/opusenc/tests/modes.rs rename to crates/opusenc/tests/it/modes.rs index 843cf3d9..c01f64e6 100644 --- a/crates/opusenc/tests/modes.rs +++ b/crates/opusenc/tests/it/modes.rs @@ -2,7 +2,7 @@ //! mode/bandwidth decodes, waveform quality on speech-like and music-like signals, bitrate //! tracking, stereo, the application tables and delay alignment. -mod common; +use crate::common; use common::*; use effectcraft_opusenc::{Application, Bandwidth, Mode, OpusEncoder}; diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 4d55fef8..7006979c 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -16,6 +16,7 @@ pub(crate) struct Decoded { /// The longest side of a decoded image (larger images are refused rather than exhausting /// memory). const MAX_SIDE: usize = 16384; +const MAX_PIXELS: usize = 64 << 20; fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> { file.get(d, long).or_else(|| file.get(d, short)) @@ -114,6 +115,11 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> { use zune_jpeg::zune_core::options::DecoderOptions; let mut probe = JpegDecoder::new(std::io::Cursor::new(data)); probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?; + // Bound the header-declared size before decode() allocates the output. + let (hw, hh) = probe.dimensions().ok_or_else(|| "image (JPEG)".to_string())?; + if hw.checked_mul(hh).is_none_or(|p| p > MAX_PIXELS) { + return Err("image (JPEG)".to_string()); + } let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3); let out = match n { 1 => ColorSpace::Luma, @@ -134,8 +140,17 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> { /// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers. fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec { - let row_bytes = (w * n * bpc).div_ceil(8); - let mut out = Vec::with_capacity(w * h * n); + let Some(samples) = w.checked_mul(h).and_then(|p| p.checked_mul(n)).filter(|count| *count <= MAX_PIXELS.saturating_mul(4)) else { return Vec::new() }; + let Some(row_bits) = w.checked_mul(n).and_then(|v| v.checked_mul(bpc)) else { return Vec::new() }; + let row_bytes = row_bits.div_ceil(8); + let Some(row_len) = row_bytes.checked_mul(h) else { return Vec::new() }; + if row_len > data.len() { + return Vec::new(); + } + let mut out = Vec::new(); + if out.try_reserve_exact(samples).is_err() { + return Vec::new(); + } for y in 0..h { let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]); for i in 0..w * n { @@ -157,10 +172,22 @@ fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec { /// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`. fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec { + let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { return Vec::new() }; + if mw.checked_mul(mh).is_none_or(|n| n > m.len()) { + return Vec::new(); + } if mw == w && mh == h { - return m.to_vec(); + let mut out = Vec::new(); + if out.try_reserve_exact(pixels).is_err() { + return Vec::new(); + } + out.extend_from_slice(&m[..pixels]); + return out; + } + let mut out = Vec::new(); + if out.try_reserve_exact(pixels).is_err() { + return Vec::new(); } - let mut out = Vec::with_capacity(w * h); for y in 0..h { let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1)); for x in 0..w { @@ -176,12 +203,16 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize let Obj::Stream(d, raw) = file.resolve(o) else { return None }; let w = file.get_num(d, "Width")? as usize; let h = file.get_num(d, "Height")? as usize; - if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w.checked_mul(h).is_none_or(|p| p > MAX_PIXELS) { return None; } let (data, jpeg_n) = samples(file, d, raw).ok()?; + let pixels = w.checked_mul(h)?; if jpeg_n.is_some() { - return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h)); + let mut out = Vec::new(); + out.try_reserve_exact(pixels).ok()?; + out.extend(data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(pixels)); + return Some((out, w, h)); } let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 }; // Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably @@ -192,19 +223,21 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize let max = ((1u32 << bpc) - 1).max(1) as f64; let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2); let v = unpack(&data, w, h, 1, bpc); - let out = v - .into_iter() - .map(|s| { - let t = s as f64 / max; - let t = match &dec { - Some(dd) => dd[0] + t * (dd[1] - dd[0]), - None => t, - }; - // Explicit (stencil) masks: sample 1 masks out with the default Decode. - let a = if soft { t } else { 1.0 - t }; - (a.clamp(0.0, 1.0) * 255.0).round() as u8 - }) - .collect(); + if v.len() != pixels { + return None; + } + let mut out = Vec::new(); + out.try_reserve_exact(pixels).ok()?; + out.extend(v.into_iter().map(|s| { + let t = s as f64 / max; + let t = match &dec { + Some(dd) => dd[0] + t * (dd[1] - dd[0]), + None => t, + }; + // Explicit (stencil) masks: sample 1 masks out with the default Decode. + let a = if soft { t } else { 1.0 - t }; + (a.clamp(0.0, 1.0) * 255.0).round() as u8 + })); Some((out, w, h)) } @@ -212,15 +245,24 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result { let w = file.get_num(d, "Width").unwrap_or(0.0) as usize; let h = file.get_num(d, "Height").unwrap_or(0.0) as usize; - if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 { + let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { + return Err("image (size)".into()); + }; + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { return Err("image (size)".into()); } let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true))); let (data, jpeg_n) = samples(file, d, raw)?; let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default(); - let mut rgba = vec![0u8; w * h * 4]; + let rgba_len = pixels.checked_mul(4).ok_or_else(|| "image (size)".to_string())?; + let mut rgba = Vec::new(); + rgba.try_reserve_exact(rgba_len).map_err(|_| "image (allocation)".to_string())?; + rgba.resize(rgba_len, 0); if stencil { let v = unpack(&data, w, h, 1, 1); + if v.len() != pixels { + return Err("image (sample allocation)".into()); + } let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 }; let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8); for (i, s) in v.into_iter().enumerate() { @@ -243,8 +285,14 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { return Err(format!("image ({bpc} bits per component)")); } + let Some(sample_count) = pixels.checked_mul(n).filter(|n| *n <= MAX_PIXELS.saturating_mul(4)) else { + return Err("image (size)".into()); + }; let max = ((1u32 << bpc) - 1) as f64; let raw_samples = unpack(&data, w, h, n, bpc); + if raw_samples.len() != sample_count { + return Err("image (sample allocation)".into()); + } // Decode ranges per component. let indexed = matches!(cs, Cs::Indexed { .. }); let range = |k: usize| -> (f64, f64) { @@ -262,7 +310,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()), _ => None, }; - for i in 0..w * h { + for i in 0..pixels { if let Some(pal) = &palette { let s = raw_samples[i] as f64 / max; let (a, b) = ranges[0]; @@ -288,7 +336,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill Some(Obj::Array(a)) => { let keys: Vec = a.iter().filter_map(|x| file.resolve(x).num()).collect(); if keys.len() >= 2 * n { - for i in 0..w * h { + for i in 0..pixels { if (0..n).all(|k| { let v = raw_samples[i * n + k] as f64; v >= keys[2 * k] && v <= keys[2 * k + 1] @@ -300,7 +348,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill } Some(m @ Obj::Stream(..)) => { if let Some((m, mw, mh)) = mask_image(file, m, false) { - for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() { rgba[i * 4 + 3] = a; } } @@ -310,7 +358,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill if let Some(sm) = file.get(d, "SMask") && let Some((m, mw, mh)) = mask_image(file, sm, true) { - for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() { rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8; } } diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index af1e14b5..52c65efb 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -840,7 +840,7 @@ function __uiJson(c, inherited) { o.value = Number(c.value) || 0; o.min = Number(c.minvalue) || 0; o.max = Number(c.maxvalue); - if (!(o.max === o.max)) o.max = 100; + if (Number.isNaN(o.max)) o.max = 100; } if (c.items) { o.items = c.items.map(function (it) { return String(it.text); }); diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs index d85ff80c..35ed5729 100644 --- a/crates/segment/src/pt.rs +++ b/crates/segment/src/pt.rs @@ -6,6 +6,15 @@ use std::collections::HashMap; +const MAX_ARCHIVE_BYTES: usize = 1 << 30; +const MAX_PICKLE_OPS: usize = 4_000_000; +const MAX_TENSORS: usize = 1_000_000; +const MAX_TENSOR_ELEMENTS: usize = 1 << 28; +const MAX_ENTRY_BYTES: usize = 1 << 30; +const MAX_PICKLE_NODES: usize = 4_000_000; +const MAX_TOTAL_ELEMENTS: usize = 1 << 29; +const MAX_TENSOR_DIMS: usize = 64; + /// A tensor read from a checkpoint: its shape and its values as f32 (row-major, contiguous). #[derive(Clone, Debug, PartialEq)] pub struct Tensor { @@ -38,6 +47,9 @@ struct Entry { /// The entries of a zip archive by name. fn zip_entries(b: &[u8]) -> Result> { + if b.len() > MAX_ARCHIVE_BYTES { + return Err("checkpoint exceeds the 1024 MiB archive limit".into()); + } // End of central directory: the last `PK\x05\x06` (a comment may follow it). let eocd = (0..b.len().saturating_sub(21)).rev().find(|&i| b.get(i..i + 4) == Some(b"PK\x05\x06")).ok_or("not a zip archive (no end of central directory)")?; @@ -96,6 +108,9 @@ fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result> match e.method { 0 => Ok(std::borrow::Cow::Borrowed(raw)), 8 => { + if e.size > MAX_ENTRY_BYTES { + return Err("zip entry exceeds the size limit".into()); + } miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}")) } m => Err(format!("unsupported zip compression {m}")), @@ -142,7 +157,58 @@ fn int_list(o: &Obj) -> Option> { } } +/// Nodes in `o`, stopping once the count passes `budget`. +fn nodes(o: &Obj, budget: usize) -> usize { + let kids: Vec<&Obj> = match o { + Obj::Tuple(v) | Obj::List(v) => v.iter().collect(), + Obj::Dict(v) => v.iter().flat_map(|(k, x)| [k, x]).collect(), + _ => return 1, + }; + let mut n = 1; + for k in kids { + if n > budget { + break; + } + n += nodes(k, budget - n); + } + n +} + +fn tensor_element_count(name: &str, offset: usize, size: &[usize], stride: &[usize], values_len: usize) -> Result { + if size.len() != stride.len() || size.len() > MAX_TENSOR_DIMS { + return Err(format!("{name}: invalid tensor dimensionality")); + } + let n = size + .iter() + .try_fold(1usize, |n, &dim| n.checked_mul(dim)) + .filter(|n| *n <= MAX_TENSOR_ELEMENTS) + .ok_or_else(|| format!("{name}: tensor exceeds the element limit"))?; + if n == 0 { + return Ok(0); + } + let mut max_at = offset; + for (&dim, &step) in size.iter().zip(stride) { + max_at = max_at + .checked_add((dim - 1).checked_mul(step).ok_or_else(|| format!("{name}: tensor stride overflow"))?) + .ok_or_else(|| format!("{name}: tensor offset overflow"))?; + } + if max_at >= values_len { + return Err(format!("{name}: data out of range")); + } + Ok(n) +} + /// Run the pickle program and return its result. +/// A memoized object, charged against the clone budget (memo reuse can otherwise grow trees exponentially). +fn memo_get(memo: &HashMap, k: u64, cloned: &mut usize) -> Result { + let o = memo.get(&k).ok_or("pickle: bad memo")?; + *cloned = cloned.saturating_add(nodes(o, MAX_PICKLE_NODES)); + if *cloned > MAX_PICKLE_NODES { + return Err("pickle exceeds the object limit".into()); + } + Ok(o.clone()) +} + fn unpickle(p: &[u8]) -> Result { let mut stack: Vec = Vec::new(); let mut memo: HashMap = HashMap::new(); @@ -166,7 +232,10 @@ fn unpickle(p: &[u8]) -> Result { *i += n + 1; Ok(s) }; + let mut op_count = 0usize; + let mut cloned = 0usize; loop { + op_count = op_count.checked_add(1).filter(|n| *n <= MAX_PICKLE_OPS).ok_or("pickle exceeds the opcode limit")?; let op = *p.get(i).ok_or("truncated pickle")?; i += 1; match op { @@ -253,11 +322,11 @@ fn unpickle(p: &[u8]) -> Result { } b'h' => { let k = take(&mut i, 1)?[0] as u64; - stack.push(memo.get(&k).cloned().ok_or("pickle: bad memo")?); + stack.push(memo_get(&memo, k, &mut cloned)?); } b'j' => { let s = take(&mut i, 4)?; - stack.push(memo.get(&(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64)).cloned().ok_or("pickle: bad memo")?); + stack.push(memo_get(&memo, u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, &mut cloned)?); } b't' => { let items = pop_mark(&mut stack)?; @@ -290,9 +359,10 @@ fn unpickle(p: &[u8]) -> Result { stack.push(match f { Obj::Global(m, n) if m == "torch._utils" && n == "_rebuild_tensor_v2" => { match (args.first(), args.get(1), args.get(2).and_then(int_list), args.get(3).and_then(int_list)) { - (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => { - Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset: usize::try_from(*off).unwrap_or(0), size, stride } - } + (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => match usize::try_from(*off) { + Ok(offset) => Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset, size, stride }, + Err(_) => Obj::Other, + }, _ => Obj::Other, } } @@ -337,16 +407,24 @@ fn unpickle(p: &[u8]) -> Result { } } -/// Element bytes and conversion of a storage dtype. -fn to_f32(dtype: &str, bytes: &[u8]) -> Option> { +/// Bytes per element of a float storage dtype. +fn elem_size(dtype: &str) -> Option { match dtype { - "FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()), - "HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()), - "BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()), + "FloatStorage" => Some(4), + "HalfStorage" | "BFloat16Storage" => Some(2), _ => None, } } +/// Element `i` of a storage (`i` must be below `bytes.len() / elem_size`). +fn elem_f32(dtype: &str, bytes: &[u8], i: usize) -> f32 { + match dtype { + "FloatStorage" => f32::from_le_bytes([bytes[4 * i], bytes[4 * i + 1], bytes[4 * i + 2], bytes[4 * i + 3]]), + "HalfStorage" => half_to_f32(u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]])), + _ => f32::from_bits((u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]) as u32) << 16), + } +} + pub(crate) fn half_to_f32(h: u16) -> f32 { let (s, e, m) = ((h >> 15) as u32, ((h >> 10) & 0x1f) as u32, (h & 0x3ff) as u32); let bits = match (e, m) { @@ -382,22 +460,42 @@ pub fn read(bytes: &[u8]) -> Result { Some((_, Obj::Dict(inner))) => inner.clone(), _ => dict, }; + if dict.len() > MAX_TENSORS { + return Err("checkpoint exceeds the tensor count limit".into()); + } let mut storages: HashMap> = HashMap::new(); let mut out = Weights::new(); + let mut total = 0usize; + let mut inflated = 0usize; for (k, v) in dict { let (Obj::Str(name), Obj::Tensor { dtype, key, offset, size, stride }) = (k, v) else { continue }; if !storages.contains_key(&key) { let e = entries.get(&format!("{prefix}data/{key}")).ok_or_else(|| format!("missing storage {key}"))?; + if e.method == 8 { + inflated = inflated.saturating_add(e.size); + if inflated > MAX_ENTRY_BYTES { + return Err("checkpoint exceeds the decompressed storage limit".into()); + } + } storages.insert(key.clone(), read_entry(bytes, e)?); } - let Some(values) = storages.get(&key).and_then(|b| to_f32(&dtype, b)) else { continue }; - let n: usize = size.iter().product(); - // Gather (any strides) into a contiguous row-major tensor. - let mut data = Vec::with_capacity(n); + let (Some(esz), Some(values)) = (elem_size(&dtype), storages.get(&key)) else { continue }; + // Check hostile stride math before allocating or indexing the backing storage. + let n = tensor_element_count(&name, offset, &size, &stride, values.len() / esz)?; + total = total.saturating_add(n); + if total > MAX_TOTAL_ELEMENTS { + return Err("checkpoint exceeds the total element limit".into()); + } + let mut data = Vec::new(); + data.try_reserve_exact(n).map_err(|_| format!("{name}: tensor allocation failed"))?; let mut idx = vec![0usize; size.len()]; for _ in 0..n { - let at = offset + idx.iter().zip(&stride).map(|(i, s)| i * s).sum::(); - data.push(*values.get(at).ok_or_else(|| format!("{name}: data out of range"))?); + let at = idx + .iter() + .zip(&stride) + .try_fold(offset, |at, (i, s)| i.checked_mul(*s).and_then(|v| at.checked_add(v))) + .ok_or_else(|| format!("{name}: tensor offset overflow"))?; + data.push(elem_f32(&dtype, values, at)); for d in (0..idx.len()).rev() { idx[d] += 1; if idx[d] < size[d] { @@ -490,6 +588,15 @@ mod tests { assert!(!w.contains_key("n"), "integer tensors are skipped"); } + #[test] + fn rejects_tensor_shape_overflow_and_invalid_strides() { + assert!(tensor_element_count("bad", 0, &[usize::MAX, 2], &[1, 1], 16).is_err()); + assert!(tensor_element_count("bad", 0, &[2, 2], &[usize::MAX, 1], 16).is_err()); + assert!(tensor_element_count("bad", 0, &[2, 2], &[1], 16).is_err()); + assert!(tensor_element_count("bad", usize::MAX, &[2], &[1], 16).is_err()); + assert_eq!(tensor_element_count("empty", 0, &[0, usize::MAX], &[0, 0], 0), Ok(0)); + } + #[test] fn damaged_files_are_errors() { assert!(read(b"not a zip").is_err()); diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index af59e33d..8ccfa58c 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -20,12 +20,47 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image { /// Rasterise `doc` with `m` mapping document pixels to image pixels. pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image { - let mut img = Image::new(w, h); - draw_group(&doc.root, m, 1.0, &mut img); + let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES }; + let Some(mut img) = budget.image(w, h, None) else { return Image::default() }; + draw_group(&doc.root, m, 1.0, &mut img, &mut budget); img } -fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { +/// Bound the simultaneously live offscreen image surfaces created by nested groups. +const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024; + +struct SurfaceBudget { + live_bytes: usize, + limit_bytes: usize, +} + +impl SurfaceBudget { + fn image(&mut self, width: u32, height: u32, copy: Option<&Image>) -> Option { + let pixels = (width as usize).checked_mul(height as usize)?; + let bytes = pixels.checked_mul(std::mem::size_of::<[f32; 4]>())?; + let new_live = self.live_bytes.checked_add(bytes)?; + if new_live > self.limit_bytes { + return None; + } + let mut data = Vec::new(); + data.try_reserve_exact(pixels).ok()?; + data.resize(pixels, [0.0; 4]); + if let Some(source) = copy { + if source.width != width || source.height != height || source.data.len() != pixels { + return None; + } + data.copy_from_slice(&source.data); + } + self.live_bytes = new_live; + Some(Image { width, height, data }) + } + + fn release(&mut self, image: &Image) { + self.live_bytes = self.live_bytes.saturating_sub(image.data.len().saturating_mul(std::mem::size_of::<[f32; 4]>())); + } +} + +fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) { let m = m * g.transform; let o = opacity * g.opacity; if o <= 0.0 { @@ -38,7 +73,7 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { || g.knockout; if !offscreen { for c in &g.children { - draw_node(c, m, o, dst); + draw_node(c, m, o, dst, budget); } return; } @@ -46,12 +81,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { // Draw the children on their own (isolated) or over a copy of the backdrop, keep what // every clip path covers and the soft mask lets through, then composite with the group's // opacity and blend mode. - let backdrop = if g.isolated { None } else { Some(dst.clone()) }; + let backdrop = if g.isolated { None } else { budget.image(dst.width, dst.height, Some(dst)) }; + if !g.isolated && backdrop.is_none() { + return; + } let mut tmp = match &backdrop { - Some(b) => b.clone(), - None => Image::new(dst.width, dst.height), + Some(b) => match budget.image(dst.width, dst.height, Some(b)) { + Some(image) => image, + None => { + budget.release(b); + return; + } + }, + None => match budget.image(dst.width, dst.height, None) { + Some(image) => image, + None => return, + }, }; - draw_children(g, m, &mut tmp, backdrop.as_ref()); + draw_children(g, m, &mut tmp, backdrop.as_ref(), budget); if let Some(b) = &backdrop { if only_clip { // A non-isolated clipping group: the backdrop where the clip excludes the children. @@ -61,12 +108,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { d[i] = d[i] * (1.0 - c) + t[i] * c; } }); + budget.release(&tmp); + drop(tmp); + if let Some(b) = &backdrop { + budget.release(b); + } return; } // Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own // alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0). - let mut solo = Image::new(dst.width, dst.height); - draw_children(g, m, &mut solo, None); + let Some(mut solo) = budget.image(dst.width, dst.height, None) else { + budget.release(&tmp); + drop(tmp); + if let Some(b) = &backdrop { + budget.release(b); + } + return; + }; + draw_children(g, m, &mut solo, None, budget); tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| { let ag = s[3]; if ag <= 0.0 { @@ -83,6 +142,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { out[3] = ag; *t = out; }); + budget.release(&solo); + drop(solo); } if !g.clip.is_empty() { let cov = clip_coverage(g, m, dst.width, dst.height); @@ -93,8 +154,15 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { }); } if let Some(sm) = &g.mask { - let mut mk = Image::new(dst.width, dst.height); - draw_group(&sm.content, m, 1.0, &mut mk); + let Some(mut mk) = budget.image(dst.width, dst.height, None) else { + budget.release(&tmp); + drop(tmp); + if let Some(b) = &backdrop { + budget.release(b); + } + return; + }; + draw_group(&sm.content, m, 1.0, &mut mk, budget); let bc = sm.backdrop.map(|v| v as f32); let lum = sm.luminosity; tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| { @@ -110,6 +178,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { *v *= k; } }); + budget.release(&mk); + drop(mk); } if g.blend == BlendMode::Normal { over(dst, &tmp, o as f32); @@ -123,6 +193,11 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { } }); } + budget.release(&tmp); + drop(tmp); + if let Some(b) = &backdrop { + budget.release(b); + } } /// The product of a group's clip-path coverages. @@ -142,19 +217,22 @@ fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec { /// Draw a group's children into `dst`. Knockout groups composite each child with the /// group's initial backdrop (`backdrop`, or transparent) instead of with the children below: /// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`. -fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) { +fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>, budget: &mut SurfaceBudget) { if !g.knockout { for c in &g.children { - draw_node(c, m, 1.0, dst); + draw_node(c, m, 1.0, dst, budget); } return; } for c in &g.children { - let mut ci = Image::new(dst.width, dst.height); - draw_node(c, m, 1.0, &mut ci); + let Some(mut ci) = budget.image(dst.width, dst.height, None) else { return }; + draw_node(c, m, 1.0, &mut ci, budget); // Shape: the child drawn fully opaque (constant opacities to 1). - let mut si = Image::new(dst.width, dst.height); - draw_node(&opaque(c), m, 1.0, &mut si); + let Some(mut si) = budget.image(dst.width, dst.height, None) else { + budget.release(&ci); + return; + }; + draw_node(&opaque(c), m, 1.0, &mut si, budget); let zero = [0.0f32; 4]; dst.data.par_iter_mut().enumerate().for_each(|(i, d)| { let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3])); @@ -166,6 +244,10 @@ fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image> d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]); } }); + budget.release(&ci); + budget.release(&si); + drop(ci); + drop(si); } } @@ -299,14 +381,15 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { c.map(|v| (v - mn) * s / (mx - mn)) } -fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) { +fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) { match n { - Node::Group(g) => draw_group(g, m, opacity, dst), + Node::Group(g) => draw_group(g, m, opacity, dst, budget), Node::Shape(s) => { if s.opacity < 1.0 && s.fill.is_some() && s.stroke.is_some() { - let mut tmp = Image::new(dst.width, dst.height); + let Some(mut tmp) = budget.image(dst.width, dst.height, None) else { return }; draw_shape(s, m, 1.0, &mut tmp); over(dst, &tmp, (opacity * s.opacity) as f32); + budget.release(&tmp); } else { draw_shape(s, m, opacity * s.opacity, dst); } @@ -514,12 +597,12 @@ pub(crate) fn eval_stops(stops: &[(f64, [f64; 4])], t: f64) -> [f32; 4] { if t <= first.0 { return first.1.map(|v| v as f32); } - for w in stops.windows(2) { - let (a, b) = (w[0], w[1]); - if t <= b.0 { - let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 }; - return [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32); - } - } - stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32)) + // Find the first upper stop (not the last duplicate): equal-offset segments retain the + // reference algorithm's exact-boundary behavior, while nearby values are interpolated from + // the preceding stop as before. + let upper = stops.partition_point(|stop| stop.0 < t); + let Some(b) = stops.get(upper) else { return stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32)) }; + let Some(a) = stops.get(upper.saturating_sub(1)) else { return first.1.map(|v| v as f32) }; + let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 }; + [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32) } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 93db715c..ee13a79f 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -11,6 +11,25 @@ fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { (0..4).all(|i| (a[i] - b[i]).abs() <= tol) } +#[test] +fn gradient_stop_lookup_handles_duplicate_offsets() { + use crate::raster::eval_stops; + let stops = [(0.0, [0.0, 0.0, 0.0, 1.0]), (0.5, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])]; + assert_eq!(eval_stops(&stops, 0.5), [1.0, 0.0, 0.0, 1.0]); + assert!(near(eval_stops(&stops, 0.5001), [0.0, 1.0, 0.0, 1.0], 3e-4)); + assert_eq!(eval_stops(&stops, 0.75), [0.0, 0.5, 0.5, 1.0]); +} + +#[test] +fn raster_surface_budget_rejects_oversized_destination_before_allocation() { + let doc = parse(br#""#).unwrap(); + // 16,384² RGBA-f32 pixels exceed the bounded destination-surface budget; no giant + // allocation should be attempted and the caller receives an empty image. + let img = rasterize(&doc, 16_384, 16_384, 1.0); + assert!(img.data.is_empty()); + assert_eq!((img.width, img.height), (0, 0)); +} + #[test] fn basic_shapes_structure() { let d = parse(BASIC).unwrap(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1a..7ca5bd13 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -597,7 +597,15 @@ fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { } /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. -fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option) -> Vec { +fn paragraph( + text: &str, + base: usize, + runs: &[(Range, TextStyle)], + primaries: &[Resolved], + variation_ids: &[u32], + para: &ParagraphStyle, + width: Option, +) -> Vec { let rtl_para = para.rtl == Some(true); let margins_for = |first: bool| -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; @@ -702,18 +710,26 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar char_adv[g.cluster.min(text.len())] += g.adv + g.pre; } } - let range_w = |r: Range| -> f32 { char_adv[r].iter().sum() }; - let trailing_ws = |r: &Range| -> (usize, f32) { - let mut end = r.end; - let mut w = 0.0; - for (b, c) in text[r.clone()].char_indices().rev() { - if !c.is_whitespace() { - break; - } - end = r.start + b; - w += char_adv[r.start + b]; + // Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1). + // Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes + // or rescanning its trailing whitespace would otherwise add another factor of text length. + let mut prefix_adv = vec![0.0f64; text.len() + 1]; + for i in 0..text.len() { + prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]); + } + let mut previous_non_ws = vec![0usize; text.len() + 1]; + let mut non_ws_end = 0usize; + for (b, c) in text.char_indices() { + previous_non_ws[b] = non_ws_end; + if !c.is_whitespace() { + non_ws_end = b + c.len_utf8(); } - (end, w) + previous_non_ws[b + c.len_utf8()] = non_ws_end; + } + let range_w = |r: Range| -> f32 { (prefix_adv[r.end] - prefix_adv[r.start]) as f32 }; + let trailing_ws = |r: &Range| -> (usize, f32) { + let end = previous_non_ws[r.end].max(r.start).min(r.end); + (end, (prefix_adv[r.end] - prefix_adv[end]) as f32) }; // Hanging punctuation widths (left, right) of a line's content. let hang_of = |r: &Range, ce: usize| -> (f32, f32) { @@ -824,7 +840,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let mut underline: Option<(f32, f32)> = None; for run in vruns { let rtl = levels[run.start].is_rtl(); - let mut idx: Vec = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect(); + // Items are ordered and disjoint: binary-search both ends of the overlapping slice. + let run_limit = items.partition_point(|item| item.range.start < run.end); + let run_first = items[..run_limit].partition_point(|item| item.range.end <= run.start); + let mut idx: Vec = (run_first..run_limit).collect(); if rtl { idx.reverse(); } @@ -845,7 +864,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar h_scale: st.h_scale, v_scale: st.v_scale, run: g.run, - variations: crate::variable::intern(&st.variations), + variations: variation_ids[g.run], }); pen += g.adv; if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce { @@ -862,18 +881,25 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar } } } - // caret stops for each char boundary in the line + // Caret stops for each char boundary in the line. let mut carets = Vec::new(); let mut cluster_starts: Vec = extents.keys().copied().collect(); cluster_starts.sort_unstable(); let char_bytes: Vec = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect(); + let mut char_index = 0usize; for (ci, &cs) in cluster_starts.iter().enumerate() { let (x0, x1, rtl) = extents[&cs]; let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end); - let members: Vec = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect(); - let n = members.len().max(1) as f32; - for (mi, b) in members.iter().enumerate() { - let f = mi as f32 / n; + while char_index < char_bytes.len() && char_bytes[char_index] < cs { + char_index += 1; + } + let begin = char_index; + while char_index < char_bytes.len() && char_bytes[char_index] < next { + char_index += 1; + } + let count = (char_index - begin).max(1) as f32; + for (mi, b) in char_bytes[begin..char_index].iter().enumerate() { + let f = mi as f32 / count; let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f }; carets.push((base + b, x)); } @@ -900,7 +926,9 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar carets.dedup_by_key(|c| c.0); // Line metrics: the tallest face and the largest leading on the line. let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); - let mut seen: Vec = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect(); + let char_start = chars.partition_point(|c| c.0 < r.start); + let char_end = chars.partition_point(|c| c.0 < r.end); + let mut seen: Vec = chars[char_start..char_end].iter().map(|c| c.6).collect(); seen.sort_unstable(); seen.dedup(); if seen.is_empty() { @@ -945,10 +973,17 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para)) } +/// Maximum input size processed by one layout request. Above this, return an empty layout +/// retaining the source length, so untrusted text cannot fan out into unbounded shaped data. +const MAX_LAYOUT_BYTES: usize = 1 << 20; + /// Lay out `text` with character style runs (byte ranges covering the text, in order; at least /// one) and paragraph settings (one per CR/LF/CRLF-separated paragraph; the last repeats). The wrap /// width is the first paragraph's. pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], paras: &[ParagraphStyle]) -> Layout { + if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES { + return Layout { text_len: text.len(), ..Default::default() }; + } let fallback = [(0..text.len(), TextStyle::default())]; let runs = if runs.is_empty() { &fallback[..] } else { runs }; let default_para = ParagraphStyle::default(); @@ -975,11 +1010,29 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para } else { runs }; + // A location that cannot be interned would outline at the default; shape there too. + let cleared: Vec<(Range, TextStyle)>; + let runs = if runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0) { + cleared = runs + .iter() + .map(|(r, s)| { + let mut s = s.clone(); + if crate::variable::intern(&s.variations) == 0 { + s.variations.clear(); + } + (r.clone(), s) + }) + .collect(); + &cleared[..] + } else { + runs + }; + let variation_ids: Vec = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect(); let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() }; let mut plines: Vec<(usize, ParaLine)> = Vec::new(); for (pi, span) in paragraph_ranges(text).enumerate() { let p = text.get(span.bytes.clone()).unwrap_or_default(); - plines.extend(paragraph(p, span.bytes.start, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l))); + plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l))); } let mut baseline = 0.0f32; for (i, (pi, pl)) in plines.into_iter().enumerate() { @@ -1060,6 +1113,9 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc, TextStyle)], paras: &[ParagraphStyle]) -> Arc { + if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES { + return Arc::new(layout_rich_uncached(text, runs, paras)); + } let mut h = std::collections::hash_map::DefaultHasher::new(); text.hash(&mut h); runs.hash(&mut h); @@ -1415,6 +1471,15 @@ mod tests { assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right"); } + #[test] + fn oversized_layout_requests_are_bounded_without_caching() { + let text = "a".repeat(MAX_LAYOUT_BYTES + 1); + let layout = layout(&text, &TextStyle::default(), &ParagraphStyle::default()); + assert_eq!(layout.text_len, text.len()); + assert!(layout.lines.is_empty() && layout.glyphs.is_empty()); + assert!(layout_rich_uncached("ok", &[], &[]).lines.len() == 1); + } + #[test] fn rtl_paragraph_carets_run_right_to_left() { let text = "\u{5d0}\u{5d1}\u{5d2}"; diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index 753bbdd2..a5e3907c 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -201,13 +201,21 @@ fn native_name(b: &[u8], id: u16) -> Option { let count = be16(b, 2)? as usize; let storage = be16(b, 4)? as usize; for i in 0..count { - let r = 6 + i * 12; - let (plat, enc, lang, nid, len, off) = - (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize); + let r = 6usize.checked_add(i.checked_mul(12)?)?; + let (plat, enc, lang, nid, len, off) = ( + be16(b, r)?, + be16(b, r.checked_add(2)?)?, + be16(b, r.checked_add(4)?)?, + be16(b, r.checked_add(6)?)?, + be16(b, r.checked_add(8)?)? as usize, + be16(b, r.checked_add(10)?)? as usize, + ); if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 { continue; } - let raw = b.get(storage + off..storage + off + len)?; + let start = storage.checked_add(off)?; + let end = start.checked_add(len)?; + let raw = b.get(start..end)?; let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); let s = String::from_utf16_lossy(&u); if !s.trim().is_empty() { @@ -221,32 +229,45 @@ fn native_name(b: &[u8], id: u16) -> Option { fn name_string(b: &[u8], id: u16) -> Option { let count = be16(b, 2)? as usize; let storage = be16(b, 4)? as usize; - let mut best: Option<(u8, String)> = None; + let mut cands: Vec<(u8, u16, u16, &[u8])> = Vec::new(); for i in 0..count { - let r = 6 + i * 12; + let r = 6usize.checked_add(i.checked_mul(12)?)?; let (plat, enc, lang, nid, len, off) = (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize); if nid != id { continue; } - let raw = b.get(storage + off..storage + off + len)?; - let (rank, s) = match plat { + let start = storage.checked_add(off)?; + let end = start.checked_add(len)?; + let raw = b.get(start..end)?; + let rank = match plat { 3 if enc == 1 || enc == 10 => { - let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u)) + if lang == 0x409 { + 0 + } else { + 1 + } } - 0 => { - let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (2, String::from_utf16_lossy(&u)) - } - 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()), + 0 => 2, + 1 if enc == 0 => 3, _ => continue, }; - if best.as_ref().is_none_or(|(r0, _)| rank < *r0) { - best = Some((rank, s)); - } + cands.push((rank, plat, enc, raw)); } - best.map(|(_, s)| s).filter(|s| !s.trim().is_empty()) + // Best rank first; a blank record falls through to the next. Decoding is lazy, so aliased + // records are not decoded once a usable one is found. + cands.sort_by_key(|c| c.0); + cands.into_iter().find_map(|(_, plat, enc, raw)| { + let s = match (plat, enc) { + (3, 1 | 10) | (0, _) => { + let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + String::from_utf16_lossy(&u) + } + (1, 0) => raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect(), + _ => return None, + }; + (!s.trim().is_empty()).then_some(s) + }) } /// Synthetic font files for tests: just `name` and `fvar`, enough for the scan and the resolver. @@ -367,6 +388,28 @@ mod tests { /// The scan marks a face with an `fvar` table that declares axes as variable, reading only /// the table directory and the fvar header; a static font, an fvar without axes and a /// truncated fvar are not. + #[test] + fn name_table_offsets_are_checked_and_duplicate_records_do_not_repeat_decode() { + let valid = names_font("Name Bounds", None); + let mut invalid = valid.clone(); + // name record storage offset near u16::MAX must be rejected rather than overflowing + // when combined with its length (the name table starts at byte 28 in this fixture). + invalid[28 + 6 + 10] = 0xFF; + invalid[28 + 6 + 11] = 0xFF; + assert!(read_faces_bytes(&invalid).is_empty()); + + // Repeated aliases for one id retain the same selected name and do not change the face. + let mut duplicated = valid; + let count = u16::from_be_bytes([duplicated[28 + 2], duplicated[28 + 3]]); + let first = duplicated[28 + 6..28 + 18].to_vec(); + for i in 1..count as usize { + duplicated[28 + 6 + i * 12..28 + 18 + i * 12].copy_from_slice(&first); + } + let faces = read_faces_bytes(&duplicated); + assert_eq!(faces.len(), 1); + assert_eq!((faces[0].family.as_str(), faces[0].style.as_str()), ("Name Bounds", "Regular")); + } + #[test] fn detects_variable_faces_from_fvar() { let v = read_faces_bytes(&names_font("Var Test", Some(Some(1)))); diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index fc9d7ab4..bf4fde39 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -4,6 +4,19 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; +/// Keep process-wide variation storage bounded; id 0 is the safe default location when full. +const MAX_INTERNED_LOCATIONS: usize = 8192; +const MAX_AXES_PER_LOCATION: usize = 32; +const MAX_AXIS_TAG_BYTES: usize = 16; + +type CoordKey = Vec<(String, u32)>; + +#[derive(Default)] +struct Table { + values: Vec>>, + by_coords: HashMap, +} + use kurbo::BezPath; use skrifa::instance::{LocationRef, Size}; use skrifa::outline::DrawSettings; @@ -14,15 +27,27 @@ use crate::{CharGlyph, Pen}; /// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them. pub fn intern(coords: &[(String, f32)]) -> u32 { - if coords.is_empty() { + if coords.is_empty() || coords.len() > MAX_AXES_PER_LOCATION || coords.iter().any(|(tag, v)| tag.len() > MAX_AXIS_TAG_BYTES || !v.is_finite()) { + return 0; + } + // Axis order is not significant. Canonicalize it once so equivalent locations share one key. + let mut key: CoordKey = coords.iter().map(|(tag, v)| (tag.clone(), if *v == 0.0 { 0 } else { v.to_bits() })).collect(); + key.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + if key.windows(2).any(|w| w[0].0 == w[1].0) { return 0; } let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); - if let Some(i) = t.iter().position(|c| c.as_slice() == coords) { - return i as u32 + 1; + if let Some(id) = t.by_coords.get(&key) { + return *id; } - t.push(Arc::new(coords.to_vec())); - t.len() as u32 + if t.values.len() >= MAX_INTERNED_LOCATIONS { + return 0; + } + let Some(id) = u32::try_from(t.values.len()).ok().and_then(|n| n.checked_add(1)) else { return 0 }; + let value = Arc::new(key.iter().map(|(tag, bits)| (tag.clone(), f32::from_bits(*bits))).collect()); + t.values.push(value); + t.by_coords.insert(key, id); + id } /// The axis values of an interned id (empty for 0). @@ -30,11 +55,11 @@ pub fn coords(id: u32) -> Arc> { if id == 0 { return Arc::new(Vec::new()); } - table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default() + table().lock().unwrap_or_else(|e| e.into_inner()).values.get(id as usize - 1).cloned().unwrap_or_default() } -fn table() -> &'static Mutex>>> { - static T: OnceLock>>>> = OnceLock::new(); +fn table() -> &'static Mutex { + static T: OnceLock> = OnceLock::new(); T.get_or_init(Default::default) } @@ -197,6 +222,9 @@ mod tests { let id = intern(&[("wght".into(), 700.0)]); assert_eq!(intern(&[("wght".into(), 700.0)]), id); assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]); + assert_eq!(intern(&[("wdth".into(), 20.0), ("wght".into(), 700.0)]), intern(&[("wght".into(), 700.0), ("wdth".into(), 20.0)])); + assert_eq!(intern(&[("wght".into(), f32::NAN)]), 0); + assert_eq!(intern(&vec![("wght".into(), 1.0); MAX_AXES_PER_LOCATION + 1]), 0); let Some((face, axes)) = find_variable_face() else { eprintln!("no variable font installed: skipped"); return; diff --git a/crates/text/tests/it/main.rs b/crates/text/tests/it/main.rs new file mode 100644 index 00000000..1319b7d5 --- /dev/null +++ b/crates/text/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod script_fallback; +mod system_font_menu; +mod vertical_punctuation; diff --git a/crates/text/tests/script_fallback.rs b/crates/text/tests/it/script_fallback.rs similarity index 100% rename from crates/text/tests/script_fallback.rs rename to crates/text/tests/it/script_fallback.rs diff --git a/crates/text/tests/system_font_menu.rs b/crates/text/tests/it/system_font_menu.rs similarity index 100% rename from crates/text/tests/system_font_menu.rs rename to crates/text/tests/it/system_font_menu.rs diff --git a/crates/text/tests/vertical_punctuation.rs b/crates/text/tests/it/vertical_punctuation.rs similarity index 100% rename from crates/text/tests/vertical_punctuation.rs rename to crates/text/tests/it/vertical_punctuation.rs diff --git a/crates/track/src/roto/mod.rs b/crates/track/src/roto/mod.rs index 4efb4335..305065d2 100644 --- a/crates/track/src/roto/mod.rs +++ b/crates/track/src/roto/mod.rs @@ -224,13 +224,19 @@ pub struct FrameSeg { impl FrameSeg { pub fn empty(w: usize, h: usize) -> FrameSeg { - FrameSeg { w, h, matte: vec![0; w * h], refine: vec![0; w * h], ..Default::default() } + let Some(n) = w.checked_mul(h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else { + return FrameSeg::default(); + }; + FrameSeg { w, h, matte: vec![0; n], refine: vec![0; n], ..Default::default() } } pub fn area(&self) -> usize { self.matte.iter().filter(|v| **v != 0).count() } /// Centroid of the matte (pixels), if any. pub fn centroid(&self) -> Option<[f64; 2]> { + if self.w == 0 || self.w.checked_mul(self.h)? > self.matte.len() { + return None; + } let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0); for (i, v) in self.matte.iter().enumerate() { if *v != 0 { @@ -246,8 +252,18 @@ impl FrameSeg { /// Worker to the page): `ECSG`, width, height (u32 LE), the colour models as JSON (length /// prefixed), then the matte and the Refine Edge band run-length encoded (u16 LE run, byte). pub fn to_bytes(&self) -> Vec { + let Some(pixels) = self.w.checked_mul(self.h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else { + return Vec::new(); + }; + if self.matte.len() != pixels || self.refine.len() != pixels || self.w > u32::MAX as usize || self.h > u32::MAX as usize { + return Vec::new(); + } let models = serde_json::to_vec(&(&self.fg, &self.bg)).unwrap_or_default(); - let mut out = Vec::with_capacity(16 + models.len() + 64); + let Some(header_len) = 16usize.checked_add(models.len()) else { return Vec::new() }; + let mut out = Vec::new(); + if out.try_reserve(header_len.saturating_add(64)).is_err() { + return Vec::new(); + } out.extend_from_slice(b"ECSG"); out.extend_from_slice(&(self.w as u32).to_le_bytes()); out.extend_from_slice(&(self.h as u32).to_le_bytes()); @@ -261,6 +277,9 @@ impl FrameSeg { while i + n < plane.len() && plane[i + n] == v && n < u16::MAX as usize { n += 1; } + if out.try_reserve(3).is_err() { + return Vec::new(); + } out.extend_from_slice(&(n as u16).to_le_bytes()); out.push(v); i += n; @@ -277,23 +296,40 @@ impl FrameSeg { let u = |i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize; let (w, h, ml) = (u(4), u(8), u(12)); let n = w.checked_mul(h)?; - let models = b.get(16..16 + ml)?; + if n > rle::MAX_PLANE_BYTES || w == 0 || h == 0 { + return None; + } + let models_end = 16usize.checked_add(ml)?; + let models = b.get(16..models_end)?; let (fg, bg): (Gmm, Gmm) = serde_json::from_slice(models).ok()?; - let mut rest = &b[16 + ml..]; + let mut rest = b.get(models_end..)?; + // Each run covers at most u16::MAX pixels; refuse before reserving a plane the input can't fill. + if rest.len() < n.div_ceil(usize::from(u16::MAX)).saturating_mul(6) { + return None; + } let mut plane = || -> Option> { - let mut v = Vec::with_capacity(n); + let mut v = Vec::new(); + v.try_reserve_exact(n).ok()?; while v.len() < n { if rest.len() < 3 { return None; } - let run = u16::from_le_bytes([rest[0], rest[1]]) as usize; - v.resize(v.len() + run, rest[2]); - rest = &rest[3..]; + let run = u16::from_le_bytes([*rest.first()?, *rest.get(1)?]) as usize; + let next = v.len().checked_add(run)?; + if run == 0 || next > n { + return None; + } + let value = *rest.get(2)?; + v.resize(next, value); + rest = rest.get(3..)?; } (v.len() == n).then_some(v) }; let matte = plane()?; let refine = plane()?; + if !rest.is_empty() { + return None; + } Some(FrameSeg { w, h, matte, refine, fg, bg }) } } diff --git a/crates/track/src/roto/rle.rs b/crates/track/src/roto/rle.rs index 308b2689..914bcad8 100644 --- a/crates/track/src/roto/rle.rs +++ b/crates/track/src/roto/rle.rs @@ -17,6 +17,10 @@ pub fn base64_encode(data: &[u8]) -> String { } pub fn base64_decode(s: &str) -> Option> { + base64_decode_bounded(s, usize::MAX) +} + +fn base64_decode_bounded(s: &str, max_bytes: usize) -> Option> { let val = |c: u8| -> Option { Some(match c { b'A'..=b'Z' => (c - b'A') as u32, @@ -27,13 +31,25 @@ pub fn base64_decode(s: &str) -> Option> { _ => return None, }) }; - let bytes: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace()).collect(); - if !bytes.len().is_multiple_of(4) { + let significant = s.bytes().filter(|c| !c.is_ascii_whitespace()).count(); + let max_encoded = max_bytes.checked_add(2).and_then(|n| n.checked_div(3)).and_then(|n| n.checked_mul(4)).unwrap_or(usize::MAX); + if !significant.is_multiple_of(4) || significant > max_encoded { return None; } - let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + let mut bytes = Vec::new(); + bytes.try_reserve_exact(significant).ok()?; + bytes.extend(s.bytes().filter(|c| !c.is_ascii_whitespace())); + let decoded_len = bytes.len().checked_div(4)?.checked_mul(3)?; + if decoded_len > max_bytes.saturating_add(2) { + return None; + } + let mut out = Vec::new(); + out.try_reserve_exact(decoded_len.min(max_bytes)).ok()?; for c in bytes.chunks(4) { let pad = c.iter().rev().take_while(|b| **b == b'=').count(); + if pad > 2 || c[..4 - pad].contains(&b'=') { + return None; + } let mut n = 0u32; for (i, b) in c.iter().enumerate() { n |= if *b == b'=' { 0 } else { val(*b)? } << (18 - 6 * i); @@ -96,36 +112,66 @@ pub fn encode(values: &[u8]) -> String { base64_encode(&out) } -/// Decode [`encode`]'s output (None when malformed). -pub fn decode(s: &str) -> Option> { - let data = base64_decode(s)?; - let mut pos = 0; - let n = get(&data, &mut pos)? as usize; - if n > 1 << 30 { +/// Decode [`encode`]'s output (None when malformed or larger than the caller's validated plane). +pub fn decode_bounded(s: &str, max_len: usize) -> Option> { + let encoded_limit = max_len.saturating_mul(16).saturating_add(128); + if s.len() > encoded_limit { return None; } - let mut out = Vec::with_capacity(n); + // An alternating byte plane takes at most one value byte and a ten-byte varint per + // pixel, plus the declared-length varint. + let decoded_limit = max_len.saturating_mul(11).saturating_add(10); + let data = base64_decode_bounded(s, decoded_limit)?; + let mut pos = 0; + let n = usize::try_from(get(&data, &mut pos)?).ok()?; + if n > max_len { + return None; + } + let mut out = Vec::new(); + out.try_reserve_exact(n).ok()?; while out.len() < n { let v = *data.get(pos)?; pos += 1; - let run = get(&data, &mut pos)? as usize; - if out.len() + run > n { + let run = usize::try_from(get(&data, &mut pos)?).ok()?; + let next = out.len().checked_add(run)?; + if run == 0 || next > n { return None; } - out.resize(out.len() + run, v); + out.resize(next, v); } Some(out) } +/// Decode [`encode`]'s output, capped at the standard matte-plane limit. +pub fn decode(s: &str) -> Option> { + decode_bounded(s, MAX_PLANE_BYTES) +} + +/// Maximum frozen matte plane size (256 MiB at one byte per pixel). +pub const MAX_PLANE_BYTES: usize = 256 * 1024 * 1024; + #[cfg(test)] mod tests { use super::*; + #[test] + fn refuses_oversized_and_zero_runs_before_expansion() { + let mut declared = Vec::new(); + put(&mut declared, 1024); + declared.extend_from_slice(&[255, 0x80, 0x08]); // 1024-byte run + assert!(decode_bounded(&base64_encode(&declared), 16).is_none()); + let mut zero_run = Vec::new(); + put(&mut zero_run, 1); + zero_run.extend_from_slice(&[0, 0]); + assert!(decode_bounded(&base64_encode(&zero_run), 16).is_none()); + assert!(decode_bounded(&"A".repeat(100_000), 16).is_none()); + } + #[test] fn round_trips() { for n in [0usize, 1, 2, 3, 4, 5, 100, 1000] { let v: Vec = (0..n).map(|i| ((i / 7) % 3) as u8 * 100).collect(); - assert_eq!(decode(&encode(&v)).unwrap(), v); + assert_eq!(decode(&encode(&v)).unwrap(), v, "RLE n={n}"); let raw: Vec = (0..n).map(|i| (i * 31) as u8).collect(); assert_eq!(base64_decode(&base64_encode(&raw)).unwrap(), raw); } diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index 65d1743f..3c4645cb 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -95,6 +95,21 @@ fn strokes_segment_the_base_frame() { assert!(bytes.len() < seg.w * seg.h / 4, "{} bytes", bytes.len()); assert!(FrameSeg::from_bytes(&bytes[..bytes.len() - 1]).is_none()); assert!(FrameSeg::from_bytes(b"nope").is_none()); + + // Untrusted plane dimensions and run lengths are rejected before allocation/resize. + let mut hostile = b"ECSG".to_vec(); + hostile.extend_from_slice(&u32::MAX.to_le_bytes()); + hostile.extend_from_slice(&u32::MAX.to_le_bytes()); + hostile.extend_from_slice(&0u32.to_le_bytes()); + assert!(FrameSeg::from_bytes(&hostile).is_none()); + let mut overrun = bytes.clone(); + let plane_at = 16 + u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; + overrun[plane_at..plane_at + 2].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(FrameSeg::from_bytes(&overrun).is_none()); + let mut trailing = bytes.clone(); + trailing.push(0); + assert!(FrameSeg::from_bytes(&trailing).is_none()); + assert_eq!(FrameSeg::empty(usize::MAX, 2), FrameSeg::default()); } #[test] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index be02fef5..549e3ecb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -26,6 +26,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } kurbo = { workspace = true } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15493ad7..7fe382a5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -321,6 +321,26 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques Err(e) => return err(e), }; let m = modifiers(p); + // Validate the complete gesture before mutating the queue; the loopback control + // channel is externally reachable and must not amplify tiny requests into huge work. + const MAX_PENDING_EVENTS: usize = 4096; + const MAX_CLICK_COUNT: u64 = 512; + let pending = app.synthetic.len(); + let gesture_events = if req.method == "ui.click" { + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + if count == 0 || count > MAX_CLICK_COUNT { + return err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}")); + } + let Some(events) = usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)) else { + return err("click event count overflow"); + }; + events + } else { + 3 + }; + if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { + return err("synthetic input queue is full"); + } // egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event) // and the synthetic events arrive one per frame: hold them for the whole gesture. app.synthetic.push(egui::Event::ModifiersChanged(m)); @@ -348,6 +368,15 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques (Err(e), _) | (_, Err(e)) => return err(e), }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); + const MAX_PENDING_EVENTS: usize = 4096; + const MAX_DRAG_STEPS: u64 = 2048; + if steps > MAX_DRAG_STEPS { + return err(format!("`steps` must be at most {MAX_DRAG_STEPS}")); + } + let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") }; + if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { + return err("synthetic input queue is full"); + } let m = modifiers(p); app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 46bbb44f..338f9ef8 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -67,21 +67,32 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// Unix seconds → `YYYY-MM-DD HH:MM` (UTC). Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn format_utc(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M")) } /// How long ago `then` was, as the Home screen's Opened column shows it ("3 hours ago", then the @@ -95,7 +106,8 @@ pub fn format_ago(now: i64, then: i64) -> String { 3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"), 86_400..172_800 => tr("Yesterday").to_string(), 172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"), - _ => format_utc(then).get(..10).unwrap_or_default().to_string(), + // The date part: up to the space, not a fixed 10 bytes, so a year past 9999 stays whole. + _ => format_utc(then).split(' ').next().unwrap_or_default().to_string(), } } @@ -510,6 +522,12 @@ mod tests { assert_eq!(format_utc(0), "1970-01-01 00:00"); assert_eq!(format_utc(1_790_000_000), "2026-09-21 14:13"); assert_eq!(format_utc(951_782_400), "2000-02-29 00:00"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them), including negatives and the i64 extremes, rather than collapsing to the epoch. + assert_eq!(format_utc(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(format_utc(-10_000_000_000_000), "-314918-08-13 06:13"); + assert_eq!(format_utc(i64::MAX), "292277026596-12-04 15:30"); + assert_eq!(format_utc(i64::MIN), "-292277022657-01-27 08:29"); let img = egui::ColorImage::new([200, 100], vec![Color32::from_rgb(10, 200, 30); 200 * 100]); let text = encode_thumb(&img); let back = decode_thumb(&text).unwrap(); @@ -533,6 +551,8 @@ mod tests { assert_eq!(format_ago(now, now + 100), "Just now"); assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into())); assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project")); + // After a week it is the date, whole even for a five-digit year. + assert_eq!(format_ago(i64::MAX, 253_402_300_800), "10000-01-01"); } /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69..73bd92e8 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -35,21 +35,33 @@ pub fn hms(secs: f64) -> String { format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60) } -/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `unix` is `u64`, so `days` +/// always fits an `i64`. +fn utc_parts(unix: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((unix / 86_400) as i64, (unix % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn utc_stamp(unix: u64) -> String { - let days = (unix / 86_400) as i64; - let rem = unix % 86_400; - // Civil-from-days (proleptic Gregorian). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60) + let (y, t) = utc_parts(unix); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M:%S")) } fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { @@ -807,6 +819,10 @@ mod tests { fn stamps() { assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00"); assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(super::utc_stamp(10_000_000_000_000), "318857-05-20 17:46:40"); + assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 00000000..630d865b --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,83 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod render_queue_snapshot; +mod shared_docking_ui; +mod ui_3d; +mod ui_agent_audit; +mod ui_angles; +mod ui_audio_scrub; +mod ui_azerty_shortcuts; +mod ui_blank_context_menus; +mod ui_camera_tracker; +mod ui_character_eyedropper; +mod ui_color_hex; +mod ui_comp_presets; +mod ui_control_modifiers; +mod ui_dock_resize; +mod ui_drag_drop; +mod ui_effect_controls; +mod ui_effect_drop_highlight; +mod ui_effect_names; +mod ui_font_search; +mod ui_form_fit; +mod ui_frame_step; +mod ui_fx_editors; +mod ui_gpu; +mod ui_gradient_editor; +mod ui_gradient_handles; +mod ui_gradient_value; +mod ui_graph_fit; +mod ui_group_rename; +mod ui_guide_layers; +mod ui_inline_edit; +mod ui_keyframes; +mod ui_large_project; +mod ui_layer_panel_zoom; +mod ui_live_preview; +mod ui_locked_color; +mod ui_media_browser_cache; +mod ui_media_browser_copy_path; +mod ui_menus; +mod ui_object_matte; +mod ui_panel_focus; +mod ui_panel_windows; +mod ui_panels; +mod ui_parameter_spacing; +mod ui_presets_folder; +mod ui_preview_align; +mod ui_project_delete; +mod ui_project_keyboard; +mod ui_project_new_folder; +mod ui_project_rows; +mod ui_quick_offset; +mod ui_ram_preview; +mod ui_render_queue; +mod ui_script_console; +mod ui_scriptui; +mod ui_scrolling; +mod ui_settings_info; +mod ui_tabs; +mod ui_tail; +mod ui_text_edit; +mod ui_text_range_handles; +mod ui_theme_favorites; +mod ui_time_zoom_controls; +mod ui_timeline_effect_label; +mod ui_timeline_shapes; +mod ui_timeline_switches; +mod ui_timeline_trim; +mod ui_timeline_zoom; +mod ui_toolbar_gradient; +mod ui_tracker; +mod ui_unreadable_footage; +mod ui_video_preview_off; +mod ui_viewer; +mod ui_viewers; +mod ui_visual_review; +mod ui_web_gpu_storage; +mod ui_web_job_gpu; +mod ui_web_offload; +mod ui_workflow_selection; +mod ui_workspace_focus; +mod viewer_geometry; +mod viewer_passive_display; diff --git a/crates/ui-egui/tests/render_queue_snapshot.rs b/crates/ui-egui/tests/it/render_queue_snapshot.rs similarity index 98% rename from crates/ui-egui/tests/render_queue_snapshot.rs rename to crates/ui-egui/tests/it/render_queue_snapshot.rs index 41ce13d2..702421ca 100644 --- a/crates/ui-egui/tests/render_queue_snapshot.rs +++ b/crates/ui-egui/tests/it/render_queue_snapshot.rs @@ -1,6 +1,6 @@ //! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU //! adapter); run with -//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`. +//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it render_queue_snapshot:: -- --ignored`. use effectcraft_engine::Session; use effectcraft_engine::project::render_queue::RenderStatus; diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs similarity index 100% rename from crates/ui-egui/tests/shared_docking_ui.rs rename to crates/ui-egui/tests/it/shared_docking_ui.rs diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/it/ui_3d.rs similarity index 99% rename from crates/ui-egui/tests/ui_3d.rs rename to crates/ui-egui/tests/it/ui_3d.rs index 223d9960..a654f745 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/it/ui_3d.rs @@ -1,6 +1,6 @@ //! Headless UI checks for the 3D viewer (egui_kittest). The `snapshot` test renders the window //! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui -//! --test ui_3d -- --ignored`); the other test only runs the UI logic. +//! --test it ui_3d:: -- --ignored`); the other test only runs the UI logic. use effectcraft_engine::Session; use effectcraft_engine::render::three_d::View3D; diff --git a/crates/ui-egui/tests/ui_agent_audit.rs b/crates/ui-egui/tests/it/ui_agent_audit.rs similarity index 100% rename from crates/ui-egui/tests/ui_agent_audit.rs rename to crates/ui-egui/tests/it/ui_agent_audit.rs diff --git a/crates/ui-egui/tests/ui_angles.rs b/crates/ui-egui/tests/it/ui_angles.rs similarity index 100% rename from crates/ui-egui/tests/ui_angles.rs rename to crates/ui-egui/tests/it/ui_angles.rs diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs similarity index 100% rename from crates/ui-egui/tests/ui_audio_scrub.rs rename to crates/ui-egui/tests/it/ui_audio_scrub.rs diff --git a/crates/ui-egui/tests/ui_azerty_shortcuts.rs b/crates/ui-egui/tests/it/ui_azerty_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/ui_azerty_shortcuts.rs rename to crates/ui-egui/tests/it/ui_azerty_shortcuts.rs diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/it/ui_blank_context_menus.rs similarity index 100% rename from crates/ui-egui/tests/ui_blank_context_menus.rs rename to crates/ui-egui/tests/it/ui_blank_context_menus.rs diff --git a/crates/ui-egui/tests/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs similarity index 100% rename from crates/ui-egui/tests/ui_camera_tracker.rs rename to crates/ui-egui/tests/it/ui_camera_tracker.rs diff --git a/crates/ui-egui/tests/ui_character_eyedropper.rs b/crates/ui-egui/tests/it/ui_character_eyedropper.rs similarity index 100% rename from crates/ui-egui/tests/ui_character_eyedropper.rs rename to crates/ui-egui/tests/it/ui_character_eyedropper.rs diff --git a/crates/ui-egui/tests/ui_color_hex.rs b/crates/ui-egui/tests/it/ui_color_hex.rs similarity index 100% rename from crates/ui-egui/tests/ui_color_hex.rs rename to crates/ui-egui/tests/it/ui_color_hex.rs diff --git a/crates/ui-egui/tests/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs similarity index 100% rename from crates/ui-egui/tests/ui_comp_presets.rs rename to crates/ui-egui/tests/it/ui_comp_presets.rs diff --git a/crates/ui-egui/tests/ui_control_modifiers.rs b/crates/ui-egui/tests/it/ui_control_modifiers.rs similarity index 72% rename from crates/ui-egui/tests/ui_control_modifiers.rs rename to crates/ui-egui/tests/it/ui_control_modifiers.rs index 46567aed..3288d6f1 100644 --- a/crates/ui-egui/tests/ui_control_modifiers.rs +++ b/crates/ui-egui/tests/it/ui_control_modifiers.rs @@ -37,6 +37,24 @@ fn send(app: &mut EffectcraftApp, method: &str, params: Value) { let _ = control::handle(app, &ctx, &req); } +#[test] +fn control_gestures_reject_excessive_counts_without_queue_mutation() { + let mut app = EffectcraftApp::new(Session::default()); + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": u64::MAX})); + assert!(app.take_synthetic_input().is_empty(), "an oversized click must not partially queue input"); + + send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": u64::MAX})); + assert!(app.take_synthetic_input().is_empty(), "an oversized drag must not partially queue input"); + + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512})); + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512})); + assert_eq!(app.take_synthetic_input().len(), 2054, "two maximum accepted clicks fit under the aggregate cap"); + for _ in 0..4 { + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512})); + } + assert_eq!(app.take_synthetic_input().len(), 3081, "the pending event limit is enforced before requests can exceed it"); +} + #[test] fn shift_click_holds_shift_from_press_to_release() { let mut app = EffectcraftApp::new(Session::default()); diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/it/ui_dock_resize.rs similarity index 100% rename from crates/ui-egui/tests/ui_dock_resize.rs rename to crates/ui-egui/tests/it/ui_dock_resize.rs diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs similarity index 100% rename from crates/ui-egui/tests/ui_drag_drop.rs rename to crates/ui-egui/tests/it/ui_drag_drop.rs diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs similarity index 100% rename from crates/ui-egui/tests/ui_effect_controls.rs rename to crates/ui-egui/tests/it/ui_effect_controls.rs diff --git a/crates/ui-egui/tests/ui_effect_drop_highlight.rs b/crates/ui-egui/tests/it/ui_effect_drop_highlight.rs similarity index 100% rename from crates/ui-egui/tests/ui_effect_drop_highlight.rs rename to crates/ui-egui/tests/it/ui_effect_drop_highlight.rs diff --git a/crates/ui-egui/tests/ui_effect_names.rs b/crates/ui-egui/tests/it/ui_effect_names.rs similarity index 100% rename from crates/ui-egui/tests/ui_effect_names.rs rename to crates/ui-egui/tests/it/ui_effect_names.rs diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs similarity index 100% rename from crates/ui-egui/tests/ui_font_search.rs rename to crates/ui-egui/tests/it/ui_font_search.rs diff --git a/crates/ui-egui/tests/ui_form_fit.rs b/crates/ui-egui/tests/it/ui_form_fit.rs similarity index 100% rename from crates/ui-egui/tests/ui_form_fit.rs rename to crates/ui-egui/tests/it/ui_form_fit.rs diff --git a/crates/ui-egui/tests/ui_frame_step.rs b/crates/ui-egui/tests/it/ui_frame_step.rs similarity index 100% rename from crates/ui-egui/tests/ui_frame_step.rs rename to crates/ui-egui/tests/it/ui_frame_step.rs diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/it/ui_fx_editors.rs similarity index 100% rename from crates/ui-egui/tests/ui_fx_editors.rs rename to crates/ui-egui/tests/it/ui_fx_editors.rs diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs similarity index 100% rename from crates/ui-egui/tests/ui_gpu.rs rename to crates/ui-egui/tests/it/ui_gpu.rs diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/it/ui_gradient_editor.rs similarity index 100% rename from crates/ui-egui/tests/ui_gradient_editor.rs rename to crates/ui-egui/tests/it/ui_gradient_editor.rs diff --git a/crates/ui-egui/tests/ui_gradient_handles.rs b/crates/ui-egui/tests/it/ui_gradient_handles.rs similarity index 100% rename from crates/ui-egui/tests/ui_gradient_handles.rs rename to crates/ui-egui/tests/it/ui_gradient_handles.rs diff --git a/crates/ui-egui/tests/ui_gradient_value.rs b/crates/ui-egui/tests/it/ui_gradient_value.rs similarity index 100% rename from crates/ui-egui/tests/ui_gradient_value.rs rename to crates/ui-egui/tests/it/ui_gradient_value.rs diff --git a/crates/ui-egui/tests/ui_graph_fit.rs b/crates/ui-egui/tests/it/ui_graph_fit.rs similarity index 100% rename from crates/ui-egui/tests/ui_graph_fit.rs rename to crates/ui-egui/tests/it/ui_graph_fit.rs diff --git a/crates/ui-egui/tests/ui_group_rename.rs b/crates/ui-egui/tests/it/ui_group_rename.rs similarity index 100% rename from crates/ui-egui/tests/ui_group_rename.rs rename to crates/ui-egui/tests/it/ui_group_rename.rs diff --git a/crates/ui-egui/tests/ui_guide_layers.rs b/crates/ui-egui/tests/it/ui_guide_layers.rs similarity index 100% rename from crates/ui-egui/tests/ui_guide_layers.rs rename to crates/ui-egui/tests/it/ui_guide_layers.rs diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/it/ui_inline_edit.rs similarity index 100% rename from crates/ui-egui/tests/ui_inline_edit.rs rename to crates/ui-egui/tests/it/ui_inline_edit.rs diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/it/ui_keyframes.rs similarity index 100% rename from crates/ui-egui/tests/ui_keyframes.rs rename to crates/ui-egui/tests/it/ui_keyframes.rs diff --git a/crates/ui-egui/tests/ui_large_project.rs b/crates/ui-egui/tests/it/ui_large_project.rs similarity index 100% rename from crates/ui-egui/tests/ui_large_project.rs rename to crates/ui-egui/tests/it/ui_large_project.rs diff --git a/crates/ui-egui/tests/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs similarity index 100% rename from crates/ui-egui/tests/ui_layer_panel_zoom.rs rename to crates/ui-egui/tests/it/ui_layer_panel_zoom.rs diff --git a/crates/ui-egui/tests/ui_live_preview.rs b/crates/ui-egui/tests/it/ui_live_preview.rs similarity index 100% rename from crates/ui-egui/tests/ui_live_preview.rs rename to crates/ui-egui/tests/it/ui_live_preview.rs diff --git a/crates/ui-egui/tests/ui_locked_color.rs b/crates/ui-egui/tests/it/ui_locked_color.rs similarity index 100% rename from crates/ui-egui/tests/ui_locked_color.rs rename to crates/ui-egui/tests/it/ui_locked_color.rs diff --git a/crates/ui-egui/tests/ui_media_browser_cache.rs b/crates/ui-egui/tests/it/ui_media_browser_cache.rs similarity index 100% rename from crates/ui-egui/tests/ui_media_browser_cache.rs rename to crates/ui-egui/tests/it/ui_media_browser_cache.rs diff --git a/crates/ui-egui/tests/ui_media_browser_copy_path.rs b/crates/ui-egui/tests/it/ui_media_browser_copy_path.rs similarity index 100% rename from crates/ui-egui/tests/ui_media_browser_copy_path.rs rename to crates/ui-egui/tests/it/ui_media_browser_copy_path.rs diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs similarity index 100% rename from crates/ui-egui/tests/ui_menus.rs rename to crates/ui-egui/tests/it/ui_menus.rs diff --git a/crates/ui-egui/tests/ui_object_matte.rs b/crates/ui-egui/tests/it/ui_object_matte.rs similarity index 100% rename from crates/ui-egui/tests/ui_object_matte.rs rename to crates/ui-egui/tests/it/ui_object_matte.rs diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/it/ui_panel_focus.rs similarity index 100% rename from crates/ui-egui/tests/ui_panel_focus.rs rename to crates/ui-egui/tests/it/ui_panel_focus.rs diff --git a/crates/ui-egui/tests/ui_panel_windows.rs b/crates/ui-egui/tests/it/ui_panel_windows.rs similarity index 100% rename from crates/ui-egui/tests/ui_panel_windows.rs rename to crates/ui-egui/tests/it/ui_panel_windows.rs diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs similarity index 99% rename from crates/ui-egui/tests/ui_panels.rs rename to crates/ui-egui/tests/it/ui_panels.rs index 240f2de8..21298910 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -414,7 +414,7 @@ fn settings_buttons_stay_in_view_at_200_percent() { } /// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with -/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`. +/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test it ui_panels:: -- --ignored`. #[test] #[ignore] fn panels_snapshot() { diff --git a/crates/ui-egui/tests/ui_parameter_spacing.rs b/crates/ui-egui/tests/it/ui_parameter_spacing.rs similarity index 100% rename from crates/ui-egui/tests/ui_parameter_spacing.rs rename to crates/ui-egui/tests/it/ui_parameter_spacing.rs diff --git a/crates/ui-egui/tests/ui_presets_folder.rs b/crates/ui-egui/tests/it/ui_presets_folder.rs similarity index 100% rename from crates/ui-egui/tests/ui_presets_folder.rs rename to crates/ui-egui/tests/it/ui_presets_folder.rs diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs similarity index 100% rename from crates/ui-egui/tests/ui_preview_align.rs rename to crates/ui-egui/tests/it/ui_preview_align.rs diff --git a/crates/ui-egui/tests/ui_project_delete.rs b/crates/ui-egui/tests/it/ui_project_delete.rs similarity index 100% rename from crates/ui-egui/tests/ui_project_delete.rs rename to crates/ui-egui/tests/it/ui_project_delete.rs diff --git a/crates/ui-egui/tests/ui_project_keyboard.rs b/crates/ui-egui/tests/it/ui_project_keyboard.rs similarity index 100% rename from crates/ui-egui/tests/ui_project_keyboard.rs rename to crates/ui-egui/tests/it/ui_project_keyboard.rs diff --git a/crates/ui-egui/tests/ui_project_new_folder.rs b/crates/ui-egui/tests/it/ui_project_new_folder.rs similarity index 100% rename from crates/ui-egui/tests/ui_project_new_folder.rs rename to crates/ui-egui/tests/it/ui_project_new_folder.rs diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/it/ui_project_rows.rs similarity index 100% rename from crates/ui-egui/tests/ui_project_rows.rs rename to crates/ui-egui/tests/it/ui_project_rows.rs diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/it/ui_quick_offset.rs similarity index 100% rename from crates/ui-egui/tests/ui_quick_offset.rs rename to crates/ui-egui/tests/it/ui_quick_offset.rs diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/it/ui_ram_preview.rs similarity index 100% rename from crates/ui-egui/tests/ui_ram_preview.rs rename to crates/ui-egui/tests/it/ui_ram_preview.rs diff --git a/crates/ui-egui/tests/ui_render_queue.rs b/crates/ui-egui/tests/it/ui_render_queue.rs similarity index 100% rename from crates/ui-egui/tests/ui_render_queue.rs rename to crates/ui-egui/tests/it/ui_render_queue.rs diff --git a/crates/ui-egui/tests/ui_script_console.rs b/crates/ui-egui/tests/it/ui_script_console.rs similarity index 98% rename from crates/ui-egui/tests/ui_script_console.rs rename to crates/ui-egui/tests/it/ui_script_console.rs index 526a5293..e705a20b 100644 --- a/crates/ui-egui/tests/ui_script_console.rs +++ b/crates/ui-egui/tests/it/ui_script_console.rs @@ -44,7 +44,7 @@ fn script_console_runs_scripts() { } /// Headless look at the panel (wgpu offscreen); run with -/// `SC_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test ui_script_console -- --ignored`. +/// `SC_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it ui_script_console:: -- --ignored`. #[test] #[ignore] fn script_console_snapshot() { diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs similarity index 100% rename from crates/ui-egui/tests/ui_scriptui.rs rename to crates/ui-egui/tests/it/ui_scriptui.rs diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/it/ui_scrolling.rs similarity index 100% rename from crates/ui-egui/tests/ui_scrolling.rs rename to crates/ui-egui/tests/it/ui_scrolling.rs diff --git a/crates/ui-egui/tests/ui_settings_info.rs b/crates/ui-egui/tests/it/ui_settings_info.rs similarity index 100% rename from crates/ui-egui/tests/ui_settings_info.rs rename to crates/ui-egui/tests/it/ui_settings_info.rs diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs similarity index 99% rename from crates/ui-egui/tests/ui_tabs.rs rename to crates/ui-egui/tests/it/ui_tabs.rs index 7f4689bf..fe69d56f 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -123,7 +123,7 @@ fn dragged_tabs_land_where_the_insertion_mark_is() { assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline, PanelKind::EffectControls])); } -/// `cargo test -p effectcraft-ui-egui --test ui_tabs -- --ignored`: the insertion mark mid-drag +/// `cargo test -p effectcraft-ui-egui --test it ui_tabs:: -- --ignored`: the insertion mark mid-drag /// (`target/test-out/tab-drag.png`). #[test] #[ignore] diff --git a/crates/ui-egui/tests/ui_tail.rs b/crates/ui-egui/tests/it/ui_tail.rs similarity index 100% rename from crates/ui-egui/tests/ui_tail.rs rename to crates/ui-egui/tests/it/ui_tail.rs diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs similarity index 100% rename from crates/ui-egui/tests/ui_text_edit.rs rename to crates/ui-egui/tests/it/ui_text_edit.rs diff --git a/crates/ui-egui/tests/ui_text_range_handles.rs b/crates/ui-egui/tests/it/ui_text_range_handles.rs similarity index 100% rename from crates/ui-egui/tests/ui_text_range_handles.rs rename to crates/ui-egui/tests/it/ui_text_range_handles.rs diff --git a/crates/ui-egui/tests/ui_theme_favorites.rs b/crates/ui-egui/tests/it/ui_theme_favorites.rs similarity index 100% rename from crates/ui-egui/tests/ui_theme_favorites.rs rename to crates/ui-egui/tests/it/ui_theme_favorites.rs diff --git a/crates/ui-egui/tests/ui_time_zoom_controls.rs b/crates/ui-egui/tests/it/ui_time_zoom_controls.rs similarity index 100% rename from crates/ui-egui/tests/ui_time_zoom_controls.rs rename to crates/ui-egui/tests/it/ui_time_zoom_controls.rs diff --git a/crates/ui-egui/tests/ui_timeline_effect_label.rs b/crates/ui-egui/tests/it/ui_timeline_effect_label.rs similarity index 100% rename from crates/ui-egui/tests/ui_timeline_effect_label.rs rename to crates/ui-egui/tests/it/ui_timeline_effect_label.rs diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs similarity index 100% rename from crates/ui-egui/tests/ui_timeline_shapes.rs rename to crates/ui-egui/tests/it/ui_timeline_shapes.rs diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/it/ui_timeline_switches.rs similarity index 100% rename from crates/ui-egui/tests/ui_timeline_switches.rs rename to crates/ui-egui/tests/it/ui_timeline_switches.rs diff --git a/crates/ui-egui/tests/ui_timeline_trim.rs b/crates/ui-egui/tests/it/ui_timeline_trim.rs similarity index 100% rename from crates/ui-egui/tests/ui_timeline_trim.rs rename to crates/ui-egui/tests/it/ui_timeline_trim.rs diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/it/ui_timeline_zoom.rs similarity index 100% rename from crates/ui-egui/tests/ui_timeline_zoom.rs rename to crates/ui-egui/tests/it/ui_timeline_zoom.rs diff --git a/crates/ui-egui/tests/ui_toolbar_gradient.rs b/crates/ui-egui/tests/it/ui_toolbar_gradient.rs similarity index 100% rename from crates/ui-egui/tests/ui_toolbar_gradient.rs rename to crates/ui-egui/tests/it/ui_toolbar_gradient.rs diff --git a/crates/ui-egui/tests/ui_tracker.rs b/crates/ui-egui/tests/it/ui_tracker.rs similarity index 100% rename from crates/ui-egui/tests/ui_tracker.rs rename to crates/ui-egui/tests/it/ui_tracker.rs diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/it/ui_unreadable_footage.rs similarity index 100% rename from crates/ui-egui/tests/ui_unreadable_footage.rs rename to crates/ui-egui/tests/it/ui_unreadable_footage.rs diff --git a/crates/ui-egui/tests/ui_video_preview_off.rs b/crates/ui-egui/tests/it/ui_video_preview_off.rs similarity index 100% rename from crates/ui-egui/tests/ui_video_preview_off.rs rename to crates/ui-egui/tests/it/ui_video_preview_off.rs diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs similarity index 100% rename from crates/ui-egui/tests/ui_viewer.rs rename to crates/ui-egui/tests/it/ui_viewer.rs diff --git a/crates/ui-egui/tests/ui_viewers.rs b/crates/ui-egui/tests/it/ui_viewers.rs similarity index 98% rename from crates/ui-egui/tests/ui_viewers.rs rename to crates/ui-egui/tests/it/ui_viewers.rs index e556d92a..0019cc9c 100644 --- a/crates/ui-egui/tests/ui_viewers.rs +++ b/crates/ui-egui/tests/it/ui_viewers.rs @@ -134,7 +134,7 @@ fn a_locked_viewer_keeps_its_comp_when_the_project_panel_opens_another() { assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(b))); } -/// `cargo test -p effectcraft-ui-egui --test ui_viewers -- --ignored`: two viewers side by side +/// `cargo test -p effectcraft-ui-egui --test it ui_viewers:: -- --ignored`: two viewers side by side /// (`target/test-out/viewers.png`). #[test] #[ignore] diff --git a/crates/ui-egui/tests/ui_visual_review.rs b/crates/ui-egui/tests/it/ui_visual_review.rs similarity index 100% rename from crates/ui-egui/tests/ui_visual_review.rs rename to crates/ui-egui/tests/it/ui_visual_review.rs diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/it/ui_web_gpu_storage.rs similarity index 100% rename from crates/ui-egui/tests/ui_web_gpu_storage.rs rename to crates/ui-egui/tests/it/ui_web_gpu_storage.rs diff --git a/crates/ui-egui/tests/ui_web_job_gpu.rs b/crates/ui-egui/tests/it/ui_web_job_gpu.rs similarity index 100% rename from crates/ui-egui/tests/ui_web_job_gpu.rs rename to crates/ui-egui/tests/it/ui_web_job_gpu.rs diff --git a/crates/ui-egui/tests/ui_web_offload.rs b/crates/ui-egui/tests/it/ui_web_offload.rs similarity index 100% rename from crates/ui-egui/tests/ui_web_offload.rs rename to crates/ui-egui/tests/it/ui_web_offload.rs diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/it/ui_workflow_selection.rs similarity index 100% rename from crates/ui-egui/tests/ui_workflow_selection.rs rename to crates/ui-egui/tests/it/ui_workflow_selection.rs diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/it/ui_workspace_focus.rs similarity index 100% rename from crates/ui-egui/tests/ui_workspace_focus.rs rename to crates/ui-egui/tests/it/ui_workspace_focus.rs diff --git a/crates/ui-egui/tests/viewer_geometry.rs b/crates/ui-egui/tests/it/viewer_geometry.rs similarity index 100% rename from crates/ui-egui/tests/viewer_geometry.rs rename to crates/ui-egui/tests/it/viewer_geometry.rs diff --git a/crates/ui-egui/tests/viewer_passive_display.rs b/crates/ui-egui/tests/it/viewer_passive_display.rs similarity index 100% rename from crates/ui-egui/tests/viewer_passive_display.rs rename to crates/ui-egui/tests/it/viewer_passive_display.rs diff --git a/docs/agents.md b/docs/agents.md index c47bfb60..b2589a7a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -216,7 +216,7 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built ### Cookbook (from end-to-end QA) -The scenarios in `crates/automation/tests/qa/` and `apps/effectcraft-cli/tests/qa_template.rs` +The scenarios in `crates/automation/tests/qa/` and `apps/effectcraft-cli/tests/it/qa_template.rs` build real projects through these interfaces; they are worked examples of everything below. * **Name things when you create them**: `layer.newText`, `layer.newShape`, `layer.newSolid`, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4d01ef2e..8cd7b769 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,7 @@ export formats, parity summary), `jobs.list` (Progress panel jobs, e.g. the back `footage.check` after File ▸ Open). Every engine command is reachable through `engine.execute`, and every interactive widget of every -panel registers an element id; both are enforced by `crates/ui-egui/tests/ui_agent_audit.rs`. +panel registers an element id; both are enforced by `crates/ui-egui/tests/it/ui_agent_audit.rs`. ## UI state diff --git a/docs/fidelity/workflow-checks.md b/docs/fidelity/workflow-checks.md index 4bea37ee..613d6878 100644 --- a/docs/fidelity/workflow-checks.md +++ b/docs/fidelity/workflow-checks.md @@ -4,7 +4,7 @@ These checks use original compositions and public interaction behavior. No Adobe implementation code are included. Adobe's [selection documentation](https://helpx.adobe.com/after-effects/desktop/work-with-layers/select-and-arrange-layers/selecting-arranging-layers.html) describes Timeline range selection and Composition Shift-click/Shift-marquee selection. -`crates/ui-egui/tests/ui_workflow_selection.rs` exercises actual pointer and keyboard events: +`crates/ui-egui/tests/it/ui_workflow_selection.rs` exercises actual pointer and keyboard events: - Timeline Shift-click selects the visible range, skipping locked layers; Ctrl/Command-click toggles a layer. - A drag beginning in the empty Timeline outline selects the intersected layer rows without reordering them. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ebaa452..43af6354 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1068,7 +1068,7 @@ resolution, adjustment) and composited at 8 and 32 bpc: Bucket's flood-filled region, Eyedropper Fill's sample); segments and convex polygons are binned into 16 × 16 tiles and rasterised on the GPU with the CPU's coverage functions, glows and compositing included. The CPU effects were refactored to draw from the same plans with - bit-identical output (`crates/effects/tests/gen_golden.rs`: 288 frame hashes pinned before + bit-identical output (`crates/effects/tests/it/gen_golden.rs`: 288 frame hashes pinned before the refactor). Exception: CC Threads decides band membership and fibre shade per pixel, and at round parameter values (the defaults) pixel centres sit exactly on band edges, where f32 and the CPU's f64 can fall on either side: up to 5 % of pixels allowed (measured ≤ 3.6 % at half @@ -1082,7 +1082,7 @@ resolution, adjustment) and composited at 8 and 32 bpc: 7 more effects run on the GPU (280 in all, with M13.28's 37), and five CPU fallbacks are gone. Each case is checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full and half resolution) and composited at 8 and 32 bpc; the CPU refactors behind the shared plans -are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.rs`, +are pinned bit for bit by golden hashes (`crates/effects/tests/it/particle_golden.rs`, `timewarp_golden.rs`, `sim_golden.rs`). - **Particle render passes** (`gpu::fx_particles`): CC Particle World and CC Particle Systems II diff --git a/docs/testing.md b/docs/testing.md index 3a975747..69a70f4d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,6 +5,13 @@ cargo test --workspace # everything cargo xtask ci # fmt, clippy -D warnings, tests, layers, assets, wasm ``` +`cargo xtask ci` builds in the dev profile and runs the tests with `cargo nextest run` (plus +`cargo test --doc`) when `cargo-nextest` is installed, and with `cargo test` otherwise. + +Each crate with several integration-test files has one test binary, `tests/it/main.rs`, whose +modules are the files in `tests/it/`. Run one file with `cargo test -p --test it ::` +or `cargo nextest run -p -E 'binary(it) and test(/^::/)'`. + ## What the tests cover - **Engine commands** (`crates/engine/src/tests*.rs`): every command family, including undo and @@ -16,7 +23,7 @@ cargo xtask ci # fmt, clippy -D warnings, tests, layers, assets, was - **Effects** (`crates/effects`): each effect has a determinism test and at least one behaviour test (identity at neutral settings, known pixel results, or simulations giving the same frame whether you seek straight to it or play up to it). A registry-wide test checks that effects - which read the clock are declared time-dependent. `crates/effects/tests/sim_golden.rs` pins + which read the clock are declared time-dependent. `crates/effects/tests/it/sim_golden.rs` pins exact pixel hashes of the simulation effects the GPU shares plans with (CC Rainfall … Card Wipe) at several settings, times, bit depths and resolutions, so any change to their CPU output fails (re-pin with `SIM_GOLDEN_PRINT=1` after an intended change; pinned for aarch64 macOS, diff --git a/docs/web.md b/docs/web.md index e00ec166..c18f7e86 100644 --- a/docs/web.md +++ b/docs/web.md @@ -462,19 +462,19 @@ every request and reply, a render through an in-process offload; `tests_roto.rs` propagation through an offload streaming segmentations; `tests_mask_warp.rs`: Warp Stabilizer plans solved in the job, never by `warp.status` on the page), the frame-worker protocol (`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels; -`crates/ui-egui/tests/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true` +`crates/ui-egui/tests/it/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true` deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`, `tests_panels.rs`). M13.24: deferred readbacks on a real device (`crates/gpu/src/tests_deferred.rs`: whole-frame GPU and CPU-composited frames in passes match the CPU, placeholders never reach the layer cache; `deferred.rs`: keys, late readbacks of an older frame dropped), the frame server's passes and Auto timing (`remote.rs`, a deferred stand-in accelerator), the browser's routing and -disk cache (`crates/ui-egui/tests/ui_web_gpu_storage.rs`: a GPU frame worker renders effect frames, +disk cache (`crates/ui-egui/tests/it/ui_web_gpu_storage.rs`: a GPU frame worker renders effect frames, the page's GPU keeps frames without effects, frames stored under their disk keys come back after a "reload" without rendering, the blue cache bar sees them; the storage manager's widgets and commands), the disk index (`disk_cache.rs`), the store's usage by kind and clearing (`store.rs`), `storage.*` (`storage.rs`), and every WGSL float literal fitting f32 (browsers reject the whole module otherwise; `tests.rs`). M13.30: job workers on a deferred device -(`crates/ui-egui/tests/ui_web_job_gpu.rs`: a Render Queue job and a Warp Stabilizer analysis +(`crates/ui-egui/tests/it/ui_web_job_gpu.rs`: a Render Queue job and a Warp Stabilizer analysis render their frames in passes on the device, with the CPU worker's pixels / result), particles, Advanced 3D and the job pass driver in passes (`crates/gpu/src/tests_deferred_jobs.rs`), layer buffers through the disk store and the prefetch plan (`remote.rs` diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs index 3d4f1c02..e7296ce9 100644 --- a/examples/check-ae-ease.mjs +++ b/examples/check-ae-ease.mjs @@ -5,11 +5,18 @@ import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const cli = process.argv[2]; -if (!cli || !fs.existsSync(cli)) throw new Error('Usage: node examples/check-ae-ease.mjs '); +// The argument is the operator's own CLI build to compare against; it is resolved and must be an +// existing regular file before it is used. +const cli = path.resolve(process.argv[2] ?? ''); // NOSONAR +// Reject anything that is not an existing regular file before the checker uses it. +if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) { // NOSONAR + throw new Error('Usage: node examples/check-ae-ease.mjs '); +} const tolerance = 1e-7; function run(command, args) { - const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); + // A developer-run comparison script; spawnSync never uses a shell and every command is either + // a fixed binary (`cargo`, `process.execPath`) or the CLI script path validated above. + const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); // NOSONAR if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Process failed'); return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); } diff --git a/packaging/env.sh b/packaging/env.sh index 6a1bfa28..68b57026 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,10 +19,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${EFFECTCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${EFFECTCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${EFFECTCRAFT_BUILD_SHA:-}" ]]; then EFFECTCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export EFFECTCRAFT_BUILD_SHA @@ -41,18 +42,22 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done + return } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 7418dd85..b5d1dc44 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ APP_ID=ai.storyteller.effectcraft SKIP_BUILD=0 DRY_RUN=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; @@ -36,7 +36,7 @@ case "$MACHINE" in arm64 | aarch64) ARCH=aarch64 ;; *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; esac -if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 fi @@ -50,11 +50,11 @@ export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" # natively under QEMU). With one codegen unit and thin LTO the largest crates each optimize on a # single emulated core and the job runs past its 5-hour limit, so trade a little runtime speed for # parallel codegen there. x86_64 keeps the workspace's release profile. -if [ "$ARCH" = aarch64 ]; then +if [[ "$ARCH" = aarch64 ]]; then export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}" export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-off}" fi -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli) fi WORK="$CARGO_TARGET_DIR/freebsd-package" @@ -64,7 +64,7 @@ mkdir -p "$WORK" BIN="$CARGO_TARGET_DIR/release" OUT_DIR="$DIST" -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. BIN="$WORK/stub-bin" OUT_DIR="$WORK" @@ -89,13 +89,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$EFFECTCRAFT_BUILD_DATE/g" \ cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/effectcraft" for f in NOTICE ATTRIBUTION.md; do - if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/effectcraft/"; fi + if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/effectcraft/"; fi done for f in bin/effectcraft bin/effectcraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ share/doc/effectcraft/LICENSE-MIT share/doc/effectcraft/LICENSE-APACHE; do - if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi + if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi done # ---- .tar.gz ------------------------------------------------------------------------------------ @@ -104,7 +104,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" "$STAGE/bin/effectcraft-cli" --version -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" fi diff --git a/packaging/icons.sh b/packaging/icons.sh index afeb13f8..770cda91 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -18,7 +18,10 @@ trap 'rm -rf "$TMP"' EXIT command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/effectcraft" ] || { echo "error: $TARBALL has no bin/effectcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/effectcraft" ]] || { echo "error: $TARBALL has no bin/effectcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=effectcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index e32d66ac..a9349ca4 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -25,7 +25,7 @@ APP_ID=ai.storyteller.effectcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -46,8 +46,8 @@ BASENAME="effectcraft-$VERSION-linux-$ARCH" echo "==> EffectCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli) @@ -79,7 +79,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local format="$1" + case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -103,6 +106,23 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + case "$ARCH" in + x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;; + aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;; + *) APPIMAGETOOL_SHA256="" ;; + esac + # appimagetool otherwise fetches the unpinned type2-runtime `continuous` build and embeds it. + APPIMAGE_RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d ;; + aarch64) APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 ;; + *) APPIMAGE_RUNTIME_SHA256="" ;; + esac + if [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH; build the AppImage on x86_64 or aarch64" >&2 + exit 2 + fi APPDIR="$WORK/EffectCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -112,18 +132,37 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" + if [[ -z "$TOOL" ]]; then + if [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + if [[ ! -x "$TOOL" ]]; then + download="$TOOL.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + chmod 755 "$download" + mv "$download" "$TOOL" + fi + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - fi + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" + if [[ ! -f "$RUNTIME" ]]; then + download="$RUNTIME.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" + printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - + mv "$download" "$RUNTIME" + fi + printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -135,18 +174,18 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/effectcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/effectcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index e0178b6a..8e3462cd 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 37f81f48..95827b22 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/effectcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/effectcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> EffectCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -115,7 +115,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/EffectCraft-notarize.zip" notarize "$WORK/EffectCraft-notarize.zip" xcrun stapler staple "$APP" @@ -134,7 +134,7 @@ ln -s /Applications "$STAGE/Applications" # CI (the shared release workflow doesn't install resvg; a warm cargo cache may hold it): install # the pinned version. Locally, say which version to install. RESVG_VERSION=0.48.1 -if [ -n "${CI:-}" ] && ! command -v resvg >/dev/null; then +if [[ -n "${CI:-}" ]] && ! command -v resvg >/dev/null; then cargo install resvg --locked --version "$RESVG_VERSION" fi command -v resvg >/dev/null || { echo "resvg not found (cargo install resvg --locked --version $RESVG_VERSION)" >&2; exit 1; } @@ -160,7 +160,7 @@ hdiutil_retry() { if hdiutil "$@"; then return 0 fi - [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + [[ "$attempt" = 5 ]] && { echo "hdiutil $what failed 5 times" >&2; return 1; } echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 sleep $((attempt * 10)) done @@ -178,7 +178,7 @@ if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/EffectCraft.app"; the exit 1 fi hdiutil_retry detach detach "$MOUNT" >/dev/null -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -196,7 +196,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/effectcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/effectcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 89d767ea..dc3aaa90 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -11,10 +11,10 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the # crate's exact version. Locally, `cargo xtask web` says which version to install. - if [ -n "${CI:-}" ]; then + if [[ -n "${CI:-}" ]]; then WASM_BINDGEN_VERSION="$(sed -n 's/^const WASM_BINDGEN: &str = "\(.*\)";$/\1/p' "$ROOT/xtask/src/main.rs")" if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF "$WASM_BINDGEN_VERSION"; }; then cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked @@ -24,7 +24,7 @@ if [ "${1:-}" != "--skip-build" ]; then fi SITE="$CARGO_TARGET_DIR/web/dist" -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix. if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index d8f2f649..7f89586a 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'EffectCraft', '/du', 'https://github.com/storytold/effectcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "effectcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "effectcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa87..b9c781bc 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,6 @@ publish = false [dependencies] serde_json = { workspace = true } + +chrono = { workspace = true } +tempfile = "3" diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs index b368d942..24f81c1c 100644 --- a/xtask/src/bench.rs +++ b/xtask/src/bench.rs @@ -655,19 +655,25 @@ fn iso_now() -> String { utc(unix_now()) } -/// `YYYY-MM-DD HH:MM UTC` for a Unix time (civil-from-days, no time zone database). +/// `YYYY-MM-DD HH:MM UTC` for a Unix time. Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. fn utc(t: u64) -> String { - let (days, secs) = ((t / 86_400) as i64, t % 86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", secs / 3600, secs % 3600 / 60) + use chrono::Datelike; + // Reduce by whole 400-year Gregorian cycles (146_097 days) into chrono's range, convert the + // remainder with chrono, and carry the year back; `t` is `u64`, so `days` fits an `i64`. + let (days, sod) = ((t / 86_400) as i64, (t % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + let (y, dt) = match (date, time) { + (Some(d), Some(ti)) => { + let yy = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(yy).unwrap_or(i64::MAX), d.and_time(ti)) + } + _ => (1970, chrono::NaiveDateTime::default()), + }; + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%d %H:%M UTC")) } #[allow(clippy::too_many_arguments)] @@ -1095,6 +1101,17 @@ fn compare_table(old: &BTreeMap, new: &BTreeMap) - mod tests { use super::*; + #[test] + fn utc_preserves_out_of_range_years() { + assert_eq!(utc(0), "1970-01-01 00:00 UTC"); + assert_eq!(utc(1_700_000_000), "2023-11-14 22:13 UTC"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(utc(253_402_300_800), "10000-01-01 00:00 UTC"); + assert_eq!(utc(10_000_000_000_000), "318857-05-20 17:46 UTC"); + assert_eq!(utc(u64::MAX), "584554051223-11-09 07:00 UTC"); + } + const BSD: &str = " 0.33 real 0.67 user 0.15 sys 743882752 maximum resident set size 0 average shared memory size diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e79ac63a..468f832c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,7 +9,8 @@ //! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). -//! - `ci`: fmt check, clippy -D warnings, tests, PDF dependency policy, layers, assets, wasm. +//! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests, +//! PDF dependency policy, layers, assets, wasm. Everything builds in the dev profile. //! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios //! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md). @@ -235,7 +236,7 @@ const WEB_CRATES: &[&str] = &["effectcraft-ui-egui", "effectcraft-web"]; fn wasm() -> Result<(), String> { let md = metadata()?; let mut cmd = Command::new(env!("CARGO")); - cmd.args(["check", "--target", "wasm32-unknown-unknown"]); + cmd.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); let mut n = 0; for (name, _) in workspace_crates(&md) { if layer_of(&name).is_some_and(|l| l <= 4) || WEB_CRATES.contains(&name.as_str()) { @@ -406,15 +407,46 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } -fn ci() -> Result<(), String> { +/// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, +/// which nextest doesn't run. +/// +/// `effectcraft-gpu`'s tests share one device and serialise on a lock inside their process (several +/// devices at once crash software Vulkan and GL), so they run in one `cargo test` process instead of +/// nextest's process per test. +/// +/// Each run goes ahead when an earlier one fails; the error names every run that failed. +fn tests() -> Result<(), String> { let cargo = env!("CARGO"); - run(Command::new(cargo).args(["fmt", "--check"]))?; - run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?; - run(Command::new(cargo).args(["test", "--workspace", "--release"]))?; - pdf_deps::run(&root())?; - layers()?; - assets()?; - wasm() + let targets = ["--workspace", "--lib", "--bins", "--tests", "--no-fail-fast"]; + let nextest = Command::new(cargo).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); + let mut runs = Vec::new(); + if nextest { + runs.push(run(Command::new(cargo).args(["nextest", "run"]).args(targets).args(["-E", "not package(effectcraft-gpu)"]))); + runs.push(run(Command::new(cargo).args(["test", "-p", "effectcraft-gpu", "--lib", "--no-fail-fast"]))); + } else { + println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)"); + runs.push(run(Command::new(cargo).arg("test").args(targets))); + } + runs.push(run(Command::new(cargo).args(["test", "--workspace", "--doc", "--no-fail-fast"]))); + let failed: Vec = runs.into_iter().filter_map(Result::err).collect(); + if failed.is_empty() { Ok(()) } else { Err(failed.join("; ")) } +} + +type Stage = fn() -> Result<(), String>; + +/// Every stage runs even when an earlier one fails, so one run reports all the problems. +fn ci() -> Result<(), String> { + let stages: [(&str, Stage); 7] = [ + ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), + ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), + ("tests", tests), + ("pdf-deps", || pdf_deps::run(&root())), + ("layers", layers), + ("assets", assets), + ("wasm", wasm), + ]; + let failed: Vec<&str> = stages.iter().filter_map(|(name, stage)| stage().map_err(|e| eprintln!("ci: {name} failed: {e}")).err().map(|()| *name)).collect(); + if failed.is_empty() { Ok(()) } else { Err(format!("ci failed: {}", failed.join(", "))) } } fn main() -> ExitCode { diff --git a/xtask/tests/bench.rs b/xtask/tests/bench.rs index 919b5b94..641b0d56 100644 --- a/xtask/tests/bench.rs +++ b/xtask/tests/bench.rs @@ -1,20 +1,15 @@ //! Regression gates must fail when benchmark evidence is corrupt or incomplete. use serde_json::{Value, json}; -use std::path::PathBuf; use std::process::{Command, Output}; -struct ResultsDir(PathBuf); +struct ResultsDir(tempfile::TempDir); impl ResultsDir { fn new() -> Self { - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT: AtomicU64 = AtomicU64::new(0); - let dir = std::env::temp_dir().join(format!("effectcraft-bench-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); - std::fs::create_dir_all(&dir).unwrap(); - Self(dir) + Self(tempfile::Builder::new().prefix("effectcraft-bench-").tempdir().unwrap()) } fn compare(&self, old: &str, new: &str, gate: bool) -> Output { - let baseline = self.0.join("baseline.jsonl"); - let candidate = self.0.join("candidate.jsonl"); + let baseline = self.0.path().join("baseline.jsonl"); + let candidate = self.0.path().join("candidate.jsonl"); std::fs::write(&baseline, old).unwrap(); std::fs::write(&candidate, new).unwrap(); let mut command = Command::new(env!("CARGO_BIN_EXE_xtask")); @@ -25,11 +20,6 @@ impl ResultsDir { command.output().unwrap() } } -impl Drop for ResultsDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} fn row(scenario: &str, ok: bool) -> Value { json!({"scenario": scenario, "run": "full", "ok": ok,