feat: law-source-eurlex 0.1.0 (source.eu-recht)
All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 1m45s

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-17 00:29:06 +02:00
commit 0e768ae6db
16 changed files with 1726 additions and 0 deletions

66
.forgejo/workflows/ci.yml Normal file
View file

@ -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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
target/
**/*.rs.bk
.DS_Store

709
Cargo.lock generated Normal file
View file

@ -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"

48
Cargo.toml Normal file
View file

@ -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 <chain@flemming.ai>"]
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.

73
LICENSE Normal file
View file

@ -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.

46
MODULE.de.md Normal file
View file

@ -0,0 +1,46 @@
# source.eu-recht
<!-- chain:io-card:start -->
<!-- Generated by `chain doc` from module.yaml — do not edit by hand. -->
![law-source-eurlex inputs and outputs](law-source-eurlex.io.de.svg)
<!-- chain:io-card:end -->
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).

46
MODULE.md Normal file
View file

@ -0,0 +1,46 @@
# source.eu-recht
<!-- chain:io-card:start -->
<!-- Generated by `chain doc` from module.yaml — do not edit by hand. -->
![law-source-eurlex inputs and outputs](law-source-eurlex.io.svg)
<!-- chain:io-card:end -->
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).

28
NOTICE Normal file
View file

@ -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.

28
STORE.yaml Normal file
View file

@ -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

18
assets/icon.svg Normal file
View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="12" fill="#08090A"/>
<circle cx="32" cy="32" r="17" fill="none" stroke="#E9E7E2" stroke-width="3"/>
<g fill="#2E8F9E">
<circle cx="32" cy="18.5" r="2"/>
<circle cx="38.75" cy="20.3" r="2"/>
<circle cx="43.7" cy="25.25" r="2"/>
<circle cx="45.5" cy="32" r="2"/>
<circle cx="43.7" cy="38.75" r="2"/>
<circle cx="38.75" cy="43.7" r="2"/>
<circle cx="32" cy="45.5" r="2"/>
<circle cx="25.25" cy="43.7" r="2"/>
<circle cx="20.3" cy="38.75" r="2"/>
<circle cx="18.5" cy="32" r="2"/>
<circle cx="20.3" cy="25.25" r="2"/>
<circle cx="25.25" cy="20.3" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 736 B

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1012 300" width="1012" height="300" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" role="img" aria-label="Module I/O card for law-source-eurlex"><style>
.bg{fill:#fafbfb;stroke:#e4e7e9;stroke-width:1}
.title{fill:#12171a;font-size:24px;font-weight:700}
.ver{fill:#6b7580;font-size:16px;font-weight:500}
.cap{fill:#2e8f9e;font-size:14px;font-weight:600;letter-spacing:.02em}
.section{fill:#8a939b;font-size:11px;font-weight:700;letter-spacing:.14em}
.body{fill:#ffffff;stroke:#2e8f9e;stroke-width:2}
.bodyname{fill:#12171a;font-size:18px;font-weight:700}
.wire{stroke:#c2cace;stroke-width:2}
.port{stroke:#fafbfb;stroke-width:2}
.port.in{fill:#2e8f9e}
.port.out{fill:#b4703a}
.fname{fill:#12171a;font-size:15px;font-weight:600}
.ftype{fill:#6b7580;font-size:13px;font-weight:500}
.fdesc{fill:#5b656d;font-size:12.5px}
.rule{stroke:#e4e7e9;stroke-width:1}
.footer{fill:#6b7580;font-size:12.5px}
@media (prefers-color-scheme: dark){
.bg{fill:#12161a;stroke:#252b31}
.title{fill:#eef1f2}
.ver{fill:#9aa3ab}
.cap{fill:#4fb3c2}
.section{fill:#7c868e}
.body{fill:#1a2025;stroke:#4fb3c2}
.bodyname{fill:#eef1f2}
.wire{stroke:#39424a}
.port{stroke:#12161a}
.port.in{fill:#4fb3c2}
.port.out{fill:#d08a52}
.fname{fill:#eef1f2}
.ftype{fill:#7c868e}
.fdesc{fill:#a7b0b7}
.rule{stroke:#252b31}
.footer{fill:#9aa3ab}
}
</style><rect class="bg" x="0" y="0" width="1012" height="300" rx="16"/><text class="title" x="32" y="44">law-source-eurlex<tspan class="ver" dx="10">v0.1.0</tspan></text><text class="cap" x="32" y="70">source.eu-recht@0.1.0</text><text class="section" x="32" y="80">EINGÄNGE</text><text class="section" text-anchor="end" x="980" y="80">AUSGÄNGE</text><rect class="body" x="396" y="100" width="220" height="136" rx="14"/><text class="bodyname" text-anchor="middle" x="506" y="173">law-source-eurlex</text><line class="wire" x1="352" y1="130" x2="396" y2="130"/><circle class="port in" cx="396" cy="130" r="6"/><text class="fname" text-anchor="end" x="340" y="122">celex<tspan class="ftype"> · text</tspan></text><text class="fdesc" text-anchor="end" x="340" y="142">CELEX-Nummer des Rechtsakts, z. B.</text><text class="fdesc" text-anchor="end" x="340" y="157">32016R0679 (DSGVO) oder konsolidiert</text><text class="fdesc" text-anchor="end" x="340" y="172">02016R0679-20160504. Optionales</text><line class="wire" x1="352" y1="206" x2="396" y2="206"/><circle class="port in" cx="396" cy="206" r="6"/><text class="fname" text-anchor="end" x="340" y="198">language<tspan class="ftype"> · text</tspan></text><text class="fdesc" text-anchor="end" x="340" y="218">Zwei-Buchstaben-Sprachcode von EUR-Lex</text><text class="fdesc" text-anchor="end" x="340" y="233">(Standard DE; EN, FR, …). Wird gegen</text><text class="fdesc" text-anchor="end" x="340" y="248">die 24 Amtssprachen-Codes geprüft.</text><line class="wire" x1="616" y1="130" x2="660" y2="130"/><circle class="port out" cx="616" cy="130" r="6"/><text class="fname" text-anchor="start" x="672" y="122">formex<tspan class="ftype"> · bytes</tspan></text><text class="fdesc" text-anchor="start" x="672" y="142">FORMEX-4-XML wie von EUR-Lex geliefert</text><text class="fdesc" text-anchor="start" x="672" y="157">(application/xml). Für die</text><text class="fdesc" text-anchor="start" x="672" y="172">strukturierte Akoma-Ntoso-Darstellung an</text><line class="wire" x1="616" y1="206" x2="660" y2="206"/><circle class="port out" cx="616" cy="206" r="6"/><text class="fname" text-anchor="start" x="672" y="198">meta<tspan class="ftype"> · json</tspan></text><text class="fdesc" text-anchor="start" x="672" y="218">JSON-codiertes SourceMeta (Felder s.</text><text class="fdesc" text-anchor="start" x="672" y="233">EN). source_sha256 ist der</text><text class="fdesc" text-anchor="start" x="672" y="248">Versionsanker: derselbe CELEX kann</text><line class="rule" x1="32" y1="252" x2="980" y2="252"/><text class="footer" x="32" y="278">Berechtigungen: net: publications.europa.eu</text></svg>

After

Width:  |  Height:  |  Size: 4 KiB

36
law-source-eurlex.io.svg Normal file
View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1012 300" width="1012" height="300" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" role="img" aria-label="Module I/O card for law-source-eurlex"><style>
.bg{fill:#fafbfb;stroke:#e4e7e9;stroke-width:1}
.title{fill:#12171a;font-size:24px;font-weight:700}
.ver{fill:#6b7580;font-size:16px;font-weight:500}
.cap{fill:#2e8f9e;font-size:14px;font-weight:600;letter-spacing:.02em}
.section{fill:#8a939b;font-size:11px;font-weight:700;letter-spacing:.14em}
.body{fill:#ffffff;stroke:#2e8f9e;stroke-width:2}
.bodyname{fill:#12171a;font-size:18px;font-weight:700}
.wire{stroke:#c2cace;stroke-width:2}
.port{stroke:#fafbfb;stroke-width:2}
.port.in{fill:#2e8f9e}
.port.out{fill:#b4703a}
.fname{fill:#12171a;font-size:15px;font-weight:600}
.ftype{fill:#6b7580;font-size:13px;font-weight:500}
.fdesc{fill:#5b656d;font-size:12.5px}
.rule{stroke:#e4e7e9;stroke-width:1}
.footer{fill:#6b7580;font-size:12.5px}
@media (prefers-color-scheme: dark){
.bg{fill:#12161a;stroke:#252b31}
.title{fill:#eef1f2}
.ver{fill:#9aa3ab}
.cap{fill:#4fb3c2}
.section{fill:#7c868e}
.body{fill:#1a2025;stroke:#4fb3c2}
.bodyname{fill:#eef1f2}
.wire{stroke:#39424a}
.port{stroke:#12161a}
.port.in{fill:#4fb3c2}
.port.out{fill:#d08a52}
.fname{fill:#eef1f2}
.ftype{fill:#7c868e}
.fdesc{fill:#a7b0b7}
.rule{stroke:#252b31}
.footer{fill:#9aa3ab}
}
</style><rect class="bg" x="0" y="0" width="1012" height="300" rx="16"/><text class="title" x="32" y="44">law-source-eurlex<tspan class="ver" dx="10">v0.1.0</tspan></text><text class="cap" x="32" y="70">source.eu-recht@0.1.0</text><text class="section" x="32" y="80">INPUTS</text><text class="section" text-anchor="end" x="980" y="80">OUTPUTS</text><rect class="body" x="396" y="100" width="220" height="136" rx="14"/><text class="bodyname" text-anchor="middle" x="506" y="173">law-source-eurlex</text><line class="wire" x1="352" y1="130" x2="396" y2="130"/><circle class="port in" cx="396" cy="130" r="6"/><text class="fname" text-anchor="end" x="340" y="122">celex<tspan class="ftype"> · text</tspan></text><text class="fdesc" text-anchor="end" x="340" y="142">CELEX identifier of the act, e.g.</text><text class="fdesc" text-anchor="end" x="340" y="157">32016R0679 (GDPR) or the consolidated</text><text class="fdesc" text-anchor="end" x="340" y="172">form 02016R0679-20160504. An optional</text><line class="wire" x1="352" y1="206" x2="396" y2="206"/><circle class="port in" cx="396" cy="206" r="6"/><text class="fname" text-anchor="end" x="340" y="198">language<tspan class="ftype"> · text</tspan></text><text class="fdesc" text-anchor="end" x="340" y="218">Two-letter EUR-Lex language code (DE</text><text class="fdesc" text-anchor="end" x="340" y="233">default; EN, FR, …). Validated against</text><text class="fdesc" text-anchor="end" x="340" y="248">the 24 official-language codes.</text><line class="wire" x1="616" y1="130" x2="660" y2="130"/><circle class="port out" cx="616" cy="130" r="6"/><text class="fname" text-anchor="start" x="672" y="122">formex<tspan class="ftype"> · bytes</tspan></text><text class="fdesc" text-anchor="start" x="672" y="142">FORMEX-4 XML body as served by EUR-Lex</text><text class="fdesc" text-anchor="start" x="672" y="157">(application/xml). Feed into</text><text class="fdesc" text-anchor="start" x="672" y="172">text.akoma-normalize (0.2+) for the</text><line class="wire" x1="616" y1="206" x2="660" y2="206"/><circle class="port out" cx="616" cy="206" r="6"/><text class="fname" text-anchor="start" x="672" y="198">meta<tspan class="ftype"> · json</tspan></text><text class="fdesc" text-anchor="start" x="672" y="218">JSON-encoded SourceMeta: {</text><text class="fdesc" text-anchor="start" x="672" y="233">schema_version: "0.1", celex:</text><text class="fdesc" text-anchor="start" x="672" y="248">"32016R0679", language: "DE", url:</text><line class="rule" x1="32" y1="252" x2="980" y2="252"/><text class="footer" x="32" y="278">Permissions: net: publications.europa.eu</text></svg>

After

Width:  |  Height:  |  Size: 4 KiB

BIN
module.wasm Normal file

Binary file not shown.

77
module.yaml Normal file
View file

@ -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"

4
rust-toolchain.toml Normal file
View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.86"
targets = ["wasm32-wasip2"]
components = ["rustfmt", "clippy"]

508
src/lib.rs Normal file
View file

@ -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<String>,
}
#[chain_module]
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
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<String, ModuleError> {
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/<uuid>.<seq>/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<String> {
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("<ENACTING.TERMS") || text.contains("<CONS.ACT")
}
}
/// `YYYY-MM-DD` from a consolidated CELEX's `-YYYYMMDD` tail.
fn consolidation_date(celex: &str) -> Option<String> {
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 (`<!DOCTYPE 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("<?xml") {
// Rendered HTML served with an XML prolog would still slip
// through here; the html check below catches the common case.
return !head.to_ascii_lowercase().contains("<html");
}
head.starts_with('<') && !head.to_ascii_lowercase().starts_with("<!doctype html")
}
fn hex_sha256(data: &[u8]) -> 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<u8>, 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<u8>, 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<u8>, 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<u8>, Option<String>), 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<u8>, Option<String>), 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#"<html><head><title>300 Multiple-Choice Response</title></head><body> List of URI's:<ul><li title="manifestation">cellar:3e485e15-11bd-11e6-ba9a-01aa75ed71a1.0004.02<ul><li title="item"><a href="http://publications.europa.eu/resource/cellar/3e485e15-11bd-11e6-ba9a-01aa75ed71a1.0004.02/DOC_1">x</a></li><li title="item"><a href="http://publications.europa.eu/resource/cellar/3e485e15-11bd-11e6-ba9a-01aa75ed71a1.0004.02/DOC_2">y</a></li></ul></li></ul></body></html>"#;
#[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"<html>no links</html>").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#"<a href="https://evil.example/resource/cellar/x.0004.02/DOC_1">x</a>
<a href="http://publications.europa.eu.evil.example/resource/cellar/y/DOC_1">y</a>
<a href="https://publications.europa.eu/resource/cellar/ok.0004.02/DOC_1">ok</a>"#;
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"<?xml version=\"1.0\"?><ACT><BIB.INSTANCE/><ENACTING.TERMS><ARTICLE/></ENACTING.TERMS></ACT>";
let toc =
b"<?xml version=\"1.0\"?><DOC><BIB.DOC/><PUBLICATION.REF FILE=\"x.toc.xml\"/></DOC>";
assert!(is_act_body(act));
assert!(!is_act_body(toc));
}
#[test]
fn formex_shape_check_accepts_xml_rejects_html() {
assert!(looks_like_formex(b"<?xml version=\"1.0\"?><ACT>...</ACT>"));
assert!(looks_like_formex(b"<CONS.ACT><BIB.INSTANCE/></CONS.ACT>"));
assert!(!looks_like_formex(
b"<!DOCTYPE html><html><body>Not found</body></html>"
));
assert!(!looks_like_formex(
b"<?xml version=\"1.0\"?><html xmlns=\"http://www.w3.org/1999/xhtml\">"
));
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"));
}
}