From edb3f936d94915008cb165a32cff45ac1e223fad Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 25 Jul 2026 14:34:24 +0200 Subject: [PATCH 1/2] feat: prepare the 0.1.0 release foundation fix #9 fix #10 fix #11 fix #13 Refs #12 Refs #18 --- .github/workflows/ci.yml | 18 ++- CHANGELOG.md | 8 ++ CONTRIBUTING.md | 7 +- README.md | 9 +- datex4j-builders/pom.xml | 2 +- datex4j-consumer-tests/pom.xml | 47 +++++++ .../consumer/SingleModelClasspathTest.java | 57 +++++++++ datex4j-domain-evcharging/pom.xml | 2 +- datex4j-domain-parking/pom.xml | 2 +- datex4j-domain-srti/pom.xml | 2 +- datex4j-domain-traffic/pom.xml | 2 +- datex4j-domain-uvar/pom.xml | 2 +- datex4j-integration-tests/pom.xml | 6 + datex4j-json/pom.xml | 1 + .../dev/juherr/datex4j/json/DatexJson.java | 6 +- .../juherr/datex4j/json/DatexJsonMapper.java | 2 +- .../json/internal/DatexJsonModule.java | 35 +++--- .../internal/MultilingualString36Json.java | 87 ------------- .../json/internal/MultilingualStringJson.java | 119 ++++++++++++------ .../datex4j/json/internal/package-info.java | 25 ++++ .../internal/MultilingualStringJsonTest.java | 4 +- datex4j-location/pom.xml | 2 +- datex4j-ocpi/pom.xml | 2 +- datex4j-validation/pom.xml | 9 ++ .../datex4j/validation/DatexValidator.java | 15 ++- .../validation/SecureValidationSource.java | 55 ++++++++ .../validation/DatexValidatorTest.java | 36 ++++++ .../validation/ValidationResultTest.java | 62 +++++++++ datex4j-xml/pom.xml | 17 ++- .../juherr/datex4j/xml/ClasspathSchemas.java | 16 ++- .../dev/juherr/datex4j/xml/JaxbContexts.java | 50 ++++++++ .../datex4j/xml/JaxbDatexMarshaller.java | 13 +- .../juherr/datex4j/xml/SecureXmlSource.java | 55 ++++++++ .../datex4j/xml/DatexRoundtripTest.java | 20 +++ .../datex4j/xml/DatexRuntimeCacheTest.java | 37 ++++++ .../datex4j/xml/DatexXmlSecurityTest.java | 66 ++++++++++ docs/architecture.md | 22 ++-- docs/guides/models-and-versions.md | 3 +- mise.toml | 2 +- pom.xml | 25 +++- 40 files changed, 758 insertions(+), 192 deletions(-) create mode 100644 datex4j-consumer-tests/pom.xml create mode 100644 datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java delete mode 100644 datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java create mode 100644 datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java create mode 100644 datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java create mode 100644 datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java create mode 100644 datex4j-xml/src/main/java/dev/juherr/datex4j/xml/JaxbContexts.java create mode 100644 datex4j-xml/src/main/java/dev/juherr/datex4j/xml/SecureXmlSource.java create mode 100644 datex4j-xml/src/test/java/dev/juherr/datex4j/xml/DatexRuntimeCacheTest.java create mode 100644 datex4j-xml/src/test/java/dev/juherr/datex4j/xml/DatexXmlSecurityTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41faa0..86181a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,27 +14,23 @@ concurrency: jobs: build: - name: Build (${{ matrix.os }}) + name: Build (Java ${{ matrix.java }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest] + java: ['21', '25'] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up toolchain (Java) via mise - uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3 + - name: Set up Java + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: - cache: true - - - name: Cache Maven repository - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: ${{ runner.os }}-maven- + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven - name: Build and verify run: ./mvnw --batch-mode --no-transfer-progress verify diff --git a/CHANGELOG.md b/CHANGELOG.md index a51ece6..2f8dc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,16 +17,24 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - Offline integration coverage for real and synthetic DATEX II 2.x/3.x XML and JSON feeds. - Runnable, tested examples and separate user/contributor documentation paths. - Documentation checks based on `mdbook-lint` and Lychee. +- Consumer-classpath verification for applications that install a single DATEX II model. +- Adversarial XML tests covering DTDs, external entities, and recursive entity expansion. ### Changed - Documentation now reflects the per-version model architecture, complete version support, and ownership boundaries between NAP pages, source indexes, and fixture metadata. +- Java 21 is now the consumer baseline, with Java 21 and 25 both verified in CI. +- XML and JSON facades no longer pull every generated model transitively; applications select the + version artifacts they need. +- JAXB contexts and compiled XML Schemas are reused per model version and classloader. ### Fixed - Corrected stale v3-only statements, obsolete generated-model paths, broken links to integration tests, and duplicated fixture headings. +- Hardened XML reading and validation so documents containing DTDs or entity declarations are + rejected before JAXB binding or schema validation. [Unreleased]: https://github.com/juherr/datex4j/commits/main [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6705009..2de09f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ boundary between generated standards models and handwritten library code. ## Prerequisites -The project requires Java 25. [mise](https://mise.jdx.dev) provisions the JDK, and the committed +The project requires Java 21 or newer. [mise](https://mise.jdx.dev) provisions the baseline JDK, +CI also verifies Java 25, and the committed Maven wrapper pins Maven. ```bash @@ -31,7 +32,9 @@ Each version module owns its schemas, XJC bindings, generated classes, and a sma facades to discover only the versions present on the classpath. Handwritten support code lives in the facade, helper, domain, and integration modules. The -`examples` and `datex4j-integration-tests` modules verify public behavior and are not published. +`examples`, `datex4j-consumer-tests`, and `datex4j-integration-tests` modules verify public behavior +and are not published. Consumer tests deliberately install one model version and must remain free +of the all-version aggregate. ## Generated sources diff --git a/README.md b/README.md index d25ad12..749798c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ datex4j reads, writes, validates, and converts DATEX II publications without exp its public facades. The SDK bundles generated models for DATEX II 2.0–2.3 and 3.0–3.7, provides helpers for common user domains, and has no application-framework dependency. -- **Java 25**, JPMS-compatible, and framework-free. +- **Java 21+**, JPMS-compatible, and framework-free. - **Generated DATEX II models** built reproducibly from vendored official XML Schemas. - **Small public facades** for XML, JSON, and structured validation. - **Optional domain modules** for traffic, SRTI, parking, EV charging, and UVAR. @@ -80,8 +80,9 @@ in every version transitively. The AFIR-specific `AfirEnergyInfrastructure` and packages are part of the 3.7 model; earlier versions expose only the modules defined by their own root schema. -Domain builders currently target DATEX II 3.7. The XML, JSON, and validation facades can target any -bundled version when the corresponding model artifact is present. +Domain builders currently target DATEX II 3.7. The XML and validation facades can target any +bundled version when the corresponding model artifact is present. Conformant JSON fixtures cover +DATEX II 3.6 and 3.7. See [Models and versions](docs/guides/models-and-versions.md) for dependency choices, version selection, and the differences between DATEX II 2.x and 3.x. @@ -119,7 +120,7 @@ boundaries, SPI discovery, and JPMS strategy. ## Build from source -The project requires Java 25. The committed Maven wrapper pins Maven, while +The project requires Java 21 or newer. The committed Maven wrapper pins Maven, while [mise](https://mise.jdx.dev) provisions the JDK. ```bash diff --git a/datex4j-builders/pom.xml b/datex4j-builders/pom.xml index 97df029..98dcb02 100644 --- a/datex4j-builders/pom.xml +++ b/datex4j-builders/pom.xml @@ -22,7 +22,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 dev.juherr.datex4j diff --git a/datex4j-consumer-tests/pom.xml b/datex4j-consumer-tests/pom.xml new file mode 100644 index 0000000..52e150c --- /dev/null +++ b/datex4j-consumer-tests/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + dev.juherr.datex4j + datex4j + 0.1.0-SNAPSHOT + + + datex4j-consumer-tests + jar + + datex4j-consumer-tests + Consumer-classpath tests for optional DATEX II model isolation. Not published. + + + true + true + + + + + dev.juherr.datex4j + datex4j-xml + test + + + dev.juherr.datex4j + datex4j-json + ${project.version} + test + + + dev.juherr.datex4j + datex4j-model-v3_7 + test + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java b/datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java new file mode 100644 index 0000000..f1d2770 --- /dev/null +++ b/datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 the datex4j authors. + * + * 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 + * + * https://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. + */ +package dev.juherr.datex4j.consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.juherr.datex4j.core.DatexVersion; +import dev.juherr.datex4j.json.DatexJson; +import dev.juherr.datex4j.model.spi.DatexModelProvider; +import dev.juherr.datex4j.xml.DatexXml; +import java.util.ServiceLoader; +import java.util.stream.StreamSupport; +import org.junit.jupiter.api.Test; + +class SingleModelClasspathTest { + + @Test + void exposesOnlyTheModelExplicitlySelectedByTheConsumer() { + Iterable providers = ServiceLoader.load(DatexModelProvider.class); + + assertThat(StreamSupport.stream(providers.spliterator(), false).map(DatexModelProvider::version)) + .containsExactly(DatexVersion.V3_7); + } + + @Test + void createsXmlAndJsonFacadesForTheInstalledModel() { + assertThatCode(() -> DatexXml.builder().version(DatexVersion.V3_7).build()) + .doesNotThrowAnyException(); + assertThatCode(() -> DatexJson.builder().version(DatexVersion.V3_7).build()) + .doesNotThrowAnyException(); + } + + @Test + void reportsAUsefulErrorForAModelThatIsNotInstalled() { + assertThatThrownBy(() -> DatexXml.builder().version(DatexVersion.V3_6).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("datex4j-model-v3_6"); + assertThatThrownBy(() -> DatexJson.builder().version(DatexVersion.V3_6).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("datex4j-model-v3_6"); + } +} diff --git a/datex4j-domain-evcharging/pom.xml b/datex4j-domain-evcharging/pom.xml index 6289c05..6580253 100644 --- a/datex4j-domain-evcharging/pom.xml +++ b/datex4j-domain-evcharging/pom.xml @@ -25,7 +25,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-parking/pom.xml b/datex4j-domain-parking/pom.xml index 9e1cf3f..8da9846 100644 --- a/datex4j-domain-parking/pom.xml +++ b/datex4j-domain-parking/pom.xml @@ -23,7 +23,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-srti/pom.xml b/datex4j-domain-srti/pom.xml index 6171082..fc0a0f1 100644 --- a/datex4j-domain-srti/pom.xml +++ b/datex4j-domain-srti/pom.xml @@ -25,7 +25,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-traffic/pom.xml b/datex4j-domain-traffic/pom.xml index ff5bfb7..d58f3da 100644 --- a/datex4j-domain-traffic/pom.xml +++ b/datex4j-domain-traffic/pom.xml @@ -23,7 +23,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-uvar/pom.xml b/datex4j-domain-uvar/pom.xml index 701c5f0..e68369a 100644 --- a/datex4j-domain-uvar/pom.xml +++ b/datex4j-domain-uvar/pom.xml @@ -25,7 +25,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-integration-tests/pom.xml b/datex4j-integration-tests/pom.xml index 48e732f..5b9a8ac 100644 --- a/datex4j-integration-tests/pom.xml +++ b/datex4j-integration-tests/pom.xml @@ -39,6 +39,12 @@ ${project.version} test + + dev.juherr.datex4j + datex4j-model + ${project.version} + test + org.junit.jupiter junit-jupiter diff --git a/datex4j-json/pom.xml b/datex4j-json/pom.xml index 16e280b..2117eeb 100644 --- a/datex4j-json/pom.xml +++ b/datex4j-json/pom.xml @@ -23,6 +23,7 @@ dev.juherr.datex4j datex4j-model + test dev.juherr.datex4j diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java index d4b14e0..f2c9267 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java @@ -40,9 +40,9 @@ * National Access Points (NAPs), anchored against a real-world Fintraffic AFIR fixture; it may * need to adapt once an official JSON binding is published. * - *

Both bundled DATEX II model versions are supported via {@link - * Builder#version(DatexVersion)}: pass {@link DatexVersion#V3_6} or {@link DatexVersion#V3_7} (the - * default is {@link DatexVersion#current()}). + *

DATEX II 3.6 and 3.7 are covered by conformant JSON fixtures. Select the model through {@link + * Builder#version(DatexVersion)} and add the matching {@code datex4j-model-vX_Y} artifact at + * runtime. The default is {@link DatexVersion#current()}. * *

Use {@link #createMapper()} for the default configuration (pretty-printed) or {@link * #builder()} to customize it. diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java index dbaf3a8..2260d00 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java @@ -38,7 +38,7 @@ * DATEX II model, and maps the XML temporal types via {@link DatexTemporalModule}. Instances are * immutable and thread-safe and may be shared across an application. * - *

Any DATEX II object of any bundled version can be written; {@link #read} returns the + *

Any object from the configured DATEX II model can be written; {@link #read} returns the * requested type. A {@code MessageContainer} value is the one exception: {@link #write} and {@link * #writeToString} emit the conformant {@code payload}/{@code exchangeInformation} envelope for it * (see {@link #readContainer}), while every other type is serialized directly with no wrapper. diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java index f6fce6d..e3df15b 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java @@ -90,20 +90,27 @@ public DatexJsonModule(DatexVersion version) { @SuppressWarnings("unchecked") private void registerMultilingualString(DatexVersion version) { - if (version == DatexVersion.V3_7) { - addSerializer( - dev.juherr.datex4j.model.v3_7.common.MultilingualString.class, - new MultilingualStringJson.Serializer()); - addDeserializer( - dev.juherr.datex4j.model.v3_7.common.MultilingualString.class, - new MultilingualStringJson.Deserializer()); - } else { - addSerializer( - dev.juherr.datex4j.model.v3_6.common.MultilingualString.class, - new MultilingualString36Json.Serializer()); - addDeserializer( - dev.juherr.datex4j.model.v3_6.common.MultilingualString.class, - new MultilingualString36Json.Deserializer()); + String commonPackage = MODEL_PACKAGE_PREFIX + version.packageSegment() + ".common."; + Class stringType = loadModelClass(commonPackage + "MultilingualString", version); + Class valueType = loadModelClass(commonPackage + "MultilingualStringValue", version); + addSerializer((Class) stringType, new MultilingualStringJson.Serializer()); + addDeserializer((Class) stringType, new MultilingualStringJson.Deserializer<>(stringType, valueType)); + } + + private static Class loadModelClass(String className, DatexVersion version) { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + if (loader == null) { + loader = DatexJsonModule.class.getClassLoader(); + } + try { + return Class.forName(className, true, loader); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "DATEX II model " + + version + + " is not available; add dev.juherr.datex4j:datex4j-model-" + + version.packageSegment(), + e); } } diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java deleted file mode 100644 index 75fbfc6..0000000 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2026 the datex4j authors. - * - * 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 - * - * https://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. - */ -package dev.juherr.datex4j.json.internal; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import dev.juherr.datex4j.model.v3_6.common.MultilingualString; -import dev.juherr.datex4j.model.v3_6.common.MultilingualStringValue; -import java.io.IOException; -import java.util.List; - -/** - * DATEX II v3.6 counterpart of {@link MultilingualStringJson}, flattening the v3.6 {@link - * MultilingualString} model into its conformant {@code {"values":[{lang,value}]}} JSON encoding. - * - *

The v3.6 and v3.7 {@code MultilingualString} classes are distinct generated types with - * identical shape; the logic mirrors {@link MultilingualStringJson} exactly. - */ -public final class MultilingualString36Json { - - private MultilingualString36Json() {} - - /** Serializes a v3.6 {@link MultilingualString} to the flat {@code {"values":[...]}} JSON shape. */ - public static final class Serializer extends JsonSerializer { - @Override - public void serialize(MultilingualString value, JsonGenerator generator, SerializerProvider serializers) - throws IOException { - generator.writeStartObject(); - generator.writeArrayFieldStart("values"); - List values = - value.getValues() == null ? List.of() : value.getValues().getValue(); - for (MultilingualStringValue msv : values) { - generator.writeStartObject(); - generator.writeStringField("lang", msv.getLang()); - generator.writeStringField("value", msv.getValue()); - generator.writeEndObject(); - } - generator.writeEndArray(); - generator.writeEndObject(); - } - } - - /** Deserializes the flat {@code {"values":[...]}} JSON shape back into a v3.6 {@link MultilingualString}. */ - public static final class Deserializer extends JsonDeserializer { - @Override - public MultilingualString deserialize(JsonParser parser, DeserializationContext context) throws IOException { - JsonNode root = parser.getCodec().readTree(parser); - MultilingualString.Values values = new MultilingualString.Values(); - JsonNode valuesNode = root.get("values"); - if (valuesNode != null) { - for (JsonNode item : valuesNode) { - MultilingualStringValue msv = new MultilingualStringValue(); - JsonNode lang = item.get("lang"); - if (lang != null) { - msv.setLang(lang.asText()); - } - JsonNode text = item.get("value"); - if (text != null) { - msv.setValue(text.asText()); - } - values.getValue().add(msv); - } - } - MultilingualString result = new MultilingualString(); - result.setValues(values); - return result; - } - } -} diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java index c285289..f9b6d13 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java @@ -22,19 +22,19 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; -import dev.juherr.datex4j.model.v3_7.common.MultilingualString; -import dev.juherr.datex4j.model.v3_7.common.MultilingualStringValue; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; import java.util.List; /** - * Jackson (de)serializers flattening the DATEX II {@link MultilingualString} model into its - * conformant JSON encoding. + * Version-neutral Jackson (de)serializers flattening a generated DATEX II + * {@code MultilingualString} into its conformant JSON encoding. * *

The generated JAXB model nests the language/value pairs under an intermediate {@code Values} * wrapper ({@code getValues().getValue()}), mirroring the XML schema's anonymous wrapper element. * The conformant DATEX II JSON encoding flattens that wrapper away, exposing a single {@code - * values} array of {@code {lang, value}} objects directly on the {@link MultilingualString}: + * values} array of {@code {lang, value}} objects directly on the generated string: * *

{@code {"values":[{"lang":"fi","value":"Kärkitie 4"}]}}
*/ @@ -42,50 +42,97 @@ public final class MultilingualStringJson { private MultilingualStringJson() {} - /** Serializes a {@link MultilingualString} to the flat {@code {"values":[...]}} JSON shape. */ - public static final class Serializer extends JsonSerializer { + /** Serializes any generated multilingual string to the flat {@code {"values":[...]}} shape. */ + public static final class Serializer extends JsonSerializer { @Override - public void serialize(MultilingualString value, JsonGenerator generator, SerializerProvider serializers) + public void serialize(Object value, JsonGenerator generator, SerializerProvider serializers) throws IOException { - generator.writeStartObject(); - generator.writeArrayFieldStart("values"); - List values = - value.getValues() == null ? List.of() : value.getValues().getValue(); - for (MultilingualStringValue msv : values) { + try { + Method getValues = value.getClass().getMethod("getValues"); + Object wrapper = getValues.invoke(value); + List values = wrapper == null + ? List.of() + : (List) wrapper.getClass().getMethod("getValue").invoke(wrapper); + generator.writeStartObject(); - generator.writeStringField("lang", msv.getLang()); - generator.writeStringField("value", msv.getValue()); + generator.writeArrayFieldStart("values"); + for (Object item : values) { + generator.writeStartObject(); + generator.writeStringField("lang", (String) + item.getClass().getMethod("getLang").invoke(item)); + generator.writeStringField("value", (String) + item.getClass().getMethod("getValue").invoke(item)); + generator.writeEndObject(); + } + generator.writeEndArray(); generator.writeEndObject(); + } catch (ReflectiveOperationException e) { + throw new IOException( + "Failed to serialize DATEX II multilingual string " + + value.getClass().getName(), + e); } - generator.writeEndArray(); - generator.writeEndObject(); } } - /** Deserializes the flat {@code {"values":[...]}} JSON shape back into a {@link MultilingualString}. */ - public static final class Deserializer extends JsonDeserializer { + /** Deserializes the flat JSON shape into one version-specific generated string type. */ + public static final class Deserializer extends JsonDeserializer { + private final Constructor stringConstructor; + private final Constructor valuesConstructor; + private final Constructor valueConstructor; + private final Method setValues; + private final Method values; + private final Method setLang; + private final Method setValue; + + /** Creates a deserializer for the supplied generated multilingual string class. */ + public Deserializer(Class stringType, Class valueType) { + try { + Class valuesType = + Class.forName(stringType.getName() + "$Values", true, stringType.getClassLoader()); + this.stringConstructor = stringType.getDeclaredConstructor(); + this.valuesConstructor = valuesType.getDeclaredConstructor(); + this.valueConstructor = valueType.getDeclaredConstructor(); + this.setValues = stringType.getMethod("setValues", valuesType); + this.values = valuesType.getMethod("getValue"); + this.setLang = valueType.getMethod("setLang", String.class); + this.setValue = valueType.getMethod("setValue", String.class); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Unsupported DATEX II multilingual string type " + stringType.getName(), e); + } + } + @Override - public MultilingualString deserialize(JsonParser parser, DeserializationContext context) throws IOException { + public T deserialize(JsonParser parser, DeserializationContext context) throws IOException { JsonNode root = parser.getCodec().readTree(parser); - MultilingualString.Values values = new MultilingualString.Values(); - JsonNode valuesNode = root.get("values"); - if (valuesNode != null) { - for (JsonNode item : valuesNode) { - MultilingualStringValue msv = new MultilingualStringValue(); - JsonNode lang = item.get("lang"); - if (lang != null) { - msv.setLang(lang.asText()); - } - JsonNode text = item.get("value"); - if (text != null) { - msv.setValue(text.asText()); + try { + Object wrapper = valuesConstructor.newInstance(); + @SuppressWarnings("unchecked") + List generatedValues = (List) values.invoke(wrapper); + JsonNode valuesNode = root.get("values"); + if (valuesNode != null) { + for (JsonNode item : valuesNode) { + Object generatedValue = valueConstructor.newInstance(); + JsonNode lang = item.get("lang"); + if (lang != null) { + setLang.invoke(generatedValue, lang.asText()); + } + JsonNode text = item.get("value"); + if (text != null) { + setValue.invoke(generatedValue, text.asText()); + } + generatedValues.add(generatedValue); } - values.getValue().add(msv); } + Object result = stringConstructor.newInstance(); + setValues.invoke(result, wrapper); + @SuppressWarnings("unchecked") + T typedResult = (T) result; + return typedResult; + } catch (ReflectiveOperationException e) { + throw new IOException("Failed to deserialize DATEX II multilingual string", e); } - MultilingualString result = new MultilingualString(); - result.setValues(values); - return result; } } } diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java new file mode 100644 index 0000000..6f4c80d --- /dev/null +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 the datex4j authors. + * + * 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 + * + * https://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. + */ + +/** + * Internal Jackson integration details. + * + *

Types in this package are implementation details, are not part of datex4j's supported public + * API, and may change without deprecation. Applications should use {@link + * dev.juherr.datex4j.json.DatexJson} and {@link + * dev.juherr.datex4j.json.DatexJsonMapper}. + */ +package dev.juherr.datex4j.json.internal; diff --git a/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java b/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java index 7f9dcdc..cc2515d 100644 --- a/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java +++ b/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java @@ -29,7 +29,9 @@ class MultilingualStringJsonTest { private static ObjectMapper newMapper() { SimpleModule module = new SimpleModule(); module.addSerializer(MultilingualString.class, new MultilingualStringJson.Serializer()); - module.addDeserializer(MultilingualString.class, new MultilingualStringJson.Deserializer()); + module.addDeserializer( + MultilingualString.class, + new MultilingualStringJson.Deserializer<>(MultilingualString.class, MultilingualStringValue.class)); ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(module); return mapper; diff --git a/datex4j-location/pom.xml b/datex4j-location/pom.xml index d86ac0d..1e56a79 100644 --- a/datex4j-location/pom.xml +++ b/datex4j-location/pom.xml @@ -21,7 +21,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-ocpi/pom.xml b/datex4j-ocpi/pom.xml index 0d71868..31e82d9 100644 --- a/datex4j-ocpi/pom.xml +++ b/datex4j-ocpi/pom.xml @@ -37,7 +37,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 ${project.version} diff --git a/datex4j-validation/pom.xml b/datex4j-validation/pom.xml index 9d48d8b..f574edf 100644 --- a/datex4j-validation/pom.xml +++ b/datex4j-validation/pom.xml @@ -18,6 +18,10 @@ Structured, error-collecting validation of DATEX II documents against the official XML Schemas. + + 0.85 + + dev.juherr.datex4j @@ -33,6 +37,11 @@ junit-jupiter test + + dev.juherr.datex4j + datex4j-model + test + diff --git a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java index 5b658a5..286af58 100644 --- a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java +++ b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java @@ -26,8 +26,8 @@ import java.util.ArrayList; import java.util.List; import javax.xml.XMLConstants; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Source; -import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.Validator; import org.xml.sax.ErrorHandler; @@ -88,7 +88,7 @@ public static DatexValidator forVersion(DatexVersion version) { * @throws DatexValidationException if the document cannot be read */ public ValidationResult validate(byte[] xml) { - return validate(new StreamSource(new ByteArrayInputStream(xml))); + return validate(new ByteArrayInputStream(xml)); } /** @@ -99,7 +99,11 @@ public ValidationResult validate(byte[] xml) { * @throws DatexValidationException if the document cannot be read */ public ValidationResult validate(InputStream in) { - return validate(new StreamSource(in)); + try { + return validate(SecureValidationSource.from(in)); + } catch (ParserConfigurationException | SAXException e) { + throw new DatexValidationException("Failed to configure secure XML validation", e); + } } /** @@ -127,6 +131,9 @@ private ValidationResult validate(Source source) { try { validator.validate(source); } catch (SAXException e) { + if (e.getCause() instanceof IOException io) { + throw new DatexValidationException("Failed to read the document to validate", io); + } // A fatal error was already reported to the handler before the parser gave up. handler.recordFatalIfEmpty(e); } catch (IOException e) { @@ -135,7 +142,7 @@ private ValidationResult validate(Source source) { return new ValidationResult(handler.messages()); } - private static final class CollectingErrorHandler implements ErrorHandler { + static final class CollectingErrorHandler implements ErrorHandler { private final List messages = new ArrayList<>(); diff --git a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java new file mode 100644 index 0000000..a7b84f7 --- /dev/null +++ b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 the datex4j authors. + * + * 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 + * + * https://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. + */ +package dev.juherr.datex4j.validation; + +import java.io.InputStream; +import javax.xml.XMLConstants; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.sax.SAXSource; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; + +/** Creates fail-closed SAX sources for schema validation of untrusted XML. */ +final class SecureValidationSource { + + private static final String DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl"; + private static final String EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities"; + private static final String EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities"; + private static final String LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; + + private SecureValidationSource() {} + + static SAXSource from(InputStream input) throws ParserConfigurationException, SAXException { + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature(DISALLOW_DOCTYPE, true); + factory.setFeature(EXTERNAL_GENERAL_ENTITIES, false); + factory.setFeature(EXTERNAL_PARAMETER_ENTITIES, false); + factory.setFeature(LOAD_EXTERNAL_DTD, false); + + SAXParser parser = factory.newSAXParser(); + parser.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + XMLReader reader = parser.getXMLReader(); + reader.setEntityResolver((publicId, systemId) -> { + throw new SAXException("External XML entities are forbidden"); + }); + return new SAXSource(reader, new InputSource(input)); + } +} diff --git a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java index 18db5d0..b16e8eb 100644 --- a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java +++ b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java @@ -54,6 +54,42 @@ void collectsErrorsForAnInvalidDocument() { assertThat(result.errors().get(0).lineNumber()).isPositive(); } + @Test + void rejectsDocumentsThatDeclareEntities() { + String valid = DatexXml.builder().build().writeToString(validPublication()); + String invalid = valid.replaceFirst("\\?>", "?>]>") + .replace("lang=\"en\"", "lang=\"&probe;\""); + + ValidationResult result = validator.validate(invalid.getBytes(StandardCharsets.UTF_8)); + + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()) + .anySatisfy(message -> assertThat(message.message()).containsIgnoringCase("DOCTYPE")); + } + + @Test + void reportsMalformedXmlAsAFatalValidationMessage() { + ValidationResult result = validator.validate(" validator.validate(failing)) + .isInstanceOf(DatexValidationException.class) + .hasMessageContaining("Failed to read the document to validate") + .hasRootCauseMessage("simulated read failure"); + } + @Test void acceptsAValidMessageContainerDocument() { byte[] xml = resource("/messagecontainer/message-container-v3_7.xml"); diff --git a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java new file mode 100644 index 0000000..7413731 --- /dev/null +++ b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 the datex4j authors. + * + * 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 + * + * https://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. + */ +package dev.juherr.datex4j.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.juherr.datex4j.validation.ValidationMessage.Severity; +import java.util.ArrayList; +import org.junit.jupiter.api.Test; +import org.xml.sax.SAXParseException; + +class ValidationResultTest { + + @Test + void separatesWarningsFromErrorsAndFatalMessages() { + var warning = new ValidationMessage(Severity.WARNING, "warning", 1, 2); + var error = new ValidationMessage(Severity.ERROR, "error", 3, 4); + var fatal = new ValidationMessage(Severity.FATAL, "fatal", 5, 6); + + ValidationResult result = new ValidationResult(new ArrayList<>(java.util.List.of(warning, error, fatal))); + + assertThat(result.isValid()).isFalse(); + assertThat(result.messages()).containsExactly(warning, error, fatal); + assertThat(result.warnings()).containsExactly(warning); + assertThat(result.errors()).containsExactly(error, fatal); + assertThatThrownBy(() -> result.messages().add(warning)).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void warningsAloneDoNotInvalidateADocument() { + var warning = new ValidationMessage(Severity.WARNING, "warning", 1, 2); + + ValidationResult result = new ValidationResult(java.util.List.of(warning)); + + assertThat(result.isValid()).isTrue(); + assertThat(result.errors()).isEmpty(); + } + + @Test + void preservesWarningLocationFromTheSchemaValidator() { + var handler = new DatexValidator.CollectingErrorHandler(); + + handler.warning(new SAXParseException("schema warning", null, null, 12, 34)); + + assertThat(handler.messages()) + .containsExactly(new ValidationMessage(Severity.WARNING, "schema warning", 12, 34)); + } +} diff --git a/datex4j-xml/pom.xml b/datex4j-xml/pom.xml index 626f0f7..f16e31d 100644 --- a/datex4j-xml/pom.xml +++ b/datex4j-xml/pom.xml @@ -18,16 +18,21 @@ Small, JAXB-hiding API for reading, writing, validating and pretty-printing DATEX II XML. + + 0.85 + + dev.juherr.datex4j datex4j-model-spi - + dev.juherr.datex4j datex4j-model + test dev.juherr.datex4j @@ -52,6 +57,16 @@ + + org.jacoco + jacoco-maven-plugin + + + + **/ClasspathSchemas$StreamLsInput* + + + - 25 + 21 UTF-8 UTF-8 @@ -97,6 +98,7 @@ 3.8.0 2.96.0 0.8.15 + 0.00 3.4.0 @@ -358,6 +360,27 @@ report + + jacoco-check + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + ${jacoco.line.minimum} + + + + + + From 7303fb0b3685f7e56ec210cf4d4b81137a6f9a83 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 25 Jul 2026 14:53:58 +0200 Subject: [PATCH 2/2] fix: address release foundation review feedback --- .github/workflows/ci.yml | 4 +- .../datex4j/validation/DatexValidator.java | 3 +- .../validation/SecureValidationSource.java | 55 ------------------- .../validation/DatexValidatorTest.java | 11 ++++ .../juherr/datex4j/xml/SecureXmlSource.java | 12 +++- docs/guides/models-and-versions.md | 4 +- 6 files changed, 28 insertions(+), 61 deletions(-) delete mode 100644 datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86181a7..c34bd01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Java uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 @@ -40,6 +42,6 @@ jobs: continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: jacoco-reports + name: jacoco-reports-java-${{ matrix.java }} path: '**/target/site/jacoco/**' if-no-files-found: ignore diff --git a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java index 286af58..1ce892d 100644 --- a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java +++ b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java @@ -20,6 +20,7 @@ import dev.juherr.datex4j.xml.DatexMarshaller; import dev.juherr.datex4j.xml.DatexSchemaFactory; import dev.juherr.datex4j.xml.DatexXml; +import dev.juherr.datex4j.xml.SecureXmlSource; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -100,7 +101,7 @@ public ValidationResult validate(byte[] xml) { */ public ValidationResult validate(InputStream in) { try { - return validate(SecureValidationSource.from(in)); + return validate(SecureXmlSource.from(in)); } catch (ParserConfigurationException | SAXException e) { throw new DatexValidationException("Failed to configure secure XML validation", e); } diff --git a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java deleted file mode 100644 index a7b84f7..0000000 --- a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/SecureValidationSource.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2026 the datex4j authors. - * - * 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 - * - * https://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. - */ -package dev.juherr.datex4j.validation; - -import java.io.InputStream; -import javax.xml.XMLConstants; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; -import javax.xml.transform.sax.SAXSource; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; -import org.xml.sax.XMLReader; - -/** Creates fail-closed SAX sources for schema validation of untrusted XML. */ -final class SecureValidationSource { - - private static final String DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl"; - private static final String EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities"; - private static final String EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities"; - private static final String LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; - - private SecureValidationSource() {} - - static SAXSource from(InputStream input) throws ParserConfigurationException, SAXException { - SAXParserFactory factory = SAXParserFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature(DISALLOW_DOCTYPE, true); - factory.setFeature(EXTERNAL_GENERAL_ENTITIES, false); - factory.setFeature(EXTERNAL_PARAMETER_ENTITIES, false); - factory.setFeature(LOAD_EXTERNAL_DTD, false); - - SAXParser parser = factory.newSAXParser(); - parser.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - XMLReader reader = parser.getXMLReader(); - reader.setEntityResolver((publicId, systemId) -> { - throw new SAXException("External XML entities are forbidden"); - }); - return new SAXSource(reader, new InputSource(input)); - } -} diff --git a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java index b16e8eb..58bd7eb 100644 --- a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java +++ b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java @@ -28,6 +28,7 @@ import javax.xml.datatype.DatatypeConfigurationException; import javax.xml.datatype.DatatypeFactory; import org.junit.jupiter.api.Test; +import org.xml.sax.SAXException; class DatexValidatorTest { @@ -75,6 +76,16 @@ void reportsMalformedXmlAsAFatalValidationMessage() { assertThat(result.errors()).extracting(ValidationMessage::severity).contains(ValidationMessage.Severity.FATAL); } + @Test + void recordsFatalSaxErrorsWithoutLocation() { + var handler = new DatexValidator.CollectingErrorHandler(); + + handler.recordFatalIfEmpty(new SAXException("fatal parser error")); + + assertThat(handler.messages()) + .containsExactly(new ValidationMessage(ValidationMessage.Severity.FATAL, "fatal parser error", -1, -1)); + } + @Test void wrapsIoFailuresFromInputStreams() { InputStream failing = new InputStream() { diff --git a/datex4j-xml/src/main/java/dev/juherr/datex4j/xml/SecureXmlSource.java b/datex4j-xml/src/main/java/dev/juherr/datex4j/xml/SecureXmlSource.java index 4b059be..10dc12e 100644 --- a/datex4j-xml/src/main/java/dev/juherr/datex4j/xml/SecureXmlSource.java +++ b/datex4j-xml/src/main/java/dev/juherr/datex4j/xml/SecureXmlSource.java @@ -26,7 +26,7 @@ import org.xml.sax.XMLReader; /** Creates fail-closed SAX sources for untrusted DATEX II XML documents. */ -final class SecureXmlSource { +public final class SecureXmlSource { private static final String DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl"; private static final String EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities"; @@ -35,7 +35,15 @@ final class SecureXmlSource { private SecureXmlSource() {} - static SAXSource from(InputStream input) throws ParserConfigurationException, SAXException { + /** + * Creates a fail-closed SAX source for an untrusted XML stream. + * + * @param input the XML stream + * @return a SAX source with DTDs and external entities disabled + * @throws ParserConfigurationException if the secure parser cannot be configured + * @throws SAXException if the secure parser cannot be created + */ + public static SAXSource from(InputStream input) throws ParserConfigurationException, SAXException { SAXParserFactory factory = SAXParserFactory.newInstance(); factory.setNamespaceAware(true); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); diff --git a/docs/guides/models-and-versions.md b/docs/guides/models-and-versions.md index 3f72c5b..ee72c4d 100644 --- a/docs/guides/models-and-versions.md +++ b/docs/guides/models-and-versions.md @@ -10,8 +10,8 @@ a small classpath, or use the aggregate when an application must process several | 2.x | 2.0–2.3 | `d2LogicalModel` | `dev.juherr.datex4j.model.v2_X.*` | | 3.x | 3.0–3.7 | `payload` or Exchange 2020 `messageContainer` | `dev.juherr.datex4j.model.v3_X.*` | -`DatexVersion.current()` returns 3.7. Domain builders and location helpers target 3.7, while the XML, -XML and validation facades can select any bundled model. Conformant JSON fixtures currently cover +`DatexVersion.current()` returns 3.7. Domain builders and location helpers target 3.7, while the XML +and validation facades can select any bundled model. Conformant JSON fixtures currently cover DATEX II 3.6 and 3.7. AFIR-specific `AfirEnergyInfrastructure` and `AfirFacilities` packages belong to the 3.7 model.