Skip to content

jdt2jar: offline JTD-to-JAR compiler CLI + minimal distroless container #147

Closed

Description

@simbo1905

Goal

Build jdt2jar: a CLI tool + minimal container that compiles JTD schemas into standalone validator JARs at build time, eliminating the need for a JDK 24+ runtime.

User Story

# On any machine with Docker (or natively with JDK 24+)
docker run --rm -v $(pwd)/schemas:/schemas -v $(pwd)/out:/out \
  ghcr.io/simbo1905/jdt2jar:latest \
  /schemas/user.jtd.json --output /out/user-validator.jar

# The output JAR runs on JDK 21+ with zero ClassFile API dependency
java -jar /out/user-validator.jar --validate /data/payload.json

CLI Interface

jdt2jar <schema.json> [options]

Options:
  --output <path>       Output JAR path (default: <schema-name>-validator.jar)
  --package <name>      Java package for generated classes (default: jtd.generated)
  --class <name>        Validator class name (default: SchemaValidator)
  --main                Include a main() for standalone CLI validation
  --runtime <version>   Target bytecode version (default: 21)
  --include-sources     Also output generated .java files alongside the JAR
  --help                Show help

Generated JAR Contents

user-validator.jar
├── META-INF/MANIFEST.MF
├── jtd/generated/SchemaValidator.class      # implements JtdValidator
├── jtd/generated/SchemaValidator$*.class    # helper classes
├── jtd/generated/SchemaValidator.java       # (optional, --include-sources)
└── jtd/schema.json                          # embedded original schema

Usage Patterns

1. Library Integration

// Generated class implements JtdValidator
JtdValidator validator = new jtd.generated.SchemaValidator();
JtdValidationResult result = validator.validate(jsonPayload);
if (!result.isValid()) {
    for (var err : result.errors()) {
        System.err.println(err.instancePath() + ": " + err.schemaPath());
    }
}

2. Standalone CLI Validation

# JAR built with --main flag
java -jar user-validator.jar --validate payload.json
# Exit code 0 = valid, 1 = invalid, 2 = parse error

java -jar user-validator.jar --validate payload.json --format json
# Outputs RFC 8927 error pairs as JSON

3. CI/CD Pipeline

# GitHub Actions example
- name: Compile JTD validators
  run: |
    docker run --rm -v $PWD/schemas:/schemas -v $PWD/validators:/out \
      ghcr.io/simbo1905/jdt2jar:latest \
      /schemas/events.jtd.json --output /out/events-validator.jar --main

- name: Validate test fixtures
  run: java -jar validators/events-validator.jar --validate tests/fixtures/event.json

4. Maven Plugin (future)

<plugin>
  <groupId>io.github.simbo1905.json</groupId>
  <artifactId>jdt2jar-maven-plugin</artifactId>
  <executions>
    <execution>
      <phase>generate-sources</phase>
      <goals><goal>compile</goal></goals>
      <configuration>
        <schemaDir>${project.basedir}/schemas</schemaDir>
        <outputDir>${project.build.directory}/generated-classes</outputDir>
      </configuration>
    </execution>
  </executions>
</plugin>

Implementation Notes: Container Packaging Specification

Objective

Package the Java CLI tool jdt2jar as:

  • a minimal OCI container image
  • based on custom jlink runtime + Distroless Debian 13 ("trixie") base
  • suitable for Kubernetes, CI runners, ephemeral execution, rootless runtime
  • no shell or package manager in final image

Use: gcr.io/distroless/base-debian13:nonroot

Runtime Requirements

  • Java 21 target bytecode
  • jdt2jar packaged as fat jar or modular jar
  • CLI entrypoint: java -jar /app/jdt2jar.jar

Filesystem Layout

/
├── app/
│   └── jdt2jar.jar
├── jre/
│   ├── bin/java
│   └── lib/...
└── work/

Runtime user: uid=65532, gid=65532 (nonroot distroless default)

Build Strategy

Multi-stage Docker build:

  1. builder stage: full JDK → compile, tests, jdeps, jlink
  2. runtime stage: distroless Debian 13 → stripped runtime + application jar

Java Module Minimization

jdeps --ignore-missing-deps --recursive --multi-release 21 --print-module-deps /build/jdt2jar.jar

Expected minimum modules: java.base
Potential additions: java.logging, java.xml, java.naming, jdk.zipfs

jlink Requirements

jlink --add-modules "$MODULES" --strip-debug --compress=2 --no-header-files --no-man-pages --output /opt/jre

Base Image

FROM gcr.io/distroless/base-debian13:nonroot

Do not use Java distroless variants — runtime is supplied via jlink.

Container Constraints

  • No shell: all Docker directives must use JSON/vector form
  • TLS: distroless base includes CA certs
  • Writable paths: only /work is writable
  • Signal handling: JVM runs as PID 1, must handle SIGTERM/SIGINT
  • Logging: stdout/stderr only, no filesystem logging

Recommended JVM Flags

-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-Djava.io.tmpdir=/work/tmp
-XX:+ExitOnOutOfMemoryError  (optional)

Dockerfile

# -------------------------------------------------
# Build stage
# -------------------------------------------------
FROM eclipse-temurin:24-jdk AS build
WORKDIR /build
COPY . .
RUN ./mvnw clean package -DskipTests
RUN cp target/jdt2jar.jar /build/jdt2jar.jar
RUN jdeps \
      --ignore-missing-deps \
      --recursive \
      --multi-release 21 \
      --print-module-deps \
      /build/jdt2jar.jar \
      > /build/modules.txt
RUN jlink \
      --add-modules $(cat /build/modules.txt) \
      --strip-debug \
      --compress=2 \
      --no-header-files \
      --no-man-pages \
      --output /opt/jre

# -------------------------------------------------
# Runtime stage
# -------------------------------------------------
FROM gcr.io/distroless/base-debian13:nonroot
WORKDIR /work
COPY --from=build /opt/jre /jre
COPY --from=build /build/jdt2jar.jar /app/jdt2jar.jar
ENTRYPOINT ["/jre/bin/java","-jar","/app/jdt2jar.jar"]

Expected Image Sizes

Variant Size
Full JDK image 350MB–500MB
Distroless Java runtime 120MB–180MB
jlink + distroless 35MB–80MB

Security Characteristics

Final image contains:

  • no package manager, no shell, no compiler, no debugger, no build tools
  • attack surface minimized to: glibc + JVM runtime modules + application code

CI Validation

# Verify runtime starts
docker run --rm jdt2jar --help

# Verify no shell exists
docker run --rm jdt2jar /bin/sh
# Expected: executable file not found

# Verify nonroot execution
docker run --rm jdt2jar id
# Expected: uid=65532(nonroot)

Optional Enhancements

  • SBOM generation via syft
  • Vulnerability scanning via grype
  • Multi-arch builds (linux/amd64, linux/arm64) via docker buildx

Activity

  1. simbo1905 commented on May 20, 2026

    @simbo1905
    OwnerAuthor

    Non-Functional Requirements: Image Visibility (syft + grype)

    Once you move to distroless, stripped runtimes, and custom jlink images, you lose most normal operational visibility. syft and grype restore visibility at the image layer.

    syft — SBOM Generation

    syft generates a Software Bill Of Materials, answering: what exactly is inside this container?

    It inventories:

    • JVM modules
    • shared libraries
    • JAR dependencies
    • OS packages
    • native binaries
    syft packages image:jdt2jar

    Output:

    NAME           VERSION
    glibc          2.41
    libz           1.3.1
    java.base      21.0.3
    jackson-core   2.17.1
    slf4j-api      2.0.13
    

    This matters because:

    • distroless hides package managers
    • jlink removes normal distro metadata
    • scanners otherwise miss components

    SBOMs are now commonly required for: enterprise supply chain, SLSA, FedRAMP, SOC2, ISO 27001, procurement/security review.

    grype — Vulnerability Scanning

    grype consumes an SBOM or image and answers: which known CVEs are in this image?

    grype jdt2jar

    Output:

    CVE-2025-12345
    Severity: High
    Package: glibc
    Fixed in: 2.41-r2
    

    This is important because distroless reduces attack surface but does NOT eliminate vulnerabilities — especially glibc, openssl, JVM native libs, and bundled JARs.

    Why This Matters More With Distroless

    Traditional images: apt list, rpm -qa, apk info all work.
    Distroless: no package manager, no shell, minimal metadata. Normal ops tooling breaks.

    syft reconstructs component inventory directly from: ELF binaries, JAR manifests, filesystem contents, package DB remnants, JVM module structure.

    Why This Matters More With jlink

    jlink creates custom JVMs with arbitrary subsets of modules. Scanners cannot assume a standard JRE; security teams cannot assume stock distributions.

    Example material differences:

    • java.desktop missing
    • jdk.crypto.ec present
    • jdk.naming.rmi absent

    That materially changes: attack surface, crypto capability, TLS support, exploitability. SBOM tooling lets you prove exactly what shipped.

    Practical CI Pipeline

    docker build -t jdt2jar .
    syft packages jdt2jar -o spdx-json > sbom.json
    grype sbom:sbom.json --fail-on critical

    Fail build on: critical CVEs, forbidden packages, unexpected dependencies.

    Concrete Example

    A transitive Maven dependency pulls commons-compress with a vulnerable TAR parser. Without SBOM scanning it's hidden inside the fat JAR, invisible in the distroless runtime. With syft + grype the dependency is identified and the CVE surfaced automatically.

    Important Limitation

    These tools do NOT prove code safety, absence of logic bugs, or absence of RCEs. They only identify known vulnerable components and supply-chain composition. They are inventory + vulnerability correlation systems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions