diff --git a/build.gradle b/build.gradle
index ca97d748f..f065399dc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -214,6 +214,7 @@ dependencies {
implementation libs.asm.util // ASM utilities
implementation libs.icu4j // Unicode support
implementation libs.jsoup // HTML5 parsing for HTML::Content::Extractor
+ implementation libs.jing // RELAX NG validation for XML::LibXML
implementation libs.snakeyaml.engine // YAML processing
implementation libs.tomlj // TOML processing
implementation libs.commons.csv // CSV processing
@@ -226,6 +227,8 @@ dependencies {
implementation libs.sqlite.jdbc // SQLite JDBC driver
implementation libs.bcprov // Bouncy Castle crypto (SHA-3, Keccak, etc.)
implementation libs.bcpkix // Bouncy Castle PEM/PKCS parsing
+ implementation libs.snappy.java // Official Sereal Java codec compression
+ implementation libs.zstd.jni // Official Sereal Java codec compression
implementation 'org.jruby.joni:joni:2.2.7' // Stack-safe recursive regex backend
implementation 'io.netty:netty-codec-http:4.1.115.Final' // Netty HTTP codec for PSGI server
diff --git a/dev/design/jcpan-compiler-tooling-followup.md b/dev/design/jcpan-compiler-tooling-followup.md
new file mode 100644
index 000000000..9ff51562b
--- /dev/null
+++ b/dev/design/jcpan-compiler-tooling-followup.md
@@ -0,0 +1,76 @@
+# jcpan compiler and tooling follow-up
+
+## Goal
+
+Remove shared PerlOnJava compiler/runtime/tooling blockers encountered while testing Data::Checks, POE::Component::MessageQueue, Net::Server::POP3::Skeleton, Mail::BIMI, Imager::Album, Test::Mimic::Recorder, Fuse::Filesys::Virtual, and their dependencies. Distribution preferences are deliberately avoided.
+
+## Implementation
+
+### Compiler and runtime semantics
+
+- Tied `@ISA` values are fetched through the tie interface during method resolution.
+- Constant subroutines preserve their scalar-reference stash proxy without losing the callable CODE slot.
+- `use open` defaults are read from lexical `%^H` call-site snapshots instead of leaking through a process-global `${^OPEN}` value.
+
+### Bundled module tooling
+
+- `DynaLoader::dl_load_flags` is available to XS-style loaders.
+- HTTP::Tiny installs each convenience verb once and returns its standard status-599 response for transport failures.
+- Time::HiRes provides the requested `ualarm` export.
+- XML::LibXML::RelaxNG uses Jing for schema compilation and validation.
+- Sereal::Encoder and Sereal::Decoder use the official Booking.com Java codec with Snappy and Zstandard support.
+- Cache::FastMmap has a JVM-backed compatibility implementation and preserves the distribution's Perl serialization, expiry, and callback layer. Its map is process-local; the share file is created for API compatibility.
+- Crypt::OpenSSL::X509 and Crypt::OpenSSL::Verify use the existing Bouncy Castle/JCA stack rather than adding another crypto implementation.
+
+The imported Sereal Java sources are based on upstream commit `9ad81cf3023ccc456c2accd83bea2c2803a82e16`.
+
+## System-Perl exclusions
+
+The following are not treated as PerlOnJava regressions because their current distributions or tests fail under the available system Perl or require unavailable platform facilities:
+
+- Data::Checks: its fresh system-Perl dependency set lacks `builtin.pm`.
+- Net::Server::POP3::Skeleton: the release metadata points `VERSION_FROM` at a nonexistent `lib/Tk/Carp.pm`.
+- Imager::Album: its system-Perl dependency closure lacks Imager and relies on the legacy Gtk/display stack.
+- Test::Mimic::Recorder: its own test fails on system Perl with a hard-coded reference-history assumption.
+- Fuse::Filesys::Virtual: Fuse 0.16 refuses to configure on Darwin without OSXFUSE.
+- POE::Component::MessageQueue: its remaining bind test cannot run in this sandbox; a minimal system-Perl bind fails with the same platform restriction.
+
+## Progress Tracking
+
+### Current Status: implementation complete; PR ready for review
+
+### Completed Phases
+
+- [x] Phase 1: classify upstream/system failures (2026-08-15)
+ - Reproduced each exclusion with system Perl or the relevant platform configuration step.
+- [x] Phase 2: compiler/runtime fixes (2026-08-15)
+ - Fixed tied inheritance, constant stash proxies, and lexical open-layer handling.
+- [x] Phase 3: reusable Java module bridges (2026-08-15)
+ - Added Sereal, cache, OpenSSL/X509, and RelaxNG support using existing or upstream Java libraries.
+- [x] Phase 4: regression coverage (2026-08-15)
+ - Added focused unit tests and validated new Perl semantics with system Perl before PerlOnJava.
+- [x] Phase 5: final verification (2026-08-15)
+ - Full `make` passed.
+ - Mail::BIMI passed all 31 test programs and 82 assertions; network- and author-only tests skipped as expected.
+ - Preserved the platform-default I/O layer when no lexical `open` pragma is
+ active, including Windows `:crlf`, without consulting the leaked
+ process-global `${^OPEN}` value.
+- [x] Phase 6: pull request and CI (2026-08-15)
+ - Opened PR #962 from `fix/jcpan-compiler-tooling-followup`.
+ - GitHub Actions passed on Ubuntu and Windows; the Windows run specifically
+ confirmed the platform-default newline fix in `pipe_jperl_shebang.t`.
+
+### Next Steps
+
+1. Review PR #962.
+2. Merge after approval.
+
+### Open Questions
+
+- A future Cache::FastMmap implementation could provide true cross-process mmap sharing; current requested tests only require same-process behavior.
+
+## References
+
+- [Sereal](https://github.com/Sereal/Sereal)
+- [Jing and Trang](https://github.com/relaxng/jing-trang)
+- Skills: `debug-perlonjava`, `port-cpan-module`, `port-native-module`
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b06e06cfc..9c1999f26 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,10 +6,13 @@ commons-csv = "1.14.1"
commonmark = "0.29.0"
icu4j = "78.3"
jsoup = "1.23.1"
+jing = "20241231"
junit-jupiter = "6.1.3"
snakeyaml-engine = "3.1.1"
+snappy-java = "1.1.10.8"
sqlite-jdbc = "3.53.2.1"
tomlj = "1.1.1"
+zstd-jni = "1.5.7-8"
[libraries]
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
@@ -25,12 +28,15 @@ commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", versi
commonmark-task-list = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" }
icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
+jing = { module = "org.relaxng:jing", version.ref = "jing" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" }
snakeyaml-engine = { module = "org.snakeyaml:snakeyaml-engine", version.ref = "snakeyaml-engine" }
+snappy-java = { module = "org.xerial.snappy:snappy-java", version.ref = "snappy-java" }
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }
tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" }
+zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" }
[plugins]
cyclonedx = "org.cyclonedx.bom:2.3.0"
diff --git a/src/main/java/com/booking/sereal/ByteArray.java b/src/main/java/com/booking/sereal/ByteArray.java
new file mode 100644
index 000000000..cb033e30e
--- /dev/null
+++ b/src/main/java/com/booking/sereal/ByteArray.java
@@ -0,0 +1,32 @@
+package com.booking.sereal;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+public class ByteArray {
+ public byte[] array;
+ public int start;
+ public int length;
+
+ public ByteArray(byte[] array) {
+ this(array, 0, array.length);
+ }
+
+ public ByteArray(byte[] array, int length) {
+ this(array, 0, length);
+ }
+
+ public ByteArray(byte[] array, int start, int length) {
+ this.array = array;
+ this.start = start;
+ this.length = length;
+ }
+
+ public ByteArray(ByteBuffer buffer) {
+ this(buffer.array(), buffer.limit());
+ }
+
+ public void ensure(int size) {
+ if (size > array.length) array = Arrays.copyOf(array, size * 3 / 2);
+ }
+}
diff --git a/src/main/java/com/booking/sereal/DeSereal.java b/src/main/java/com/booking/sereal/DeSereal.java
new file mode 100644
index 000000000..ffe254fcc
--- /dev/null
+++ b/src/main/java/com/booking/sereal/DeSereal.java
@@ -0,0 +1,26 @@
+package com.booking.sereal;
+
+import java.io.File;
+import java.io.IOException;
+
+public class DeSereal {
+ /**
+ * @param args command arguments
+ * @throws IOException Indicated file cannot accessed.
+ * @throws SerealException Sereal data cannot be processed.
+ */
+ public static void main(String[] args) throws IOException, SerealException {
+ if (args.length == 0) {
+ throw new UnsupportedOperationException("Usage: DeSereal test_data");
+ }
+
+ DecoderOptions decoder_options =
+ new DecoderOptions().perlReferences(true).perlAliases(true).preferLatin1(true);
+
+ Decoder dec = new Decoder(decoder_options);
+ final File target = new File(args[0]).getCanonicalFile(); // to absorb ".." in paths
+ // dec.log.setLevel( Level.FINE );
+ Object data = Utils.decodeFile(dec, target);
+ System.out.println(Utils.dump(data));
+ }
+}
diff --git a/src/main/java/com/booking/sereal/Decoder.java b/src/main/java/com/booking/sereal/Decoder.java
new file mode 100644
index 000000000..edca41ed6
--- /dev/null
+++ b/src/main/java/com/booking/sereal/Decoder.java
@@ -0,0 +1,914 @@
+package com.booking.sereal;
+
+import com.booking.sereal.impl.RefpMap;
+import com.github.luben.zstd.Zstd;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigInteger;
+import org.xerial.snappy.Snappy;
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+/**
+ * Sereal decoder with Perl-like interface.
+ *
+ * This class can be used to decoder Perl-like data-structures: (boxed) primitive types, strings, arrays
+ * and maps.
+ */
+public class Decoder implements SerealHeader {
+
+ private static final DecoderOptions DEFAULT_OPTIONS = new DecoderOptions();
+ private static final Charset charset_utf8 = Charset.forName("UTF-8");
+ private static final Charset charset_latin1 = Charset.forName("ISO-8859-1");
+ private final boolean perlRefs;
+ private final boolean perlAlias;
+ private final boolean preserveUndef;
+ private final boolean refuseSnappy;
+ private final boolean refuseZlib;
+ private final boolean refuseZstd;
+ private final boolean preferLatin1;
+ private final boolean forceJavaStringForByteArrayValues;
+ private final boolean refuseObjects;
+ private final boolean stripObjects;
+ private final TypeMapper typeMapper;
+ private final boolean useObjectArray;
+ private final int decodeBufferSize;
+ private final int maxSize;
+
+ private final int maxRecursionDepth;
+ private final int maxNumMapEntries;
+ private final int maxNumArrayEntries;
+ private final int maxStringLength;
+
+ private byte[] data;
+ private int position, end;
+ private ByteArray originalData;
+ // where we track items for REFP purposes
+ private RefpMap tracked = new RefpMap();
+ private int protocolVersion = -1;
+ private int encoding = -1;
+ private int baseOffset = Integer.MAX_VALUE;
+ private long userHeaderPosition = -1;
+ private long userHeaderSize = -1;
+ private Inflater inflater;
+
+ private int recursionDepth = 0;
+
+ /** Create a new Decoder with default options. */
+ public Decoder() {
+ this(DEFAULT_OPTIONS);
+ }
+
+ /**
+ * Create a new Decoder with the specified options.
+ *
+ * @param options {@link DecoderOptions} to apply.
+ */
+ public Decoder(DecoderOptions options) {
+ perlRefs = options.perlReferences();
+ perlAlias = options.perlAliases();
+ preserveUndef = options.preserveUndef();
+ refuseSnappy = options.refuseSnappy();
+ refuseZlib = options.refuseZlib();
+ refuseZstd = options.refuseZstd();
+ preferLatin1 = options.preferLatin1();
+ forceJavaStringForByteArrayValues = options.forceJavaStringForByteArrayValues();
+ refuseObjects = options.refuseObjects();
+ stripObjects = options.stripObjects();
+ typeMapper = options.typeMapper();
+ useObjectArray = typeMapper.useObjectArray();
+ decodeBufferSize = options.bufferSize();
+ maxSize = options.maxBufferSize();
+
+ maxRecursionDepth = options.maxRecursionDepth();
+ maxNumMapEntries = options.maxNumMapEntries();
+ maxNumArrayEntries = options.maxNumArrayEntries();
+ maxStringLength = options.maxStringLength();
+ }
+
+ private void checkHeader() throws SerealException {
+
+ if ((end - position) < 4) {
+ throw new SerealException("Invalid Sereal header: too few bytes");
+ }
+
+ int magic =
+ ((int) (data[position] & 0xff) << 24)
+ + ((int) (data[position + 1] & 0xff) << 16)
+ + ((int) (data[position + 2] & 0xff) << 8)
+ + ((int) (data[position + 3] & 0xff) << 0);
+ position += 4;
+ if (magic != MAGIC && magic != MAGIC_V3) {
+ throw new SerealException(
+ String.format("Invalid Sereal header (%08x): doesn't match magic", magic));
+ }
+ }
+
+ private void checkHeaderSuffix() {
+ long suffix_size = read_varint();
+ long basePosition = position;
+
+ userHeaderSize = 0;
+ if (suffix_size > 0) {
+ byte bitfield = data[position++];
+
+ if ((bitfield & 0x01) == 0x01) {
+ userHeaderPosition = position;
+ userHeaderSize = suffix_size - 1;
+ }
+ }
+
+ // skip everything in the optional suffix part
+ position = (int) (basePosition + suffix_size);
+ }
+
+ private void checkNoEOD() throws SerealException {
+
+ if ((end - position) <= 0) {
+ throw new SerealException("Unexpected end of data at byte " + position);
+ }
+ }
+
+ private void checkProtoAndFlags() throws SerealException {
+
+ if ((end - position) < 1) {
+ throw new SerealException("Invalid Sereal header: no protocol/version byte");
+ }
+
+ int protoAndFlags = data[position++];
+ protocolVersion = protoAndFlags & 15; // 4 bits for version
+
+ if (protocolVersion < 0 || protocolVersion > 4) {
+ throw new SerealException(
+ String.format("Invalid Sereal header: unsupported protocol version %d", protocolVersion));
+ }
+
+ encoding = (protoAndFlags & ~15) >> 4;
+ if ((encoding == 1 || encoding == 2) && refuseSnappy) {
+ throw new SerealException("Unsupported encoding: Snappy");
+ } else if (encoding == 3 && refuseZlib) {
+ throw new SerealException("Unsupported encoding: Zlib");
+ } else if (encoding == 4 && refuseZstd) {
+ throw new SerealException("Unsupported encoding: Zstd");
+ } else if (encoding == 4 && protocolVersion < 4) {
+ throw new SerealException(
+ "Unsupported encoding zstd for protocol version " + protocolVersion);
+ } else if (encoding < 0 || encoding > 4) {
+ throw new SerealException("Unsupported encoding: unknown");
+ }
+ }
+
+ /**
+ * Indicates if the Sereal document has an header.
+ *
+ * @return {@code true} if the Sereal document has an header, {@code false} otherwise
+ *
+ * @throws SerealException if header cannot be parsed.
+ */
+ public boolean hasHeader() throws SerealException {
+ parseHeader();
+
+ return userHeaderSize > 0;
+ }
+
+ /**
+ * Size of the Sereal header.
+ *
+ * @return Size of the Sereal header, 0 if there is no header.
+ *
+ * @throws SerealException if header cannot be parsed.
+ */
+ public long headerSize() throws SerealException {
+ parseHeader();
+
+ return userHeaderSize > 0 ? userHeaderSize : 0;
+ }
+
+ /**
+ * Decode the Sereal document header and returns the decoded value.
+ *
+ * @return returns the header decoded value.
+ *
+ * @throws SerealException if header cannot be parsed.
+ */
+ public Object decodeHeader() throws SerealException {
+ parseHeader();
+
+ if (userHeaderSize <= 0) throw new SerealException("Sereal user header not present");
+ byte[] originalData = data;
+ int originalPosition = position, originalSize = end;
+ try {
+ data = originalData;
+ end = (int) (userHeaderPosition + userHeaderSize);
+ position = (int) userHeaderPosition;
+
+ return readSingleValue();
+ } finally {
+ data = originalData;
+ end = originalSize;
+ position = originalPosition;
+ resetTracked();
+ }
+ }
+
+ private void parseHeader() throws SerealException {
+ if (userHeaderSize >= 0) return;
+
+ checkHeader();
+ checkProtoAndFlags();
+ checkHeaderSuffix();
+ }
+
+ /**
+ * Decode the Sereal document body and returns the decoded value.
+ *
+ * @return the decoded value.
+ *
+ * @throws SerealException if data cannot be parsed.
+ */
+ public Object decode() throws SerealException {
+
+ recursionDepth = 0;
+
+ if (data == null) {
+ throw new SerealException("No data set");
+ }
+
+ parseHeader();
+
+ if (encoding != 0) {
+ if (encoding == 1 || encoding == 2) uncompressSnappy();
+ else if (encoding == 3) uncompressZlib();
+ else if (encoding == 4) uncompressZstd();
+ if (protocolVersion == 1) baseOffset = 0;
+ else
+ // because offsets start at 1
+ baseOffset = -1;
+ } else {
+ if (protocolVersion == 1) baseOffset = 0;
+ else
+ // because offsets start at 1
+ baseOffset = position - 1;
+ }
+ Object out;
+ try {
+ out = readSingleValue();
+ } catch (StackOverflowError error) {
+ throw new SerealException("StackOverflowError: Reached recursion limit during deserialization");
+ }
+
+ return out;
+ }
+
+ private void uncompressSnappy() throws SerealException {
+ int len = originalData.length - (position - originalData.start);
+ int pos = protocolVersion == 1 ? position : originalData.start;
+
+ if (encoding == 2) {
+ len = (int) read_varint();
+ }
+
+ byte[] uncompressed;
+ try {
+ if (!Snappy.isValidCompressedBuffer(originalData.array, position, len)) {
+ throw new SerealException("Invalid snappy data");
+ }
+ int uncompressedLength = Snappy.uncompressedLength(originalData.array, position, len);
+ if (uncompressedLength > this.maxSize) {
+ throw new SerealException("The expected uncompressed size is larger than the allowed maximum size");
+ }
+ uncompressed = new byte[pos + uncompressedLength];
+ Snappy.uncompress(originalData.array, position, len, uncompressed, pos);
+ } catch (IOException e) {
+ throw new SerealException(e);
+ }
+ this.data = uncompressed;
+ this.position = pos;
+ this.end = uncompressed.length;
+ }
+
+ private void uncompressZlib() throws SerealException {
+ if (inflater == null) {
+ inflater = new Inflater();
+ }
+ inflater.reset();
+
+ long uncompressedLength = read_varint();
+ if (uncompressedLength > this.maxSize) {
+ throw new SerealException("The expected uncompressed size is larger than the allowed maximum size");
+ }
+
+ long compressedLength = read_varint();
+ inflater.setInput(originalData.array, position, (int) compressedLength);
+ try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[this.decodeBufferSize];
+ while (!inflater.finished()) {
+ if (outputStream.size() > this.maxSize) {
+ throw new SerealException("The uncompressed size is larger than the allowed maximum size");
+ } else if (outputStream.size() > uncompressedLength) {
+ throw new SerealException("The uncompressed size is larger than the expected size");
+ }
+ int count = inflater.inflate(buffer);
+ if (count == 0 ) {
+ break;
+ }
+ outputStream.write(buffer, 0, count);
+ }
+ this.data = outputStream.toByteArray();
+ } catch (DataFormatException | IOException e) {
+ throw new SerealException(e);
+ }
+ this.position = 0;
+ this.end = this.data.length;
+ }
+
+ private void uncompressZstd() throws SerealException {
+ int len = (int) read_varint();
+
+ byte[] compressedData = Arrays.copyOfRange(originalData.array, position, position + len);
+ long decompressedSize = Zstd.decompressedSize(compressedData);
+
+ if (decompressedSize > this.maxSize) {
+ throw new SerealException("The expected uncompressed size is larger than the allowed maximum size");
+ }
+ if (decompressedSize > Integer.MAX_VALUE) {
+ throw new SerealException("Decompressed size exceeds integer MAX_VALUE: " + decompressedSize);
+ }
+ byte[] uncompressed = new byte[(int) decompressedSize];
+ long status = Zstd.decompress(uncompressed, compressedData);
+ if (Zstd.isError(status)) {
+ throw new SerealException(Zstd.getErrorName(status));
+ }
+ this.data = uncompressed;
+ this.position = 0;
+ this.end = uncompressed.length;
+ }
+
+ /**
+ * Decode a Sereal ARRAY tag to a native Java arrya
+ *
+ * @param length number of items in the array
+ * @param track we might need to track since array elements could refer to us
+ */
+ private Object[] readNativeArray(int length, int track) throws SerealException {
+ if (maxNumArrayEntries != 0 && length > maxNumArrayEntries) {
+ throw new SerealException("Got input array with " + length + " entries, but the configured maximum is just " + maxNumArrayEntries);
+ }
+
+ Object[] out = new Object[length];
+ if (track != 0) { // track ourself
+ track_stuff(track, out);
+ }
+
+ for (int i = 0; i < length; i++) {
+ out[i] = readSingleValue();
+ }
+
+ return out;
+ }
+
+ /**
+ * Decode a Sereal ARRAY tag to a native Java List
+ *
+ * @param length number of items in the list
+ * @param track we might need to track since array elements could refer to us
+ */
+ private List