From 887fe3a3a85e28f007f3a1861b3cc54d70867488 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:14:27 -0400 Subject: [PATCH 01/23] security: use crypto.randomUUID for temp file suffix (SonarCloud S2245) Replace Math.random() with crypto.randomUUID() when generating the unique suffix for the atomic write-then-move temp file. The value isn't security- sensitive, but a CSPRNG gives a stronger uniqueness guarantee and resolves SonarCloud javascript:S2245. --- apps/effectcraft-web/js/host.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 5c438550..658a5199 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -495,7 +495,7 @@ export async function cacheRead(name) { 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 tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name; const fh = await dir.getFileHandle(tmp, { create: true }); if (fh.createSyncAccessHandle) { const h = await fh.createSyncAccessHandle(); From 798339669252c5ace2c45b48a277b425527c8ce6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:20:35 -0400 Subject: [PATCH 02/23] fix: JS reliability bugs (SonarCloud S1764, S9383, S4275) - scriptui.js: replace the self-comparison NaN check (o.max === o.max) with the explicit Number.isNaN(o.max) (S1764). - sw.js: mark the fire-and-forget cache.put() with void and a .catch() so the promise rejection is handled (S9383). - prelude.js: return __err(...) from the activeCamera getter so every path yields a value (S4275); __err always throws, so behavior is unchanged. --- apps/effectcraft-web/web/sw.js | 2 +- crates/expr/src/prelude.js | 2 +- crates/script/src/scriptui.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 8c7efc2e..c5605c81 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -38,7 +38,7 @@ self.addEventListener("fetch", (event) => { const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }); if (hit) return hit; const res = await fetch(req); - if (res.ok && res.type === "basic") cache.put(req, res.clone()); + if (res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {}); return res; })(), ); 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/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); }); From 15b8a916c528e73d48f7a9cb4ff58276c7a2d3dd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:39:32 -0400 Subject: [PATCH 03/23] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2cd7ed3a..1c52a75b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1200,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -1231,7 +1231,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.14.0" +version = "0.15.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1359,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1410,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1481,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1541,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.14.0" +version = "0.15.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.14.0" +version = "0.15.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.14.0" +version = "0.15.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1605,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1629,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1681,11 +1681,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.14.0" +version = "0.15.0" [[package]] name = "effectcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index eecbbc7e..129718b2 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.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 6871aa223653e030c8720afc2daadb478e82447e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 19:19:19 -0400 Subject: [PATCH 04/23] security: check crypto.randomUUID availability in canMove --- apps/effectcraft-web/js/host.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 658a5199..12cb90bf 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -494,7 +494,11 @@ 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 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) { From ec0cc83a2955cbfb16bfceba80697b1775e635fd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 19:19:20 -0400 Subject: [PATCH 05/23] fix(web): fall back to network when caches.open fails in service worker --- apps/effectcraft-web/web/sw.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index c5605c81..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") void cache.put(req, res.clone()).catch(() => {}); + if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {}); return res; })(), ); From 9581b53c3f8945683252af0eb5fac79e0e43aed4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 20:06:09 -0400 Subject: [PATCH 06/23] Release 0.16.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c52a75b..bba6288d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.15.0" +version = "0.16.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1200,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.15.0" +version = "0.16.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.15.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1231,7 +1231,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.15.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.15.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.15.0" +version = "0.16.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1359,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.15.0" +version = "0.16.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1410,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1481,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.15.0" +version = "0.16.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1541,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.15.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.15.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.15.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1605,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1629,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.15.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1681,11 +1681,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.15.0" +version = "0.16.0" [[package]] name = "effectcraft-web" -version = "0.15.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.15.0" +version = "0.16.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 129718b2..7ab23e29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 2cf90507f38ad87bd64b7e6d41a6cd4fdb6a8cc5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:38:10 -0400 Subject: [PATCH 07/23] svg: bound raster surfaces and handle duplicate gradient stops (#2190) --- crates/svg/src/raster.rs | 141 +++++++++++++++++++++++++++++++-------- crates/svg/src/tests.rs | 19 ++++++ 2 files changed, 131 insertions(+), 29 deletions(-) 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(); From 4b48482e8fdf5202bf193392e6f3af9490fcf266 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:45:10 -0400 Subject: [PATCH 08/23] ui: bound queued synthetic input gestures (#2197) * ui: bound queued synthetic input gestures * ui: reserve exact click event count --- crates/ui-egui/src/control.rs | 29 ++++++++++++++++++++ crates/ui-egui/tests/ui_control_modifiers.rs | 18 ++++++++++++ 2 files changed, 47 insertions(+) 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/tests/ui_control_modifiers.rs b/crates/ui-egui/tests/ui_control_modifiers.rs index 46567aed..3288d6f1 100644 --- a/crates/ui-egui/tests/ui_control_modifiers.rs +++ b/crates/ui-egui/tests/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()); From ca8043cbbabc8088cb9ebd15ec018ad954aeb389 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:55:07 -0400 Subject: [PATCH 09/23] text: bound layout and variable font metadata (#2193) * text: bound layout and variable font metadata * text: address review (intern once, f64 prefix sums, bounded item search, name fallback, consistent variation fallback) --- crates/text/src/layout.rs | 99 +++++++++++++++++++++++++++++-------- crates/text/src/sfnt.rs | 81 +++++++++++++++++++++++------- crates/text/src/variable.rs | 44 ++++++++++++++--- 3 files changed, 176 insertions(+), 48 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1a..8f692888 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -597,7 +597,7 @@ 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 +702,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 +832,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 +856,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 +873,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 +918,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 +965,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 +1002,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 +1105,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 +1463,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; From 8a4ed77c4ac0bffd060f25696719bfbaa7707ea4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:56:15 -0400 Subject: [PATCH 10/23] pdf: bound image sample and mask allocations (#2191) * pdf: bound image sample and mask allocations * pdf: fallible mask allocations and JPEG header pixel cap --- crates/pdf/src/image.rs | 98 ++++++++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 25 deletions(-) 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; } } From 09b9ebca3468cc05eda531d14e5bed4ad8a96120 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:59:06 -0400 Subject: [PATCH 11/23] roto: bound frozen matte decoding and serialization (#2194) * roto: bound frozen matte decoding and serialization * roto: address review (fallible RLE growth, pre-check plane bytes, saturate 32-bit limits) --- crates/effects/src/object_matte.rs | 8 +++- crates/effects/src/roto.rs | 10 ++-- crates/track/src/roto/mod.rs | 52 +++++++++++++++++---- crates/track/src/roto/rle.rs | 74 ++++++++++++++++++++++++------ crates/track/src/roto/tests.rs | 15 ++++++ 5 files changed, 132 insertions(+), 27 deletions(-) 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/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] From 23f11ab7cf1b9d8c992fb16c244c6c080e1948db Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:14:49 -0400 Subject: [PATCH 12/23] expr: bound rewrite expansion and preserve assignment targets (#2195) * expr: bound rewrite expansion and preserve assignment targets * expr: bound rewritten output size and rewrite computed assignment targets --- crates/expr/src/rewrite.rs | 79 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) 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 From d1d9849e2d1f83b6120761419ffe203827a93aa5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:29:39 -0400 Subject: [PATCH 13/23] fix: validate the CLI path in the AE ease checker (SonarCloud) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the argument and require a regular file before spawnSync, which never uses a shell and only runs fixed binaries or that validated path. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- examples/check-ae-ease.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs index 3d4f1c02..d98b3519 100644 --- a/examples/check-ae-ease.mjs +++ b/examples/check-ae-ease.mjs @@ -5,10 +5,14 @@ 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 '); +const cli = path.resolve(process.argv[2] ?? ''); +if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) { + throw new Error('Usage: node examples/check-ae-ease.mjs '); +} const tolerance = 1e-7; function run(command, args) { + // NOSONAR: 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 }); if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Process failed'); return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); From 400fbb79e99d5d5fa22bde9d06f9bcb828f3aa34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:44:38 -0400 Subject: [PATCH 14/23] fix: put the NOSONAR marker on the spawnSync line (SonarCloud) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar applies NOSONAR to the line that carries the marker, so the explanation above the call did not suppress anything. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- examples/check-ae-ease.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs index d98b3519..275f04e2 100644 --- a/examples/check-ae-ease.mjs +++ b/examples/check-ae-ease.mjs @@ -11,9 +11,9 @@ if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) } const tolerance = 1e-7; function run(command, args) { - // NOSONAR: 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 }); + // 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()); } From c75071dfb8e931ccb6a730ff5a05d357e74eb630 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 10:10:47 -0400 Subject: [PATCH 15/23] fix: document the AE checker's operator-supplied CLI path (SonarCloud) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path is the developer's own CLI build, stat'ed as a regular file before use; suppress the taint finding with that reasoning. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- examples/check-ae-ease.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs index 275f04e2..bfa4b7c2 100644 --- a/examples/check-ae-ease.mjs +++ b/examples/check-ae-ease.mjs @@ -5,7 +5,9 @@ import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const cli = path.resolve(process.argv[2] ?? ''); +// 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 if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) { throw new Error('Usage: node examples/check-ae-ease.mjs '); } From 0c8d47f42ac1639a6387aa7918c8d393c307ed66 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 10:28:48 -0400 Subject: [PATCH 16/23] fix: suppress the stat check's taint finding (SonarCloud) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stat is the validation that rejects anything but an existing regular file; annotate it with that reasoning. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- examples/check-ae-ease.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs index bfa4b7c2..e7296ce9 100644 --- a/examples/check-ae-ease.mjs +++ b/examples/check-ae-ease.mjs @@ -8,7 +8,8 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // 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 -if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) { +// 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; From dd325c7ab8556e7ff33d64391c78e2316561da55 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 10:44:42 -0400 Subject: [PATCH 17/23] segment: bound checkpoint tensors and pickle work (#2192) * segment: bound checkpoint tensors and pickle work * segment: bound entry size, memo clones, and total tensor elements * segment: cap cumulative decompressed storage bytes --- crates/segment/src/pt.rs | 139 ++++++++++++++++++++++++++++++++++----- 1 file changed, 123 insertions(+), 16 deletions(-) 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()); From bd3ae8ab14e1472728f7cba1eb7615ea71d3e6c9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:17:15 -0400 Subject: [PATCH 18/23] xtask: use unique temporary directories for bench tests (#2196) --- Cargo.lock | 1 + xtask/Cargo.toml | 2 ++ xtask/tests/bench.rs | 18 ++++-------------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bba6288d..8585b9b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6628,6 +6628,7 @@ name = "xtask" version = "0.16.0" dependencies = [ "serde_json", + "tempfile", ] [[package]] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa87..ea80b285 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,5 @@ publish = false [dependencies] serde_json = { workspace = true } + +tempfile = "3" 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, From 49d179738bd6f53f3bda8139241dca16debb5fea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:17:22 -0400 Subject: [PATCH 19/23] packaging: use [[ tests, named locals and explicit returns (SonarCloud S7688/S7679/S7682) (#2187) Switch the bash packaging scripts from [ to [[ for conditional tests, give positional parameters descriptive local names, and end the shared env.sh helpers with an explicit return. No behaviour change: every comparison is against a literal, and a bare return keeps the last command's status. --- packaging/env.sh | 15 ++++++++++----- packaging/freebsd/package.sh | 16 ++++++++-------- packaging/icons.sh | 5 ++++- packaging/linux/flatpak-bundle.sh | 8 ++++---- packaging/linux/package.sh | 21 ++++++++++++--------- packaging/macos/import-cert.sh | 6 +++--- packaging/macos/package.sh | 26 +++++++++++++------------- packaging/web/package.sh | 6 +++--- 8 files changed, 57 insertions(+), 46 deletions(-) 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..9a9693aa 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 @@ -112,9 +115,9 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then + if [[ ! -x "$TOOL" ]]; then curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi @@ -137,16 +140,16 @@ if has appimage; then # 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") 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 From f6ef1a790625657259f449f3f4f0773d7df449bb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:29:09 -0400 Subject: [PATCH 20/23] Use chrono instead of hand-rolled UTC conversion (#2023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days / days_from_civil copies (log timestamp, ISO metadata dates, Align Video to Data timestamp parsing, the Numbers effect's date, the Home and Render Queue date columns, and the benchmark report date) with chrono as a workspace dependency. Output formats are unchanged and pinned by the existing tests. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Keep 5-digit years unprefixed; derive Numbers weekday from the same date chrono's %Y writes a '+' before years past 9999, where the old code wrote {y:04}. timestamp, iso_date, format_utc, utc_stamp and xtask's utc now write the year with {:04} again and let chrono format the rest, so a far-future clock keeps the plain zero-padded year. date_from_days took its weekday from the raw input modulo while the year/month/day fell back to the epoch for day counts outside chrono's range, so an unrepresentable count printed a fallback date paired with the input's weekday. The weekday now comes from the same resolved date, so both agree. Tests cover the year-10000 timestamp and the out-of-range Numbers date. * Keep the whole date in format_ago for five-digit years format_ago cut format_utc to 10 bytes, so a year past 9999 lost its last digit (10000-01-0). Take the date up to the space instead. Tested. * Keep Numbers dates exact past chrono's range date_from_days converted the day count by building a Unix timestamp and calling DateTime::from_timestamp, which falls back to the epoch (and so reads 1970-01-01, a Thursday) once the date leaves chrono's NaiveDate range near year ±262143. Numbers' `value` slider is a plain scalar with no range guard, so a keyframed count reaches that: Long Date of 100_000_000 showed "Thursday, January 1, 1970" instead of the real "Monday, September 13, 275790" -- exactly the plausible unrelated-date fallback the review rejects, and the derived weekday matched the stand-in date, not the input. Route it through the 400-year Gregorian cycle like pdfcraft 9d4cb3ca: chrono converts the day within one cycle (num_days_from_ce of the rebased rest, always in range) and the whole cycles are added back as years, in i128 so i64::MIN/MAX can't overflow. chrono stays the only calendar logic. The weekday still comes from the resolved date, so it always agrees with it. Tested: date_from_days_past_chrono_range pins the pre-chrono dates and weekdays at/over chrono's bound on both sides of the 2000-01-01 epoch (and that the extremes don't overflow); numbers_formats adds the same through the public numbers_text entry (Long/Short Date). cargo test -p effectcraft-effects, fmt --check and clippy -D warnings all clean. * time: preserve out-of-range years instead of the epoch `DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: inputs the old hand-rolled civil-from-days converted (e.g. `10_000_000_000_000` s → 318857-05-20 17:46:40) collapsed to the epoch. Restore the old conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the in-range remainder with chrono, and carry the year back as i128 (saturating to the output type). Only the year leaves chrono's range; month, day and time of day stay exact. chrono remains the only calendar logic (NaiveDate::from_num_days_from_ce_opt); no new features. Sites (UTC): - crates/engine logging.rs `timestamp` (u64 ms): far-future clock keeps its year. 10e12 s → "318857-05-20T17:46:40.000Z", u64::MAX ms → "584556019-04-03T14:25:51.615Z" (was "1970-01-01..."). - crates/engine media_browser.rs `iso_date` (u64): 10e12 s → "318857-05-20T17:46:40Z", u64::MAX → "584554051223-11-09T07:00:15Z". - crates/ui-egui panels/home.rs `format_utc` (i64): 10e12 → "318857-05-20 17:46", i64::MIN → "-292277022657-01-27 08:29". - crates/ui-egui panels/render_queue.rs `utc_stamp` (u64): 10e12 → "318857-05-20 17:46:40", u64::MAX → "584554051223-11-09 07:00:15". - xtask bench.rs `utc` (u64): 10e12 → "318857-05-20 17:46 UTC", u64::MAX → "584554051223-11-09 07:00 UTC"; added a test. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 2 + CLAUDE.md | 1 + Cargo.lock | 13 +++++ Cargo.toml | 2 + crates/effects/Cargo.toml | 1 + crates/effects/src/textfx.rs | 71 ++++++++++++++++++----- crates/engine/Cargo.toml | 1 + crates/engine/src/commands/align_data.rs | 20 ++++--- crates/engine/src/logging.rs | 51 +++++++++------- crates/engine/src/media_browser.rs | 46 ++++++++++----- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/panels/home.rs | 50 +++++++++++----- crates/ui-egui/src/panels/render_queue.rs | 44 +++++++++----- xtask/Cargo.toml | 1 + xtask/src/bench.rs | 41 +++++++++---- 15 files changed, 247 insertions(+), 98 deletions(-) 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 8585b9b4..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" @@ -1233,6 +1242,7 @@ dependencies = [ name = "effectcraft-effects" version = "0.16.0" dependencies = [ + "chrono", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1250,6 +1260,7 @@ dependencies = [ name = "effectcraft-engine" version = "0.16.0" dependencies = [ + "chrono", "craft-docpath", "effectcraft-color", "effectcraft-effects", @@ -1651,6 +1662,7 @@ dependencies = [ name = "effectcraft-ui-egui" version = "0.16.0" dependencies = [ + "chrono", "craft-ui", "effectcraft-color", "effectcraft-engine", @@ -6627,6 +6639,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" name = "xtask" version = "0.16.0" dependencies = [ + "chrono", "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 7ab23e29..b1573fd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" 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/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/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/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/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/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/xtask/Cargo.toml b/xtask/Cargo.toml index ea80b285..b9c781bc 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -8,4 +8,5 @@ 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 From 0173abde8c09d0045e66b656397a0b4910b3aa58 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:59:51 -0400 Subject: [PATCH 21/23] CI: dev-profile gate, one test binary per crate, nextest, line tables only (#2235) * Dev profile: line tables only for workspace crates, no debug info for dependencies * xtask ci: dev-profile gate, nextest, tests without examples, wasm --keep-going * One integration-test binary per crate * xtask ci: run effectcraft-gpu's shared-device tests in one cargo test process * xtask ci: run every stage and report all failures * xtask ci: name the stage type (clippy type_complexity) * xtask ci: run every test pass and report all failures * cargo fmt --- Cargo.toml | 3 + apps/effectcraft-cli/tests/{ => it}/cli.rs | 0 apps/effectcraft-cli/tests/it/main.rs | 4 + .../tests/{ => it}/qa_template.rs | 0 crates/av1enc/README.md | 4 +- crates/av1enc/tests/{ => it}/bench.rs | 4 +- crates/av1enc/tests/{ => it}/common/mod.rs | 0 crates/av1enc/tests/{ => it}/ffmpeg_oracle.rs | 2 +- crates/av1enc/tests/it/main.rs | 6 ++ crates/av1enc/tests/{ => it}/roundtrip.rs | 2 +- crates/effects/tests/{ => it}/gen_golden.rs | 0 crates/effects/tests/it/main.rs | 6 ++ .../effects/tests/{ => it}/particle_golden.rs | 0 crates/effects/tests/{ => it}/sim_golden.rs | 0 .../effects/tests/{ => it}/timewarp_golden.rs | 0 .../engine/src/tests_timeline_interchange.rs | 2 +- .../{ => it}/key_time_post_snap_bounds.rs | 0 .../tests/{ => it}/key_time_reply_contract.rs | 0 crates/engine/tests/it/main.rs | 5 ++ .../tests/{ => it}/parenting_3d_position.rs | 0 .../tests/{ => it}/audio_sample_positions.rs | 0 .../tests/{ => it}/hevc_av1_roundtrip.rs | 0 crates/export/tests/it/main.rs | 14 ++++ .../{ => it}/mov_audio_sample_positions.rs | 2 - .../tests/{ => it}/ms_rounded_footage.rs | 0 .../{ => it}/native_audio_sample_positions.rs | 2 - .../export/tests/{ => it}/opus_roundtrip.rs | 0 .../export/tests/{ => it}/render_settings.rs | 0 crates/export/tests/{ => it}/roundtrip.rs | 0 .../export/tests/{ => it}/uncompressed_mov.rs | 0 crates/export/tests/{ => it}/vp9_roundtrip.rs | 0 .../host/tests/{ => it}/background_presets.rs | 0 crates/host/tests/{ => it}/determinism.rs | 0 crates/host/tests/{ => it}/exr_layers.rs | 0 crates/host/tests/{ => it}/footage_reload.rs | 0 crates/host/tests/{ => it}/gif_animates.rs | 0 crates/host/tests/it/main.rs | 13 +++ crates/host/tests/{ => it}/plugins.rs | 0 crates/host/tests/{ => it}/relink.rs | 0 crates/host/tests/{ => it}/tga_import.rs | 0 .../host/tests/{ => it}/timeline_interop.rs | 0 crates/host/tests/{ => it}/vector_import.rs | 4 +- crates/host/tests/{ => it}/webm_metadata.rs | 0 crates/media/tests/{ => it}/downmix.rs | 0 .../media/tests/{ => it}/exif_orientation.rs | 0 crates/media/tests/{ => it}/footage.rs | 2 +- crates/media/tests/it/main.rs | 7 ++ crates/media/tests/{ => it}/open_in_place.rs | 0 crates/media/tests/{ => it}/tga.rs | 0 crates/opusenc/README.md | 2 +- crates/opusenc/tests/{ => it}/common/mod.rs | 0 crates/opusenc/tests/{ => it}/ffmpeg.rs | 2 +- crates/opusenc/tests/it/main.rs | 5 ++ crates/opusenc/tests/{ => it}/modes.rs | 2 +- crates/text/src/layout.rs | 10 ++- crates/text/tests/it/main.rs | 5 ++ crates/text/tests/{ => it}/script_fallback.rs | 0 .../text/tests/{ => it}/system_font_menu.rs | 0 .../tests/{ => it}/vertical_punctuation.rs | 0 crates/ui-egui/tests/it/main.rs | 83 +++++++++++++++++++ .../tests/{ => it}/render_queue_snapshot.rs | 2 +- .../tests/{ => it}/shared_docking_ui.rs | 0 crates/ui-egui/tests/{ => it}/ui_3d.rs | 2 +- .../ui-egui/tests/{ => it}/ui_agent_audit.rs | 0 crates/ui-egui/tests/{ => it}/ui_angles.rs | 0 .../ui-egui/tests/{ => it}/ui_audio_scrub.rs | 0 .../tests/{ => it}/ui_azerty_shortcuts.rs | 0 .../tests/{ => it}/ui_blank_context_menus.rs | 0 .../tests/{ => it}/ui_camera_tracker.rs | 0 .../tests/{ => it}/ui_character_eyedropper.rs | 0 crates/ui-egui/tests/{ => it}/ui_color_hex.rs | 0 .../ui-egui/tests/{ => it}/ui_comp_presets.rs | 0 .../tests/{ => it}/ui_control_modifiers.rs | 0 .../ui-egui/tests/{ => it}/ui_dock_resize.rs | 0 crates/ui-egui/tests/{ => it}/ui_drag_drop.rs | 0 .../tests/{ => it}/ui_effect_controls.rs | 0 .../{ => it}/ui_effect_drop_highlight.rs | 0 .../ui-egui/tests/{ => it}/ui_effect_names.rs | 0 .../ui-egui/tests/{ => it}/ui_font_search.rs | 0 crates/ui-egui/tests/{ => it}/ui_form_fit.rs | 0 .../ui-egui/tests/{ => it}/ui_frame_step.rs | 0 .../ui-egui/tests/{ => it}/ui_fx_editors.rs | 0 crates/ui-egui/tests/{ => it}/ui_gpu.rs | 0 .../tests/{ => it}/ui_gradient_editor.rs | 0 .../tests/{ => it}/ui_gradient_handles.rs | 0 .../tests/{ => it}/ui_gradient_value.rs | 0 crates/ui-egui/tests/{ => it}/ui_graph_fit.rs | 0 .../ui-egui/tests/{ => it}/ui_group_rename.rs | 0 .../ui-egui/tests/{ => it}/ui_guide_layers.rs | 0 .../ui-egui/tests/{ => it}/ui_inline_edit.rs | 0 crates/ui-egui/tests/{ => it}/ui_keyframes.rs | 0 .../tests/{ => it}/ui_large_project.rs | 0 .../tests/{ => it}/ui_layer_panel_zoom.rs | 0 .../ui-egui/tests/{ => it}/ui_live_preview.rs | 0 .../ui-egui/tests/{ => it}/ui_locked_color.rs | 0 .../tests/{ => it}/ui_media_browser_cache.rs | 0 .../{ => it}/ui_media_browser_copy_path.rs | 0 crates/ui-egui/tests/{ => it}/ui_menus.rs | 0 .../ui-egui/tests/{ => it}/ui_object_matte.rs | 0 .../ui-egui/tests/{ => it}/ui_panel_focus.rs | 0 .../tests/{ => it}/ui_panel_windows.rs | 0 crates/ui-egui/tests/{ => it}/ui_panels.rs | 2 +- .../tests/{ => it}/ui_parameter_spacing.rs | 0 .../tests/{ => it}/ui_presets_folder.rs | 0 .../tests/{ => it}/ui_preview_align.rs | 0 .../tests/{ => it}/ui_project_delete.rs | 0 .../tests/{ => it}/ui_project_keyboard.rs | 0 .../tests/{ => it}/ui_project_new_folder.rs | 0 .../ui-egui/tests/{ => it}/ui_project_rows.rs | 0 .../ui-egui/tests/{ => it}/ui_quick_offset.rs | 0 .../ui-egui/tests/{ => it}/ui_ram_preview.rs | 0 .../ui-egui/tests/{ => it}/ui_render_queue.rs | 0 .../tests/{ => it}/ui_script_console.rs | 2 +- crates/ui-egui/tests/{ => it}/ui_scriptui.rs | 0 crates/ui-egui/tests/{ => it}/ui_scrolling.rs | 0 .../tests/{ => it}/ui_settings_info.rs | 0 crates/ui-egui/tests/{ => it}/ui_tabs.rs | 2 +- crates/ui-egui/tests/{ => it}/ui_tail.rs | 0 crates/ui-egui/tests/{ => it}/ui_text_edit.rs | 0 .../tests/{ => it}/ui_text_range_handles.rs | 0 .../tests/{ => it}/ui_theme_favorites.rs | 0 .../tests/{ => it}/ui_time_zoom_controls.rs | 0 .../{ => it}/ui_timeline_effect_label.rs | 0 .../tests/{ => it}/ui_timeline_shapes.rs | 0 .../tests/{ => it}/ui_timeline_switches.rs | 0 .../tests/{ => it}/ui_timeline_trim.rs | 0 .../tests/{ => it}/ui_timeline_zoom.rs | 0 .../tests/{ => it}/ui_toolbar_gradient.rs | 0 crates/ui-egui/tests/{ => it}/ui_tracker.rs | 0 .../tests/{ => it}/ui_unreadable_footage.rs | 0 .../tests/{ => it}/ui_video_preview_off.rs | 0 crates/ui-egui/tests/{ => it}/ui_viewer.rs | 0 crates/ui-egui/tests/{ => it}/ui_viewers.rs | 2 +- .../tests/{ => it}/ui_visual_review.rs | 0 .../tests/{ => it}/ui_web_gpu_storage.rs | 0 .../ui-egui/tests/{ => it}/ui_web_job_gpu.rs | 0 .../ui-egui/tests/{ => it}/ui_web_offload.rs | 0 .../tests/{ => it}/ui_workflow_selection.rs | 0 .../tests/{ => it}/ui_workspace_focus.rs | 0 .../ui-egui/tests/{ => it}/viewer_geometry.rs | 0 .../tests/{ => it}/viewer_passive_display.rs | 0 docs/agents.md | 2 +- docs/control-protocol.md | 2 +- docs/fidelity/workflow-checks.md | 2 +- docs/target-app-parity.md | 4 +- docs/testing.md | 9 +- docs/web.md | 6 +- xtask/src/main.rs | 52 +++++++++--- 148 files changed, 237 insertions(+), 43 deletions(-) rename apps/effectcraft-cli/tests/{ => it}/cli.rs (100%) create mode 100644 apps/effectcraft-cli/tests/it/main.rs rename apps/effectcraft-cli/tests/{ => it}/qa_template.rs (100%) rename crates/av1enc/tests/{ => it}/bench.rs (97%) rename crates/av1enc/tests/{ => it}/common/mod.rs (100%) rename crates/av1enc/tests/{ => it}/ffmpeg_oracle.rs (99%) create mode 100644 crates/av1enc/tests/it/main.rs rename crates/av1enc/tests/{ => it}/roundtrip.rs (99%) rename crates/effects/tests/{ => it}/gen_golden.rs (100%) create mode 100644 crates/effects/tests/it/main.rs rename crates/effects/tests/{ => it}/particle_golden.rs (100%) rename crates/effects/tests/{ => it}/sim_golden.rs (100%) rename crates/effects/tests/{ => it}/timewarp_golden.rs (100%) rename crates/engine/tests/{ => it}/key_time_post_snap_bounds.rs (100%) rename crates/engine/tests/{ => it}/key_time_reply_contract.rs (100%) create mode 100644 crates/engine/tests/it/main.rs rename crates/engine/tests/{ => it}/parenting_3d_position.rs (100%) rename crates/export/tests/{ => it}/audio_sample_positions.rs (100%) rename crates/export/tests/{ => it}/hevc_av1_roundtrip.rs (100%) create mode 100644 crates/export/tests/it/main.rs rename crates/export/tests/{ => it}/mov_audio_sample_positions.rs (99%) rename crates/export/tests/{ => it}/ms_rounded_footage.rs (100%) rename crates/export/tests/{ => it}/native_audio_sample_positions.rs (99%) rename crates/export/tests/{ => it}/opus_roundtrip.rs (100%) rename crates/export/tests/{ => it}/render_settings.rs (100%) rename crates/export/tests/{ => it}/roundtrip.rs (100%) rename crates/export/tests/{ => it}/uncompressed_mov.rs (100%) rename crates/export/tests/{ => it}/vp9_roundtrip.rs (100%) rename crates/host/tests/{ => it}/background_presets.rs (100%) rename crates/host/tests/{ => it}/determinism.rs (100%) rename crates/host/tests/{ => it}/exr_layers.rs (100%) rename crates/host/tests/{ => it}/footage_reload.rs (100%) rename crates/host/tests/{ => it}/gif_animates.rs (100%) create mode 100644 crates/host/tests/it/main.rs rename crates/host/tests/{ => it}/plugins.rs (100%) rename crates/host/tests/{ => it}/relink.rs (100%) rename crates/host/tests/{ => it}/tga_import.rs (100%) rename crates/host/tests/{ => it}/timeline_interop.rs (100%) rename crates/host/tests/{ => it}/vector_import.rs (99%) rename crates/host/tests/{ => it}/webm_metadata.rs (100%) rename crates/media/tests/{ => it}/downmix.rs (100%) rename crates/media/tests/{ => it}/exif_orientation.rs (100%) rename crates/media/tests/{ => it}/footage.rs (99%) create mode 100644 crates/media/tests/it/main.rs rename crates/media/tests/{ => it}/open_in_place.rs (100%) rename crates/media/tests/{ => it}/tga.rs (100%) rename crates/opusenc/tests/{ => it}/common/mod.rs (100%) rename crates/opusenc/tests/{ => it}/ffmpeg.rs (99%) create mode 100644 crates/opusenc/tests/it/main.rs rename crates/opusenc/tests/{ => it}/modes.rs (99%) create mode 100644 crates/text/tests/it/main.rs rename crates/text/tests/{ => it}/script_fallback.rs (100%) rename crates/text/tests/{ => it}/system_font_menu.rs (100%) rename crates/text/tests/{ => it}/vertical_punctuation.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ => it}/render_queue_snapshot.rs (98%) rename crates/ui-egui/tests/{ => it}/shared_docking_ui.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_3d.rs (99%) rename crates/ui-egui/tests/{ => it}/ui_agent_audit.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_angles.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_audio_scrub.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_azerty_shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_blank_context_menus.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_camera_tracker.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_character_eyedropper.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_color_hex.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_comp_presets.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_control_modifiers.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_dock_resize.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_drag_drop.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_effect_controls.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_effect_drop_highlight.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_effect_names.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_font_search.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_form_fit.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_frame_step.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_fx_editors.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_gpu.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_gradient_editor.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_gradient_handles.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_gradient_value.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_graph_fit.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_group_rename.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_guide_layers.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_inline_edit.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_keyframes.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_large_project.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_layer_panel_zoom.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_live_preview.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_locked_color.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_media_browser_cache.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_media_browser_copy_path.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_menus.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_object_matte.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_panel_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_panel_windows.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_panels.rs (99%) rename crates/ui-egui/tests/{ => it}/ui_parameter_spacing.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_presets_folder.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_preview_align.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_project_delete.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_project_keyboard.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_project_new_folder.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_project_rows.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_quick_offset.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_ram_preview.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_render_queue.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_script_console.rs (98%) rename crates/ui-egui/tests/{ => it}/ui_scriptui.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_scrolling.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_settings_info.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_tabs.rs (99%) rename crates/ui-egui/tests/{ => it}/ui_tail.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_text_edit.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_text_range_handles.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_theme_favorites.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_time_zoom_controls.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_timeline_effect_label.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_timeline_shapes.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_timeline_switches.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_timeline_trim.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_timeline_zoom.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_toolbar_gradient.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_tracker.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_unreadable_footage.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_video_preview_off.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_viewer.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_viewers.rs (98%) rename crates/ui-egui/tests/{ => it}/ui_visual_review.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_web_gpu_storage.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_web_job_gpu.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_web_offload.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_workflow_selection.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_workspace_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/viewer_geometry.rs (100%) rename crates/ui-egui/tests/{ => it}/viewer_passive_display.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index b1573fd7..d71d2933 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,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/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/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/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/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/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/text/src/layout.rs b/crates/text/src/layout.rs index 8f692888..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], variation_ids: &[u32], 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 }; 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/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 100% rename from crates/ui-egui/tests/ui_control_modifiers.rs rename to crates/ui-egui/tests/it/ui_control_modifiers.rs 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/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 { From 5a027fa77f72b3a78728c62c949dc579a8aeb685 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:20 -0400 Subject: [PATCH 22/23] Packaging: pin the signing client and appimagetool, and check their hashes (#1976) * Packaging: pin the Windows signing client and check its hash * Packaging: pin appimagetool and check its hash * Reject AppImage download for architectures without a pinned appimagetool * Pin the AppImage runtime --- packaging/linux/package.sh | 46 +++++++++++++++++++++++++++++++++----- packaging/windows/sign.ps1 | 13 ++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 9a9693aa..a9349ca4 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -106,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" @@ -116,17 +133,36 @@ if has appimage; then 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 "$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 @@ -138,7 +174,7 @@ 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 echo "wrote $OUT.zsync" 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 From 89b8db77185fddf8aee562b874f06731f4b57c9e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:36 -0400 Subject: [PATCH 23/23] Footage, Lottie and models: resolve stored paths with craft-docpath's resolve_absolute (#1975) * Footage: use resolve_absolute for stored paths * Lottie and models: use resolve_absolute for linked files * CI: release only with git dependencies from storytold * Use the shared git-sources check and pin checkout in release.yml * Resolve a model's side files through resolve_absolute; report C:\x as invalid * CI: parse cargo config, including the legacy .cargo/config, in the git-sources check --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++ crates/engine/src/doc_path.rs | 25 +++++++++++++----- crates/engine/src/doc_path/tests.rs | 16 ++++++++++++ crates/lottie/src/import.rs | 8 +++--- crates/media/src/pool.rs | 2 +- crates/media/src/probe.rs | 27 +++++++++++++++++-- 6 files changed, 105 insertions(+), 13 deletions(-) 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/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/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 9a8ec5c2..ffd65739 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]);