From bf8eff53e31d50a1fb3d3bd9edf1539b3e9dd83d Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 05:58:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E6=B8=B8=E6=88=8F=E9=9D=99?= =?UTF-8?q?=E6=80=81=E9=97=A8=E4=B8=8E=E5=9B=BE=E9=9B=86=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 Oxc AST 与语义符号验证 JavaScript 模块、作用域和控制流 锚定图集事务目录并保证九文件快照与错误回滚一致 修复早注册捕获监听器下的可信点击因果验收 补齐默认与命名空间模块投影、回归测试和长期技术文档 --- .../src-tauri/Cargo.lock | 453 ++++- .../src-tauri/Cargo.toml | 6 + .../src/agent/generation/canvas_generation.rs | 1263 +++++++++++-- .../runtime_protocol/autonomous_completion.rs | 1673 +++++++++++------ .../autonomous_completion_contract_tests.rs | 317 +++- .../src-tauri/src/browser/playtest/generic.rs | 16 +- .../src-tauri/src/browser/tests.rs | 26 +- .../shared-memory/decision-log.md | 7 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 9 files changed, 3047 insertions(+), 718 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 019554f64..4d425cdc5 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -503,6 +503,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -665,6 +674,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -724,6 +746,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -791,7 +819,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -1020,6 +1048,12 @@ dependencies = [ "serde", ] +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "dtoa" version = "1.0.11" @@ -1561,6 +1595,12 @@ dependencies = [ "image", "jsonschema", "libc", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_parser", + "oxc_semantic", + "oxc_span", "platform-agent", "platform-llm", "portable-pty", @@ -2194,6 +2234,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2630,13 +2679,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -2654,6 +2709,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-cmp" version = "0.1.0" @@ -2700,7 +2765,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -3026,6 +3091,252 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc-miette" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0df30faa68797917ca4263e7a2f889ec829e4da2dcb3d6dc752f7a494180f3" +dependencies = [ + "cfg-if", + "memchr", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc-miette-derive" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc072d11d45ebe7801459b4e829184ba0934d68027fdc51d327335b53a95a49" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "oxc_allocator" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603f4ff4617fc04377aa7557396eaa17c77f82e64ffb22947731f75605951f" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf40d60818cd9ff034774fb371c67d915aa3a6bb0129fdfcb0157a72bda840ff" +dependencies = [ + "bitflags 2.13.0", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd7135befda5e9fab0d549031bf6c0763966bdf596b5429a74c70f0307fcdf5" +dependencies = [ + "phf 0.14.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2fe96292c0c8752825d95641696be3252be8ddd32a161257cf03874468884" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_data_structures" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd2aa418d3599ef5e9880d1a359b27bffff242d4a59e0d62ffe08ebf40acdd99" + +[[package]] +name = "oxc_diagnostics" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdd1fcc5edb6d4666ffc82f1647d9784aa15338e671434b8c70211dd44cb66" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33440685fff66ad5af668cce75130267db8a864938e6433bc264975cd40b2f2c" +dependencies = [ + "dragonbox_ecma", + "itoa", + "num-bigint 0.5.1", + "num-traits", + "oxc_ast", + "oxc_data_structures", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2267dd438727a1afe72883eb7bf1533511ee713a5ace72cb59ea439f6bc74dca" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f6b2ea4e4b0538aa7925d98af33a68cc246c1a85d5543a9540cde24101c7e2" +dependencies = [ + "bitflags 2.13.0", + "cow-utils", + "memchr", + "num-bigint 0.5.1", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde40ebbc9bd3d9a35fa67e518197a2ef92da71c6a0b1496eb7443b5014f3fa9" +dependencies = [ + "bitflags 2.13.0", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf 0.14.0", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_semantic" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0c6133ad34ad58b9f00ee277fe044e9f47f1c064dd1cb4cf428fce21a8efe5" +dependencies = [ + "itertools", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "self_cell", + "smallvec", +] + +[[package]] +name = "oxc_span" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d7e5d38e008df87a3ec92b2c228b8555e6acd56f7befbaff315d4900c3438bb" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66064b0255f08443382c4b79cf98d0695ad0a40b62644fe3dc232461afd7b941" +dependencies = [ + "compact_str", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb571d2462c910943c527d0230064702513c7354a63b107aa6d953e46a0696c1" +dependencies = [ + "bitflags 2.13.0", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf 0.14.0", + "unicode-id-start", +] + [[package]] name = "pango" version = "0.18.3" @@ -3109,8 +3420,19 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3120,8 +3442,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -3131,7 +3453,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3140,8 +3472,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -3156,6 +3501,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3964,7 +4318,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -3972,6 +4326,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -3982,6 +4342,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -4267,6 +4633,15 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "socket2" @@ -4345,6 +4720,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4353,7 +4734,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -4363,8 +4744,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -4434,6 +4815,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4801,7 +5193,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf", + "phf 0.13.1", "plist", "proc-macro2", "quote", @@ -4855,6 +5247,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5357,12 +5760,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -5378,6 +5793,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -5704,7 +6125,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index efdb7eb4b..7102839ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -19,6 +19,12 @@ futures = "0.3" http = "1" image = { version = "0.25", default-features = false, features = ["png"] } jsonschema = { version = "0.49.3", default-features = false } +oxc_allocator = "0.143.0" +oxc_ast = "0.143.0" +oxc_ast_visit = "0.143.0" +oxc_parser = "0.143.0" +oxc_semantic = "0.143.0" +oxc_span = "0.143.0" rmcp = { version = "2.2.0", default-features = false, features = ["client", "reqwest-native-tls", "transport-child-process", "transport-streamable-http-client-reqwest"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 92c146816..32f16148b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1635,6 +1635,7 @@ fn cleanup_interrupted_platform_art_contract_files_at(root: &Path) -> Result<(), let prefixes = [ format!(".{file_name}.previous."), format!(".{file_name}.replacement."), + format!(".{file_name}.installing."), ]; let entries = match fs::read_dir(parent) { Ok(entries) => entries, @@ -1881,8 +1882,13 @@ fn open_platform_art_transaction_file_for_read(path: &Path) -> std::io::Result std::io::Res { use std::os::windows::fs::OpenOptionsExt; - const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x0000_0007; + const DELETE_ACCESS: u32 = 0x0001_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003; const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; options - .share_mode(FILE_SHARE_READ_WRITE_DELETE) + .access_mode(GENERIC_READ | DELETE_ACCESS) + // Deliberately omit FILE_SHARE_DELETE. While this handle is alive the + // directory cannot be renamed, replaced, or deleted by another process. + .share_mode(FILE_SHARE_READ_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); } options.open(path) } +fn open_platform_art_transaction_directory_for_identity(path: &Path) -> std::io::Result { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x0000_0007; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let mut options = fs::OpenOptions::new(); + options + .read(true) + // The trusted handle itself denies delete sharing. Identity probes + // must allow its DELETE access while requesting only read access. + .share_mode(FILE_SHARE_READ_WRITE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + return options.open(path); + } + #[cfg(not(windows))] + open_platform_art_transaction_directory_for_read(path) +} + +#[cfg(unix)] +fn open_platform_art_transaction_child_at( + directory: &fs::File, + name: &std::ffi::OsStr, +) -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let name = std::ffi::CString::new(name.as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "平台图集事务文件名包含 NUL", + ) + })?; + // SAFETY: directory is a live directory descriptor and name is NUL-terminated. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: openat returned a new owned descriptor. + Ok(unsafe { fs::File::from_raw_fd(descriptor) }) +} + +#[cfg(unix)] +fn remove_platform_art_transaction_child_at( + directory: &fs::File, + name: &std::ffi::OsStr, +) -> std::io::Result<()> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::AsRawFd; + + let name = std::ffi::CString::new(name.as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "平台图集事务文件名包含 NUL", + ) + })?; + // SAFETY: directory is a live directory descriptor and name is NUL-terminated. + if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + #[cfg(windows)] fn platform_art_transaction_windows_file_identity(file: &fs::File) -> Result<(u32, u64), String> { use std::ffi::c_void; @@ -1986,6 +2068,10 @@ struct TrustedPlatformArtTransactionDirectory { path: PathBuf, handle: fs::File, metadata: fs::Metadata, + #[cfg(unix)] + parent_handle: fs::File, + #[cfg(unix)] + directory_name: std::ffi::OsString, } impl TrustedPlatformArtTransactionDirectory { @@ -1997,18 +2083,60 @@ impl TrustedPlatformArtTransactionDirectory { { return Err("平台图集事务路径不是可信目录,已拒绝恢复".to_string()); } + #[cfg(unix)] + let (parent_handle, directory_name, handle) = { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let parent = path + .parent() + .ok_or_else(|| "平台图集事务目录缺少父目录".to_string())?; + let directory_name = path + .file_name() + .ok_or_else(|| "平台图集事务目录缺少目录名".to_string())? + .to_os_string(); + let parent_handle = open_platform_art_transaction_directory_for_read(parent) + .map_err(|error| format!("安全打开平台图集事务父目录失败:{error}"))?; + let name = std::ffi::CString::new(directory_name.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + // SAFETY: parent_handle is live and name is a NUL-terminated leaf name. + let descriptor = unsafe { + libc::openat( + parent_handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(format!( + "安全相对打开平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned descriptor. + let handle = unsafe { fs::File::from_raw_fd(descriptor) }; + (parent_handle, directory_name, handle) + }; + #[cfg(not(unix))] let handle = open_platform_art_transaction_directory_for_read(path) .map_err(|error| format!("安全打开平台图集事务目录失败:{error}"))?; let metadata = handle .metadata() .map_err(|error| format!("读取已打开平台图集事务目录元数据失败:{error}"))?; + let current = open_platform_art_transaction_directory_for_identity(path) + .map_err(|error| format!("复核打开平台图集事务目录失败:{error}"))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取复核平台图集事务目录元数据失败:{error}"))?; if !platform_art_transaction_directory_metadata_is_trusted(&metadata) + || !platform_art_transaction_directory_metadata_is_trusted(¤t_metadata) || !platform_art_transaction_open_files_match( &handle, &metadata, - &handle, - &path_metadata, + ¤t, + ¤t_metadata, )? + || !platform_art_transaction_metadata_unchanged(&path_metadata, ¤t_metadata) { return Err("平台图集事务目录在打开期间发生变化,已拒绝恢复".to_string()); } @@ -2016,6 +2144,10 @@ impl TrustedPlatformArtTransactionDirectory { path: path.to_path_buf(), handle, metadata, + #[cfg(unix)] + parent_handle, + #[cfg(unix)] + directory_name, }; trusted.verify()?; Ok(trusted) @@ -2029,7 +2161,7 @@ impl TrustedPlatformArtTransactionDirectory { { return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); } - let current = open_platform_art_transaction_directory_for_read(&self.path) + let current = open_platform_art_transaction_directory_for_identity(&self.path) .map_err(|error| format!("复核打开平台图集事务目录失败:{error}"))?; let current_metadata = current .metadata() @@ -2046,6 +2178,166 @@ impl TrustedPlatformArtTransactionDirectory { } Ok(()) } + + fn leaf_name<'a>(&self, path: &'a Path, label: &str) -> Result<&'a std::ffi::OsStr, String> { + if path.parent() != Some(self.path.as_path()) { + return Err(format!("{label}不属于已锚定的平台图集事务目录")); + } + let name = path + .file_name() + .ok_or_else(|| format!("{label}缺少事务目录叶子文件名"))?; + if Path::new(name).components().count() != 1 { + return Err(format!("{label}不是安全的事务目录叶子文件名")); + } + Ok(name) + } + + fn open_child_for_read(&self, path: &Path, label: &str) -> Result { + let name = self + .leaf_name(path, label) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + #[cfg(unix)] + { + return open_platform_art_transaction_child_at(&self.handle, name); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let mut options = fs::OpenOptions::new(); + options + .read(true) + // This denies writers and delete/rename opens while the trusted + // child handle is alive. + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + return options.open(self.path.join(name)); + } + #[cfg(not(any(unix, windows)))] + { + open_platform_art_transaction_file_for_read(&self.path.join(name)) + } + } + + fn child_names(&self) -> Result, String> { + self.verify()?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + use std::os::unix::io::AsRawFd; + + // SAFETY: dup creates an independently owned descriptor for fdopendir. + let duplicate = unsafe { libc::dup(self.handle.as_raw_fd()) }; + if duplicate < 0 { + return Err(format!( + "复制平台图集事务目录句柄失败:{}", + std::io::Error::last_os_error() + )); + } + // SAFETY: duplicate is an owned directory descriptor. + let directory = unsafe { libc::fdopendir(duplicate) }; + if directory.is_null() { + // SAFETY: fdopendir failed and did not take ownership. + unsafe { libc::close(duplicate) }; + return Err(format!( + "枚举平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + let mut names = Vec::new(); + loop { + // SAFETY: directory is live until closed below. + let entry = unsafe { libc::readdir(directory) }; + if entry.is_null() { + break; + } + // SAFETY: d_name is NUL-terminated for a valid dirent. + let name = unsafe { + std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) + .to_bytes() + .to_vec() + }; + if name != b"." && name != b".." { + names.push(std::ffi::OsString::from_vec(name)); + } + } + // SAFETY: directory came from fdopendir and has not been closed. + if unsafe { libc::closedir(directory) } != 0 { + return Err(format!( + "关闭平台图集事务目录枚举句柄失败:{}", + std::io::Error::last_os_error() + )); + } + return Ok(names); + } + #[cfg(not(unix))] + { + fs::read_dir(&self.path) + .map_err(|error| format!("枚举平台图集事务目录失败:{error}"))? + .map(|entry| { + entry + .map(|entry| entry.file_name()) + .map_err(|error| format!("读取平台图集事务目录项失败:{error}")) + }) + .collect() + } + } + + fn remove_child(&self, name: &std::ffi::OsStr) -> Result<(), String> { + if Path::new(name).components().count() != 1 { + return Err("平台图集事务清理目标不是安全叶子文件名".to_string()); + } + #[cfg(unix)] + { + return remove_platform_art_transaction_child_at(&self.handle, name) + .map_err(|error| format!("清理平台图集事务文件失败:{error}")); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + const DELETE_ACCESS: u32 = 0x0001_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let mut options = fs::OpenOptions::new(); + options + .access_mode(GENERIC_READ | DELETE_ACCESS) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let child = options + .open(self.path.join(name)) + .map_err(|error| format!("通过可信目录打开事务清理目标失败:{error}"))?; + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: child is live and disposition points to a correctly sized structure. + if unsafe { + SetFileInformationByHandle( + child.as_raw_handle().cast(), + FileDispositionInfo, + (&raw const disposition).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "通过可信句柄清理平台图集事务文件失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + fs::remove_file(self.path.join(name)) + .map_err(|error| format!("清理平台图集事务文件失败:{error}")) + } + } } fn read_platform_art_transaction_file_once( @@ -2181,11 +2473,66 @@ fn read_bounded_platform_art_transaction_file_in_directory( max_bytes: u64, label: &str, ) -> Result, String> { - if path.parent() != Some(transaction_directory.path.as_path()) { - return Err(format!("{label}不属于已锚定的平台图集事务目录")); - } transaction_directory.verify()?; - let read_result = read_bounded_platform_art_transaction_file(path, max_bytes, label); + let mut file = transaction_directory + .open_child_for_read(path, label) + .map_err(|error| format!("安全相对打开{label}失败:{}: {error}", path.display()))?; + let opened_metadata_before = file + .metadata() + .map_err(|error| format!("读取已打开{label}元数据失败:{}: {error}", path.display()))?; + if !platform_art_transaction_metadata_is_trusted(&opened_metadata_before, max_bytes) { + return Err(format!("{label}不是可信普通文件或超出大小上限")); + } + let initial_current = transaction_directory + .open_child_for_read(path, label) + .map_err(|error| format!("复核相对打开{label}失败:{}: {error}", path.display()))?; + let initial_current_metadata = initial_current + .metadata() + .map_err(|error| format!("读取复核{label}元数据失败:{}: {error}", path.display()))?; + if !platform_art_transaction_metadata_is_trusted(&initial_current_metadata, max_bytes) + || !platform_art_transaction_open_files_match( + &file, + &opened_metadata_before, + &initial_current, + &initial_current_metadata, + )? + { + return Err(format!("{label}在读取前发生变化,已拒绝继续")); + } + let first = read_platform_art_transaction_file_once(&mut file, max_bytes, label, path)?; + let second = read_platform_art_transaction_file_once(&mut file, max_bytes, label, path)?; + let opened_metadata_after = file.metadata().map_err(|error| { + format!( + "读取已打开{label}结束元数据失败:{}: {error}", + path.display() + ) + })?; + let current = transaction_directory + .open_child_for_read(path, label) + .map_err(|error| format!("结束复核相对打开{label}失败:{}: {error}", path.display()))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取结束复核{label}元数据失败:{}: {error}", path.display()))?; + let read_result = + if !platform_art_transaction_metadata_is_trusted(&opened_metadata_after, max_bytes) + || !platform_art_transaction_metadata_is_trusted(¤t_metadata, max_bytes) + || !platform_art_transaction_metadata_unchanged( + &opened_metadata_before, + &opened_metadata_after, + ) + || !platform_art_transaction_open_files_match( + &file, + &opened_metadata_after, + ¤t, + ¤t_metadata, + )? + || first != second + || u64::try_from(second.len()).unwrap_or(u64::MAX) != opened_metadata_after.len() + { + Err(format!("{label}在读取期间发生变化,已拒绝继续")) + } else { + Ok(second) + }; let identity_result = transaction_directory.verify(); match (read_result, identity_result) { (_, Err(error)) => Err(error), @@ -2197,23 +2544,84 @@ fn strict_platform_art_transaction_directory(root: &Path) -> Result Result<(), String> { + transaction_directory.verify()?; + for name in transaction_directory.child_names()? { + transaction_directory.remove_child(&name)?; + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::AsRawFd; + + let name = std::ffi::CString::new(transaction_directory.directory_name.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + // SAFETY: parent_handle is live and name is a NUL-terminated leaf directory. + if unsafe { + libc::unlinkat( + transaction_directory.parent_handle.as_raw_fd(), + name.as_ptr(), + libc::AT_REMOVEDIR, + ) + } != 0 + { + return Err(format!( + "安全相对清理平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: handle is live and disposition points to a correctly sized structure. + if unsafe { + SetFileInformationByHandle( + transaction_directory.handle.as_raw_handle().cast(), + FileDispositionInfo, + (&raw const disposition).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "通过可信句柄清理平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + } + #[cfg(not(any(unix, windows)))] + fs::remove_dir(&transaction_directory.path) + .map_err(|error| format!("清理平台图集事务目录失败:{error}"))?; + #[cfg(unix)] + transaction_directory + .parent_handle + .sync_all() + .map_err(|error| format!("同步平台图集事务父目录句柄失败:{error}"))?; + #[cfg(not(unix))] + if let Some(parent) = transaction_directory.path.parent() { + sync_platform_art_directory(parent, "平台图集事务父")?; + } + Ok(()) +} + fn remove_strict_platform_art_transaction_directory( transaction_directory: &Path, ) -> Result<(), String> { match fs::symlink_metadata(transaction_directory) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err("平台图集事务路径不是可信目录,已拒绝清理".to_string()); - } - Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Ok(_) => {} Err(error) => return Err(format!("读取平台图集事务目录失败:{error}")), } - fs::remove_dir_all(transaction_directory) - .map_err(|error| format!("清理平台图集事务目录失败:{error}"))?; - if let Some(parent) = transaction_directory.parent() { - sync_platform_art_directory(parent, "平台图集事务父")?; - } - Ok(()) + let trusted = TrustedPlatformArtTransactionDirectory::open(transaction_directory)?; + remove_trusted_platform_art_transaction_directory(trusted) } fn strict_platform_art_transaction_marker_exists( @@ -2223,14 +2631,19 @@ fn strict_platform_art_transaction_marker_exists( label: &str, ) -> Result { transaction_directory.verify()?; - let result = match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - Err(format!("平台图集事务 {label} marker 不是可信普通文件")) + let presence_guard = match transaction_directory + .open_child_for_read(path, &format!("平台图集事务 {label} marker")) + { + Ok(file) => Some(file), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "安全相对检查平台图集事务 {label} marker 失败:{error}" + )); } - Ok(metadata) if metadata.len() > 64 => { - Err(format!("平台图集事务 {label} marker 超出大小上限")) - } - Ok(_) => { + }; + let result = match presence_guard { + Some(_presence_guard) => { let actual = read_bounded_platform_art_transaction_file_in_directory( transaction_directory, path, @@ -2242,8 +2655,7 @@ fn strict_platform_art_transaction_marker_exists( } Ok(true) } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(format!("读取平台图集事务 {label} marker 失败:{error}")), + None => Ok(false), }; let identity_result = transaction_directory.verify(); match (result, identity_result) { @@ -2252,7 +2664,51 @@ fn strict_platform_art_transaction_marker_exists( } } -fn preflight_platform_art_recovery_target(path: &Path) -> Result<(), String> { +fn preflight_platform_art_recovery_target(root: &Path, path: &Path) -> Result<(), String> { + let relative = path + .strip_prefix(root) + .map_err(|_| format!("平台图集事务恢复目标越出项目根目录:{}", path.display()))?; + if relative.as_os_str().is_empty() + || relative + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(format!( + "平台图集事务恢复目标不是项目根目录内的规范相对路径:{}", + path.display() + )); + } + let root_metadata = fs::symlink_metadata(root) + .map_err(|error| format!("检查平台图集事务项目根目录失败:{error}"))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("平台图集事务项目根目录不是可信目录".to_string()); + } + let relative_parent = relative + .parent() + .ok_or_else(|| "平台图集事务恢复目标缺少可信父目录".to_string())?; + let mut ancestor = root.to_path_buf(); + for component in relative_parent.components() { + let std::path::Component::Normal(component) = component else { + return Err("平台图集事务恢复目标包含非规范父路径".to_string()); + }; + ancestor.push(component); + match fs::symlink_metadata(&ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(format!( + "平台图集事务恢复目标父路径不是可信目录:{}", + ancestor.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "检查平台图集事务恢复目标父路径失败:{}: {error}", + ancestor.display() + )); + } + } + } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err(format!( @@ -2269,28 +2725,7 @@ fn preflight_platform_art_recovery_target(path: &Path) -> Result<(), String> { )); } } - let mut ancestor = path.parent(); - while let Some(parent) = ancestor { - match fs::symlink_metadata(parent) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err(format!( - "平台图集事务恢复目标父路径不是可信目录:{}", - parent.display() - )); - } - Ok(_) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - ancestor = parent.parent(); - } - Err(error) => { - return Err(format!( - "检查平台图集事务恢复目标父路径失败:{}: {error}", - parent.display() - )); - } - } - } - Err("平台图集事务恢复目标缺少可信父目录".to_string()) + Ok(()) } fn sync_strict_platform_art_contract_state_at( @@ -2392,12 +2827,34 @@ fn read_platform_art_recovery_file_state( } fn install_platform_art_recovery_state_cas( + root: &Path, canonical: &Path, expected: &PlatformArtRecoveryFileState, desired: &PlatformArtRecoveryFileState, suffix: &str, ) -> Result<(), String> { - preflight_platform_art_recovery_target(canonical)?; + install_platform_art_recovery_state_cas_with_hook( + root, + canonical, + expected, + desired, + suffix, + || Ok(()), + ) +} + +fn install_platform_art_recovery_state_cas_with_hook( + root: &Path, + canonical: &Path, + expected: &PlatformArtRecoveryFileState, + desired: &PlatformArtRecoveryFileState, + suffix: &str, + after_canonical_install: F, +) -> Result<(), String> +where + F: FnOnce() -> Result<(), String>, +{ + preflight_platform_art_recovery_target(root, canonical)?; let observed = read_platform_art_recovery_file_state( canonical, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, @@ -2413,54 +2870,94 @@ fn install_platform_art_recovery_state_cas( return Ok(()); } - if matches!(expected, PlatformArtRecoveryFileState::Missing) { - return match desired { - PlatformArtRecoveryFileState::Missing => Ok(()), - PlatformArtRecoveryFileState::Present(bytes) => { - if let Some(parent) = canonical.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建平台图集事务恢复目录失败:{}: {error}", - parent.display() - ) - })?; - } - preflight_platform_art_recovery_target(canonical)?; - match read_platform_art_recovery_file_state( - canonical, - STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, - "平台图集事务 CAS 缺失目标", - )? { - PlatformArtRecoveryFileState::Missing => {} - PlatformArtRecoveryFileState::Present(_) => { - return Err(format!( - "平台图集事务 CAS 缺失目标已被并发创建,已拒绝覆盖:{}", - canonical.display() - )); - } - } - write_durable_platform_art_transaction_file( - canonical, - bytes, - "平台图集事务 CAS 安装结果", - ) - } - }; - } - let file_name = canonical .file_name() .and_then(|value| value.to_str()) .unwrap_or("manifest.json"); + if let Some(parent) = canonical.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建平台图集事务恢复目录失败:{}: {error}", + parent.display() + ) + })?; + } + preflight_platform_art_recovery_target(root, canonical)?; + + // Prepare desired bytes under a private create-new leaf first. Linking this + // durable inode into the canonical name is atomic and never overwrites a + // concurrently-created target, so write/sync failures cannot leave a partial + // canonical contract file. + let installing = canonical.with_file_name(format!(".{file_name}.installing.{suffix}")); + let prepared_install = match desired { + PlatformArtRecoveryFileState::Present(bytes) => { + if fs::symlink_metadata(&installing).is_ok() { + return Err(format!( + "平台图集事务 CAS 安装暂存路径已存在,已拒绝覆盖:{}", + installing.display() + )); + } + if let Err(error) = write_durable_platform_art_transaction_file( + &installing, + bytes, + "平台图集事务 CAS 安装暂存", + ) { + let _ = fs::remove_file(&installing); + return Err(error); + } + Some(installing.as_path()) + } + PlatformArtRecoveryFileState::Missing => None, + }; + + if matches!(expected, PlatformArtRecoveryFileState::Missing) { + match read_platform_art_recovery_file_state( + canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 缺失目标", + )? { + PlatformArtRecoveryFileState::Missing => {} + PlatformArtRecoveryFileState::Present(_) => { + if prepared_install.is_some() { + let _ = fs::remove_file(&installing); + } + return Err(format!( + "平台图集事务 CAS 缺失目标已被并发创建,已拒绝覆盖:{}", + canonical.display() + )); + } + } + let Some(prepared_install) = prepared_install else { + return Ok(()); + }; + if let Err(error) = fs::hard_link(prepared_install, canonical) { + let _ = fs::remove_file(&installing); + return Err(format!("原子安装平台图集事务 CAS 缺失目标失败:{error}")); + } + // From this point canonical contains desired even if temporary cleanup + // fails; the caller observes that state and includes this item in rollback. + after_canonical_install()?; + fs::remove_file(&installing) + .map_err(|error| format!("回收平台图集事务 CAS 安装暂存失败:{error}"))?; + return Ok(()); + } + let backup = canonical.with_file_name(format!(".{file_name}.previous.{suffix}")); if fs::symlink_metadata(&backup).is_ok() { + if prepared_install.is_some() { + let _ = fs::remove_file(&installing); + } return Err(format!( "平台图集事务 CAS 备份路径已存在,已拒绝覆盖:{}", backup.display() )); } - fs::rename(canonical, &backup) - .map_err(|error| format!("平台图集事务 CAS 锁定既有目标失败:{error}"))?; + if let Err(error) = fs::rename(canonical, &backup) { + if prepared_install.is_some() { + let _ = fs::remove_file(&installing); + } + return Err(format!("平台图集事务 CAS 锁定既有目标失败:{error}")); + } let moved = read_platform_art_recovery_file_state( &backup, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, @@ -2483,6 +2980,9 @@ fn install_platform_art_recovery_state_cas( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集事务 CAS 校验失败且恢复原目标失败:{error}" )); } + if prepared_install.is_some() { + let _ = fs::remove_file(&installing); + } return Err(moved .err() .unwrap_or_else(|| "平台图集事务 CAS 目标在锁定前发生变化,已拒绝覆盖".to_string())); @@ -2490,13 +2990,11 @@ fn install_platform_art_recovery_state_cas( let install_result = match desired { PlatformArtRecoveryFileState::Missing => Ok(()), - PlatformArtRecoveryFileState::Present(bytes) => { - write_durable_platform_art_transaction_file( - canonical, - bytes, - "平台图集事务 CAS 安装结果", - ) - } + PlatformArtRecoveryFileState::Present(_) => fs::hard_link( + prepared_install.expect("present desired state has prepared install"), + canonical, + ) + .map_err(|error| format!("原子安装平台图集事务 CAS 结果失败:{error}")), }; if let Err(error) = install_result { let restore_result = match fs::symlink_metadata(canonical) { @@ -2510,19 +3008,40 @@ fn install_platform_art_recovery_state_cas( )); } }; - return match restore_result { - Ok(()) => Err(error), - Err(restore_error) => Err(format!( - "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};恢复平台图集事务 CAS 原目标失败:{restore_error}" + let cleanup_result = if prepared_install.is_some() { + fs::remove_file(&installing) + } else { + Ok(()) + }; + return match (restore_result, cleanup_result) { + (Ok(()), Ok(())) => Err(error), + (Ok(()), Err(cleanup_error)) => Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};回收平台图集事务 CAS 安装暂存失败:{cleanup_error}" + )), + (Err(restore_error), cleanup_result) => Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};恢复平台图集事务 CAS 原目标失败:{restore_error}{}", + cleanup_result + .err() + .map(|cleanup_error| format!(";回收安装暂存失败:{cleanup_error}")) + .unwrap_or_default() )), }; } + // From this point canonical contains desired (including the intentionally + // missing state) even if a later cleanup step reports an error. + after_canonical_install()?; + if prepared_install.is_some() { + // canonical is already desired if this cleanup reports an error. + fs::remove_file(&installing) + .map_err(|error| format!("回收平台图集事务 CAS 安装暂存失败:{error}"))?; + } fs::remove_file(&backup) .map_err(|error| format!("回收平台图集事务 CAS 原目标备份失败:{error}"))?; Ok(()) } fn rollback_applied_platform_art_recovery( + root: &Path, applied: &[AppliedPlatformArtRecovery], recovery_suffix: &str, ) -> Result<(), String> { @@ -2530,6 +3049,7 @@ fn rollback_applied_platform_art_recovery( let mut errors = Vec::new(); for applied_entry in applied.iter().rev() { let result = install_platform_art_recovery_state_cas( + root, &applied_entry.canonical, &applied_entry.installed, &applied_entry.previous, @@ -2555,7 +3075,7 @@ fn platform_art_recovery_error_after_rollback( if applied.is_empty() { return error; } - match rollback_applied_platform_art_recovery(applied, recovery_suffix) { + match rollback_applied_platform_art_recovery(root, applied, recovery_suffix) { Ok(()) => error, Err(rollback_error) => format!( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};反向回滚本轮平台图集恢复失败:{rollback_error};项目根目录:{}", @@ -2564,6 +3084,40 @@ fn platform_art_recovery_error_after_rollback( } } +fn include_platform_art_recovery_current_item_after_install_error( + canonical: &Path, + previous: &PlatformArtRecoveryFileState, + desired: &PlatformArtRecoveryFileState, + applied: &mut Vec, + error: String, +) -> String { + match read_platform_art_recovery_file_state( + canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 错误后安装状态", + ) { + Ok(observed) if observed == *desired => { + // A durable rename/link may have installed the desired value before a + // later cleanup/sync step failed. Include this item in the same reverse + // rollback as all earlier items. + applied.push(AppliedPlatformArtRecovery { + canonical: canonical.to_path_buf(), + previous: previous.clone(), + installed: desired.clone(), + }); + error + } + Ok(observed) if observed == *previous => error, + Ok(_) => format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};平台图集事务 CAS 错误后目标既不是恢复前状态也不是目标状态:{}", + canonical.display() + ), + Err(observe_error) => format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};复核平台图集事务 CAS 错误后状态失败:{observe_error}" + ), + } +} + fn restore_strict_platform_art_transaction_at_with_hook( root: &Path, transaction_directory: &Path, @@ -2585,7 +3139,7 @@ where cleanup_interrupted_platform_art_contract_files_at(root)?; sync_strict_platform_art_contract_state_at(root, true)?; trusted_transaction_directory.verify()?; - remove_strict_platform_art_transaction_directory(transaction_directory)?; + remove_trusted_platform_art_transaction_directory(trusted_transaction_directory)?; return Ok(false); } if !strict_platform_art_transaction_marker_exists( @@ -2595,7 +3149,7 @@ where "prepared", )? { trusted_transaction_directory.verify()?; - remove_strict_platform_art_transaction_directory(transaction_directory)?; + remove_trusted_platform_art_transaction_directory(trusted_transaction_directory)?; return Ok(false); } let journal_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL); @@ -2686,7 +3240,7 @@ where } } for (canonical, _) in &recovery_plan { - preflight_platform_art_recovery_target(canonical)?; + preflight_platform_art_recovery_target(root, canonical)?; } let mut applied = Vec::with_capacity(recovery_plan.len()); let mut total_rollback_bytes = 0_u64; @@ -2699,7 +3253,7 @@ where error, )); } - if let Err(error) = preflight_platform_art_recovery_target(&canonical) { + if let Err(error) = preflight_platform_art_recovery_target(root, &canonical) { return Err(platform_art_recovery_error_after_rollback( root, &applied, @@ -2740,11 +3294,19 @@ where }; } if let Err(error) = install_platform_art_recovery_state_cas( + root, &canonical, &previous, &desired, &recovery_suffix, ) { + let error = include_platform_art_recovery_current_item_after_install_error( + &canonical, + &previous, + &desired, + &mut applied, + error, + ); return Err(platform_art_recovery_error_after_rollback( root, &applied, @@ -2787,7 +3349,7 @@ where cleanup_interrupted_platform_art_contract_files_at(root)?; sync_strict_platform_art_contract_state_at(root, false)?; trusted_transaction_directory.verify()?; - remove_strict_platform_art_transaction_directory(transaction_directory)?; + remove_trusted_platform_art_transaction_directory(trusted_transaction_directory)?; Ok(true) } @@ -2832,8 +3394,183 @@ struct PlatformArtSliceContractRollback { armed: bool, } +struct TrustedPlatformArtContractCaptureFile { + path: PathBuf, + file: fs::File, + metadata: fs::Metadata, + bytes: Vec, +} + +enum TrustedPlatformArtContractCaptureSource { + Missing(PathBuf), + Present(TrustedPlatformArtContractCaptureFile), +} + +fn open_platform_art_contract_capture_source( + path: &Path, + max_bytes: u64, +) -> Result { + let path_metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(TrustedPlatformArtContractCaptureSource::Missing( + path.to_path_buf(), + )); + } + Err(error) => { + return Err(format!( + "读取既有平台图集切片合同元数据失败:{}: {error}", + path.display() + )); + } + }; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + return Err(format!( + "既有平台图集切片合同快照来源不是可信普通文件:{}", + path.display() + )); + } + if path_metadata.len() > max_bytes { + return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); + } + let file = open_platform_art_transaction_file_for_read(path).map_err(|error| { + format!( + "安全打开既有平台图集切片合同快照来源失败:{}: {error}", + path.display() + ) + })?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取已打开平台图集切片合同元数据失败:{}: {error}", + path.display() + ) + })?; + let current = open_platform_art_transaction_file_for_read(path).map_err(|error| { + format!( + "复核打开既有平台图集切片合同快照来源失败:{}: {error}", + path.display() + ) + })?; + let current_metadata = current.metadata().map_err(|error| { + format!( + "读取复核平台图集切片合同元数据失败:{}: {error}", + path.display() + ) + })?; + if !platform_art_transaction_metadata_is_trusted(&metadata, max_bytes) + || !platform_art_transaction_metadata_is_trusted(¤t_metadata, max_bytes) + || !platform_art_transaction_open_files_match( + &file, + &metadata, + ¤t, + ¤t_metadata, + )? + || !platform_art_transaction_metadata_unchanged(&path_metadata, ¤t_metadata) + { + return Err(format!( + "既有平台图集切片合同快照来源在打开期间发生变化,已拒绝提交:{}", + path.display() + )); + } + Ok(TrustedPlatformArtContractCaptureSource::Present( + TrustedPlatformArtContractCaptureFile { + path: path.to_path_buf(), + file, + metadata, + bytes: Vec::new(), + }, + )) +} + +fn verify_platform_art_contract_capture_sources( + sources: &mut [TrustedPlatformArtContractCaptureSource], +) -> Result<(), String> { + for source in sources { + match source { + TrustedPlatformArtContractCaptureSource::Missing(path) => { + match fs::symlink_metadata(&*path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => { + return Err(format!( + "平台图集合同文件在一致性快照期间由缺失变为存在,已拒绝提交:{}", + path.display() + )); + } + Err(error) => { + return Err(format!( + "复核缺失平台图集合同文件失败:{}: {error}", + path.display() + )); + } + } + } + TrustedPlatformArtContractCaptureSource::Present(captured) => { + let verify_bytes = read_platform_art_transaction_file_once( + &mut captured.file, + u64::try_from(captured.bytes.len()).unwrap_or(u64::MAX), + "既有平台图集切片合同一致性复核", + &captured.path, + )?; + let metadata_after = captured.file.metadata().map_err(|error| { + format!( + "读取一致性复核后的平台图集合同元数据失败:{}: {error}", + captured.path.display() + ) + })?; + let current = open_platform_art_transaction_file_for_read(&captured.path).map_err( + |error| { + format!( + "一致性复核打开当前平台图集合同失败:{}: {error}", + captured.path.display() + ) + }, + )?; + let current_metadata = current.metadata().map_err(|error| { + format!( + "读取当前平台图集合同元数据失败:{}: {error}", + captured.path.display() + ) + })?; + if verify_bytes != captured.bytes + || !platform_art_transaction_metadata_is_trusted( + &metadata_after, + u64::try_from(captured.bytes.len()).unwrap_or(u64::MAX), + ) + || !platform_art_transaction_metadata_is_trusted( + ¤t_metadata, + u64::try_from(captured.bytes.len()).unwrap_or(u64::MAX), + ) + || !platform_art_transaction_metadata_unchanged( + &captured.metadata, + &metadata_after, + ) + || !platform_art_transaction_open_files_match( + &captured.file, + &metadata_after, + ¤t, + ¤t_metadata, + )? + { + return Err(format!( + "平台图集合同文件在一致性快照期间发生变化,已拒绝提交:{}", + captured.path.display() + )); + } + } + } + } + Ok(()) +} + impl PlatformArtSliceContractRollback { fn capture(root: &Path, suffix: &str) -> Result { + Self::capture_with_hook(root, suffix, || Ok(())) + } + + fn capture_with_hook(root: &Path, suffix: &str, after_capture: F) -> Result + where + F: FnOnce() -> Result<(), String>, + { let transaction_directory = strict_platform_art_transaction_directory(root)?; match fs::symlink_metadata(&transaction_directory) { Ok(_) => return Err("发现尚未恢复的平台图集事务,已拒绝开始新提交".to_string()), @@ -2848,58 +3585,90 @@ impl PlatformArtSliceContractRollback { fs::create_dir(&transaction_directory) .map_err(|error| format!("创建平台图集事务目录失败:{error}"))?; let capture_result = (|| { - let mut entries = Vec::with_capacity(STRICT_PLATFORM_ART_CONTRACT_PATHS.len()); - let mut total_snapshot_bytes = 0_u64; - for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let mut sources = Vec::with_capacity(STRICT_PLATFORM_ART_CONTRACT_PATHS.len()); + for local_path in STRICT_PLATFORM_ART_CONTRACT_PATHS { let canonical = resolve_local_project_path(root, local_path)?; - let bytes = match fs::symlink_metadata(&canonical) { - Ok(_) => { - let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES - .saturating_sub(total_snapshot_bytes); - Some( - read_bounded_platform_art_transaction_file( - &canonical, - remaining, - "既有平台图集切片合同快照来源", - ) - .map_err(|error| { - if error.contains("大小上限") { - "平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string() - } else { - error - } - })?, + sources.push(open_platform_art_contract_capture_source( + &canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + )?); + } + + let mut total_snapshot_bytes = 0_u64; + for source in &mut sources { + if let TrustedPlatformArtContractCaptureSource::Present(captured) = source { + let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + .saturating_sub(total_snapshot_bytes); + let first = read_platform_art_transaction_file_once( + &mut captured.file, + remaining, + "既有平台图集切片合同快照来源", + &captured.path, + ) + .map_err(|error| { + if error.contains("大小上限") { + "平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string() + } else { + error + } + })?; + let second = read_platform_art_transaction_file_once( + &mut captured.file, + remaining, + "既有平台图集切片合同快照来源", + &captured.path, + )?; + let metadata_after = captured.file.metadata().map_err(|error| { + format!( + "读取平台图集合同快照来源结束元数据失败:{}: {error}", + captured.path.display() ) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => { + })?; + if first != second + || !platform_art_transaction_metadata_unchanged( + &captured.metadata, + &metadata_after, + ) + { return Err(format!( - "读取既有平台图集切片合同元数据失败:{}: {error}", - canonical.display() + "平台图集合同文件在快照读取期间发生变化,已拒绝提交:{}", + captured.path.display() )); } - }; - if let Some(bytes) = bytes { + captured.bytes = second; total_snapshot_bytes = total_snapshot_bytes .checked_add( - u64::try_from(bytes.len()) + u64::try_from(captured.bytes.len()) .map_err(|_| "平台图集事务快照大小溢出".to_string())?, ) .ok_or_else(|| "平台图集事务快照累计大小溢出".to_string())?; if total_snapshot_bytes > STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES { return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); } + } + } + + after_capture()?; + verify_platform_art_contract_capture_sources(&mut sources)?; + + let mut entries = Vec::with_capacity(STRICT_PLATFORM_ART_CONTRACT_PATHS.len()); + for (index, (local_path, source)) in STRICT_PLATFORM_ART_CONTRACT_PATHS + .iter() + .zip(sources.iter()) + .enumerate() + { + if let TrustedPlatformArtContractCaptureSource::Present(captured) = source { let snapshot = format!("{index:02}.snapshot"); write_durable_platform_art_transaction_file( &transaction_directory.join(&snapshot), - &bytes, + &captured.bytes, "平台图集事务快照", )?; entries.push(serde_json::json!({ "localPath": local_path, "existed": true, "snapshot": snapshot, - "sha256": format!("{:x}", Sha256::digest(&bytes)), + "sha256": format!("{:x}", Sha256::digest(&captured.bytes)), })); } else { entries.push(serde_json::json!({ @@ -2920,6 +3689,10 @@ impl PlatformArtSliceContractRollback { "平台图集事务 journal", )?; sync_platform_art_directory(&transaction_directory, "平台图集事务")?; + // Keep every existing source handle alive through journal durability, + // then collectively prove the same captured state again immediately + // before publishing the prepared marker. + verify_platform_art_contract_capture_sources(&mut sources)?; write_atomic_platform_art_transaction_marker( &transaction_directory, STRICT_PLATFORM_ART_TRANSACTION_PREPARED, @@ -2955,12 +3728,31 @@ impl PlatformArtSliceContractRollback { let prepared_path = self .transaction_directory .join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED); - match fs::remove_file(&prepared_path) { - Ok(()) => {} + let trusted_transaction_directory = TrustedPlatformArtTransactionDirectory::open( + &self.transaction_directory, + ) + .map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但锚定事务目录失败:{error}" + ) + })?; + match trusted_transaction_directory + .open_child_for_read(&prepared_path, "平台图集事务 prepared marker") + { + Ok(presence_guard) => { + drop(presence_guard); + trusted_transaction_directory + .remove_child(std::ffi::OsStr::new(STRICT_PLATFORM_ART_TRANSACTION_PREPARED)) + .map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但清理 prepared marker 失败:{error}" + ) + })? + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(format!( - "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但清理 prepared marker 失败:{error}" + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但检查 prepared marker 失败:{error}" )); } } @@ -2982,7 +3774,7 @@ impl PlatformArtSliceContractRollback { ) })?; Ok( - remove_strict_platform_art_transaction_directory(&self.transaction_directory) + remove_trusted_platform_art_transaction_directory(trusted_transaction_directory) .err() .map(|error| format!("平台图集已提交,但事务目录等待下次恢复清理:{error}")), ) @@ -5741,6 +6533,195 @@ mod canvas_generation_tests { ); } + #[cfg(unix)] + #[test] + fn platform_art_recovery_preflight_rejects_symlink_in_existing_intermediate_ancestor() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("create recovery ancestor project"); + let root = temporary.path().join("project"); + let outside = temporary.path().join("outside"); + fs::create_dir_all(root.join("assets")).expect("create project assets"); + fs::create_dir_all(outside.join("nested")).expect("create outside nested directory"); + symlink(&outside, root.join("assets/escape")).expect("create intermediate symlink"); + let target = root.join("assets/escape/nested/manifest.json"); + + let error = preflight_platform_art_recovery_target(&root, &target) + .expect_err("every ancestor through the project root must be checked"); + + assert!( + error.contains("父路径不是可信目录"), + "unexpected error: {error}" + ); + } + + #[cfg(unix)] + #[test] + fn trusted_transaction_directory_opens_children_relative_to_anchored_handle() { + let temporary = tempfile::tempdir().expect("create anchored transaction fixture"); + let transaction_directory = temporary.path().join("transaction"); + let displaced_directory = temporary.path().join("transaction-displaced"); + fs::create_dir(&transaction_directory).expect("create original transaction directory"); + let journal_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL); + fs::write(&journal_path, b"trusted-journal").expect("write trusted journal"); + let trusted = TrustedPlatformArtTransactionDirectory::open(&transaction_directory) + .expect("anchor transaction directory"); + + fs::rename(&transaction_directory, &displaced_directory) + .expect("displace transaction directory"); + fs::create_dir(&transaction_directory).expect("create pathname replacement"); + fs::write( + transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL), + b"substitute-journal", + ) + .expect("write pathname substitute"); + + let mut anchored = trusted + .open_child_for_read(&journal_path, "测试平台图集事务 journal") + .expect("open child relative to held directory descriptor"); + let mut bytes = Vec::new(); + anchored + .read_to_end(&mut bytes) + .expect("read anchored journal"); + assert_eq!(bytes, b"trusted-journal"); + assert_eq!( + fs::read(&journal_path).expect("read pathname substitute"), + b"substitute-journal" + ); + } + + #[cfg(unix)] + #[test] + fn trusted_transaction_directory_cleanup_never_deletes_pathname_substitute() { + let temporary = tempfile::tempdir().expect("create anchored cleanup fixture"); + let transaction_directory = temporary.path().join("transaction"); + let displaced_directory = temporary.path().join("transaction-displaced"); + fs::create_dir(&transaction_directory).expect("create original transaction directory"); + fs::write(transaction_directory.join("original.snapshot"), b"original") + .expect("write original transaction child"); + let trusted = TrustedPlatformArtTransactionDirectory::open(&transaction_directory) + .expect("anchor original transaction directory"); + + fs::rename(&transaction_directory, &displaced_directory) + .expect("displace original transaction directory"); + fs::create_dir(&transaction_directory).expect("create pathname substitute directory"); + let substitute = transaction_directory.join("substitute.snapshot"); + fs::write(&substitute, b"substitute").expect("write pathname substitute child"); + + let error = remove_trusted_platform_art_transaction_directory(trusted) + .expect_err("cleanup must fail when the anchored directory lost its pathname"); + assert!( + error.contains("目录身份发生变化"), + "unexpected error: {error}" + ); + assert_eq!( + fs::read(displaced_directory.join("original.snapshot")) + .expect("read preserved anchored child"), + b"original" + ); + assert_eq!( + fs::read(&substitute).expect("read preserved pathname substitute"), + b"substitute" + ); + } + + #[cfg(windows)] + #[test] + fn trusted_transaction_directory_windows_handle_denies_directory_replacement() { + let temporary = tempfile::tempdir().expect("create Windows transaction fixture"); + let transaction_directory = temporary.path().join("transaction"); + let displaced_directory = temporary.path().join("transaction-displaced"); + fs::create_dir(&transaction_directory).expect("create transaction directory"); + let _trusted = TrustedPlatformArtTransactionDirectory::open(&transaction_directory) + .expect("anchor transaction directory without delete sharing"); + + assert!( + fs::rename(&transaction_directory, &displaced_directory).is_err(), + "held Windows directory handle must prevent rename/replacement" + ); + } + + #[test] + fn platform_art_recovery_rolls_back_current_item_when_error_follows_install() { + let temporary = tempfile::tempdir().expect("create CAS post-install project"); + let root = temporary.path(); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + let canonical = root.join("assets/manifest.art.json"); + let previous = PlatformArtRecoveryFileState::Present(b"previous-contract".to_vec()); + let desired = PlatformArtRecoveryFileState::Present(b"desired-contract".to_vec()); + fs::write(&canonical, b"previous-contract").expect("write previous contract"); + + let error = install_platform_art_recovery_state_cas_with_hook( + root, + &canonical, + &previous, + &desired, + "post-install-error", + || Err("injected post-install cleanup failure".to_string()), + ) + .expect_err("post-install failure must be surfaced"); + assert!(error.contains("injected post-install")); + assert_eq!( + read_platform_art_recovery_file_state( + &canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "测试 CAS 错误后状态", + ) + .expect("read installed state"), + desired + ); + + let mut applied = Vec::new(); + let error = include_platform_art_recovery_current_item_after_install_error( + &canonical, + &previous, + &desired, + &mut applied, + error, + ); + assert!(error.contains("injected post-install")); + assert_eq!(applied.len(), 1, "current installed item must be tracked"); + rollback_applied_platform_art_recovery(root, &applied, "post-install-error") + .expect("roll back current item that installed before returning an error"); + assert_eq!( + fs::read(&canonical).expect("read rolled-back current item"), + b"previous-contract" + ); + } + + #[cfg(unix)] + #[test] + fn durable_strict_contract_capture_rejects_cross_file_mixed_snapshot() { + let temporary = tempfile::tempdir().expect("create coherent snapshot project"); + let root = temporary.path(); + init_local_game_project_at(root, "coherent-snapshot", "图集合同一致快照测试") + .expect("init project"); + let first = root.join(STRICT_PLATFORM_ART_CONTRACT_PATHS[0]); + let second = root.join(STRICT_PLATFORM_ART_CONTRACT_PATHS[1]); + fs::create_dir_all(second.parent().expect("second contract parent")) + .expect("create second contract parent"); + fs::write(&first, b"generation-a-main").expect("write first generation main"); + fs::write(&second, b"generation-a-slice").expect("write first generation slice"); + + let error = match PlatformArtSliceContractRollback::capture_with_hook( + root, + "coherent-snapshot", + || { + fs::write(&first, b"generation-b-main") + .map_err(|error| format!("mutate first captured contract: {error}")) + }, + ) { + Ok(_) => panic!("cross-file mixed snapshot must fail before prepared is published"), + Err(error) => error, + }; + + assert!( + error.contains("一致性快照期间发生变化"), + "unexpected error: {error}" + ); + assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); + } + #[test] fn durable_strict_contract_transaction_rejects_replaced_transaction_directory() { let temporary = tempfile::tempdir().expect("create replaced transaction directory fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 5deb7e2af..5d568a937 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -1,4 +1,29 @@ use super::*; +use oxc_allocator::Allocator as JavascriptAllocator; +use oxc_ast::ast::{ + BindingPattern as JavascriptBindingPattern, CallExpression as JavascriptCallExpression, + ComputedMemberExpression as JavascriptComputedMemberExpression, + Declaration as JavascriptDeclaration, ExportAllDeclaration as JavascriptExportAllDeclaration, + ExportDeclaration as JavascriptExportDeclaration, + ExportDefaultDeclarationKind as JavascriptExportDefaultDeclarationKind, + ExportFromDeclaration as JavascriptExportFromDeclaration, + ExportNamedDeclaration as JavascriptExportNamedDeclaration, Expression as JavascriptExpression, + Function as JavascriptFunction, ImportDeclaration as JavascriptImportDeclaration, + ImportDeclarationSpecifier as JavascriptImportDeclarationSpecifier, + ImportExpression as JavascriptImportExpression, MethodDefinition as JavascriptMethodDefinition, + ModuleExportName as JavascriptModuleExportName, ObjectProperty as JavascriptObjectProperty, + RegExpLiteral as JavascriptRegExpLiteral, Statement as JavascriptStatement, + StaticMemberExpression as JavascriptStaticMemberExpression, + StringLiteral as JavascriptStringLiteral, TemplateElement as JavascriptTemplateElement, + VariableDeclarator as JavascriptVariableDeclarator, +}; +use oxc_ast_visit::Visit as VisitJavascript; +use oxc_parser::Parser as JavascriptParser; +use oxc_semantic::{ + ScopeFlags as JavascriptScopeFlags, Scoping as JavascriptScoping, + SemanticBuilder as JavascriptSemanticBuilder, SymbolId as JavascriptSymbolId, +}; +use oxc_span::{GetSpan as JavascriptGetSpan, SourceType as JavascriptSourceType}; const AGENT_RUNTIME_AUTONOMOUS_MANIFEST_ARTIFACT_MAX_BYTES: u64 = 4 * 1024 * 1024; @@ -1017,221 +1042,275 @@ fn javascript_without_obvious_false_branches(content: &str) -> String { String::from_utf8(bytes).expect("masking JavaScript bytes with spaces preserves UTF-8") } -fn named_javascript_function_ranges(content: &str) -> Vec<(String, usize, usize)> { - let mut ranges = Vec::new(); - let mut cursor = 0; - while let Some(offset) = content[cursor..].find("function") { - let definition_start = cursor + offset; - cursor = definition_start + "function".len(); - if position_is_inside_javascript_string(content, definition_start) - || definition_start > 0 - && (is_ascii_word_byte(content.as_bytes()[definition_start - 1]) - || content.as_bytes()[definition_start - 1] == b'$') - || content - .as_bytes() - .get(cursor) - .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') - { - continue; +#[derive(Clone, Debug, Eq, PartialEq)] +struct NamedJavascriptFunctionRange { + name: String, + start: usize, + end: usize, + binding_start: Option, + invocations: Vec, +} + +#[derive(Default)] +struct JavascriptFunctionDefinitionCollector { + ranges: Vec, +} + +impl JavascriptFunctionDefinitionCollector { + fn push(&mut self, name: &str, start: usize, end: usize, binding_start: Option) { + self.ranges.push(NamedJavascriptFunctionRange { + name: name.to_ascii_lowercase(), + start, + end, + binding_start, + invocations: Vec::new(), + }); + } +} + +impl<'a> VisitJavascript<'a> for JavascriptFunctionDefinitionCollector { + fn visit_function(&mut self, function: &JavascriptFunction<'a>, flags: JavascriptScopeFlags) { + if let Some(identifier) = &function.id { + self.push( + identifier.name.as_str(), + function.span.start as usize, + function.span.end as usize, + identifier + .symbol_id + .get() + .map(|_| identifier.span.start as usize), + ); } - while content - .as_bytes() - .get(cursor) - .is_some_and(u8::is_ascii_whitespace) - { - cursor += 1; - } - if content.as_bytes().get(cursor) == Some(&b'*') { - cursor += 1; - while content - .as_bytes() - .get(cursor) - .is_some_and(u8::is_ascii_whitespace) - { - cursor += 1; - } - } - let name_start = cursor; - while content - .as_bytes() - .get(cursor) - .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$') - { - cursor += 1; - } - if cursor == name_start { - continue; - } - let name = content[name_start..cursor].to_string(); - let Some(open_offset) = content[cursor..].find('{') else { - break; - }; - let open = cursor + open_offset; - let Some(end) = matching_javascript_brace(content, open) else { - break; - }; - ranges.push((name, definition_start, end + 1)); - cursor = open + 1; + oxc_ast_visit::walk::walk_function(self, function, flags); } - let mut cursor = 0; - while let Some(offset) = content[cursor..].find("=>") { - let arrow = cursor + offset; - cursor = arrow + 2; - let mut body_start = cursor; - while content - .as_bytes() - .get(body_start) - .is_some_and(u8::is_ascii_whitespace) - { - body_start += 1; + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let JavascriptBindingPattern::BindingIdentifier(identifier) = &declarator.id { + if let Some(initializer) = &declarator.init { + if matches!( + initializer, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) { + self.push( + identifier.name.as_str(), + identifier.span.start as usize, + initializer.span().end as usize, + identifier + .symbol_id + .get() + .map(|_| identifier.span.start as usize), + ); + } + } } - if content.as_bytes().get(body_start) != Some(&b'{') { - continue; + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } + + fn visit_method_definition(&mut self, method: &JavascriptMethodDefinition<'a>) { + if let Some(name) = method.key.static_name() { + self.push( + name.as_ref(), + method.span.start as usize, + method.span.end as usize, + None, + ); } - let Some(end) = matching_javascript_brace(content, body_start) else { - continue; + oxc_ast_visit::walk::walk_method_definition(self, method); + } + + fn visit_object_property(&mut self, property: &JavascriptObjectProperty<'a>) { + if property.method { + if let Some(name) = property.key.static_name() { + self.push( + name.as_ref(), + property.span.start as usize, + property.span.end as usize, + None, + ); + } + } + oxc_ast_visit::walk::walk_object_property(self, property); + } +} + +struct JavascriptFunctionInvocationCollector<'a, 'b> { + scoping: &'b JavascriptScoping, + ranges: &'b mut [NamedJavascriptFunctionRange], + binding_ranges: BTreeMap>, + method_ranges: BTreeMap>, + marker: std::marker::PhantomData<&'a ()>, +} + +impl JavascriptFunctionInvocationCollector<'_, '_> { + fn record_identifier(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'_>, at: usize) { + let Some(reference_id) = identifier.reference_id.get() else { + return; }; - let mut assignment = arrow; - while assignment > 0 && content.as_bytes()[assignment - 1].is_ascii_whitespace() { - assignment -= 1; + let Some(symbol_id) = self.scoping.get_reference(reference_id).symbol_id() else { + return; + }; + let binding_start = self.scoping.symbol_span(symbol_id).start as usize; + if let Some(indices) = self.binding_ranges.get(&binding_start) { + for index in indices { + self.ranges[*index].invocations.push(at); + } } - if content.as_bytes().get(assignment.wrapping_sub(1)) == Some(&b')') { - let mut depth = 0usize; - let mut open = None; - for position in (0..assignment).rev() { - match content.as_bytes()[position] { - b')' => depth += 1, - b'(' => { - depth = depth.saturating_sub(1); - if depth == 0 { - open = Some(position); - break; + } + + fn record_expression(&mut self, expression: &JavascriptExpression<'_>, at: usize) { + if let JavascriptExpression::Identifier(identifier) = expression { + self.record_identifier(identifier, at); + } else if let Some(member) = expression.as_member_expression() { + if let Some(name) = member.static_property_name() { + if let Some(indices) = self.method_ranges.get(name) { + for index in indices { + self.ranges[*index].invocations.push(at); + } + } + } + } + } + + fn call_executes_callback_arguments(call: &JavascriptCallExpression<'_>) -> bool { + let callback_api = if let JavascriptExpression::Identifier(identifier) = &call.callee { + Some(identifier.name.as_str()) + } else { + call.callee + .as_member_expression() + .and_then(|member| member.static_property_name()) + }; + callback_api.is_some_and(|name| { + matches!( + name.to_ascii_lowercase().as_str(), + "addeventlistener" + | "catch" + | "every" + | "filter" + | "finally" + | "find" + | "foreach" + | "map" + | "queuemicrotask" + | "reduce" + | "requestanimationframe" + | "setinterval" + | "settimeout" + | "some" + | "then" + ) + }) + } +} + +impl<'a> VisitJavascript<'a> for JavascriptFunctionInvocationCollector<'a, '_> { + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let JavascriptBindingPattern::BindingIdentifier(identifier) = &declarator.id { + if let Some(JavascriptExpression::CallExpression(call)) = &declarator.init { + if let Some(bind_member) = call.callee.as_member_expression() { + if bind_member.static_property_name() == Some("bind") { + if let Some(target_member) = bind_member.object().as_member_expression() { + if let Some(indices) = target_member + .static_property_name() + .and_then(|name| self.method_ranges.get(name)) + { + self.binding_ranges + .entry(identifier.span.start as usize) + .or_default() + .extend(indices.iter().copied()); + } } } - _ => {} } } - let Some(open) = open else { - continue; - }; - assignment = open; - } else { - while assignment > 0 - && (is_ascii_word_byte(content.as_bytes()[assignment - 1]) - || content.as_bytes()[assignment - 1] == b'$') - { - assignment -= 1; - } } - while assignment > 0 && content.as_bytes()[assignment - 1].is_ascii_whitespace() { - assignment -= 1; - } - if content.as_bytes().get(assignment.wrapping_sub(1)) != Some(&b'=') { - continue; - } - let equals = assignment - 1; - let Some(name) = identifier_before(content, equals) else { - continue; - }; - let name_end = content[..equals].trim_end().len(); - let name_start = name_end.saturating_sub(name.len()); - ranges.push((name, name_start, end + 1)); + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); } - let mut cursor = 0usize; - while let Some(offset) = content[cursor..].find('{') { - let open_brace = cursor + offset; - cursor = open_brace + 1; - let mut close_paren = open_brace; - while close_paren > 0 && content.as_bytes()[close_paren - 1].is_ascii_whitespace() { - close_paren -= 1; - } - if content.as_bytes().get(close_paren.wrapping_sub(1)) != Some(&b')') { - continue; - } - let mut depth = 0usize; - let mut open_paren = None; - for position in (0..close_paren).rev() { - match content.as_bytes()[position] { - b')' => depth += 1, - b'(' => { - depth = depth.saturating_sub(1); - if depth == 0 { - open_paren = Some(position); - break; - } + fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { + let at = call.span.start as usize; + self.record_expression(&call.callee, at); + if Self::call_executes_callback_arguments(call) { + for argument in &call.arguments { + if let Some(expression) = argument.as_expression() { + self.record_expression(expression, at); } - _ => {} } } - let Some(open_paren) = open_paren else { - continue; - }; - let Some(name) = identifier_before(content, open_paren) else { - continue; - }; - if ["if", "for", "while", "switch", "catch", "with", "function"].contains(&name.as_str()) { - continue; - } - let Some(end) = matching_javascript_brace(content, open_brace) else { - continue; - }; - let name_end = content[..open_paren].trim_end().len(); - let name_start = name_end.saturating_sub(name.len()); - ranges.push((name, name_start, end + 1)); + oxc_ast_visit::walk::walk_call_expression(self, call); } - ranges.sort_by_key(|(_, start, end)| (*start, *end)); - ranges.dedup(); - ranges +} + +fn named_javascript_function_ranges(content: &str) -> Vec { + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new( + &allocator, + content, + JavascriptSourceType::default().with_unambiguous(true), + ) + .parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return Vec::new(); + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + let mut definitions = JavascriptFunctionDefinitionCollector::default(); + definitions.visit_program(&parsed.program); + definitions + .ranges + .sort_by_key(|range| (range.start, range.end)); + definitions.ranges.dedup_by(|left, right| { + left.name == right.name && left.start == right.start && left.end == right.end + }); + let mut binding_ranges = BTreeMap::>::new(); + let mut method_ranges = BTreeMap::>::new(); + for (index, range) in definitions.ranges.iter().enumerate() { + if let Some(binding_start) = range.binding_start { + binding_ranges.entry(binding_start).or_default().push(index); + } else { + method_ranges + .entry(range.name.clone()) + .or_default() + .push(index); + } + } + let mut invocations = JavascriptFunctionInvocationCollector { + scoping: semantic.semantic.scoping(), + ranges: &mut definitions.ranges, + binding_ranges, + method_ranges, + marker: std::marker::PhantomData, + }; + invocations.visit_program(&parsed.program); + definitions.ranges } fn javascript_named_function_is_reachable( content: &str, - ranges: &[(String, usize, usize)], + ranges: &[NamedJavascriptFunctionRange], function_index: usize, visiting: &mut BTreeSet, ) -> bool { if !visiting.insert(function_index) { return false; } - let (name, definition_start, definition_end) = &ranges[function_index]; - let invocation_markers = [ - format!("{name}("), - format!("requestanimationframe({name})"), - format!(",{name})"), - format!(", {name})"), - ]; - for (marker_index, marker) in invocation_markers.into_iter().enumerate() { - let mut cursor = 0; - while let Some(offset) = content[cursor..].find(&marker) { - let call = cursor + offset; - cursor = call + marker.len(); - if marker_index <= 1 - && call > 0 - && (is_ascii_word_byte(content.as_bytes()[call - 1]) - || content.as_bytes()[call - 1] == b'$') - { - continue; - } - if (*definition_start..*definition_end).contains(&call) - || position_is_inside_javascript_string(content, call) - || javascript_position_is_in_literal_false_block(content, call) - { - continue; - } - let parent = ranges - .iter() - .enumerate() - .filter(|(_, (_, start, end))| (*start..*end).contains(&call)) - .min_by_key(|(_, (_, start, end))| end - start) - .map(|(index, _)| index); - if parent.is_none_or(|index| { - javascript_named_function_is_reachable(content, ranges, index, visiting) - }) { - visiting.remove(&function_index); - return true; - } + let definition = &ranges[function_index]; + for call in &definition.invocations { + if (definition.start..definition.end).contains(call) + || javascript_position_is_in_literal_false_block(content, *call) + { + continue; + } + let parent = ranges + .iter() + .enumerate() + .filter(|(_, range)| (range.start..range.end).contains(call)) + .min_by_key(|(_, range)| range.end - range.start) + .map(|(index, _)| index); + if parent.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, visiting) + }) { + visiting.remove(&function_index); + return true; } } visiting.remove(&function_index); @@ -1240,7 +1319,7 @@ fn javascript_named_function_is_reachable( fn javascript_position_is_reachable( content: &str, - ranges: &[(String, usize, usize)], + ranges: &[NamedJavascriptFunctionRange], position: usize, ) -> bool { if javascript_position_is_in_literal_false_block(content, position) @@ -1251,8 +1330,8 @@ fn javascript_position_is_reachable( let enclosing = ranges .iter() .enumerate() - .filter(|(_, (_, start, end))| (*start..*end).contains(&position)) - .min_by_key(|(_, (_, start, end))| end - start) + .filter(|(_, range)| (range.start..range.end).contains(&position)) + .min_by_key(|(_, range)| range.end - range.start) .map(|(index, _)| index); enclosing.is_none_or(|index| { javascript_named_function_is_reachable(content, ranges, index, &mut BTreeSet::new()) @@ -1689,22 +1768,24 @@ fn game_index_visibly_uses_visual_asset( if identifiers.is_empty() { return false; } + let javascript = executable_javascript_from_html(&content); let mut significant_draws = 0usize; - let function_ranges = named_javascript_function_ranges(&content); + let function_ranges = named_javascript_function_ranges(&javascript); let mut draw_cursor = 0; - while let Some(offset) = content[draw_cursor..].find("drawimage(") { + while let Some(offset) = javascript[draw_cursor..].find("drawimage(") { let call = draw_cursor + offset; let arguments_start = call + "drawimage(".len(); draw_cursor = arguments_start; - if position_is_inside_javascript_string(&content, call) - || !javascript_position_is_reachable(&content, &function_ranges, call) + if position_is_inside_javascript_string(&javascript, call) + || !javascript_position_is_reachable(&javascript, &function_ranges, call) { continue; } - let Some(arguments_end) = javascript_call_arguments_end(&content, arguments_start) else { + let Some(arguments_end) = javascript_call_arguments_end(&javascript, arguments_start) + else { break; }; - let arguments = split_javascript_arguments(&content[arguments_start..arguments_end]); + let arguments = split_javascript_arguments(&javascript[arguments_start..arguments_end]); if arguments .first() .is_some_and(|identifier| identifiers.contains(*identifier)) @@ -2673,60 +2754,106 @@ fn inherited_gameplay_semantics(task: &str) -> Option String { - let mut output = Vec::with_capacity(content.len()); - let mut quote = None; - let mut escaped = false; - let mut line_comment = false; - let mut block_comment = false; - let bytes = content.as_bytes(); - let mut index = 0usize; - while index < bytes.len() { - let byte = bytes[index]; - let next = bytes.get(index + 1).copied(); - if escaped { - output.push(b' '); - escaped = false; - } else if byte == b'\\' && quote.is_some() { - output.push(b' '); - escaped = true; - } else if let Some(active) = quote { - output.push(b' '); - if byte == active { - quote = None; - } - } else if line_comment { - output.push(if byte == b'\n' { b'\n' } else { b' ' }); - if byte == b'\n' { - line_comment = false; - } - } else if block_comment { - output.push(b' '); - if byte == b'*' && next == Some(b'/') { - output.push(b' '); - index += 1; - block_comment = false; - } - } else if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - output.push(b' '); - } else if byte == b'/' && next == Some(b'/') { - output.extend_from_slice(b" "); - index += 1; - line_comment = true; - } else if byte == b'/' && next == Some(b'*') { - output.extend_from_slice(b" "); - index += 1; - block_comment = true; - } else { - output.push(byte); - } - index += 1; +fn javascript_source_type(is_module: bool) -> JavascriptSourceType { + if is_module { + JavascriptSourceType::mjs() + } else { + JavascriptSourceType::default().with_script(true) } - String::from_utf8(output).unwrap_or_default() +} + +fn javascript_is_syntactically_valid(content: &str, is_module: bool) -> bool { + let allocator = JavascriptAllocator::default(); + let parsed = + JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return false; + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + semantic.diagnostics.is_empty() +} + +#[derive(Default)] +struct JavascriptLiteralSpanCollector { + strings: Vec>, + regular_expressions: Vec>, + template_elements: Vec>, +} + +fn javascript_span_range(span: oxc_span::Span) -> std::ops::Range { + span.start as usize..span.end as usize +} + +impl<'a> VisitJavascript<'a> for JavascriptLiteralSpanCollector { + fn visit_string_literal(&mut self, literal: &JavascriptStringLiteral<'a>) { + self.strings.push(javascript_span_range(literal.span)); + } + + fn visit_reg_exp_literal(&mut self, literal: &JavascriptRegExpLiteral<'a>) { + self.regular_expressions + .push(javascript_span_range(literal.span)); + } + + fn visit_template_element(&mut self, element: &JavascriptTemplateElement<'a>) { + self.template_elements + .push(javascript_span_range(element.span)); + } +} + +fn javascript_without_string_literals_or_comments(content: &str) -> String { + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new( + &allocator, + content, + JavascriptSourceType::default().with_unambiguous(true), + ) + .parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return " ".repeat(content.len()); + } + let mut collector = JavascriptLiteralSpanCollector::default(); + collector.visit_program(&parsed.program); + let mut output = content.as_bytes().to_vec(); + for range in collector.template_elements { + output[range].fill(b' '); + } + for range in collector.strings { + if range.end.saturating_sub(range.start) > 2 { + output[range.start + 1..range.end - 1].fill(b' '); + } + } + for range in collector.regular_expressions { + let raw = &content[range.clone()]; + if let Some(close) = raw[1..].rfind('/') { + let close = range.start + 1 + close; + output[range.start + 1..close].fill(b' '); + if close > range.start + 1 { + output[range.start + 1] = b'a'; + } + } + } + for comment in &parsed.program.comments { + for byte in &mut output[javascript_span_range(comment.span())] { + if !matches!(*byte, b'\r' | b'\n') { + *byte = b' '; + } + } + } + String::from_utf8(output).expect("masking parsed JavaScript literals preserves UTF-8") } fn html_script_type_is_executable(script_tag: &str) -> bool { + if let Some(script_type) = html_attribute_value(script_tag, "type") { + let script_type = script_type.trim(); + return script_type.is_empty() + || script_type == "module" + || script_type == "text/javascript" + || script_type == "application/javascript" + || script_type == "text/ecmascript" + || script_type == "application/ecmascript" + || script_type.starts_with("text/javascript;") + || script_type.starts_with("application/javascript;"); + } if html_attribute_value(script_tag, "language").is_some_and(|language| { let language = language.trim(); !language.is_empty() @@ -2738,18 +2865,7 @@ fn html_script_type_is_executable(script_tag: &str) -> bool { }) { return false; } - let Some(script_type) = html_attribute_value(script_tag, "type") else { - return true; - }; - let script_type = script_type.trim(); - script_type.is_empty() - || script_type == "module" - || script_type == "text/javascript" - || script_type == "application/javascript" - || script_type == "text/ecmascript" - || script_type == "application/ecmascript" - || script_type.starts_with("text/javascript;") - || script_type.starts_with("application/javascript;") + true } fn html_script_is_module(script_tag: &str) -> bool { @@ -2946,8 +3062,11 @@ fn executable_javascript_from_html(content: &str) -> String { && !html_script_is_module(tag) && !html_has_attribute(tag, "src") { - executable.push_str(&content[body_start..close_start]); - executable.push('\n'); + let body = &content[body_start..close_start]; + if javascript_is_syntactically_valid(body, false) { + executable.push_str(body); + executable.push('\n'); + } } cursor = close_end; } @@ -2990,7 +3109,10 @@ fn executable_inline_module_javascript_units_from_html(content: &str) -> Vec Vec { tokens } -fn local_javascript_module_sources(content: &str, allow_static_imports: bool) -> Vec { - let tokens = javascript_lexical_tokens(content); - let mut sources = Vec::new(); - for (index, token) in tokens.iter().enumerate() { - let JavascriptLexicalToken::Identifier(keyword) = token else { - continue; - }; - if !matches!(keyword.as_str(), "import" | "export") - || index - .checked_sub(1) - .and_then(|previous| tokens.get(previous)) - == Some(&JavascriptLexicalToken::Punct('.')) - { - continue; - } - if keyword == "import" { - match tokens.get(index + 1) { - Some(JavascriptLexicalToken::StringLiteral(source)) if allow_static_imports => { - sources.push(source.clone()); - continue; - } - Some(JavascriptLexicalToken::Punct('(')) => { - if let Some(JavascriptLexicalToken::StringLiteral(source)) = - tokens.get(index + 2) - { - if matches!( - tokens.get(index + 3), - Some(JavascriptLexicalToken::Punct(')' | ',')) - ) { - sources.push(source.clone()); - } - } - continue; - } - _ => {} - } - if !matches!( - tokens.get(index + 1), - Some( - JavascriptLexicalToken::Identifier(_) - | JavascriptLexicalToken::Punct('{' | '*') - ) - ) { - continue; - } - } - if !allow_static_imports { - continue; - } - if keyword == "export" - && !matches!( - tokens.get(index + 1), - Some(JavascriptLexicalToken::Punct('{' | '*')) - ) - { - continue; - } - let mut saw_from = false; - for candidate in tokens.iter().skip(index + 1) { - match candidate { - JavascriptLexicalToken::Punct(';') => break, - JavascriptLexicalToken::Identifier(value) if value == "from" => { - saw_from = true; - } - JavascriptLexicalToken::StringLiteral(source) if saw_from => { - sources.push(source.clone()); - break; - } - _ => {} - } - } - } - sources +#[derive(Clone, Debug, Eq, PartialEq)] +enum JavascriptExportTarget { + Local(String), + Reexport { source: String, imported: String }, } -fn javascript_without_obviously_unreachable_dynamic_imports(content: &str) -> String { - let ranges = named_javascript_function_ranges(content); - let mut bytes = content.as_bytes().to_vec(); - let mut cursor = 0usize; - while let Some(offset) = content[cursor..].find("import") { - let import_start = cursor + offset; - cursor = import_start + "import".len(); - if import_start > 0 - && (is_ascii_word_byte(content.as_bytes()[import_start - 1]) - || content.as_bytes()[import_start - 1] == b'$') - || content - .as_bytes() - .get(cursor) - .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') - || position_is_inside_javascript_string(content, import_start) - { - continue; - } - let mut open = cursor; - while content - .as_bytes() - .get(open) - .is_some_and(u8::is_ascii_whitespace) - { - open += 1; - } - if content.as_bytes().get(open) != Some(&b'(') - || javascript_position_is_reachable(content, &ranges, import_start) - { - continue; - } - bytes[import_start..cursor].fill(b' '); +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct JavascriptModuleAnalysis { + static_sources: Vec, + import_declaration_ranges: Vec>, + imports: Vec<(String, BTreeMap)>, + used_import_locals: BTreeSet, + namespace_import_members: BTreeMap>, + exports: BTreeMap, + link_exports: BTreeMap, + synthetic_declarations: BTreeMap>, + star_exports: Vec, + dynamic_imports: Vec<(String, usize)>, +} + +fn javascript_module_export_name(name: &JavascriptModuleExportName<'_>) -> String { + match name { + JavascriptModuleExportName::IdentifierName(name) => name.name.to_string(), + JavascriptModuleExportName::IdentifierReference(name) => name.name.to_string(), + JavascriptModuleExportName::StringLiteral(name) => name.value.to_string(), } - String::from_utf8(bytes).expect("masking JavaScript bytes with spaces preserves UTF-8") +} + +#[derive(Default)] +struct JavascriptModuleAnalysisCollector { + analysis: JavascriptModuleAnalysis, + import_symbols: Vec<(String, JavascriptSymbolId)>, +} + +impl JavascriptModuleAnalysisCollector { + fn add_source(&mut self, source: &str) { + self.analysis.static_sources.push(source.to_string()); + } + + fn add_exported_declaration(&mut self, declaration: &JavascriptDeclaration<'_>) { + let mut names = Vec::new(); + match declaration { + JavascriptDeclaration::FunctionDeclaration(function) => { + names.extend(function.id.iter().map(|id| id.name.to_string())); + } + JavascriptDeclaration::ClassDeclaration(class) => { + names.extend(class.id.iter().map(|id| id.name.to_string())); + } + JavascriptDeclaration::VariableDeclaration(declaration) => { + names.extend(declaration.declarations.iter().filter_map(|declarator| { + if let JavascriptBindingPattern::BindingIdentifier(identifier) = &declarator.id + { + Some(identifier.name.to_string()) + } else { + None + } + })); + } + _ => {} + } + for name in names { + let normalized = name.to_ascii_lowercase(); + self.analysis + .link_exports + .insert(name.clone(), JavascriptExportTarget::Local(name)); + self.analysis.exports.insert( + normalized.clone(), + JavascriptExportTarget::Local(normalized), + ); + } + } +} + +impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { + fn visit_import_declaration(&mut self, declaration: &JavascriptImportDeclaration<'a>) { + let source = declaration.source.value.to_string(); + self.add_source(&source); + self.analysis + .import_declaration_ranges + .push(declaration.span.start as usize..declaration.span.end as usize); + let mut bindings = BTreeMap::new(); + for specifier in declaration.specifiers.iter().flatten() { + match specifier { + JavascriptImportDeclarationSpecifier::ImportSpecifier(specifier) => { + let local = specifier.local.name.to_string().to_ascii_lowercase(); + bindings.insert( + javascript_module_export_name(&specifier.imported), + local.clone(), + ); + if let Some(symbol_id) = specifier.local.symbol_id.get() { + self.import_symbols.push((local, symbol_id)); + } + } + JavascriptImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { + let local = specifier.local.name.to_string().to_ascii_lowercase(); + bindings.insert("default".to_string(), local.clone()); + if let Some(symbol_id) = specifier.local.symbol_id.get() { + self.import_symbols.push((local, symbol_id)); + } + } + JavascriptImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { + let local = specifier.local.name.to_string().to_ascii_lowercase(); + bindings.insert("*".to_string(), local.clone()); + if let Some(symbol_id) = specifier.local.symbol_id.get() { + self.import_symbols.push((local.clone(), symbol_id)); + } + } + } + } + self.analysis.imports.push((source, bindings)); + } + + fn visit_export_declaration(&mut self, declaration: &JavascriptExportDeclaration<'a>) { + self.add_exported_declaration(&declaration.declaration); + oxc_ast_visit::walk::walk_export_declaration(self, declaration); + } + + fn visit_export_named_declaration( + &mut self, + declaration: &JavascriptExportNamedDeclaration<'a>, + ) { + for specifier in &declaration.specifiers { + let exported = javascript_module_export_name(&specifier.exported); + let local = javascript_module_export_name(&specifier.local); + self.analysis.link_exports.insert( + exported.clone(), + JavascriptExportTarget::Local(local.clone()), + ); + self.analysis.exports.insert( + exported.to_ascii_lowercase(), + JavascriptExportTarget::Local(local.to_ascii_lowercase()), + ); + } + } + + fn visit_export_default_declaration( + &mut self, + declaration: &oxc_ast::ast::ExportDefaultDeclaration<'a>, + ) { + let local = match &declaration.declaration { + JavascriptExportDefaultDeclarationKind::FunctionDeclaration(function) => { + function.id.as_ref().map(|id| id.name.to_string()) + } + JavascriptExportDefaultDeclarationKind::ClassDeclaration(class) => { + class.id.as_ref().map(|id| id.name.to_string()) + } + declaration => declaration + .as_expression() + .and_then(JavascriptExpression::get_identifier_reference) + .map(|identifier| identifier.name.to_string()), + } + .unwrap_or_else(|| { + let local = "__agc_default_export__".to_string(); + let span = declaration.declaration.span(); + self.analysis + .synthetic_declarations + .insert(local.clone(), span.start as usize..span.end as usize); + local + }); + self.analysis.link_exports.insert( + "default".to_string(), + JavascriptExportTarget::Local(local.clone()), + ); + self.analysis.exports.insert( + "default".to_string(), + JavascriptExportTarget::Local(local.to_ascii_lowercase()), + ); + oxc_ast_visit::walk::walk_export_default_declaration(self, declaration); + } + + fn visit_export_from_declaration(&mut self, declaration: &JavascriptExportFromDeclaration<'a>) { + let source = declaration.source.value.to_string(); + self.add_source(&source); + for specifier in &declaration.specifiers { + let exported = javascript_module_export_name(&specifier.exported); + let imported = javascript_module_export_name(&specifier.local); + self.analysis.link_exports.insert( + exported.clone(), + JavascriptExportTarget::Reexport { + source: source.clone(), + imported: imported.clone(), + }, + ); + self.analysis.exports.insert( + exported.to_ascii_lowercase(), + JavascriptExportTarget::Reexport { + source: source.clone(), + imported: imported.to_ascii_lowercase(), + }, + ); + } + } + + fn visit_export_all_declaration(&mut self, declaration: &JavascriptExportAllDeclaration<'a>) { + let source = declaration.source.value.to_string(); + self.add_source(&source); + if let Some(exported) = &declaration.exported { + let exported = javascript_module_export_name(exported); + self.analysis.link_exports.insert( + exported.clone(), + JavascriptExportTarget::Reexport { + source: source.clone(), + imported: "*".to_string(), + }, + ); + self.analysis.exports.insert( + exported.to_ascii_lowercase(), + JavascriptExportTarget::Reexport { + source, + imported: "*".to_string(), + }, + ); + } else { + self.analysis.star_exports.push(source); + } + } + + fn visit_import_expression(&mut self, expression: &JavascriptImportExpression<'a>) { + if let oxc_ast::ast::Expression::StringLiteral(source) = &expression.source { + self.analysis + .dynamic_imports + .push((source.value.to_string(), expression.span.start as usize)); + } + oxc_ast_visit::walk::walk_import_expression(self, expression); + } +} + +struct JavascriptNamespaceUsageCollector<'a> { + scoping: &'a JavascriptScoping, + namespaces: BTreeMap, + members: BTreeMap>, +} + +impl JavascriptNamespaceUsageCollector<'_> { + fn record(&mut self, object: &JavascriptExpression<'_>, property: Option<&str>) { + let (JavascriptExpression::Identifier(identifier), Some(property)) = (object, property) + else { + return; + }; + let Some(reference_id) = identifier.reference_id.get() else { + return; + }; + let Some(symbol_id) = self.scoping.get_reference(reference_id).symbol_id() else { + return; + }; + if let Some(namespace) = self.namespaces.get(&symbol_id) { + self.members + .entry(namespace.clone()) + .or_default() + .insert(property.to_string()); + } + } +} + +impl<'a> VisitJavascript<'a> for JavascriptNamespaceUsageCollector<'_> { + fn visit_static_member_expression(&mut self, member: &JavascriptStaticMemberExpression<'a>) { + self.record(&member.object, Some(member.property.name.as_str())); + oxc_ast_visit::walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &JavascriptComputedMemberExpression<'a>, + ) { + self.record( + &member.object, + member.static_property_name().map(|name| name.as_str()), + ); + oxc_ast_visit::walk::walk_computed_member_expression(self, member); + } +} + +fn javascript_module_analysis(content: &str, is_module: bool) -> Option { + let allocator = JavascriptAllocator::default(); + let parsed = + JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return None; + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + if !semantic.diagnostics.is_empty() { + return None; + } + let mut collector = JavascriptModuleAnalysisCollector::default(); + collector.visit_program(&parsed.program); + let mut namespace_usage = JavascriptNamespaceUsageCollector { + scoping: semantic.semantic.scoping(), + namespaces: collector + .import_symbols + .iter() + .map(|(local, symbol_id)| (*symbol_id, local.clone())) + .collect(), + members: BTreeMap::new(), + }; + namespace_usage.visit_program(&parsed.program); + collector.analysis.namespace_import_members = namespace_usage.members; + for (local, symbol_id) in collector.import_symbols { + if !semantic + .semantic + .scoping() + .get_resolved_reference_ids(symbol_id) + .is_empty() + { + collector.analysis.used_import_locals.insert(local); + } + } + Some(collector.analysis) } fn reachable_local_javascript_module_sources( content: &str, allow_static_imports: bool, ) -> Vec { - let content = javascript_without_obvious_false_branches(content); - let content = javascript_without_obviously_unreachable_dynamic_imports(&content); - local_javascript_module_sources(&content, allow_static_imports) -} - -fn explicit_local_javascript_import_bindings( - content: &str, -) -> Vec<(String, std::collections::BTreeMap)> { - let tokens = javascript_lexical_tokens(content); - let mut imports = Vec::new(); - let mut index = 0usize; - while index < tokens.len() { - if !matches!(&tokens[index], JavascriptLexicalToken::Identifier(value) if value == "import") - || index > 0 && tokens[index - 1] == JavascriptLexicalToken::Punct('.') - { - index += 1; - continue; - } - match tokens.get(index + 1) { - Some(JavascriptLexicalToken::StringLiteral(source)) => { - imports.push((source.clone(), std::collections::BTreeMap::new())); - index += 2; - } - Some(JavascriptLexicalToken::Punct('(')) => index += 2, - _ => { - let mut names = std::collections::BTreeMap::new(); - let mut cursor = index + 1; - if let Some(JavascriptLexicalToken::Identifier(name)) = tokens.get(cursor) { - names.insert("default".to_string(), name.to_ascii_lowercase()); - } - if tokens.get(cursor) == Some(&JavascriptLexicalToken::Punct('{')) { - cursor += 1; - while cursor < tokens.len() - && tokens.get(cursor) != Some(&JavascriptLexicalToken::Punct('}')) - { - if let Some(JavascriptLexicalToken::Identifier(name)) = tokens.get(cursor) { - if name != "as" { - let exported = name.to_ascii_lowercase(); - let mut local = exported.clone(); - if matches!(tokens.get(cursor + 1), Some(JavascriptLexicalToken::Identifier(value)) if value == "as") - { - if let Some(JavascriptLexicalToken::Identifier(alias)) = - tokens.get(cursor + 2) - { - local = alias.to_ascii_lowercase(); - cursor += 2; - } - } - names.insert(exported, local); - } - } - cursor += 1; - } - } - let mut saw_from = false; - while cursor < tokens.len() { - match &tokens[cursor] { - JavascriptLexicalToken::Identifier(value) if value == "from" => { - saw_from = true; - } - JavascriptLexicalToken::StringLiteral(source) if saw_from => { - imports.push((source.clone(), names)); - break; - } - JavascriptLexicalToken::Punct(';') => break, - _ => {} - } - cursor += 1; - } - index = cursor.saturating_add(1); - } - } - } - imports + let Some(analysis) = javascript_module_analysis(content, allow_static_imports) else { + return Vec::new(); + }; + let ranges = named_javascript_function_ranges(content); + let mut sources = if allow_static_imports { + analysis.static_sources + } else { + Vec::new() + }; + sources.extend( + analysis + .dynamic_imports + .into_iter() + .filter_map(|(source, position)| { + javascript_position_is_reachable(content, &ranges, position).then_some(source) + }), + ); + sources } fn javascript_top_level_declarations(content: &str) -> std::collections::BTreeMap { let ranges = named_javascript_function_ranges(content); let mut declarations = std::collections::BTreeMap::new(); - for (name, start, end) in &ranges { + for range in &ranges { if !ranges .iter() - .any(|(_, outer_start, outer_end)| outer_start < start && end <= outer_end) + .any(|outer| outer.start < range.start && range.end <= outer.end) { - declarations.insert(name.clone(), content[*start..*end].to_string()); + declarations.insert( + range.name.clone(), + content[range.start..range.end].to_string(), + ); } } for keyword in ["const", "let", "var"] { @@ -3605,31 +3872,32 @@ fn javascript_top_level_declarations(content: &str) -> std::collections::BTreeMa declarations } -fn javascript_module_exports_name(content: &str, expected: &str) -> bool { - let tokens = javascript_lexical_tokens(content); - tokens.windows(3).any(|window| { - matches!(&window[0], JavascriptLexicalToken::Identifier(value) if value == "export") - && (matches!(&window[1], JavascriptLexicalToken::Identifier(kind) if matches!(kind.as_str(), "function" | "const" | "let" | "var" | "class")) - && matches!(&window[2], JavascriptLexicalToken::Identifier(name) if name == expected)) - }) || tokens.iter().enumerate().any(|(index, token)| { - matches!(token, JavascriptLexicalToken::Identifier(value) if value == "export") - && tokens.get(index + 1) == Some(&JavascriptLexicalToken::Punct('{')) - && tokens[index + 2..] - .iter() - .take_while(|token| **token != JavascriptLexicalToken::Punct('}')) - .any(|token| matches!(token, JavascriptLexicalToken::Identifier(name) if name == expected)) - }) -} - fn javascript_module_binding_projection(content: &str, imported: &BTreeSet) -> String { - let declarations = javascript_top_level_declarations(content); - let mut pending = imported.iter().cloned().collect::>(); + let Some(analysis) = javascript_module_analysis(content, true) else { + return String::new(); + }; + let mut declarations = javascript_top_level_declarations(content); + declarations.extend( + analysis + .synthetic_declarations + .iter() + .filter_map(|(name, range)| { + content + .get(range.clone()) + .map(|expression| (name.clone(), format!("const {name} = {expression};"))) + }), + ); + let mut pending = imported + .iter() + .filter_map(|exported| match analysis.exports.get(exported) { + Some(JavascriptExportTarget::Local(local)) => Some(local.clone()), + _ => None, + }) + .collect::>(); let mut included = BTreeSet::new(); let mut projection = String::new(); while let Some(name) = pending.pop() { - if !included.insert(name.clone()) - || !javascript_module_exports_name(content, &name) && imported.contains(&name) - { + if !included.insert(name.clone()) { continue; } let Some(declaration) = declarations.get(&name) else { @@ -3673,6 +3941,88 @@ fn replace_javascript_identifier(content: &str, from: &str, to: &str) -> String output } +fn replace_javascript_namespace_member( + content: &str, + namespace: &str, + member: &str, + replacement: &str, +) -> String { + let bytes = content.as_bytes(); + let mut output = String::with_capacity(content.len()); + let mut cursor = 0usize; + while let Some(offset) = content[cursor..].find(namespace) { + let start = cursor + offset; + let namespace_end = start + namespace.len(); + let has_namespace_boundary = (start == 0 + || !is_ascii_word_byte(bytes[start - 1]) && bytes[start - 1] != b'$') + && bytes + .get(namespace_end) + .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$'); + let mut access = namespace_end; + while bytes.get(access).is_some_and(u8::is_ascii_whitespace) { + access += 1; + } + let member_range = if bytes.get(access) == Some(&b'.') { + let mut member_start = access + 1; + while bytes.get(member_start).is_some_and(u8::is_ascii_whitespace) { + member_start += 1; + } + Some((member_start, member_start.saturating_add(member.len()))) + } else { + if bytes.get(access..access.saturating_add(2)) == Some(b"?.") { + access += 2; + } + if bytes.get(access) != Some(&b'[') { + None + } else { + let mut quote_at = access + 1; + while bytes.get(quote_at).is_some_and(u8::is_ascii_whitespace) { + quote_at += 1; + } + let quote = bytes.get(quote_at).copied(); + if !matches!(quote, Some(b'\'' | b'"')) { + None + } else { + let member_start = quote_at + 1; + let member_end = member_start.saturating_add(member.len()); + let mut bracket_at = member_end + 1; + while bytes.get(bracket_at).is_some_and(u8::is_ascii_whitespace) { + bracket_at += 1; + } + (bytes.get(member_end).copied() == quote + && bytes.get(bracket_at) == Some(&b']')) + .then_some((member_start, bracket_at + 1)) + } + } + }; + let Some((member_start, replacement_end)) = member_range else { + output.push_str(&content[cursor..namespace_end]); + cursor = namespace_end; + continue; + }; + let member_end = member_start.saturating_add(member.len()); + let has_member_boundary = content + .get(member_start..member_end) + .is_some_and(|value| value == member) + && bytes + .get(member_end) + .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$'); + output.push_str(&content[cursor..start]); + if has_namespace_boundary + && has_member_boundary + && !position_is_inside_javascript_string(content, start) + { + output.push_str(replacement); + cursor = replacement_end; + } else { + output.push_str(namespace); + cursor = namespace_end; + } + } + output.push_str(&content[cursor..]); + output +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(in crate::agent) struct ExternalGameplayJavascript { classic_global: String, @@ -3686,6 +4036,131 @@ impl ExternalGameplayJavascript { } } +fn normalize_javascript_module_analysis_sources( + importer_path: &str, + mut analysis: JavascriptModuleAnalysis, +) -> Result { + let normalize = |source: &str| { + local_gameplay_script_path_from(Some(importer_path), source) + .ok_or_else(|| format!("自主构建模块依赖路径不受支持:{importer_path} -> {source}")) + }; + for source in &mut analysis.static_sources { + *source = normalize(source)?; + } + for (source, _) in &mut analysis.imports { + *source = normalize(source)?; + } + for target in analysis.exports.values_mut() { + if let JavascriptExportTarget::Reexport { source, .. } = target { + *source = normalize(source)?; + } + } + for target in analysis.link_exports.values_mut() { + if let JavascriptExportTarget::Reexport { source, .. } = target { + *source = normalize(source)?; + } + } + for source in &mut analysis.star_exports { + *source = normalize(source)?; + } + Ok(analysis) +} + +fn javascript_module_resolves_export( + module: &str, + expected: &str, + analyses: &BTreeMap, + visiting: &mut BTreeSet<(String, String)>, +) -> bool { + if expected == "*" { + return analyses.contains_key(module); + } + javascript_module_export_origin(module, expected, analyses, visiting).is_some() +} + +fn javascript_module_export_origin( + module: &str, + expected: &str, + analyses: &BTreeMap, + visiting: &mut BTreeSet<(String, String)>, +) -> Option<(String, String)> { + let key = (module.to_string(), expected.to_string()); + if !visiting.insert(key.clone()) { + return None; + } + let Some(analysis) = analyses.get(module) else { + visiting.remove(&key); + return None; + }; + let origin = match analysis.link_exports.get(expected) { + Some(JavascriptExportTarget::Local(_)) => Some((module.to_string(), expected.to_string())), + Some(JavascriptExportTarget::Reexport { source, imported }) if imported == "*" => analyses + .contains_key(source) + .then(|| (source.clone(), "*".to_string())), + Some(JavascriptExportTarget::Reexport { source, imported }) => { + javascript_module_export_origin(source, imported, analyses, visiting) + } + None if expected == "default" => None, + None => { + let candidates = analysis + .star_exports + .iter() + .filter_map(|source| { + javascript_module_export_origin(source, expected, analyses, visiting) + }) + .collect::>(); + (candidates.len() == 1).then(|| candidates.into_iter().next().unwrap()) + } + }; + visiting.remove(&key); + origin +} + +fn validate_javascript_module_links( + analyses: &BTreeMap, +) -> Result<(), String> { + for (module, analysis) in analyses { + for (dependency, bindings) in &analysis.imports { + for exported in bindings.keys() { + if !javascript_module_resolves_export( + dependency, + exported, + analyses, + &mut BTreeSet::new(), + ) { + return Err(format!( + "自主构建模块链接缺少导出:{module} -> {dependency}::{exported}" + )); + } + } + } + for target in analysis.link_exports.values() { + if let JavascriptExportTarget::Reexport { source, imported } = target { + if !javascript_module_resolves_export( + source, + imported, + analyses, + &mut BTreeSet::new(), + ) { + return Err(format!( + "自主构建模块重导出链接无效:{module} -> {source}::{imported}" + )); + } + } + } + if let Some(missing) = analysis + .star_exports + .iter() + .find(|dependency| !analyses.contains_key(*dependency)) + { + return Err(format!( + "自主构建模块星号重导出缺少依赖:{module} -> {missing}" + )); + } + } + Ok(()) +} + pub(in crate::agent) fn read_external_gameplay_javascript_at( root: &Path, html: &str, @@ -3699,6 +4174,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let mut total_bytes = 0_u64; let mut pending = std::collections::VecDeque::new(); let mut module_contents = std::collections::BTreeMap::::new(); + let mut module_analyses = BTreeMap::::new(); let mut module_bindings = std::collections::BTreeMap::< String, Vec<(String, std::collections::BTreeMap)>, @@ -3724,21 +4200,12 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .enumerate() { let inline_id = format!("inline-module:{index}"); + let analysis = javascript_module_analysis(&inline_module, true) + .ok_or_else(|| format!("自主构建内联模块不是有效 JavaScript:{inline_id}"))?; + let analysis = normalize_javascript_module_analysis_sources("game/index.html", analysis)?; module_contents.insert(inline_id.clone(), inline_module.to_ascii_lowercase()); - module_bindings.entry(inline_id.clone()).or_default(); - for (imported_source, names) in explicit_local_javascript_import_bindings(&inline_module) { - let imported_path = local_gameplay_script_path_from( - Some("game/index.html"), - &imported_source, - ) - .ok_or_else(|| { - format!("自主构建内联模块依赖路径不受支持:game/index.html -> {imported_source}") - })?; - module_bindings - .entry(inline_id.clone()) - .or_default() - .push((imported_path, names)); - } + module_bindings.insert(inline_id.clone(), analysis.imports.clone()); + module_analyses.insert(inline_id.clone(), analysis); for imported_source in reachable_local_javascript_module_sources(&inline_module, true) { let imported_path = local_gameplay_script_path_from( Some("game/index.html"), @@ -3788,20 +4255,18 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( return Err("自主构建外部脚本累计超过 2 MiB".to_string()); } total_bytes += script_bytes; - if is_module { - for (imported_source, names) in explicit_local_javascript_import_bindings(&script) { - let imported_path = - local_gameplay_script_path_from(Some(&local_path), &imported_source) - .ok_or_else(|| { - format!( - "自主构建模块依赖路径不受支持:{local_path} -> {imported_source}" - ) - })?; - module_bindings - .entry(local_path.clone()) - .or_default() - .push((imported_path, names)); + if !javascript_is_syntactically_valid(&script, is_module) { + if is_module { + return Err(format!("自主构建外部脚本不是有效 JavaScript:{local_path}")); } + continue; + } + if is_module { + let analysis = javascript_module_analysis(&script, true) + .ok_or_else(|| format!("自主构建外部模块不是有效 JavaScript:{local_path}"))?; + let analysis = normalize_javascript_module_analysis_sources(&local_path, analysis)?; + module_bindings.insert(local_path.clone(), analysis.imports.clone()); + module_analyses.insert(local_path.clone(), analysis); } for imported_source in reachable_local_javascript_module_sources(&script, is_module) { let imported_path = @@ -3810,15 +4275,14 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( )?; pending.push_back((imported_path, true)); } - let script = script.to_ascii_lowercase(); if is_module { - module_contents.insert(local_path.clone(), script); - module_bindings.entry(local_path).or_default(); + module_contents.insert(local_path.clone(), script.to_ascii_lowercase()); } else { output.classic_global.push_str(&script); output.classic_global.push('\n'); } } + validate_javascript_module_links(&module_analyses)?; output .module_units .extend(module_contents.values().cloned()); @@ -3826,8 +4290,17 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let Some(importer_content) = module_contents.get(&importer) else { continue; }; - let importer_tokens = javascript_lexical_tokens(importer_content); - let mut unit = importer_content.clone(); + let Some(importer_analysis) = module_analyses.get(&importer) else { + continue; + }; + let mut unit_bytes = importer_content.as_bytes().to_vec(); + if let Some(analysis) = module_analyses.get(&importer) { + for range in &analysis.import_declaration_ranges { + unit_bytes[range.clone()].fill(b' '); + } + } + let mut unit = String::from_utf8(unit_bytes) + .expect("masking parsed JavaScript imports preserves UTF-8"); let mut added_projection = false; for (dependency, bindings) in dependencies { if bindings.is_empty() { @@ -3835,33 +4308,98 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } let used_bindings = bindings .into_iter() - .filter_map(|(exported, local)| { - (importer_tokens - .iter() - .filter(|token| matches!(token, JavascriptLexicalToken::Identifier(value) if value == &local)) - .count() - > 1) - .then_some((exported, local)) + .flat_map(|(exported, local)| { + if exported == "*" { + importer_analysis + .namespace_import_members + .get(&local) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|member| (member.clone(), member, Some(local.clone()))) + .collect::>() + } else if importer_analysis.used_import_locals.contains(&local) { + vec![(exported, local, None)] + } else { + Vec::new() + } }) .collect::>(); - let used_names = used_bindings - .iter() - .map(|(exported, _)| exported.clone()) - .collect::>(); - let Some(dependency_content) = module_contents.get(&dependency) else { - continue; - }; - let mut projection = - javascript_module_binding_projection(dependency_content, &used_names); - for (exported, local) in used_bindings { - if exported != local { - projection = replace_javascript_identifier(&projection, &exported, &local); + let mut origins = BTreeMap::)>>::new(); + for (exported, local, namespace) in used_bindings { + if let Some((origin, origin_export)) = javascript_module_export_origin( + &dependency, + &exported, + &module_analyses, + &mut BTreeSet::new(), + ) { + if origin_export == "*" { + for member in importer_analysis + .namespace_import_members + .get(&local) + .cloned() + .unwrap_or_default() + { + if let Some((member_origin, member_export)) = + javascript_module_export_origin( + &origin, + &member, + &module_analyses, + &mut BTreeSet::new(), + ) + { + origins.entry(member_origin).or_default().push(( + member_export, + member, + Some(local.clone()), + )); + } + } + } else { + origins + .entry(origin) + .or_default() + .push((origin_export, local, namespace)); + } } } - if !projection.is_empty() { - unit.push('\n'); - unit.push_str(&projection); - added_projection = true; + for (origin, bindings) in origins { + let Some(origin_content) = module_contents.get(&origin) else { + continue; + }; + let used_names = bindings + .iter() + .map(|(exported, _, _)| exported.to_ascii_lowercase()) + .collect::>(); + let mut projection = + javascript_module_binding_projection(origin_content, &used_names); + for (exported, local, namespace) in bindings { + let origin_local = module_analyses + .get(&origin) + .and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase())) + .and_then(|target| match target { + JavascriptExportTarget::Local(name) => Some(name.as_str()), + JavascriptExportTarget::Reexport { .. } => None, + }) + .unwrap_or(exported.as_str()); + if origin_local != local { + projection = + replace_javascript_identifier(&projection, origin_local, &local); + } + if let Some(namespace) = namespace { + unit = replace_javascript_namespace_member( + &unit, + &namespace, + &exported.to_ascii_lowercase(), + &local, + ); + } + } + if !projection.is_empty() { + unit.push('\n'); + unit.push_str(&projection); + added_projection = true; + } } } if added_projection { @@ -3873,14 +4411,14 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( fn reachable_named_javascript_functions<'a>( content: &'a str, - ranges: &'a [(String, usize, usize)], + ranges: &'a [NamedJavascriptFunctionRange], name_markers: &[&str], ) -> Vec<(&'a str, &'a str)> { ranges .iter() .enumerate() - .filter_map(|(index, (name, start, end))| { - let normalized_name = name.to_ascii_lowercase(); + .filter_map(|(index, range)| { + let normalized_name = range.name.to_ascii_lowercase(); (name_markers .iter() .any(|marker| normalized_name.contains(marker)) @@ -3890,24 +4428,24 @@ fn reachable_named_javascript_functions<'a>( index, &mut BTreeSet::new(), )) - .then_some((name.as_str(), &content[*start..*end])) + .then_some((range.name.as_str(), &content[range.start..range.end])) }) .collect() } fn named_javascript_functions<'a>( content: &'a str, - ranges: &'a [(String, usize, usize)], + ranges: &'a [NamedJavascriptFunctionRange], name_markers: &[&str], ) -> Vec<(&'a str, &'a str)> { ranges .iter() - .filter_map(|(name, start, end)| { - let normalized_name = name.to_ascii_lowercase(); + .filter_map(|range| { + let normalized_name = range.name.to_ascii_lowercase(); name_markers .iter() .any(|marker| normalized_name.contains(marker)) - .then_some((name.as_str(), &content[*start..*end])) + .then_some((range.name.as_str(), &content[range.start..range.end])) }) .collect() } @@ -3919,7 +4457,68 @@ fn compact_javascript(content: &str) -> String { .collect() } +fn javascript_termination_is_single_statement_guard(content: &str, start: usize) -> bool { + let prefix = content[..start].trim_end(); + if prefix.ends_with("else") { + return true; + } + let Some(close) = prefix + .len() + .checked_sub(1) + .filter(|index| prefix.as_bytes()[*index] == b')') + else { + return false; + }; + let mut depth = 0usize; + let mut open = None; + for index in (0..=close).rev() { + match prefix.as_bytes()[index] { + b')' => depth += 1, + b'(' => { + depth = depth.saturating_sub(1); + if depth == 0 { + open = Some(index); + break; + } + } + _ => {} + } + } + open.and_then(|open| identifier_before(prefix, open)) + .is_some_and(|keyword| matches!(keyword.as_str(), "if" | "while" | "for" | "with")) +} + +fn javascript_top_level_unconditional_termination_from_ast(content: &str) -> Option> { + let body_start = content.find('{')?; + let body_end = matching_javascript_brace(content, body_start)?; + let prefix = "async function* __tetris_gate__(){"; + let wrapped = format!("{prefix}{}\n}}", &content[body_start + 1..body_end]); + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, &wrapped, javascript_source_type(false)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return None; + } + let function = match parsed.program.body.first()? { + JavascriptStatement::FunctionDeclaration(function) => function, + _ => return None, + }; + let body = function.body.as_ref()?; + let termination = body.statements.iter().find(|statement| { + matches!( + statement, + JavascriptStatement::ReturnStatement(_) | JavascriptStatement::ThrowStatement(_) + ) + }); + Some( + termination + .map(|statement| body_start + 1 + statement.span().start as usize - prefix.len()), + ) +} + fn javascript_before_top_level_unconditional_termination(content: &str) -> &str { + if let Some(termination) = javascript_top_level_unconditional_termination_from_ast(content) { + return termination.map_or(content, |start| &content[..start]); + } let bytes = content.as_bytes(); let Some(body_start) = content.find('{') else { return content; @@ -3973,6 +4572,7 @@ fn javascript_before_top_level_unconditional_termination(content: &str) -> &str && bytes .get(cursor + keyword.len()) .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$') + && !javascript_termination_is_single_statement_guard(content, cursor) { return &content[..cursor]; } @@ -4847,6 +5447,9 @@ fn tetris_executable_semantics_gap( ) -> Option<&'static str> { let mut classic_global = executable_javascript_from_html(content); classic_global.push_str(&external_javascript.classic_global); + if !javascript_is_syntactically_valid(&classic_global, false) { + classic_global.clear(); + } let mut units = vec![classic_global]; units.extend( executable_inline_module_javascript_units_from_html(content) @@ -4865,7 +5468,7 @@ fn tetris_executable_semantics_gap( let mut best_gap = "board-state"; let mut complete_unit = false; for unit in units { - match tetris_executable_unit_semantics_gap(&unit) { + match tetris_executable_unit_semantics_gap(&unit.to_ascii_lowercase()) { None => complete_unit = true, Some(gap) if gap_rank(gap) > gap_rank(best_gap) => best_gap = gap, Some(_) => {} @@ -4886,9 +5489,10 @@ fn tetris_executable_semantics_gap( "clearedlines", "occupiedcells", ]; + let normalized_content = content.to_ascii_lowercase(); if telemetry_fields .iter() - .any(|field| !content.contains(field) && !external_javascript.contains(field)) + .any(|field| !normalized_content.contains(field) && !external_javascript.contains(field)) { return Some("browser-tetris-state"); } @@ -4904,8 +5508,13 @@ pub(in crate::agent) fn inherited_gameplay_semantics_gap_with_external_javascrip let Ok(html) = std::str::from_utf8(html) else { return Some("invalid-html-utf8".to_string()); }; - let content = strip_art_reference_comments(html).to_ascii_lowercase(); - let contains_any = |markers: &[&str]| markers.iter().any(|marker| content.contains(marker)); + let content = strip_art_reference_comments(html); + let normalized_content = content.to_ascii_lowercase(); + let contains_any = |markers: &[&str]| { + markers + .iter() + .any(|marker| normalized_content.contains(marker)) + }; let missing = match gameplay { AutonomousInheritedGameplaySemantics::Tetris => { if !contains_any(&["俄罗斯方块", "tetromino", "tetris"]) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index c3d60e674..7a7139ccb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -1566,6 +1566,37 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { None, "the legacy JavaScript language value remains executable", ); + let explicit_javascript_type_overrides_language = valid.replacen( + "", + "", + ); + let shadowed_import = read_external_gameplay_javascript_at(root, &shadowed_import_html) + .expect("read a module whose import is only shadowed by a parameter"); + assert!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + shadowed_import_html.as_bytes(), + &shadowed_import, + ) + .is_some(), + "a shadowed parameter reference must not project an otherwise unused import", + ); + + let default_scope_a = gameplay[..split_at] + .replace("let board=", "export let board=") + .replace("let current=", "export let current=") + .replace( + "function rotatePiece()", + "export default function rotatePiece()", + ) + .replace("function clearLines()", "export function clearLines()"); + fs::write(root.join("game/scope-a.mjs"), default_scope_a) + .expect("write default-exported gameplay bindings"); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "import rotatePiece, {{ board, current, clearLines as clearRows }} from './scope-a.mjs';\nrotatePiece();\n{}", + gameplay[split_at..].replace("clearLines();", "clearRows();") + ), + ) + .expect("write default-import gameplay consumer"); + let default_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read modules joined by a default import"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &default_modules, + ), + None, + "a named default export must retain its declaration through import projection", + ); + + let anonymous_default_scope_a = gameplay[..split_at] + .replace("let board=", "export let board=") + .replace("let current=", "export let current=") + .replace("function rotatePiece()", "export default function()") + .replace("function clearLines()", "export function clearLines()"); + fs::write(root.join("game/scope-a.mjs"), anonymous_default_scope_a) + .expect("write anonymous default-exported gameplay bindings"); + let anonymous_default_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read modules joined by an anonymous default import"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &anonymous_default_modules, + ), + None, + "an anonymous default function must receive the importer binding during projection", + ); + + let namespace_scope_a = gameplay[..split_at] + .replace("let board=", "export let board=") + .replace("let current=", "export let current=") + .replace("function rotatePiece()", "export function rotatePiece()") + .replace("function clearLines()", "export function clearLines()"); + fs::write(root.join("game/scope-a.mjs"), namespace_scope_a) + .expect("write namespace-exported gameplay bindings"); + let namespace_consumer = gameplay[split_at..] + .replace("board", "gameplay.board") + .replace("current", "gameplay.current") + .replace("clearLines", "gameplay.clearLines"); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "import * as gameplay from './scope-a.mjs';\ngameplay.rotatePiece();\n{namespace_consumer}" + ), + ) + .expect("write namespace-import gameplay consumer"); + let namespace_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read modules joined by a namespace import"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &namespace_modules, + ), + None, + "namespace member references must retain exported gameplay semantics", + ); + + fs::write( + root.join("game/namespace-bridge.mjs"), + "export * as gameplay from './scope-a.mjs';", + ) + .expect("write namespace re-export bridge"); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "import {{ gameplay }} from './namespace-bridge.mjs';\ngameplay['rotatePiece']();\n{namespace_consumer}" + ), + ) + .expect("write namespace re-export consumer"); + let namespace_reexport_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read modules joined by a namespace re-export"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &namespace_reexport_modules, + ), + None, + "a namespace re-export must preserve statically referenced member bindings", + ); + + fs::write( + root.join("game/invalid-link.mjs"), + "import { missingGameplay } from './scope-a.mjs'; missingGameplay();", + ) + .expect("write invalid module link"); + let invalid_link_html = external_html.replace( + "", + "", + ); + let invalid_link_error = read_external_gameplay_javascript_at(root, &invalid_link_html) + .expect_err("an imported name missing from the dependency must fail module linking"); + assert!( + invalid_link_error.contains("missingGameplay") + || invalid_link_error.contains("missinggameplay"), + "unexpected module-link error: {invalid_link_error}", + ); + + fs::write( + root.join("game/case-sensitive-export.mjs"), + "export const GameplayState = 1;", + ) + .expect("write case-sensitive export"); + fs::write( + root.join("game/case-mismatch-link.mjs"), + "import { gameplayState } from './case-sensitive-export.mjs'; gameplayState;", + ) + .expect("write case-mismatched import"); + let case_mismatch_html = external_html.replace( + "", + "", + ); + assert!( + read_external_gameplay_javascript_at(root, &case_mismatch_html) + .expect_err("ESM exported names must remain case-sensitive during linking") + .contains("gameplayState"), + ); + + fs::write(root.join("game/star-a.mjs"), "export const duplicate=1;") + .expect("write first star export"); + fs::write(root.join("game/star-b.mjs"), "export const duplicate=2;") + .expect("write second star export"); + fs::write( + root.join("game/star-bridge.mjs"), + "export * from './star-a.mjs'; export * from './star-b.mjs';", + ) + .expect("write ambiguous star bridge"); + fs::write( + root.join("game/ambiguous-link.mjs"), + "import {duplicate} from './star-bridge.mjs'; duplicate;", + ) + .expect("write ambiguous import"); + let ambiguous_link_html = external_html.replace( + "", + "", + ); + assert!( + read_external_gameplay_javascript_at(root, &ambiguous_link_html) + .expect_err("ambiguous star exports must fail ESM linking") + .contains("duplicate"), + ); + + fs::write( + root.join("game/default-only.mjs"), + "export default function rotatePiece() {}", + ) + .expect("write default-only export"); + fs::write( + root.join("game/star-default-bridge.mjs"), + "export * from './default-only.mjs';", + ) + .expect("write star default bridge"); + fs::write( + root.join("game/missing-default-link.mjs"), + "import rotatePiece from './star-default-bridge.mjs'; rotatePiece();", + ) + .expect("write invalid default import through star export"); + let missing_default_html = external_html.replace( + "", + "", + ); + assert!( + read_external_gameplay_javascript_at(root, &missing_default_html) + .expect_err("export star must not re-export a dependency's default binding") + .contains("default"), + ); + + fs::write( + root.join("game/invalid-syntax.mjs"), + "export function broken( {", + ) + .expect("write invalid JavaScript module"); + let invalid_syntax_html = external_html.replace( + "", + "", + ); + assert!( + read_external_gameplay_javascript_at(root, &invalid_syntax_html) + .expect_err("a syntactically invalid module must fail closed") + .contains("JavaScript"), + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs index 933ed4560..9d85205fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs @@ -24,12 +24,12 @@ pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SA pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = concat!( - "primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-post-click-dispatch-microtask-sequence-advance\n", + "primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-host-completed-cdp-click-gesture-sequence-advance\n", "tetris-primary-action=same-piece-rotation-change\n", "tetris-start-opportunity=same-piece-gravity-row-or-semantic-lock-progress\n", "tetris-post-action=probe-before-gameplay-to-new-piece-lock-board-and-line-check-progress\n", "tetris-restart=board-counters-reset\n", - "restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-post-click-dispatch-microtask-sequence-advance" + "restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-host-completed-cdp-click-gesture-sequence-advance" ); #[derive(Clone, Debug, Deserialize)] @@ -79,7 +79,6 @@ fn generic_action_sequence_probe_script( let stateObserver = null; const inputEventTypes = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']; let synchronousAfterState = null; - let clickObserved = false; const cleanup = () => {{ if (timeoutId !== null) clearTimeout(timeoutId); if (stateObserver !== null) stateObserver.disconnect(); @@ -114,7 +113,6 @@ fn generic_action_sequence_probe_script( if (!baselineCaptured) {{ baselineCaptured = true; }} - if (event.type === 'click') clickObserved = true; }} stateObserver = new MutationObserver(() => {{ const activeEvent = globalThis.event; @@ -134,9 +132,9 @@ fn generic_action_sequence_probe_script( window.addEventListener(type, observeInput, {{ capture: true }}); }} globalThis[finishKey] = () => finish({{ - status: baselineCaptured && inputEventObserved && clickObserved ? 'completed' : 'missing-input-baseline', + status: baselineCaptured && inputEventObserved ? 'completed' : 'missing-input-baseline', beforeState, - afterState: baselineCaptured && inputEventObserved && clickObserved + afterState: baselineCaptured && inputEventObserved ? (synchronousAfterState === null ? beforeState : synchronousAfterState) : null, }}); @@ -1184,16 +1182,14 @@ mod tests { assert!(script.contains("new MutationObserver")); assert!(script.contains("const activeEvent = globalThis.event")); assert!(script.contains("window.addEventListener(type, observeInput")); - assert!(script.contains("if (event.type === 'click') clickObserved = true")); assert!(script.contains("synchronousAfterState = readState()")); assert!(script.contains("globalThis[finishKey] = () => finish")); assert!( script.contains("synchronousAfterState === null ? beforeState : synchronousAfterState") ); assert!(script.contains("content.length <= 131072")); - assert!(script.contains( - "status: baselineCaptured && inputEventObserved && clickObserved ? 'completed'" - )); + assert!(script.contains("status: baselineCaptured && inputEventObserved ? 'completed'")); + assert!(!script.contains("clickObserved")); assert!(script.contains("finishKey")); assert!(script.contains("resolve(value)")); assert!(!script.contains("__genarrativeGenericActionSequenceProbe")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 00897bd8a..309a9456b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); assert_eq!( generic, - "fc145c8d8af2801e29114c2ce6e4a3a5a6940617c53fd79803103b539b5ca48b" + "e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d" ); assert_eq!( tetris, - "ea42a14d583426da748bd1aa6ed1d2c2427d20451161fbe5ec32e97bc0eed214" + "b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c" ); assert_eq!( lane, @@ -1265,7 +1265,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() { #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] -async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_flow() { +async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() { use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc; @@ -1309,29 +1309,25 @@ async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_ }); const primary = document.querySelector('[data-playtest-id="primary-action"]'); const restart = document.querySelector('[data-playtest-id="restart"]'); - let restartReachedDocumentBubble = false; window.addEventListener('pointerdown', (event) => { if (event.isTrusted && event.target === primary && state.phase === 'playing') { advance(() => { state.score += 1; }); } }, true); - primary.addEventListener('click', (event) => { - if (event.isTrusted) event.stopPropagation(); - }); - document.addEventListener('click', (event) => { - if (event.isTrusted && event.target === restart) { - restartReachedDocumentBubble = true; - } - }); window.addEventListener('click', (event) => { - if (event.isTrusted && event.target === restart && restartReachedDocumentBubble) { + if (!event.isTrusted) return; + if (event.target === primary) { + event.stopImmediatePropagation(); + return; + } + if (event.target === restart) { advance(() => { state.phase = 'ready'; state.score = 0; }); - restartReachedDocumentBubble = false; + event.stopImmediatePropagation(); } - }); + }, true); })(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a5b112ad9..49118f18a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5973,3 +5973,10 @@ - MCP 边界:动态 MCP 函数的 `arguments.input` 必须在创建 durable pending 前按当前 catalog 的原始 `inputSchema` 本地校验;native parser 负责把错误归类为可修复的 arguments-schema,统一 enrichment 覆盖 legacy 兼容解析并把错误接回同一 repair 链。实际 MCP 调用前还必须按当前 catalog schema 重验一次,阻断升级前遗留的 schema 外 durable pending。校验器关闭 HTTP 与文件解析能力,外部 `$ref`、无效 schema、required/type/enum/additionalProperties 不匹配全部失败关闭,错误不得回显参数或 schema 私密值。 - Native Prompt:Provider 请求只描述实际广告的 `update_agent_plan`、动作函数、`respond_to_user` 和动态 MCP 函数;内部 `mcp.call` wrapper、`thinkingSummary/planUpdate` envelope、空 actions 以及无 function-tools 文本回退不再进入实时 Prompt。required-nullable 字段未使用时显式传 JSON `null`,空对象 input 只允许权威空 schema 工具。 - Supervisor 合同:配置 External Editor API Key 时,`art-asset-plan` 的 owner 产物统一为 `assets/manifest.art.json` 与 `assets/art-spritesheet.png`;未配置 Key 时只要求 `assets/manifest.art.json`,不得伪造或要求三个 PNG。版本化 Bundle 的视觉合同和 playbook 不得给出互斥 expectedArtifacts。 + +## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口 + +- 图集事务:九文件旧合同在写 `prepared` 前必须全部持有可信源句柄并整体复读;Unix 事务控制文件统一通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW` 操作,Windows 目录句柄拒绝 delete sharing 并用文件身份复核。恢复目标从 canonical 项目根逐组件拒绝 symlink / reparse point,CAS 安装后的任何清理错误都按实际 canonical 状态把当前项纳入逆序回滚,不能留下新旧混合合同。 +- JavaScript / ESM:Tetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、re-export 与缺失导出链接不再由字符串扫描猜测;HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。 +- 浏览器因果:状态证据仍只冻结 trusted input listener 及其点击派生微任务内的变化;完整手势身份改由宿主在成功完成 Chromium 元素鼠标输入后调用隔离世界 finish。更早注册的 `window` capture listener 即使调用 `stopImmediatePropagation()` 也不能阻断探针自身的完成身份,页面脚本不能伪造 host finish,RAF / timer 继续不计入动作结果。 +- 验证边界:Linux 定向回归覆盖目录相对读写与清理、祖先 symlink、CAS 安装后错误、九文件混合快照、Tetris AST 反例和七项真实 Chrome generic 试玩。Windows cfg 代码必须继续在真实 Windows CI / 发布构建验证;本地缺少 MinGW C compiler 时,安装了 Rust target 也不能把交叉 `cargo check` 失败误报为源码失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index acce5d5e1..6d88c4dcf 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -827,9 +827,9 @@ game-project/ - 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。 - game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 - `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 -- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须通过可信有界双读快照到 `.agent/runtime` 私有事务目录并写 `prepared` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;marker、journal 和快照必须由跨平台不跟随 symlink / reparse point 的句柄读取,拒绝同长度并发改写。恢复开始时锚定事务目录句柄,每次控制文件读取前后都复核目录身份;随后在同一项目写锁内完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理。每项写入前重新校验目标与父目录,晚序目标竞态或任一末尾快照损坏时逆序撤销本轮已应用项;回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 +- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须先全部打开可信源句柄,完成有界双读,并在 journal 持久化后、发布 `prepared` marker 前再次整体复读与身份校验;任一文件在九文件捕获窗口变化都失败关闭,不能形成跨版本混合快照。事务控制文件在 Unix 通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW` 读取和清理;Windows 以拒绝 delete sharing 的目录句柄阻止 rename / replacement,并以句柄文件身份复核子文件,不能退回 pathname 前后检查。恢复目标必须从 canonical 项目根开始逐组件检查全部祖先,任何中间 symlink / reparse point 都拒绝。CAS 安装先把目标字节持久化到同目录私有 staging,再以不覆盖的原子 hard-link / rename 切换;即使 canonical 已安装后清理 backup/staging 才报错,也必须把当前项纳入同轮逆序回滚。Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复继续在同一项目写锁内完整验证全部 journal 条目和快照、形成内存计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理。回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 - 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。 -- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并按 `type / language / nomodule` 判断可执行脚本;它忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达匿名或命名函数、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于未被局部变量或函数参数遮蔽的正式棋盘。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,只按显式 export/import binding 传递语义,side-effect import 不共享局部词法绑定;ASI 换行和 template `${...}` 内真实表达式仍参与依赖解析,文件按去重数量并受 256 文件、累计 2 MiB 上限约束,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 同步产生的最后状态,并在 CDP 点击返回后收口冻结值;这样后注册的同步 click listener 会计入,RAF / timer 不会污染证据。受测页面不能通过全局变量改写证据,既有 capture-phase、stopPropagation 或 window bubble 处理器也不会被误判;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 +- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并在 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据;filter / splice 消行仍必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 与传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 - 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。