commit 0e768ae6db754b995daa946a0dd90048b9d17fc9 Author: flemming-it Date: Fri Jul 17 00:29:06 2026 +0200 feat: law-source-eurlex 0.1.0 (source.eu-recht) Fetch one EU legal act from EUR-Lex by CELEX number and return its FORMEX-4 XML content-addressed (SHA-256), so a flow's audit trail pins the exact Gesetzesstand it processed. - CELLAR content negotiation (application/xml;type=fmx4) on publications.europa.eu; 303 multiple-choice item selection - host-pinned redirect handling (max 3 hops), 64 MB body cap - consolidated CELEX (0...-YYYYMMDD) yields consolidation_date - net permission pinned to publications.europa.eu; no auth - 10 unit tests (no network), wasm32-wasip2 build green Signed-off-by: flemming-it diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..3a0ad04 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + RUST_TOOLCHAIN: "1.86" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +jobs: + ci: + name: Linux x86_64 (Forgejo) + runs-on: ubuntu-latest + steps: + - name: Checkout law-source-eurlex via external URL + run: | + set -eu + mkdir -p "$GITHUB_WORKSPACE" + cd "$GITHUB_WORKSPACE" + git init -q + git remote add origin \ + "https://x-access-token:${GITHUB_TOKEN}@git.flemming.ai/${GITHUB_REPOSITORY}.git" + git fetch --depth=1 origin "$GITHUB_SHA" + git checkout -q FETCH_HEAD + + - name: Configure git URL rewrite for SDK fetch + env: + SDK_PAT: ${{ secrets.MODULE_SDK_PAT }} + run: | + git config --global \ + "url.https://x-access-token:${SDK_PAT}@git.flemming.ai/.insteadOf" \ + "https://git.flemming.ai/" + + - name: Install system dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends \ + curl ca-certificates build-essential pkg-config libssl-dev git + + - name: Install Rust toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal \ + --default-toolchain "$RUST_TOOLCHAIN" \ + --target wasm32-wasip2 --component rustfmt --component clippy + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Cargo fmt --check + run: cargo fmt --all -- --check + + - name: Cargo clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Cargo build (host) + run: cargo build --all-targets + + - name: Cargo test (host) + run: cargo test --all-targets + + - name: Cargo build (wasm32-wasip2) + run: cargo build --release --target wasm32-wasip2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e66a2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +**/*.rs.bk +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4634242 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,709 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chain-module-sdk" +version = "0.3.0" +source = "git+https://git.flemming.ai/fai/chain-module-sdk-rust.git?tag=v0.3.0#a4415ada7e76aac5e3aabf3e4c4311ffc348d8d0" +dependencies = [ + "chain-module-sdk-macros", + "serde", + "serde_json", + "thiserror", + "wit-bindgen 0.36.0", +] + +[[package]] +name = "chain-module-sdk-macros" +version = "0.3.0" +source = "git+https://git.flemming.ai/fai/chain-module-sdk-rust.git?tag=v0.3.0#a4415ada7e76aac5e3aabf3e4c4311ffc348d8d0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "law_source_eurlex" +version = "0.1.0" +dependencies = [ + "chain-module-sdk", + "serde", + "serde_json", + "sha2", + "waki", +] + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waki" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2db2daf1dfbadf228fd8b3c22b96a359135fd673b3d2c203274ee6a0df9c77" +dependencies = [ + "anyhow", + "form_urlencoded", + "http", + "serde", + "waki-macros", + "wit-bindgen 0.34.0", +] + +[[package]] +name = "waki-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a061143f321cc5eeb523f60bdbcd45cfc3ee8851f8cf24f7a4b963bddc5642eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-encoder" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aa79bcd666a043b58f5fa62b221b0b914dd901e6f620e8ab7371057a797f3e1" +dependencies = [ + "leb128", + "wasmparser 0.219.2", +] + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser 0.220.1", +] + +[[package]] +name = "wasm-metadata" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ef51bd442042a2a7b562dddb6016ead52c4abab254c376dcffc83add2c9c34" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder 0.219.2", + "wasmparser 0.219.2", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder 0.220.1", + "wasmparser 0.220.1", +] + +[[package]] +name = "wasmparser" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5220ee4c6ffcc0cb9d7c47398052203bc902c8ef3985b0c8134118440c0b2921" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e11ad55616555605a60a8b2d1d89e006c2076f46c465c892cc2c153b20d4b30" +dependencies = [ + "wit-bindgen-rt 0.34.0", + "wit-bindgen-rust-macro 0.34.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt 0.36.0", + "wit-bindgen-rust-macro 0.36.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163cee59d3d5ceec0b256735f3ab0dccac434afb0ec38c406276de9c5a11e906" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.219.2", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.220.1", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744845cde309b8fa32408d6fb67456449278c66ea4dcd96de29797b302721f02" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6919521fc7807f927a739181db93100ca7ed03c29509b84d5f96b27b2e49a9a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata 0.219.2", + "wit-bindgen-core 0.34.0", + "wit-component 0.219.2", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata 0.220.1", + "wit-bindgen-core 0.36.0", + "wit-component 0.220.1", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c967731fc5d50244d7241ecfc9302a8929db508eea3c601fbc5371b196ba38a5" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core 0.34.0", + "wit-bindgen-rust 0.34.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core 0.36.0", + "wit-bindgen-rust 0.36.0", +] + +[[package]] +name = "wit-component" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b8479a29d81c063264c3ab89d496787ef78f8345317a2dcf6dece0f129e5fcd" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.219.2", + "wasm-metadata 0.219.2", + "wasmparser 0.219.2", + "wit-parser 0.219.2", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.220.1", + "wasm-metadata 0.220.1", + "wasmparser 0.220.1", + "wit-parser 0.220.1", +] + +[[package]] +name = "wit-parser" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca004bb251010fe956f4a5b9d4bf86b4e415064160dd6669569939e8cbf2504f" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.219.2", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.220.1", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..01dc945 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,48 @@ +# Standalone Cargo.toml — targets wasm32-wasip2. +# +# Build with: +# cargo build --release --target wasm32-wasip2 + +[package] +name = "law_source_eurlex" +version = "0.1.0" +edition = "2024" +authors = ["Dr. Stefan Flemming "] +license = "Apache-2.0" +publish = false +description = "F∆I module — fetch EU legal acts from EUR-Lex by CELEX number (FORMEX-4 XML, content-addressed)" +repository = "https://git.flemming.ai/chain-modules/law-source-eurlex" +rust-version = "1.85" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +chain-module-sdk = { git = "https://git.flemming.ai/fai/chain-module-sdk-rust.git", tag = "v0.3.0" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" + +# WASI-0.2 HTTP client — same one http-request / llm-chat use. +# Only compiled into the wasm32-wasip2 build. +[target.'cfg(target_arch = "wasm32")'.dependencies] +waki = "0.5" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = "symbols" + +[lints.rust] +unsafe_op_in_unsafe_fn = "warn" + +[lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +print_stdout = "deny" +print_stderr = "deny" + +[workspace] +# Empty workspace table so this package is NOT picked up by a parent. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cdde5b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright 2026 Flemming.AI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/MODULE.de.md b/MODULE.de.md new file mode 100644 index 0000000..a6574c0 --- /dev/null +++ b/MODULE.de.md @@ -0,0 +1,46 @@ +# source.eu-recht + + + +![law-source-eurlex inputs and outputs](law-source-eurlex.io.de.svg) + + +EU-Rechtsakte per CELEX-Nummer von EUR-Lex holen + +## Was es tut + +Löst eine CELEX-Nummer auf (z. B. `32016R0679` für die DSGVO oder +konsolidiert `02016R0679-20160504`), ruft den Rechtsakt vom +öffentlichen EUR-Lex-Endpunkt ab und liefert das FORMEX-4-XML plus +einen Metadaten-Datensatz. Die geholten Bytes sind inhaltsadressiert +(SHA-256) — das Prüfprotokoll des Flows hält damit den exakten +Gesetzesstand fest; ein späterer Abruf „derselben" CELEX-Nummer, den +EUR-Lex anders rendert, wird sichtbar statt still. + +Ausschließlich öffentliche EUR-Lex-Daten; die Netzwerk-Berechtigung +ist auf `publications.europa.eu` festgelegt (CELLAR, der Speicher des +EU-Amts für Veröffentlichungen hinter EUR-Lex). + +## Verwendung + +```yaml +steps: + - id: fetch + use: source.eu-recht@^0 + with: + celex: "32016R0679" + language: "DE" # optional, Standard DE + - id: normalize + use: text.akoma-normalize@^0 # ab 0.2 mit FORMEX-4-Adapter + with: + content: "{{ fetch.formex }}" + mime: "application/x-formex+xml" +``` + +Ausgaben: `formex` (Bytes, `application/xml`) und `meta` (JSON mit +`celex`, `language`, `url`, `source_sha256`, `byte_len` sowie +`consolidation_date` bei konsolidierten CELEX-Nummern). + +## Lizenz + +Apache-2.0 — ein Ch∆In-Plattform-Modul (Flemming.AI). diff --git a/MODULE.md b/MODULE.md new file mode 100644 index 0000000..6886e4a --- /dev/null +++ b/MODULE.md @@ -0,0 +1,46 @@ +# source.eu-recht + + + +![law-source-eurlex inputs and outputs](law-source-eurlex.io.svg) + + +Fetch EU legal acts from EUR-Lex by CELEX number + +## What it does + +Resolves a CELEX identifier (e.g. `32016R0679` for the GDPR, or the +consolidated form `02016R0679-20160504`) against the public EUR-Lex +CELLAR endpoint and returns the act's FORMEX-4 XML plus a +metadata record. The fetched bytes are content-addressed (SHA-256), +so the flow's audit trail pins the exact Gesetzesstand that was +processed — a later re-fetch of "the same" CELEX that renders +differently upstream becomes visible instead of silent. + +Public EUR-Lex data only; network permission is pinned to +`publications.europa.eu` (CELLAR, the EU Publications Office store +behind EUR-Lex). + +## How to use + +```yaml +steps: + - id: fetch + use: source.eu-recht@^0 + with: + celex: "32016R0679" + language: "DE" # optional, default DE + - id: normalize + use: text.akoma-normalize@^0 # 0.2+ understands FORMEX-4 + with: + content: "{{ fetch.formex }}" + mime: "application/x-formex+xml" +``` + +Outputs: `formex` (bytes, `application/xml`) and `meta` (JSON with +`celex`, `language`, `url`, `source_sha256`, `byte_len`, and +`consolidation_date` for consolidated CELEX numbers). + +## License + +Apache-2.0 — a Ch∆In platform module (Flemming.AI). diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..86eef87 --- /dev/null +++ b/NOTICE @@ -0,0 +1,28 @@ +law-source-eurlex +Copyright 2026 Flemming.AI + +This product includes software developed by Dr. Stefan Flemming +and contributors (https://flemming.ai/). + +------------------------------------------------------------------ +Third-party acknowledgments + +XML parsing uses the `quick-xml` crate +(https://github.com/tafia/quick-xml), MIT licensed. + +SHA-256 hashing uses the `sha2` crate +(https://github.com/RustCrypto/hashes), Apache-2.0 / MIT licensed. + +Module SDK glue is provided by `chain-module-sdk` +(https://git.flemming.ai/fai/chain-module-sdk-rust), Apache-2.0. + +------------------------------------------------------------------ +Methodological lineage + +The output schema is aligned with Akoma Ntoso 1.0 (OASIS +LegalDocML), the XML format the European Parliament has used +since 2016 for legislative texts. + +ELI synthesis in v0.1.0 is a deterministic fallback because +Germany lacks an upstream Bund-level ELI registrar. Replace once +BMJ publishes ELI URIs upstream. diff --git a/STORE.yaml b/STORE.yaml new file mode 100644 index 0000000..aa1cd36 --- /dev/null +++ b/STORE.yaml @@ -0,0 +1,28 @@ +schema_version: 1 + +author: + name: "Flemming.AI" + email: chain@flemming.ai + url: https://flemming.ai + +license: Apache-2.0 + +tagline: + en: "Fetch EU legal acts from EUR-Lex by CELEX" + de: "EU-Rechtsakte per CELEX von EUR-Lex holen" + +description: + en: "Resolves a CELEX number against the public EUR-Lex endpoint and returns the act's FORMEX-4 XML, content-addressed (SHA-256) so the audit trail pins the exact legal state processed." + de: "Löst eine CELEX-Nummer gegen den öffentlichen EUR-Lex-Endpunkt auf und liefert das FORMEX-4-XML des Rechtsakts, inhaltsadressiert (SHA-256) — das Prüfprotokoll hält den exakten Gesetzesstand fest." + +category: data +tags: [legal, eu, eur-lex, formex, source] + +assets: + icon: assets/icon.svg + +documentation: + long_description: MODULE.md + +links: + repository: https://git.flemming.ai/chain-modules/law-source-eurlex diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..86d387c --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/law-source-eurlex.io.de.svg b/law-source-eurlex.io.de.svg new file mode 100644 index 0000000..639e552 --- /dev/null +++ b/law-source-eurlex.io.de.svg @@ -0,0 +1,36 @@ +law-source-eurlexv0.1.0source.eu-recht@0.1.0EINGÄNGEAUSGÄNGElaw-source-eurlexcelex · textCELEX-Nummer des Rechtsakts, z. B.32016R0679 (DSGVO) oder konsolidiert02016R0679-20160504. Optionaleslanguage · textZwei-Buchstaben-Sprachcode von EUR-Lex(Standard DE; EN, FR, …). Wird gegendie 24 Amtssprachen-Codes geprüft.formex · bytesFORMEX-4-XML wie von EUR-Lex geliefert(application/xml). Für diestrukturierte Akoma-Ntoso-Darstellung anmeta · jsonJSON-codiertes SourceMeta (Felder s.EN). source_sha256 ist derVersionsanker: derselbe CELEX kannBerechtigungen: net: publications.europa.eu \ No newline at end of file diff --git a/law-source-eurlex.io.svg b/law-source-eurlex.io.svg new file mode 100644 index 0000000..e43ccab --- /dev/null +++ b/law-source-eurlex.io.svg @@ -0,0 +1,36 @@ +law-source-eurlexv0.1.0source.eu-recht@0.1.0INPUTSOUTPUTSlaw-source-eurlexcelex · textCELEX identifier of the act, e.g.32016R0679 (GDPR) or the consolidatedform 02016R0679-20160504. An optionallanguage · textTwo-letter EUR-Lex language code (DEdefault; EN, FR, …). Validated againstthe 24 official-language codes.formex · bytesFORMEX-4 XML body as served by EUR-Lex(application/xml). Feed intotext.akoma-normalize (0.2+) for themeta · jsonJSON-encoded SourceMeta: {schema_version: "0.1", celex:"32016R0679", language: "DE", url:Permissions: net: publications.europa.eu \ No newline at end of file diff --git a/module.wasm b/module.wasm new file mode 100644 index 0000000..d600458 Binary files /dev/null and b/module.wasm differ diff --git a/module.yaml b/module.yaml new file mode 100644 index 0000000..0cb03ff --- /dev/null +++ b/module.yaml @@ -0,0 +1,77 @@ +schema_version: 3 +provider: chain +name: law-source-eurlex +version: 0.1.0 + +# Capability provided by this module. +# +# Fetches one EU legal act from EUR-Lex by CELEX number and returns +# the FORMEX-4 XML body content-addressed (SHA-256), so a flow's +# audit trail pins the exact Gesetzesstand it processed. Public +# EUR-Lex data only — no authentication, no personal data. +provides: + - capability: source.eu-recht + version: 0.1.0 + +inputs: + celex: + type: text + description: + en: | + CELEX identifier of the act, e.g. 32016R0679 (GDPR) or the + consolidated form 02016R0679-20160504. An optional CELEX: + prefix and lowercase letters are accepted. + de: | + CELEX-Nummer des Rechtsakts, z. B. 32016R0679 (DSGVO) oder + konsolidiert 02016R0679-20160504. Optionales CELEX:-Präfix + und Kleinbuchstaben werden akzeptiert. + language: + type: text + optional: true + description: + en: | + Two-letter EUR-Lex language code (DE default; EN, FR, …). + Validated against the 24 official-language codes. + de: | + Zwei-Buchstaben-Sprachcode von EUR-Lex (Standard DE; EN, + FR, …). Wird gegen die 24 Amtssprachen-Codes geprüft. + +outputs: + formex: + type: bytes + description: + en: | + FORMEX-4 XML body as served by EUR-Lex + (application/xml). Feed into text.akoma-normalize (0.2+) + for the structured Akoma-Ntoso-aligned representation. + de: | + FORMEX-4-XML wie von EUR-Lex geliefert (application/xml). + Für die strukturierte Akoma-Ntoso-Darstellung an + text.akoma-normalize (0.2+) weiterreichen. + meta: + type: json + description: + en: | + JSON-encoded SourceMeta: + { + schema_version: "0.1", + celex: "32016R0679", + language: "DE", + url: "https://eur-lex.europa.eu/legal-content/…", + source_sha256: "…", // hash of the fetched bytes + byte_len: 123456, + consolidation_date: "2016-05-04" // only for 0…-YYYYMMDD CELEX + } + source_sha256 is the versioning anchor: the same CELEX can + legitimately re-render upstream; the hash makes that visible. + de: | + JSON-codiertes SourceMeta (Felder s. EN). source_sha256 ist + der Versionsanker: derselbe CELEX kann upstream neu gerendert + werden; der Hash macht das sichtbar statt still. + +# Permissions required. +# +# One pinned host: the module talks to the EU Publications Office +# (CELLAR — the store behind EUR-Lex) and nothing else. +permissions: + - "net: publications.europa.eu" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..32add0c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.86" +targets = ["wasm32-wasip2"] +components = ["rustfmt", "clippy"] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..31b8d0d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,508 @@ +//! `source.eu-recht` module — fetch one EU legal act from EUR-Lex +//! by CELEX number and hand its FORMEX-4 XML downstream, content- +//! addressed so a flow's audit trail pins the exact Gesetzesstand. +//! +//! # Capability +//! +//! - **Input:** +//! * `celex` — `text`, the CELEX identifier (e.g. `32016R0679` +//! for the GDPR, or a consolidated form like +//! `02016R0679-20160504`). +//! * `language` — optional `text`, two-letter EUR-Lex language +//! code (`DE` default, `EN`, `FR`, …). +//! - **Output:** +//! * `formex` — `bytes` (`application/xml`), the FORMEX-4 body +//! as served by EUR-Lex. Feed it to `text.akoma-normalize` +//! (0.2+) for the structured representation. +//! * `meta` — `json` SourceMeta (see below): CELEX, language, +//! the resolved URL, SHA-256 of the fetched bytes, and the +//! consolidation date when the CELEX carries one. +//! +//! # Versioning ("Gesetzesstand") +//! +//! EUR-Lex encodes consolidated versions in the CELEX itself +//! (sector `0` + `-YYYYMMDD` suffix). The module never guesses a +//! version: it fetches exactly the CELEX it was given and returns +//! `source_sha256` so downstream steps and the audit log can prove +//! which bytes the flow saw. Fetching "the same" CELEX later can +//! legitimately yield different bytes (EUR-Lex re-renders) — the +//! hash makes that visible instead of silent. +//! +//! # Scope +//! +//! Public EUR-Lex data only; no authentication, no personal data. +//! Network permission is pinned to `publications.europa.eu` (CELLAR, +//! the EU Publications Office store behind EUR-Lex). + +#![allow(clippy::result_large_err)] + +use chain_module_sdk::prelude::*; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const DEFAULT_LANGUAGE: &str = "DE"; + +/// Languages EUR-Lex serves: the operator-facing two-letter code +/// (as EUR-Lex uses in its UI) mapped to the ISO-639-3 code the +/// CELLAR `Accept-Language` header expects. Validated so a typo +/// fails fast here instead of as a puzzling 404 from the remote +/// server. +const LANGUAGES: &[(&str, &str)] = &[ + ("BG", "bul"), + ("ES", "spa"), + ("CS", "ces"), + ("DA", "dan"), + ("DE", "deu"), + ("ET", "est"), + ("EL", "ell"), + ("EN", "eng"), + ("FR", "fra"), + ("GA", "gle"), + ("HR", "hrv"), + ("IT", "ita"), + ("LV", "lav"), + ("LT", "lit"), + ("HU", "hun"), + ("MT", "mlt"), + ("NL", "nld"), + ("PL", "pol"), + ("PT", "por"), + ("RO", "ron"), + ("SK", "slk"), + ("SL", "slv"), + ("FI", "fin"), + ("SV", "swe"), +]; + +fn lang3_for(two: &str) -> Option<&'static str> { + LANGUAGES + .iter() + .find(|(code, _)| *code == two) + .map(|(_, three)| *three) +} + +/// Cap on how many manifestation items the module downloads while +/// looking for the act body — CELLAR lists cover pages and annex +/// volumes as separate DOC_n items. +const MAX_ITEMS: usize = 10; + +/// Cap on any single response body. waki buffers the response in +/// memory before we see it, so this cannot stop the transfer — but +/// it turns an absurd body into a clean module error instead of a +/// memory-exhaustion trap further down (the WASM memory ceiling +/// backstops the transfer itself). +const MAX_BODY_BYTES: usize = 64 * 1024 * 1024; + +/// Redirect hops the module follows (CELLAR answers `303 See +/// Other` for some content negotiations). Targets must stay on +/// publications.europa.eu — anything else fails loudly instead of +/// silently leaving the permission's intent. +const MAX_REDIRECTS: usize = 3; + +/// The only host this module talks to; mirrors the `net:` grant in +/// module.yaml. Redirects and listing items are pinned to it as +/// defense-in-depth — the hub enforces the permission either way. +const ALLOWED_URL_PREFIX: &str = "https://publications.europa.eu/"; + +#[derive(Debug, Serialize)] +struct SourceMeta { + schema_version: &'static str, + celex: String, + /// The REQUESTED language (Accept-Language negotiation). CELLAR + /// does not guarantee delivery in this language for acts that + /// were never published in it; the authoritative record of what + /// was actually delivered is `source_sha256` + `url`. Verifying + /// the delivered language from FORMEX internals is future work. + language: String, + url: String, + source_sha256: String, + byte_len: usize, + /// `YYYY-MM-DD` when the CELEX names a consolidated version + /// (sector-0 CELEX with `-YYYYMMDD` suffix); absent otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + consolidation_date: Option, +} + +#[chain_module] +pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { + let celex_raw = inputs.require_text("celex")?; + let celex = normalize_celex(celex_raw)?; + + let language = match inputs.get("language") { + Some(Payload::Text(s)) if !s.trim().is_empty() => { + let lang = s.trim().to_ascii_uppercase(); + if lang3_for(&lang).is_none() { + return Err(ModuleError::invalid_input(format!( + "unsupported EUR-Lex language '{lang}' \ + (two-letter code expected, e.g. DE, EN, FR)" + ))); + } + lang + } + _ => DEFAULT_LANGUAGE.to_string(), + }; + + let (body, url) = fetch_formex(&celex, &language)?; + + let meta = SourceMeta { + schema_version: "0.1", + celex: celex.clone(), + language, + url, + source_sha256: hex_sha256(&body), + byte_len: body.len(), + consolidation_date: consolidation_date(&celex), + }; + + Outputs::new() + .with_bytes("formex", "application/xml", body) + .with_json("meta", &meta) +} + +/// Validate + canonicalise a CELEX identifier. Accepts the plain +/// form (`32016R0679`) and the consolidated form +/// (`02016R0679-20160504`); strips surrounding whitespace and an +/// optional `CELEX:` prefix, upper-cases. Anything else is refused +/// with a hint rather than URL-encoded into a mystery 404. +fn normalize_celex(raw: &str) -> Result { + let upper = raw.trim().to_ascii_uppercase(); + let s = upper.strip_prefix("CELEX:").unwrap_or(&upper); + let s = s.trim().to_string(); + let valid_len = (8..=24).contains(&s.len()); + let valid_chars = s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '(' || c == ')'); + let starts_with_sector = s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()); + if !(valid_len && valid_chars && starts_with_sector) { + return Err(ModuleError::invalid_input(format!( + "'{raw}' is not a CELEX number \ + (expected e.g. 32016R0679, or consolidated 02016R0679-20160504)" + ))); + } + Ok(s) +} + +/// CELLAR resource URL for a CELEX identifier. Content negotiation +/// (`Accept: application/xml;type=fmx4` + `Accept-Language`) selects +/// the FORMEX-4 manifestation in the requested language. +fn cellar_url(celex: &str) -> String { + format!("https://publications.europa.eu/resource/celex/{celex}") +} + +/// Item URLs out of CELLAR's `300 Multiple-Choice` XHTML listing — +/// one `…/cellar/./DOC_n` link per manifestation item. +/// Plain string scan on the href attributes; the listing is machine- +/// generated and flat, an HTML parser would be dead weight here. +fn parse_item_urls(listing: &[u8]) -> Vec { + let text = String::from_utf8_lossy(listing); + let mut out = Vec::new(); + for part in text.split("href=\"").skip(1) { + let Some(end) = part.find('"') else { continue }; + let url = part[..end].replace("http://", "https://"); + // Host-pinned: a manipulated listing must not be able to + // point the module anywhere but CELLAR itself. + if url.starts_with(ALLOWED_URL_PREFIX) + && url.contains("/resource/cellar/") + && url.contains("/DOC_") + && !out.contains(&url) + { + out.push(url); + } + } + out +} + +/// True for the manifestation item that carries the act body (the +/// others are cover pages / tables of contents). +fn is_act_body(body: &[u8]) -> bool { + looks_like_formex(body) && { + let text = String::from_utf8_lossy(body); + text.contains(" Option { + let (_, tail) = celex.rsplit_once('-')?; + if tail.len() != 8 || !tail.chars().all(|c| c.is_ascii_digit()) { + return None; + } + Some(format!("{}-{}-{}", &tail[..4], &tail[4..6], &tail[6..8])) +} + +/// Cheap shape check: XML declaration or an XML root tag within the +/// leading bytes. EUR-Lex error pages are HTML (``). +fn looks_like_formex(body: &[u8]) -> bool { + let head = body.get(..512.min(body.len())).unwrap_or_default(); + let head = String::from_utf8_lossy(head); + let head = head.trim_start(); + if head.starts_with(" String { + let mut hasher = Sha256::new(); + hasher.update(data); + let digest = hasher.finalize(); + let mut s = String::with_capacity(64); + for byte in digest { + s.push(nibble_to_hex(byte >> 4)); + s.push(nibble_to_hex(byte & 0x0f)); + } + s +} + +fn nibble_to_hex(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'a' + n - 10) as char, + _ => '?', + } +} + +/// Resolve a CELEX to its FORMEX-4 act body via CELLAR content +/// negotiation. The celex resource URL answers either 200 (single +/// manifestation item — the body itself) or 300 with an XHTML +/// listing of `DOC_n` items; in the latter case the items are +/// fetched in order until the one carrying the enacting terms is +/// found (cover pages and TOCs come first and are tiny). +fn fetch_formex(celex: &str, language: &str) -> Result<(Vec, String), ModuleError> { + let lang3 = lang3_for(language).unwrap_or("deu"); + let url = cellar_url(celex); + let (status, body, url) = fetch_redirected(&url, lang3)?; + + if status == 200 && is_act_body(&body) { + return Ok((body, url)); + } + if status == 300 || status == 200 { + let items = parse_item_urls(&body); + if items.is_empty() { + // A direct 200 that is FORMEX but carries no enacting + // terms IS the document — corrigenda and information + // notices have no ENACTING.TERMS, and CELLAR may serve + // them as a single manifestation without a listing. + if status == 200 && looks_like_formex(&body) { + return Ok((body, url)); + } + return Err(ModuleError::invalid_input(format!( + "EUR-Lex/CELLAR listed no FORMEX documents for CELEX {celex} \ + ({language}) — check the CELEX number" + ))); + } + let mut fallback: Option<(Vec, String)> = None; + for item in items.into_iter().take(MAX_ITEMS) { + let (s, b, item) = fetch_redirected(&item, lang3)?; + if s != 200 { + continue; + } + if is_act_body(&b) { + return Ok((b, item)); + } + // Remember the largest plain-FORMEX item as a fallback + // for document types without ENACTING.TERMS (corrigenda, + // information notices). + if looks_like_formex(&b) && fallback.as_ref().is_none_or(|(fb, _)| b.len() > fb.len()) { + fallback = Some((b, item)); + } + } + return fallback.ok_or_else(|| { + ModuleError::internal(format!( + "none of the CELLAR items for CELEX {celex} ({language}) \ + contained FORMEX XML" + )) + }); + } + Err(ModuleError::internal(format!( + "EUR-Lex/CELLAR answered HTTP {status} for {url}" + ))) +} + +/// One GET following up to [`MAX_REDIRECTS`] redirect hops (CELLAR +/// answers `303 See Other` for some content negotiations). Every +/// hop must stay on publications.europa.eu; each body is bounded by +/// [`MAX_BODY_BYTES`]. Returns the final URL alongside status and +/// body so `meta.url` names what was actually fetched. +fn fetch_redirected(url: &str, lang3: &str) -> Result<(u16, Vec, String), ModuleError> { + let mut url = url.to_string(); + for _ in 0..=MAX_REDIRECTS { + let (status, body, location) = fetch(&url, lang3)?; + if body.len() > MAX_BODY_BYTES { + return Err(ModuleError::internal(format!( + "EUR-Lex/CELLAR answered {} bytes for {url} — over the \ + {MAX_BODY_BYTES}-byte safety cap", + body.len() + ))); + } + if matches!(status, 301 | 302 | 303 | 307 | 308) { + let Some(target) = location else { + return Err(ModuleError::internal(format!( + "EUR-Lex/CELLAR answered HTTP {status} for {url} \ + without a Location header" + ))); + }; + // Resolve a relative Location against the CELLAR host. + let target = if target.starts_with('/') { + format!("https://publications.europa.eu{target}") + } else { + target.replace("http://", "https://") + }; + if !target.starts_with(ALLOWED_URL_PREFIX) { + return Err(ModuleError::internal(format!( + "EUR-Lex/CELLAR redirected {url} off-host to {target} — \ + refusing (module talks to publications.europa.eu only)" + ))); + } + url = target; + continue; + } + return Ok((status, body, url)); + } + Err(ModuleError::internal(format!( + "EUR-Lex/CELLAR redirect chain for {url} exceeded \ + {MAX_REDIRECTS} hops" + ))) +} + +/// WASM path: one GET with FORMEX content negotiation. Returns +/// status, body and the Location header (redirects are NOT +/// auto-followed under wasi-http — `fetch_redirected` handles +/// them). The hub's sandbox enforces the +/// `net: publications.europa.eu` permission. +#[cfg(target_arch = "wasm32")] +fn fetch(url: &str, lang3: &str) -> Result<(u16, Vec, Option), ModuleError> { + let lang_value = waki::header::HeaderValue::from_str(lang3) + .map_err(|e| ModuleError::internal(format!("invalid language header: {e}")))?; + let response = waki::Client::new() + .get(url) + .header( + waki::header::HeaderName::from_static("accept"), + waki::header::HeaderValue::from_static("application/xml;type=fmx4"), + ) + .header( + waki::header::HeaderName::from_static("accept-language"), + lang_value, + ) + .send() + .map_err(|e| ModuleError::internal(format!("EUR-Lex request failed: {e}")))?; + let status = response.status_code(); + let location = response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let body = response + .body() + .map_err(|e| ModuleError::internal(format!("EUR-Lex body read failed: {e}")))?; + Ok((status, body, location)) +} + +/// Host-side stub. Lets `cargo test` (no wasm target) link the rlib +/// without pulling waki; the real path is the wasm32 variant above. +#[cfg(not(target_arch = "wasm32"))] +fn fetch(_url: &str, _lang3: &str) -> Result<(u16, Vec, Option), ModuleError> { + Err(ModuleError::internal( + "source.eu-recht can only fetch under the WASM runtime", + )) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use super::*; + + #[test] + fn accepts_plain_celex() { + assert_eq!(normalize_celex("32016R0679").unwrap(), "32016R0679"); + assert_eq!(normalize_celex(" celex:32016r0679 ").unwrap(), "32016R0679"); + } + + #[test] + fn accepts_consolidated_celex_and_extracts_date() { + let c = normalize_celex("02016R0679-20160504").unwrap(); + assert_eq!(c, "02016R0679-20160504"); + assert_eq!(consolidation_date(&c).as_deref(), Some("2016-05-04")); + assert_eq!(consolidation_date("32016R0679"), None); + } + + #[test] + fn rejects_garbage_celex() { + assert!(normalize_celex("").is_err()); + assert!(normalize_celex("not a celex!").is_err()); + assert!(normalize_celex("https://eur-lex.europa.eu/x").is_err()); + } + + #[test] + fn builds_the_cellar_url() { + assert_eq!( + cellar_url("32016R0679"), + "https://publications.europa.eu/resource/celex/32016R0679" + ); + } + + #[test] + fn maps_two_letter_language_to_iso639_3() { + assert_eq!(lang3_for("DE"), Some("deu")); + assert_eq!(lang3_for("EN"), Some("eng")); + assert_eq!(lang3_for("XX"), None); + } + + /// Trimmed copy of CELLAR's real `300 Multiple-Choice` XHTML + /// (as served for CELEX 32016R0679, 2026-07). + const SAMPLE_LISTING: &str = r#"300 Multiple-Choice Response List of URI's:
  • cellar:3e485e15-11bd-11e6-ba9a-01aa75ed71a1.0004.02
"#; + + #[test] + fn parses_doc_items_from_multiple_choice_listing() { + let items = parse_item_urls(SAMPLE_LISTING.as_bytes()); + assert_eq!(items.len(), 2); + assert!(items[0].starts_with("https://publications.europa.eu/resource/cellar/")); + assert!(items[0].ends_with("/DOC_1")); + assert!(items[1].ends_with("/DOC_2")); + assert!(parse_item_urls(b"no links").is_empty()); + } + + #[test] + fn off_host_listing_items_are_filtered() { + // A manipulated listing must not steer the module anywhere + // but CELLAR itself, even before the hub's net permission + // would refuse the request. + let evil = br#"x +y +ok"#; + let items = parse_item_urls(evil); + assert_eq!(items.len(), 1); + assert!(items[0].starts_with("https://publications.europa.eu/")); + } + + #[test] + fn act_body_detection_tells_act_from_toc() { + let act = b"
"; + let toc = + b""; + assert!(is_act_body(act)); + assert!(!is_act_body(toc)); + } + + #[test] + fn formex_shape_check_accepts_xml_rejects_html() { + assert!(looks_like_formex(b"...")); + assert!(looks_like_formex(b"")); + assert!(!looks_like_formex( + b"Not found" + )); + assert!(!looks_like_formex( + b"" + )); + assert!(!looks_like_formex(b"")); + } + + #[test] + fn hashes_are_hex_and_deterministic() { + let a = hex_sha256(b"abc"); + assert_eq!(a.len(), 64); + assert_eq!(a, hex_sha256(b"abc")); + } +}