From a34b46603347d24e85350a5910b422bfcea98d5f Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:30:19 +0200 Subject: [PATCH 1/9] fix: harden Metalink parsing and add CI --- .github/workflows/plugin-ci.yml | 13 ++ Cargo.lock | 341 +++++++++++++++++--------------- Cargo.toml | 6 +- README.md | 7 + plugin.toml | 2 +- rust-toolchain.toml | 5 + src/ccf.rs | 61 ++++-- src/dlc.rs | 78 +++++--- src/error.rs | 18 ++ src/lib.rs | 1 + src/metalink.rs | 228 +++++++++++++++++++-- src/plugin_api.rs | 4 +- src/xml.rs | 18 ++ tests/wasm_smoke.rs | 32 +++ 14 files changed, 588 insertions(+), 226 deletions(-) create mode 100644 .github/workflows/plugin-ci.yml create mode 100644 rust-toolchain.toml create mode 100644 src/xml.rs create mode 100644 tests/wasm_smoke.rs diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml new file mode 100644 index 0000000..60907af --- /dev/null +++ b/.github/workflows/plugin-ci.yml @@ -0,0 +1,13 @@ +name: Plugin CI + +on: + pull_request: + push: + branches: [main] + tags: ["v*"] + +jobs: + plugin-ci: + permissions: + contents: write + uses: mpiton/vortex/.github/workflows/plugin-ci.yml@e9edb3b1b60cd795f03cf1e5acba7d6b3731f753 diff --git a/Cargo.lock b/Cargo.lock index e708afb..c4926fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.25.1" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ "gimli", ] @@ -303,46 +303,48 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a04121a197fde2fe896f8e7cac9812fc41ed6ee9c63e1906090f9f497845f6" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a09e699a94f477303820fb2167024f091543d6240783a2d3b01a3f21c42bc744" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f07732c662a9755529e332d86f8c5842171f6e98ba4d5976a178043dad838654" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" dependencies = [ "cranelift-entity", + "wasmtime-internal-core", ] [[package]] name = "cranelift-bitset" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18391da761cf362a06def7a7cf11474d79e55801dd34c2e9ba105b33dc0aef88" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" dependencies = [ "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a09b3042c69810d255aef59ddc3b3e4c0644d1d90ecfd6e3837798cc88a3c" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -354,7 +356,8 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.15.5", + "hashbrown 0.16.1", + "libm", "log", "pulley-interpreter", "regalloc2", @@ -362,14 +365,14 @@ dependencies = [ "serde", "smallvec", "target-lexicon", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen-meta" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75817926ec812241889208d1b190cadb7fedded4592a4bb01b8524babb9e4849" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -380,35 +383,36 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859158f87a59476476eda3884d883c32e08a143cf3d315095533b362a3250a63" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" [[package]] name = "cranelift-control" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b65a9aec442d715cbf54d14548b8f395476c09cef7abe03e104a378291ab88" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334c99a7e86060c24028732efd23bac84585770dcb752329c69f135d64f2fc1" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" dependencies = [ "cranelift-bitset", "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-frontend" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43ac6c095aa5b3e845d7ca3461e67e2b65249eb5401477a5ff9100369b745111" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" dependencies = [ "cranelift-codegen", "log", @@ -418,15 +422,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d3d992870ed4f0f2e82e2175275cb3a123a46e9660c6558c46417b822c91fa" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" [[package]] name = "cranelift-native" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee32e36beaf80f309edb535274cfe0349e1c5cf5799ba2d9f42e828285c6b52e" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" dependencies = [ "cranelift-codegen", "libc", @@ -435,9 +439,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.128.4" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903adeaf4938e60209a97b53a2e4326cd2d356aab9764a1934630204bae381c9" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" [[package]] name = "crc32fast" @@ -460,9 +464,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -579,9 +583,9 @@ dependencies = [ [[package]] name = "extism" -version = "1.21.0" +version = "1.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed8c5859bdab81d2eb4cd963eeacd8031d353b1ffb2fde43ee9179a0d6295120" +checksum = "4b66cd9ac5c64b49c9bac69db3d1b10d8f9386e7caab73489c2f197ba43d5e05" dependencies = [ "anyhow", "async-trait", @@ -606,9 +610,9 @@ dependencies = [ [[package]] name = "extism-convert" -version = "1.21.0" +version = "1.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1a8eac059a1730a21aa47f99a0c2075ba0ab88fd0c4e52e35027cf99cdf3e7" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" dependencies = [ "anyhow", "base64", @@ -622,9 +626,9 @@ dependencies = [ [[package]] name = "extism-convert-macros" -version = "1.21.0" +version = "1.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "848f105dd6e1af2ea4bb4a76447658e8587167df3c4e4658c4258e5b14a5b051" +checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928" dependencies = [ "manyhow", "proc-macro-crate", @@ -635,9 +639,9 @@ dependencies = [ [[package]] name = "extism-manifest" -version = "1.21.0" +version = "1.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953a22ad322939ae4567ec73a34913a3a43dcbdfa648b8307d38fe56bb3a0acd" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" dependencies = [ "base64", "serde", @@ -670,12 +674,6 @@ dependencies = [ "syn", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fastrand" version = "2.4.1" @@ -715,12 +713,24 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -885,11 +895,12 @@ dependencies = [ [[package]] name = "gimli" -version = "0.32.3" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ - "fallible-iterator", + "fnv", + "hashbrown 0.16.1", "indexmap", "stable_deref_trait", ] @@ -906,8 +917,18 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", "serde", + "serde_core", ] [[package]] @@ -1347,12 +1368,12 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" dependencies = [ "crc32fast", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap", "memchr", ] @@ -1485,21 +1506,21 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9812652c1feb63cf39f8780cecac154a32b22b3665806c733cd4072547233a4" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" dependencies = [ "cranelift-bitset", "log", "pulley-macros", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "pulley-macros" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56000349b6896e3d44286eb9c330891237f40b27fd43c1ccc84547d0b463cb40" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" dependencies = [ "proc-macro2", "quote", @@ -1508,12 +1529,11 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", - "serde", ] [[package]] @@ -1609,13 +1629,13 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.13.5" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.5", + "hashbrown 0.17.0", "log", "rustc-hash", "smallvec", @@ -2328,7 +2348,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vortex-mod-containers" -version = "1.0.0" +version = "1.0.1" dependencies = [ "aes", "base64", @@ -2351,11 +2371,10 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi-common" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49ffbbd04665d04028f66aee8f24ae7a1f46063f59a28fddfa52ca3091754a2" +checksum = "46137f5bcc41a0f002ed14688e463665388a6f3a6662a12a8c315d4b8849791c" dependencies = [ - "anyhow", "async-trait", "bitflags", "cap-fs-ext", @@ -2371,6 +2390,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "wasmtime", + "wasmtime-environ", "wiggle", "windows-sys 0.61.2", ] @@ -2440,9 +2460,9 @@ dependencies = [ [[package]] name = "wasm-compose" -version = "0.243.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af801b6f36459023eaec63fdbaedad2fd5a4ab7dc74ecc110a8b5d375c5775e4" +checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343" dependencies = [ "anyhow", "heck", @@ -2454,29 +2474,29 @@ dependencies = [ "serde_derive", "serde_yaml", "smallvec", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", "wat", ] [[package]] name = "wasm-encoder" -version = "0.243.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.243.0", + "wasmparser 0.244.0", ] [[package]] name = "wasm-encoder" -version = "0.244.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", + "wasmparser 0.245.1", ] [[package]] @@ -2503,27 +2523,27 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.243.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", "semver", - "serde", ] [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ "bitflags", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap", "semver", + "serde", ] [[package]] @@ -2539,23 +2559,22 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.243.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.243.0", + "wasmparser 0.245.1", ] [[package]] name = "wasmtime" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2a83182bf04af87571b4c642300479501684f26bab5597f68f68cded5b098fd" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" dependencies = [ "addr2line", - "anyhow", "async-trait", "bitflags", "bumpalo", @@ -2565,8 +2584,6 @@ dependencies = [ "futures", "fxprof-processed-profile", "gimli", - "hashbrown 0.15.5", - "indexmap", "ittapi", "libc", "log", @@ -2586,18 +2603,17 @@ dependencies = [ "target-lexicon", "tempfile", "wasm-compose", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", "wasmtime-environ", "wasmtime-internal-cache", "wasmtime-internal-component-macro", "wasmtime-internal-component-util", + "wasmtime-internal-core", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", "wasmtime-internal-winch", @@ -2607,15 +2623,17 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb201c41aa23a3642365cfb2e4a183573d85127a3c9d528f56b9997c984541ab" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" dependencies = [ "anyhow", "cpp_demangle", + "cranelift-bforest", "cranelift-bitset", "cranelift-entity", "gimli", + "hashbrown 0.16.1", "indexmap", "log", "object", @@ -2624,19 +2642,21 @@ dependencies = [ "semver", "serde", "serde_derive", + "sha2", "smallvec", "target-lexicon", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", "wasmprinter", "wasmtime-internal-component-util", + "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-cache" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5b3069d1a67ba5969d0eb1ccd7e141367d4e713f4649aa90356c98e8f19bea" +checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f" dependencies = [ "base64", "directories-next", @@ -2654,9 +2674,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c924400db7b6ca996fef1b23beb0f41d5c809836b1ec60fc25b4057e2d25d9b" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" dependencies = [ "anyhow", "proc-macro2", @@ -2664,20 +2684,32 @@ dependencies = [ "syn", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser 0.243.0", + "wit-parser 0.245.1", ] [[package]] name = "wasmtime-internal-component-util" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3f65daf4bf3d74ca2fbbe20af0589c42e2b398a073486451425d94fd4afef4" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" + +[[package]] +name = "wasmtime-internal-core" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "libm", + "serde", +] [[package]] name = "wasmtime-internal-cranelift" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633e889cdae76829738db0114ab3b02fce51ea4a1cd9675a67a65fce92e8b418" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" dependencies = [ "cfg-if", "cranelift-codegen", @@ -2693,18 +2725,18 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.243.0", + "wasmparser 0.245.1", "wasmtime-environ", - "wasmtime-internal-math", + "wasmtime-internal-core", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-fiber" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb126adc5d0c72695cfb77260b357f1b81705a0f8fa30b3944e7c2219c17341" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" dependencies = [ "cc", "cfg-if", @@ -2717,9 +2749,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e66ff7f90a8002187691ff6237ffd09f954a0ebb9de8b2ff7f5c62632134120" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" dependencies = [ "cc", "object", @@ -2729,36 +2761,21 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b96df23179ae16d54fb3a420f84ffe4383ec9dd06fad3e5bc782f85f66e8e08" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" dependencies = [ - "anyhow", "cfg-if", "libc", + "wasmtime-internal-core", "windows-sys 0.61.2", ] -[[package]] -name = "wasmtime-internal-math" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d1380926682b44c383e9a67f47e7a95e60c6d3fa8c072294dab2c7de6168a0" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b63cbea1c0192c7feb7c0dfb35f47166988a3742f29f46b585ef57246c65764" - [[package]] name = "wasmtime-internal-unwinder" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c392c7e5fb891a7416e3c34cfbd148849271e8c58744fda875dde4bec4d6a" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" dependencies = [ "cfg-if", "cranelift-codegen", @@ -2769,9 +2786,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f8b9796a3f0451a7b702508b303d654de640271ac80287176de222f187a237" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" dependencies = [ "proc-macro2", "quote", @@ -2780,16 +2797,16 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0063e61f1d0b2c20e9cfc58361a6513d074a23c80b417aac3033724f51648a0" +checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807" dependencies = [ "cranelift-codegen", "gimli", "log", "object", "target-lexicon", - "wasmparser 0.243.0", + "wasmparser 0.245.1", "wasmtime-environ", "wasmtime-internal-cranelift", "winch-codegen", @@ -2797,15 +2814,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "587699ca7cae16b4a234ffcc834f37e75675933d533809919b52975f5609e2ef" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" dependencies = [ "anyhow", "bitflags", "heck", "indexmap", - "wit-parser 0.243.0", + "wit-parser 0.245.1", ] [[package]] @@ -2850,38 +2867,38 @@ dependencies = [ [[package]] name = "wiggle" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69a60bcbe1475c5dc9ec89210ade54823d44f742e283cba64f98f89697c4cec" +checksum = "9c8cfd3db2f05619c6f36f257d84327c11546e28d61e3a1c1220aaad553bc4b0" dependencies = [ - "anyhow", "bitflags", "thiserror 2.0.18", "tracing", "wasmtime", + "wasmtime-environ", "wiggle-macro", "witx", ] [[package]] name = "wiggle-generate" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f3dc0fd4dcfc7736434bb216179a2147835309abc09bf226736a40d484548f" +checksum = "4bd7a197903e5b4ff5e13aef9c891960d71e92073600ecf4c86c7e795ac1c803" dependencies = [ - "anyhow", "heck", "proc-macro2", "quote", "syn", + "wasmtime-environ", "witx", ] [[package]] name = "wiggle-macro" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea2aea744eded58ae092bf57110c27517dab7d5a300513ff13897325c5c5021" +checksum = "6410b86fcec207070d9372b215d3470bad67215e6bbac46981a16999c4abbc28" dependencies = [ "proc-macro2", "quote", @@ -2922,11 +2939,10 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "41.0.4" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55de3ac5b8bd71e5f6c87a9e511dd3ceb194bdb58183c6a7bf21cd8c0e46fbc" +checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958" dependencies = [ - "anyhow", "cranelift-assembler-x64", "cranelift-codegen", "gimli", @@ -2934,10 +2950,10 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.243.0", + "wasmparser 0.245.1", "wasmtime-environ", + "wasmtime-internal-core", "wasmtime-internal-cranelift", - "wasmtime-internal-math", ] [[package]] @@ -3193,9 +3209,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.243.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", @@ -3206,16 +3222,17 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.243.0", + "wasmparser 0.244.0", ] [[package]] name = "wit-parser" -version = "0.244.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" dependencies = [ "anyhow", + "hashbrown 0.16.1", "id-arena", "indexmap", "log", @@ -3224,7 +3241,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", + "wasmparser 0.245.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 39b99fb..bee639f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vortex-mod-containers" -version = "1.0.0" +version = "1.0.1" edition = "2021" description = "Container decrypter WASM plugin for Vortex — DLC, CCF, RSDF, Metalink (decrypt to URL list)" license = "GPL-3.0" @@ -15,13 +15,13 @@ base64 = "0.22" cbc = "0.1" extism-pdk = "1.4" hex = "0.4" -quick-xml = { version = "0.36", features = ["serialize"] } +quick-xml = "0.41" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" [dev-dependencies] -extism = "1" +extism = "=1.30.0" rstest = "0.24" [profile.release] diff --git a/README.md b/README.md index c7ddbe0..50b4d22 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,13 @@ The plugin owns no networking — it transforms bytes into a structured link list. The host then routes each `ContainerLink` through the regular hoster pipeline. +## Metalink limits + +Metalink is treated as untrusted XML. Parsing rejects inputs above 1 MiB, +nesting deeper than 64 elements, more than 50,000 elements, more than 64 +attributes on one element, or text nodes above 64 KiB. Limit violations and +malformed XML return typed plugin errors without partial output. + ## Build ```bash diff --git a/plugin.toml b/plugin.toml index bb5e405..cea65af 100644 --- a/plugin.toml +++ b/plugin.toml @@ -1,6 +1,6 @@ [plugin] name = "vortex-mod-containers" -version = "1.0.0" +version = "1.0.1" category = "container" author = "vortex-community" description = "Decrypt link containers (DLC, CCF, RSDF) and parse Metalink (RFC 5854). Pure transformation, no network capability required." diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..187e1fa --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" +components = ["clippy", "llvm-tools-preview", "rustfmt"] +targets = ["wasm32-wasip1"] diff --git a/src/ccf.rs b/src/ccf.rs index c812650..fb0a5d0 100644 --- a/src/ccf.rs +++ b/src/ccf.rs @@ -22,6 +22,7 @@ use quick_xml::Reader; use crate::crypto::{aes128_cbc_decrypt, aes128_cbc_encrypt}; use crate::error::PluginError; use crate::types::ContainerLink; +use crate::xml::{decode_reference, decode_text}; pub const CCF_MAGIC: &str = "CCF1\n"; pub const CCF_KEY: [u8; 16] = *b"v0rt3xCryptL0adC"; @@ -51,15 +52,25 @@ fn parse_inner(xml: &str) -> Result, PluginError> { let mut links = Vec::new(); let mut current: Option = None; let mut active: Option = None; + let mut field_text = String::new(); loop { match reader.read_event_into(&mut buf)? { Event::Eof => break, Event::Start(e) => match e.name().as_ref() { b"file" => current = Some(InnerFile::default()), - b"url" => active = Some(Field::Url), - b"name" => active = Some(Field::Name), - b"size" => active = Some(Field::Size), + b"url" => { + active = Some(Field::Url); + field_text.clear(); + } + b"name" => { + active = Some(Field::Name); + field_text.clear(); + } + b"size" => { + active = Some(Field::Size); + field_text.clear(); + } _ => {} }, Event::End(e) => match e.name().as_ref() { @@ -68,21 +79,25 @@ fn parse_inner(xml: &str) -> Result, PluginError> { links.push(f.finalise()?); } } - b"url" | b"name" | b"size" => active = None, + b"url" | b"name" | b"size" => { + if let Some(file) = current.as_mut() { + let trimmed = field_text.trim(); + match active { + Some(Field::Url) => file.url = Some(trimmed.to_string()), + Some(Field::Name) => file.name = Some(trimmed.to_string()), + Some(Field::Size) => file.size = trimmed.parse::().ok(), + None => {} + } + } + active = None; + field_text.clear(); + } _ => {} }, - Event::Text(t) => { - let owned = t.unescape()?.into_owned(); - let trimmed = owned.trim(); - if trimmed.is_empty() { - continue; - } - match (active, current.as_mut()) { - (Some(Field::Url), Some(f)) => f.url = Some(trimmed.to_string()), - (Some(Field::Name), Some(f)) => f.name = Some(trimmed.to_string()), - (Some(Field::Size), Some(f)) => f.size = trimmed.parse::().ok(), - _ => {} - } + Event::Text(t) if active.is_some() => field_text.push_str(&decode_text(&t)?), + Event::CData(c) if active.is_some() => field_text.push_str(&c.decode()?), + Event::GeneralRef(reference) if active.is_some() => { + field_text.push_str(&decode_reference(&reference)?) } _ => {} } @@ -104,7 +119,10 @@ struct InnerFile { impl InnerFile { fn finalise(self) -> Result { - let url = self.url.ok_or(PluginError::MissingField("url"))?; + let url = self + .url + .filter(|url| !url.trim().is_empty()) + .ok_or(PluginError::MissingField("url"))?; Ok(ContainerLink { url, filename: self.name, @@ -215,6 +233,15 @@ mod tests { assert!(matches!(err, PluginError::Malformed(_))); } + #[test] + fn decode_rejects_empty_url() { + let blob = encode(&[("", None, None)]).unwrap(); + + let err = decode(&blob).unwrap_err(); + + assert!(matches!(err, PluginError::MissingField("url"))); + } + #[test] fn xml_escape_handles_special_chars() { assert_eq!(xml_escape("a & b"), "a & b"); diff --git a/src/dlc.rs b/src/dlc.rs index b1739bf..bcdbac4 100644 --- a/src/dlc.rs +++ b/src/dlc.rs @@ -29,6 +29,7 @@ use quick_xml::Reader; use crate::crypto::{aes128_cbc_decrypt, aes128_cbc_encrypt}; use crate::error::PluginError; use crate::types::ContainerLink; +use crate::xml::{decode_reference, decode_text}; /// Historic DLC v1 key (JDownloader, public). pub const DLC_KEY: [u8; 16] = *b"cb99b5cbc24db398"; @@ -82,10 +83,13 @@ fn extract_content(xml: &str) -> Result { Event::Eof => break, Event::Start(e) if e.name().as_ref() == b"content" => in_content = true, Event::End(e) if e.name().as_ref() == b"content" => in_content = false, - Event::Text(t) if in_content => text.push_str(&t.unescape()?), + Event::Text(t) if in_content => text.push_str(&decode_text(&t)?), Event::CData(c) if in_content => { text.push_str(std::str::from_utf8(c.into_inner().as_ref())?) } + Event::GeneralRef(reference) if in_content => { + text.push_str(&decode_reference(&reference)?) + } _ => {} } buf.clear(); @@ -104,15 +108,25 @@ fn parse_inner(xml: &str) -> Result, PluginError> { let mut links = Vec::new(); let mut current: Option = None; let mut active_field: Option = None; + let mut field_text = String::new(); loop { match reader.read_event_into(&mut buf)? { Event::Eof => break, Event::Start(e) => match e.name().as_ref() { b"file" => current = Some(InnerFile::default()), - b"url" => active_field = Some(InnerField::Url), - b"filename" => active_field = Some(InnerField::Filename), - b"size" => active_field = Some(InnerField::Size), + b"url" => { + active_field = Some(InnerField::Url); + field_text.clear(); + } + b"filename" => { + active_field = Some(InnerField::Filename); + field_text.clear(); + } + b"size" => { + active_field = Some(InnerField::Size); + field_text.clear(); + } _ => {} }, Event::End(e) => match e.name().as_ref() { @@ -121,27 +135,31 @@ fn parse_inner(xml: &str) -> Result, PluginError> { links.push(file.finalise()?); } } - b"url" | b"filename" | b"size" => active_field = None, - _ => {} - }, - Event::Text(t) => { - let trimmed = t.unescape()?.into_owned(); - let trimmed = trimmed.trim().to_string(); - if trimmed.is_empty() { - continue; - } - match (active_field, current.as_mut()) { - (Some(InnerField::Url), Some(f)) => { - let plain = B64.decode(&trimmed)?; - f.url = Some(String::from_utf8(plain)?); - } - (Some(InnerField::Filename), Some(f)) => { - let plain = B64.decode(&trimmed)?; - f.filename = Some(String::from_utf8(plain)?); + b"url" | b"filename" | b"size" => { + let trimmed = field_text.trim(); + match (active_field, current.as_mut()) { + (Some(InnerField::Url), Some(file)) => { + let plain = B64.decode(trimmed)?; + file.url = Some(String::from_utf8(plain)?); + } + (Some(InnerField::Filename), Some(file)) => { + let plain = B64.decode(trimmed)?; + file.filename = Some(String::from_utf8(plain)?); + } + (Some(InnerField::Size), Some(file)) => { + file.size = trimmed.parse::().ok(); + } + _ => {} } - (Some(InnerField::Size), Some(f)) => f.size = trimmed.parse::().ok(), - _ => {} + active_field = None; + field_text.clear(); } + _ => {} + }, + Event::Text(t) if active_field.is_some() => field_text.push_str(&decode_text(&t)?), + Event::CData(c) if active_field.is_some() => field_text.push_str(&c.decode()?), + Event::GeneralRef(reference) if active_field.is_some() => { + field_text.push_str(&decode_reference(&reference)?) } _ => {} } @@ -163,7 +181,10 @@ struct InnerFile { impl InnerFile { fn finalise(self) -> Result { - let url = self.url.ok_or(PluginError::MissingField("url"))?; + let url = self + .url + .filter(|url| !url.trim().is_empty()) + .ok_or(PluginError::MissingField("url"))?; Ok(ContainerLink { url, filename: self.filename, @@ -238,6 +259,15 @@ mod tests { assert!(links[1].size_bytes.is_none()); } + #[test] + fn decode_rejects_empty_url() { + let container = encode(&[("", None, None)]).unwrap(); + + let err = decode(container.as_bytes()).unwrap_err(); + + assert!(matches!(err, PluginError::MissingField("url"))); + } + #[test] fn looks_like_dlc_accepts_synthetic() { let container = encode(&entries()).unwrap(); diff --git a/src/error.rs b/src/error.rs index a0b0b85..7552974 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,12 @@ pub enum PluginError { #[error("XML parse error: {0}")] Xml(String), + #[error("resource limit exceeded for {resource} (maximum {limit})")] + LimitExceeded { + resource: &'static str, + limit: usize, + }, + #[error("base64 decode error: {0}")] Base64(String), @@ -64,3 +70,15 @@ impl From for PluginError { Self::Xml(e.to_string()) } } + +impl From for PluginError { + fn from(e: quick_xml::encoding::EncodingError) -> Self { + Self::Xml(e.to_string()) + } +} + +impl From for PluginError { + fn from(e: quick_xml::escape::EscapeError) -> Self { + Self::Xml(e.to_string()) + } +} diff --git a/src/lib.rs b/src/lib.rs index b55f7ae..1c294a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ pub mod error; pub mod metalink; pub mod rsdf; pub mod types; +mod xml; #[cfg(target_family = "wasm")] mod plugin_api; diff --git a/src/metalink.rs b/src/metalink.rs index 15870fd..9f4a235 100644 --- a/src/metalink.rs +++ b/src/metalink.rs @@ -6,13 +6,19 @@ //! and v4 elements (`` directly under ``), as well as either case //! of the hash type attribute (`sha-256`, `SHA256`, etc.). -use quick_xml::events::Event; -use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::{Reader, XmlVersion}; use crate::error::PluginError; use crate::types::{Checksum, ChecksumAlgo, ContainerLink}; +use crate::xml::{decode_reference, decode_text}; const MAGIC_HINTS: &[&str] = &[" bool { let head_len = bytes.len().min(4096); @@ -32,23 +38,45 @@ pub fn looks_like_metalink(bytes: &[u8]) -> bool { } pub fn decode(bytes: &[u8]) -> Result, PluginError> { + ensure_within_limit(bytes.len(), MAX_INPUT_BYTES, "input bytes")?; let xml = std::str::from_utf8(bytes)?; let mut reader = Reader::from_str(xml); reader.config_mut().trim_text(true); let mut state = ParseState::default(); let mut buf = Vec::new(); + let mut depth = 0; + let mut elements = 0; loop { match reader.read_event_into(&mut buf)? { Event::Eof => break, - Event::Start(e) => state.on_start(e)?, + Event::Start(e) => { + count_element(&mut elements)?; + depth += 1; + ensure_within_limit(depth, MAX_XML_DEPTH, "XML depth")?; + validate_attributes(&e)?; + state.on_start(e)?; + } Event::Empty(e) => { + count_element(&mut elements)?; + ensure_within_limit(depth + 1, MAX_XML_DEPTH, "XML depth")?; + validate_attributes(&e)?; state.on_start(e.clone())?; state.on_end(e.name().as_ref())?; } - Event::Text(t) => state.on_text(t.unescape()?.into_owned()), - Event::CData(c) => state.on_text(String::from_utf8(c.into_inner().into_owned())?), - Event::End(e) => state.on_end(e.name().as_ref())?, + Event::Text(t) => { + state.append_text(&decode_text(&t)?)?; + } + Event::CData(c) => { + state.append_text(&c.decode()?)?; + } + Event::GeneralRef(reference) => { + state.append_text(&decode_reference(&reference)?)?; + } + Event::End(e) => { + state.on_end(e.name().as_ref())?; + depth = depth.saturating_sub(1); + } _ => {} } buf.clear(); @@ -59,11 +87,41 @@ pub fn decode(bytes: &[u8]) -> Result, PluginError> { Ok(state.files) } +fn ensure_within_limit( + value: usize, + limit: usize, + resource: &'static str, +) -> Result<(), PluginError> { + if value > limit { + return Err(PluginError::LimitExceeded { resource, limit }); + } + Ok(()) +} + +fn count_element(elements: &mut usize) -> Result<(), PluginError> { + *elements += 1; + ensure_within_limit(*elements, MAX_XML_ELEMENTS, "XML elements") +} + +fn validate_attributes(element: &BytesStart<'_>) -> Result<(), PluginError> { + for (index, attribute) in element.attributes().enumerate() { + if index >= MAX_ATTRIBUTES_PER_ELEMENT { + return Err(PluginError::LimitExceeded { + resource: "attributes per element", + limit: MAX_ATTRIBUTES_PER_ELEMENT, + }); + } + attribute?; + } + Ok(()) +} + #[derive(Default)] struct ParseState { in_file: bool, current_file: Option, text_target: Option, + text_buffer: String, current_hash_algo: Option, files: Vec, } @@ -84,7 +142,7 @@ struct FileBuilder { } impl ParseState { - fn on_start(&mut self, e: quick_xml::events::BytesStart<'_>) -> Result<(), PluginError> { + fn on_start(&mut self, e: BytesStart<'_>) -> Result<(), PluginError> { let local = local_name(e.name().as_ref()); match local.as_str() { "file" => { @@ -93,30 +151,48 @@ impl ParseState { for attr in e.attributes() { let attr = attr?; if local_name(attr.key.as_ref()) == "name" { - builder.name = Some(attr.unescape_value()?.into_owned()); + builder.name = + Some(attr.normalized_value(XmlVersion::Implicit1_0)?.into_owned()); } } self.current_file = Some(builder); } - "size" if self.in_file => self.text_target = Some(TextTarget::Size), - "url" if self.in_file => self.text_target = Some(TextTarget::Url), + "size" if self.in_file => self.start_text(TextTarget::Size), + "url" if self.in_file => self.start_text(TextTarget::Url), "hash" if self.in_file => { self.current_hash_algo = parse_hash_attr(&e)?; - self.text_target = Some(TextTarget::Hash); + self.start_text(TextTarget::Hash); } _ => {} } Ok(()) } - fn on_text(&mut self, text: String) { + fn start_text(&mut self, target: TextTarget) { + self.text_target = Some(target); + self.text_buffer.clear(); + } + + fn append_text(&mut self, text: &str) -> Result<(), PluginError> { + if self.text_target.is_some() { + ensure_within_limit( + self.text_buffer.len().saturating_add(text.len()), + MAX_TEXT_BYTES, + "text bytes", + )?; + self.text_buffer.push_str(text); + } + Ok(()) + } + + fn finish_text(&mut self) { let Some(target) = self.text_target else { return; }; let Some(builder) = self.current_file.as_mut() else { return; }; - let trimmed = text.trim(); + let trimmed = self.text_buffer.trim(); if trimmed.is_empty() { return; } @@ -149,7 +225,9 @@ impl ParseState { } } "size" | "url" | "hash" => { + self.finish_text(); self.text_target = None; + self.text_buffer.clear(); if local == "hash" { self.current_hash_algo = None; } @@ -174,13 +252,14 @@ fn into_link(b: FileBuilder) -> Result { }) } -fn parse_hash_attr( - e: &quick_xml::events::BytesStart<'_>, -) -> Result, PluginError> { +fn parse_hash_attr(e: &BytesStart<'_>) -> Result, PluginError> { for attr in e.attributes() { let attr = attr?; if local_name(attr.key.as_ref()) == "type" { - let raw = attr.unescape_value()?.into_owned().to_lowercase(); + let raw = attr + .normalized_value(XmlVersion::Implicit1_0)? + .into_owned() + .to_lowercase(); let normalised = raw.replace('-', ""); return Ok(match normalised.as_str() { "md5" => Some(ChecksumAlgo::Md5), @@ -308,4 +387,119 @@ mod tests { assert_eq!(links[0].filename.as_deref(), Some("a.bin")); assert_eq!(links[1].filename.as_deref(), Some("b.bin")); } + + #[test] + fn decode_rejects_input_larger_than_one_mebibyte() { + let input = vec![b' '; 1024 * 1024 + 1]; + + let err = decode(&input).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "input bytes", + limit: 1_048_576 + } + )); + } + + #[test] + fn decode_rejects_excessive_element_depth() { + let mut xml = String::from(""); + for _ in 0..64 { + xml.push_str(""); + } + xml.push_str("https://example.com/file.bin"); + for _ in 0..64 { + xml.push_str(""); + } + xml.push_str(""); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "XML depth", + limit: 64 + } + )); + } + + #[test] + fn decode_rejects_excessive_element_count() { + let mut xml = String::from(""); + for _ in 0..50_000 { + xml.push_str(""); + } + xml.push_str("https://example.com/file.bin"); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "XML elements", + limit: 50_000 + } + )); + } + + #[test] + fn decode_rejects_excessive_attributes() { + let mut xml = String::from("https://example.com/file.bin"); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "attributes per element", + limit: 64 + } + )); + } + + #[test] + fn decode_rejects_oversized_text_nodes() { + let url = "a".repeat(64 * 1024 + 1); + let xml = format!("{url}"); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "text bytes", + limit: 65_536 + } + )); + } + + #[test] + fn decode_rejects_oversized_text_split_across_references() { + let references = "&".repeat(MAX_TEXT_BYTES + 1); + let xml = format!("{references}"); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "text bytes", + limit: 65_536 + } + )); + } + + #[test] + fn decode_reports_malformed_xml_as_typed_error() { + let err = decode(b"").unwrap_err(); + + assert!(matches!(err, PluginError::Xml(_))); + } } diff --git a/src/plugin_api.rs b/src/plugin_api.rs index 3e7e93b..9ed9e81 100644 --- a/src/plugin_api.rs +++ b/src/plugin_api.rs @@ -21,13 +21,13 @@ pub fn can_decrypt(input: Vec) -> FnResult { #[plugin_fn] pub fn detect(input: Vec) -> FnResult { let resp = crate::detect(&input); - Ok(serde_json::to_string(&resp).map_err(json_err)?) + serde_json::to_string(&resp).map_err(json_err) } #[plugin_fn] pub fn decrypt(input: Vec) -> FnResult { let resp = crate::decrypt(&input).map_err(plugin_err)?; - Ok(serde_json::to_string(&resp).map_err(json_err)?) + serde_json::to_string(&resp).map_err(json_err) } fn plugin_err(e: PluginError) -> WithReturnCode { diff --git a/src/xml.rs b/src/xml.rs new file mode 100644 index 0000000..09b3f0d --- /dev/null +++ b/src/xml.rs @@ -0,0 +1,18 @@ +use quick_xml::events::{BytesRef, BytesText}; + +use crate::error::PluginError; + +pub(crate) fn decode_text(text: &BytesText<'_>) -> Result { + Ok(text.decode()?.into_owned()) +} + +pub(crate) fn decode_reference(reference: &BytesRef<'_>) -> Result { + if let Some(character) = reference.resolve_char_ref()? { + return Ok(character.to_string()); + } + + let name = reference.decode()?; + quick_xml::escape::resolve_predefined_entity(&name) + .map(str::to_string) + .ok_or_else(|| PluginError::Xml(format!("unsupported entity reference '&{name};'"))) +} diff --git a/tests/wasm_smoke.rs b/tests/wasm_smoke.rs new file mode 100644 index 0000000..2e6fd9a --- /dev/null +++ b/tests/wasm_smoke.rs @@ -0,0 +1,32 @@ +//! ABI smoke test for the release WASM artifact. + +use std::path::PathBuf; + +const WASM_REL_PATH: &str = "target/wasm32-wasip1/release/vortex_mod_containers.wasm"; +const METALINK: &[u8] = br#"42https://example.com/demo.bin"#; + +fn load_plugin() -> extism::Plugin { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(WASM_REL_PATH); + assert!( + path.is_file(), + "missing release WASM artifact; run `cargo build --target wasm32-wasip1 --release` first" + ); + let manifest = extism::Manifest::new([extism::Wasm::file(path)]); + extism::Plugin::new(&manifest, Vec::::new(), true) + .expect("load Containers WASM") +} + +#[test] +fn wasm_container_exports_are_callable() { + let mut plugin = load_plugin(); + let can_decrypt: String = plugin.call("can_decrypt", METALINK).expect("can_decrypt"); + let detect: String = plugin.call("detect", METALINK).expect("detect"); + let decrypt: String = plugin.call("decrypt", METALINK).expect("decrypt"); + let detect: serde_json::Value = serde_json::from_str(&detect).expect("detect JSON"); + let decrypt: serde_json::Value = serde_json::from_str(&decrypt).expect("decrypt JSON"); + + assert_eq!(can_decrypt.trim(), "true"); + assert_eq!(detect["format"], "metalink"); + assert_eq!(decrypt["format"], "metalink"); + assert_eq!(decrypt["links"][0]["url"], "https://example.com/demo.bin"); +} From 8b9f4dd4070236d08a4844b29cb835ea0a99fbc6 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:50:21 +0200 Subject: [PATCH 2/9] fix: preserve CCF field whitespace --- .github/workflows/plugin-ci.yml | 7 +++++++ README.md | 4 ++-- src/ccf.rs | 10 +++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 60907af..668d401 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -8,6 +8,13 @@ on: jobs: plugin-ci: + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + permissions: + contents: read + uses: mpiton/vortex/.github/workflows/plugin-ci.yml@e9edb3b1b60cd795f03cf1e5acba7d6b3731f753 + + plugin-release: + if: ${{ startsWith(github.ref, 'refs/tags/') }} permissions: contents: write uses: mpiton/vortex/.github/workflows/plugin-ci.yml@e9edb3b1b60cd795f03cf1e5acba7d6b3731f753 diff --git a/README.md b/README.md index 50b4d22..a03e9df 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ pipeline. Metalink is treated as untrusted XML. Parsing rejects inputs above 1 MiB, nesting deeper than 64 elements, more than 50,000 elements, more than 64 -attributes on one element, or text nodes above 64 KiB. Limit violations and -malformed XML return typed plugin errors without partial output. +attributes on one element, or parsed field text above 64 KiB. Limit violations +and malformed XML return typed plugin errors without partial output. ## Build diff --git a/src/ccf.rs b/src/ccf.rs index fb0a5d0..f4f4c4e 100644 --- a/src/ccf.rs +++ b/src/ccf.rs @@ -46,7 +46,6 @@ pub fn decode(bytes: &[u8]) -> Result, PluginError> { fn parse_inner(xml: &str) -> Result, PluginError> { let mut reader = Reader::from_str(xml); - reader.config_mut().trim_text(true); let mut buf = Vec::new(); let mut links = Vec::new(); @@ -197,6 +196,15 @@ mod tests { assert_eq!(links[1].url, "https://cryptload.example/b.rar"); } + #[test] + fn parse_inner_preserves_spaces_around_entity_references() { + let xml = r#"https://example.com/fileRock & Roll.zip"#; + + let links = parse_inner(xml).unwrap(); + + assert_eq!(links[0].filename.as_deref(), Some("Rock & Roll.zip")); + } + #[test] fn looks_like_ccf_recognises_magic() { let blob = encode(&entries()).unwrap(); From c60c56c42130ec638ea8f9b5379e4b6ffad3a49b Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:55:42 +0200 Subject: [PATCH 3/9] fix(ci): separate verification and release workflows --- .github/workflows/plugin-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 668d401..be39cc0 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -11,10 +11,10 @@ jobs: if: ${{ !startsWith(github.ref, 'refs/tags/') }} permissions: contents: read - uses: mpiton/vortex/.github/workflows/plugin-ci.yml@e9edb3b1b60cd795f03cf1e5acba7d6b3731f753 + uses: mpiton/vortex/.github/workflows/plugin-ci.yml@3abf4058eb1fa946b196b1a87cf459c4ffe73a4e plugin-release: if: ${{ startsWith(github.ref, 'refs/tags/') }} permissions: contents: write - uses: mpiton/vortex/.github/workflows/plugin-ci.yml@e9edb3b1b60cd795f03cf1e5acba7d6b3731f753 + uses: mpiton/vortex/.github/workflows/plugin-release.yml@3abf4058eb1fa946b196b1a87cf459c4ffe73a4e From d3d8db0ddffc362671b5d9d62be4ec839471c719 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:21:05 +0200 Subject: [PATCH 4/9] fix(containers): close parser review gaps --- src/dlc.rs | 10 +++++++++- src/metalink.rs | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/dlc.rs b/src/dlc.rs index bcdbac4..20d20a8 100644 --- a/src/dlc.rs +++ b/src/dlc.rs @@ -142,7 +142,7 @@ fn parse_inner(xml: &str) -> Result, PluginError> { let plain = B64.decode(trimmed)?; file.url = Some(String::from_utf8(plain)?); } - (Some(InnerField::Filename), Some(file)) => { + (Some(InnerField::Filename), Some(file)) if !trimmed.is_empty() => { let plain = B64.decode(trimmed)?; file.filename = Some(String::from_utf8(plain)?); } @@ -321,4 +321,12 @@ mod tests { let links = decode(container.as_bytes()).unwrap(); assert_eq!(links[0].filename.as_deref(), Some("éàü 文档.zip")); } + + #[test] + fn decode_omits_empty_filename() { + let container = encode(&[("https://example.com/x", Some(""), None)]).unwrap(); + let links = decode(container.as_bytes()).unwrap(); + + assert_eq!(links[0].filename.as_deref(), None); + } } diff --git a/src/metalink.rs b/src/metalink.rs index 9f4a235..c594844 100644 --- a/src/metalink.rs +++ b/src/metalink.rs @@ -49,7 +49,14 @@ pub fn decode(bytes: &[u8]) -> Result, PluginError> { let mut elements = 0; loop { match reader.read_event_into(&mut buf)? { - Event::Eof => break, + Event::Eof => { + if depth != 0 { + return Err(PluginError::Xml( + "unexpected EOF with unclosed XML elements".into(), + )); + } + break; + } Event::Start(e) => { count_element(&mut elements)?; depth += 1; @@ -174,6 +181,7 @@ impl ParseState { } fn append_text(&mut self, text: &str) -> Result<(), PluginError> { + ensure_within_limit(text.len(), MAX_TEXT_BYTES, "text bytes")?; if self.text_target.is_some() { ensure_within_limit( self.text_buffer.len().saturating_add(text.len()), @@ -480,6 +488,24 @@ mod tests { )); } + #[test] + fn decode_rejects_oversized_text_in_unrecognized_element() { + let ignored = "a".repeat(MAX_TEXT_BYTES + 1); + let xml = format!( + "{ignored}https://example.com/file.bin" + ); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "text bytes", + limit: 65_536 + } + )); + } + #[test] fn decode_rejects_oversized_text_split_across_references() { let references = "&".repeat(MAX_TEXT_BYTES + 1); @@ -502,4 +528,12 @@ mod tests { assert!(matches!(err, PluginError::Xml(_))); } + + #[test] + fn decode_rejects_truncated_document_after_complete_file() { + let err = + decode(b"https://example.com/file.bin").unwrap_err(); + + assert!(matches!(err, PluginError::Xml(_))); + } } From c2d572b0a79a35eb27da6e1e38d85665758ebe8f Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:29:25 +0200 Subject: [PATCH 5/9] fix(ci): pin hardened release workflow --- .github/workflows/plugin-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index be39cc0..a866708 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -17,4 +17,4 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} permissions: contents: write - uses: mpiton/vortex/.github/workflows/plugin-release.yml@3abf4058eb1fa946b196b1a87cf459c4ffe73a4e + uses: mpiton/vortex/.github/workflows/plugin-release.yml@676ce0e2bc3d11ad7e77eb7035772183d8c337a3 From 1453f767bb7438e809a7b2fb0f6d11f2107af453 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:18:34 +0200 Subject: [PATCH 6/9] fix(ci): restrict releases to owner --- .github/workflows/plugin-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index a866708..6f79b89 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -14,7 +14,7 @@ jobs: uses: mpiton/vortex/.github/workflows/plugin-ci.yml@3abf4058eb1fa946b196b1a87cf459c4ffe73a4e plugin-release: - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ startsWith(github.ref, 'refs/tags/') && github.actor == github.repository_owner }} permissions: contents: write - uses: mpiton/vortex/.github/workflows/plugin-release.yml@676ce0e2bc3d11ad7e77eb7035772183d8c337a3 + uses: mpiton/vortex/.github/workflows/plugin-release.yml@f91b43d62504522407e2e491aaf9d53dacd2726b From 8e345c7f451dfdfe26dcd3325f7192d6ec39f8d3 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:26:50 +0200 Subject: [PATCH 7/9] fix(containers): cap fragmented ignored text --- src/metalink.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/metalink.rs b/src/metalink.rs index c594844..ea2b4b6 100644 --- a/src/metalink.rs +++ b/src/metalink.rs @@ -129,6 +129,8 @@ struct ParseState { current_file: Option, text_target: Option, text_buffer: String, + element_text_bytes: Vec, + outside_text_bytes: usize, current_hash_algo: Option, files: Vec, } @@ -150,6 +152,7 @@ struct FileBuilder { impl ParseState { fn on_start(&mut self, e: BytesStart<'_>) -> Result<(), PluginError> { + self.element_text_bytes.push(0); let local = local_name(e.name().as_ref()); match local.as_str() { "file" => { @@ -181,7 +184,18 @@ impl ParseState { } fn append_text(&mut self, text: &str) -> Result<(), PluginError> { - ensure_within_limit(text.len(), MAX_TEXT_BYTES, "text bytes")?; + let accumulated = self + .element_text_bytes + .last() + .copied() + .unwrap_or(self.outside_text_bytes) + .saturating_add(text.len()); + ensure_within_limit(accumulated, MAX_TEXT_BYTES, "text bytes")?; + if let Some(element_text_bytes) = self.element_text_bytes.last_mut() { + *element_text_bytes = accumulated; + } else { + self.outside_text_bytes = accumulated; + } if self.text_target.is_some() { ensure_within_limit( self.text_buffer.len().saturating_add(text.len()), @@ -223,6 +237,7 @@ impl ParseState { } fn on_end(&mut self, name: &[u8]) -> Result<(), PluginError> { + self.element_text_bytes.pop(); let local = local_name(name); match local.as_str() { "file" => { @@ -522,6 +537,24 @@ mod tests { )); } + #[test] + fn decode_rejects_oversized_ignored_text_split_across_references() { + let references = "&".repeat(MAX_TEXT_BYTES + 1); + let xml = format!( + "{references}https://example.com/file.bin" + ); + + let err = decode(xml.as_bytes()).unwrap_err(); + + assert!(matches!( + err, + PluginError::LimitExceeded { + resource: "text bytes", + limit: 65_536 + } + )); + } + #[test] fn decode_reports_malformed_xml_as_typed_error() { let err = decode(b"").unwrap_err(); From 7352f92bbfab5f27c3f6a666c9c2b6e5dae1c5da Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:48:38 +0200 Subject: [PATCH 8/9] fix(ci): pin canonical release workflows --- .github/workflows/plugin-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 6f79b89..626e07d 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -11,10 +11,10 @@ jobs: if: ${{ !startsWith(github.ref, 'refs/tags/') }} permissions: contents: read - uses: mpiton/vortex/.github/workflows/plugin-ci.yml@3abf4058eb1fa946b196b1a87cf459c4ffe73a4e + uses: mpiton/vortex/.github/workflows/plugin-ci.yml@9b3b7b3b7956150f7475ab409bb0eed424573bae plugin-release: if: ${{ startsWith(github.ref, 'refs/tags/') && github.actor == github.repository_owner }} permissions: contents: write - uses: mpiton/vortex/.github/workflows/plugin-release.yml@f91b43d62504522407e2e491aaf9d53dacd2726b + uses: mpiton/vortex/.github/workflows/plugin-release.yml@1b0cc276086cfc87165d63f8c5624fa3960ad2e9 From c2dbaddc56dd5214f83846e1dcfc23a75894c0ba Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:38:20 +0200 Subject: [PATCH 9/9] ci: repin reusable plugin workflows --- .github/workflows/plugin-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 626e07d..e44ee5d 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -11,10 +11,10 @@ jobs: if: ${{ !startsWith(github.ref, 'refs/tags/') }} permissions: contents: read - uses: mpiton/vortex/.github/workflows/plugin-ci.yml@9b3b7b3b7956150f7475ab409bb0eed424573bae + uses: mpiton/vortex/.github/workflows/plugin-ci.yml@f63e53f866bbdf5e5e3e8f62a37f78e13cec5298 plugin-release: if: ${{ startsWith(github.ref, 'refs/tags/') && github.actor == github.repository_owner }} permissions: contents: write - uses: mpiton/vortex/.github/workflows/plugin-release.yml@1b0cc276086cfc87165d63f8c5624fa3960ad2e9 + uses: mpiton/vortex/.github/workflows/plugin-release.yml@d85baa93ac7905c5813095b2406f2a3642458356