From 61d19fbe2e7f9fb6afd474aa23d41593b2380b04 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:00:07 +0900
Subject: [PATCH 001/219] test(conversion): define office adapter contract
regression
---
.../OfficeConversionAdapterContractTest.java | 142 ++++++++++++++++++
1 file changed, 142 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
new file mode 100644
index 00000000..9b165e4f
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -0,0 +1,142 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contract tests for the provider-neutral Office conversion boundary.
+ */
+class OfficeConversionAdapterContractTest {
+
+ @Test
+ void requestDefensivelyCopiesSourceBytesAndBindsImmutableIdentity() {
+ byte[] source = "office-source".getBytes(StandardCharsets.UTF_8);
+ UUID jobId = UUID.randomUUID();
+
+ OfficeConversionRequest request = new OfficeConversionRequest(
+ "tenant-a",
+ jobId,
+ 7L,
+ "docx",
+ "policy-v3",
+ "trace-123",
+ source
+ );
+
+ String expectedDigest = request.sourceSha256();
+ source[0] = 'X';
+ byte[] exposed = request.sourceBytes();
+ exposed[1] = 'Y';
+
+ assertEquals("tenant-a", request.tenantId());
+ assertEquals(jobId, request.jobId());
+ assertEquals(7L, request.jobGeneration());
+ assertEquals("docx", request.sourceFormat());
+ assertEquals("policy-v3", request.policyVersion());
+ assertEquals("trace-123", request.correlationId());
+ assertArrayEquals("office-source".getBytes(StandardCharsets.UTF_8), request.sourceBytes());
+ assertEquals(expectedDigest, request.sourceSha256());
+ assertEquals(64, expectedDigest.length());
+ }
+
+ @Test
+ void requestRejectsMissingIdentityAndEmptySource() {
+ byte[] source = "x".getBytes(StandardCharsets.UTF_8);
+ UUID jobId = UUID.randomUUID();
+
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ " ", jobId, 0L, "docx", "policy", "trace", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", null, 0L, "docx", "policy", "trace", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", jobId, -1L, "docx", "policy", "trace", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", jobId, 0L, " ", "policy", "trace", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", jobId, 0L, "docx", " ", "trace", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", jobId, 0L, "docx", "policy", " ", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", jobId, 0L, "docx", "policy", "trace", new byte[0]));
+ }
+
+ @Test
+ void resultDefensivelyCopiesVerifiedPdfAndCarriesProvenance() {
+ byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
+ OfficeConversionResult result = new OfficeConversionResult(
+ "fixture-adapter",
+ "1.0.0",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ pdf
+ );
+
+ String outputDigest = result.outputSha256();
+ pdf[0] = 'X';
+ byte[] exposed = result.pdfBytes();
+ exposed[1] = 'Y';
+
+ assertEquals("fixture-adapter", result.adapterId());
+ assertEquals("1.0.0", result.adapterVersion());
+ assertEquals(64, result.sourceSha256().length());
+ assertArrayEquals("%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII), result.pdfBytes());
+ assertEquals(outputDigest, result.outputSha256());
+ assertEquals(64, outputDigest.length());
+ }
+
+ @Test
+ void resultRejectsInvalidProvenanceAndNonPdfOutput() {
+ byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
+ byte[] notPdf = "not-pdf".getBytes(StandardCharsets.US_ASCII);
+ String digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult(" ", "1", digest, pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", " ", digest, pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", "bad", pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", digest, notPdf));
+ }
+
+ @Test
+ void failureCodesExposeExplicitRetryPolicy() {
+ assertFalse(OfficeConversionFailureCode.UNSUPPORTED_FORMAT.isRetryable());
+ assertFalse(OfficeConversionFailureCode.POLICY_DENIED.isRetryable());
+ assertFalse(OfficeConversionFailureCode.PASSWORD_PROTECTED.isRetryable());
+ assertFalse(OfficeConversionFailureCode.MALFORMED_INPUT.isRetryable());
+ assertFalse(OfficeConversionFailureCode.CANCELLED.isRetryable());
+ assertFalse(OfficeConversionFailureCode.INVALID_OUTPUT.isRetryable());
+ assertTrue(OfficeConversionFailureCode.ENGINE_UNAVAILABLE.isRetryable());
+ assertTrue(OfficeConversionFailureCode.TIMEOUT.isRetryable());
+ assertTrue(OfficeConversionFailureCode.ENGINE_CRASH.isRetryable());
+ }
+
+ @Test
+ void adapterContractCanReturnDeterministicFixtureEvidence() {
+ byte[] source = "fixture-docx".getBytes(StandardCharsets.UTF_8);
+ OfficeConversionRequest request = new OfficeConversionRequest(
+ "tenant-a", UUID.randomUUID(), 1L, "docx", "policy-v1", "trace-1", source);
+ byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
+
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ pdf
+ );
+
+ OfficeConversionResult result = adapter.convert(request);
+
+ assertEquals(request.sourceSha256(), result.sourceSha256());
+ assertArrayEquals(pdf, result.pdfBytes());
+ }
+}
From fa01cff3d28696ec2d9a9db2db9887055883c9a7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:04:10 +0900
Subject: [PATCH 002/219] feat(conversion): add immutable Office conversion
request
---
.../conversion/OfficeConversionRequest.java | 84 +++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
new file mode 100644
index 00000000..4858229a
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -0,0 +1,84 @@
+package com.clearfolio.viewer.conversion;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Immutable request passed across the provider-neutral Office conversion boundary.
+ *
+ * The request binds untrusted document bytes to tenant, job-generation,
+ * policy, format, and correlation identity before any converter implementation
+ * can process them. Source bytes are defensively copied at construction and on
+ * access so callers cannot mutate the digest-bound payload after validation.
+ *
+ * @param tenantId tenant that owns the conversion request
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration immutable lifecycle generation for stale-work fencing
+ * @param sourceFormat normalized source format such as {@code docx}
+ * @param policyVersion conversion and active-content policy version
+ * @param correlationId request correlation identifier used for controlled tracing
+ * @param sourceBytes untrusted source bytes, defensively copied
+ */
+public record OfficeConversionRequest(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ byte[] sourceBytes
+) {
+
+ /**
+ * Validates immutable conversion identity and copies the untrusted source bytes.
+ *
+ * @throws IllegalArgumentException when required identity or source bytes are invalid
+ */
+ public OfficeConversionRequest {
+ tenantId = requireText(tenantId, "tenantId");
+ jobId = Objects.requireNonNull(jobId, "jobId");
+ if (jobGeneration < 0L) {
+ throw new IllegalArgumentException("jobGeneration must be non-negative");
+ }
+ sourceFormat = requireText(sourceFormat, "sourceFormat");
+ policyVersion = requireText(policyVersion, "policyVersion");
+ correlationId = requireText(correlationId, "correlationId");
+ if (sourceBytes == null || sourceBytes.length == 0) {
+ throw new IllegalArgumentException("sourceBytes must not be empty");
+ }
+ sourceBytes = sourceBytes.clone();
+ }
+
+ /**
+ * Returns a defensive copy of the source bytes.
+ *
+ * @return copied source bytes
+ */
+ @Override
+ public byte[] sourceBytes() {
+ return sourceBytes.clone();
+ }
+
+ /**
+ * Returns the SHA-256 digest of the immutable source bytes.
+ *
+ * @return lowercase hexadecimal SHA-256 digest
+ */
+ public String sourceSha256() {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(sourceBytes));
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("SHA-256 is unavailable", ex);
+ }
+ }
+
+ private static String requireText(String value, String fieldName) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(fieldName + " must not be blank");
+ }
+ return value;
+ }
+}
From 3bd5b51151b6a44ba03f4b78f43c93b9358738a6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:04:25 +0900
Subject: [PATCH 003/219] feat(conversion): add verified Office conversion
result
---
.../conversion/OfficeConversionResult.java | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java
new file mode 100644
index 00000000..54a4be91
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java
@@ -0,0 +1,77 @@
+package com.clearfolio.viewer.conversion;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * Verified PDF result returned by an Office conversion adapter.
+ *
+ * The result carries adapter provenance and the exact source digest supplied
+ * to the converter. PDF bytes are copied at construction and on access so the
+ * evidence cannot be mutated after acceptance.
+ *
+ * @param adapterId stable adapter implementation identifier
+ * @param adapterVersion qualified adapter/runtime version identifier
+ * @param sourceSha256 lowercase SHA-256 digest of the source request
+ * @param pdfBytes verified PDF bytes, defensively copied
+ */
+public record OfficeConversionResult(
+ String adapterId,
+ String adapterVersion,
+ String sourceSha256,
+ byte[] pdfBytes
+) {
+
+ /**
+ * Validates result provenance and a minimal PDF media signature.
+ *
+ * @throws IllegalArgumentException when provenance or PDF bytes are invalid
+ */
+ public OfficeConversionResult {
+ adapterId = requireText(adapterId, "adapterId");
+ adapterVersion = requireText(adapterVersion, "adapterVersion");
+ if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) {
+ throw new IllegalArgumentException("sourceSha256 must be lowercase SHA-256 hex");
+ }
+ if (pdfBytes == null) {
+ throw new IllegalArgumentException("pdfBytes must not be null");
+ }
+ pdfBytes = pdfBytes.clone();
+ String prefix = new String(pdfBytes, 0, Math.min(pdfBytes.length, 5), StandardCharsets.US_ASCII);
+ if (!"%PDF-".equals(prefix)) {
+ throw new IllegalArgumentException("converter output is not a PDF");
+ }
+ }
+
+ /**
+ * Returns a defensive copy of the accepted PDF bytes.
+ *
+ * @return copied PDF bytes
+ */
+ @Override
+ public byte[] pdfBytes() {
+ return pdfBytes.clone();
+ }
+
+ /**
+ * Returns the SHA-256 digest of the accepted PDF bytes.
+ *
+ * @return lowercase hexadecimal SHA-256 digest
+ */
+ public String outputSha256() {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(pdfBytes));
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("SHA-256 is unavailable", ex);
+ }
+ }
+
+ private static String requireText(String value, String fieldName) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(fieldName + " must not be blank");
+ }
+ return value;
+ }
+}
From 9e11b822a451c5011413a92d6adf216765b64dbe Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:04:42 +0900
Subject: [PATCH 004/219] feat(conversion): classify Office conversion failures
---
.../OfficeConversionFailureCode.java | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
new file mode 100644
index 00000000..cad30d97
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
@@ -0,0 +1,44 @@
+package com.clearfolio.viewer.conversion;
+
+/**
+ * Stable failure classes returned by qualified Office conversion adapters.
+ *
+ * Retryability is part of the adapter contract so parser, policy, and user
+ * input failures cannot be mistaken for transient engine failures.
+ */
+public enum OfficeConversionFailureCode {
+
+ /** Source format is outside the qualified support matrix. */
+ UNSUPPORTED_FORMAT(false),
+ /** Source was rejected by macro, active-content, or document policy. */
+ POLICY_DENIED(false),
+ /** Source requires a password and cannot be converted unattended. */
+ PASSWORD_PROTECTED(false),
+ /** Source structure is malformed or cannot be parsed safely. */
+ MALFORMED_INPUT(false),
+ /** Caller cancelled the exact conversion generation. */
+ CANCELLED(false),
+ /** Converter returned output that failed PDF validation. */
+ INVALID_OUTPUT(false),
+ /** Qualified converter service or capacity is temporarily unavailable. */
+ ENGINE_UNAVAILABLE(true),
+ /** Conversion exceeded its bounded execution deadline. */
+ TIMEOUT(true),
+ /** Isolated converter process or service crashed during execution. */
+ ENGINE_CRASH(true);
+
+ private final boolean retryable;
+
+ OfficeConversionFailureCode(boolean retryable) {
+ this.retryable = retryable;
+ }
+
+ /**
+ * Returns whether this failure class may enter bounded retry policy.
+ *
+ * @return {@code true} only for transient engine failure classes
+ */
+ public boolean isRetryable() {
+ return retryable;
+ }
+}
From d508977e524563a3a3ba01096fd42f093e802530 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:04:49 +0900
Subject: [PATCH 005/219] feat(conversion): add provider-neutral Office adapter
interface
---
.../conversion/OfficeConversionAdapter.java | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
new file mode 100644
index 00000000..00469db1
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -0,0 +1,21 @@
+package com.clearfolio.viewer.conversion;
+
+/**
+ * Provider-neutral boundary for sandboxed or remote Office-to-PDF conversion.
+ *
+ * Implementations own converter-specific transport and process details. The
+ * Clearfolio API and job lifecycle depend only on this contract so a sandboxed
+ * sidecar, authenticated remote service, or deterministic fixture adapter can
+ * be substituted without changing document-delivery authority.
+ */
+@FunctionalInterface
+public interface OfficeConversionAdapter {
+
+ /**
+ * Converts one immutable Office request into verified PDF evidence.
+ *
+ * @param request immutable tenant- and generation-bound conversion request
+ * @return verified PDF result with source and adapter provenance
+ */
+ OfficeConversionResult convert(OfficeConversionRequest request);
+}
From 624fa4ab4eb3060d24fb8aaf28aa70c226915fd5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:05:06 +0900
Subject: [PATCH 006/219] fix(conversion): fail closed on missing job identity
---
.../viewer/conversion/OfficeConversionRequest.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index 4858229a..693a14bc 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -3,7 +3,6 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
-import java.util.Objects;
import java.util.UUID;
/**
@@ -39,7 +38,9 @@ public record OfficeConversionRequest(
*/
public OfficeConversionRequest {
tenantId = requireText(tenantId, "tenantId");
- jobId = Objects.requireNonNull(jobId, "jobId");
+ if (jobId == null) {
+ throw new IllegalArgumentException("jobId must not be null");
+ }
if (jobGeneration < 0L) {
throw new IllegalArgumentException("jobGeneration must be non-negative");
}
From 31cde21eb34d736ab2b5b5fe1ab903d83d4e24ef Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:08:56 +0900
Subject: [PATCH 007/219] test(conversion): cover fail-closed adapter
validation branches
---
.../OfficeConversionAdapterContractTest.java | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
index 9b165e4f..f9aec72e 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -52,6 +52,8 @@ void requestRejectsMissingIdentityAndEmptySource() {
byte[] source = "x".getBytes(StandardCharsets.UTF_8);
UUID jobId = UUID.randomUUID();
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ null, jobId, 0L, "docx", "policy", "trace", source));
assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
" ", jobId, 0L, "docx", "policy", "trace", source));
assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
@@ -64,6 +66,8 @@ void requestRejectsMissingIdentityAndEmptySource() {
"tenant", jobId, 0L, "docx", " ", "trace", source));
assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
"tenant", jobId, 0L, "docx", "policy", " ", source));
+ assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
+ "tenant", jobId, 0L, "docx", "policy", "trace", null));
assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest(
"tenant", jobId, 0L, "docx", "policy", "trace", new byte[0]));
}
@@ -97,12 +101,22 @@ void resultRejectsInvalidProvenanceAndNonPdfOutput() {
byte[] notPdf = "not-pdf".getBytes(StandardCharsets.US_ASCII);
String digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult(null, "1", digest, pdf));
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult(" ", "1", digest, pdf));
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult("adapter", " ", digest, pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", null, pdf));
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult("adapter", "1", "bad", pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", digest.toUpperCase(), pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", digest, null));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", digest, new byte[0]));
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult("adapter", "1", digest, notPdf));
}
From a6602988bf4210ae0c59394be49dcff69c0b4be1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:13:23 +0900
Subject: [PATCH 008/219] test(conversion): require canonical source-format
identity
---
.../OfficeConversionAdapterContractTest.java | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
index f9aec72e..b62ed49b 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -47,6 +47,21 @@ void requestDefensivelyCopiesSourceBytesAndBindsImmutableIdentity() {
assertEquals(64, expectedDigest.length());
}
+ @Test
+ void requestCanonicalizesSourceFormatBeforeAdapterRouting() {
+ OfficeConversionRequest request = new OfficeConversionRequest(
+ "tenant-a",
+ UUID.randomUUID(),
+ 1L,
+ " DoCx ",
+ "policy-v1",
+ "trace-1",
+ "source".getBytes(StandardCharsets.UTF_8)
+ );
+
+ assertEquals("docx", request.sourceFormat());
+ }
+
@Test
void requestRejectsMissingIdentityAndEmptySource() {
byte[] source = "x".getBytes(StandardCharsets.UTF_8);
From 6ffe18372ecaa1ab70a8cb875f40984ca1dcae47 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 00:14:12 +0900
Subject: [PATCH 009/219] fix(conversion): canonicalize Office source format
---
.../viewer/conversion/OfficeConversionRequest.java | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index 693a14bc..85c61ed7 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -3,6 +3,7 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
+import java.util.Locale;
import java.util.UUID;
/**
@@ -44,7 +45,7 @@ public record OfficeConversionRequest(
if (jobGeneration < 0L) {
throw new IllegalArgumentException("jobGeneration must be non-negative");
}
- sourceFormat = requireText(sourceFormat, "sourceFormat");
+ sourceFormat = normalizeSourceFormat(sourceFormat);
policyVersion = requireText(policyVersion, "policyVersion");
correlationId = requireText(correlationId, "correlationId");
if (sourceBytes == null || sourceBytes.length == 0) {
@@ -76,6 +77,10 @@ public String sourceSha256() {
}
}
+ private static String normalizeSourceFormat(String value) {
+ return requireText(value, "sourceFormat").strip().toLowerCase(Locale.ROOT);
+ }
+
private static String requireText(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
From 215b1924d766456d18088d21ce8253b6bf6daf73 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:14:32 +0900
Subject: [PATCH 010/219] test(conversion): require canonical identity and
typed failures
---
.../OfficeConversionAdapterContractTest.java | 27 ++++++++++++++++---
1 file changed, 23 insertions(+), 4 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
index b62ed49b..3d35e825 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -48,18 +48,21 @@ void requestDefensivelyCopiesSourceBytesAndBindsImmutableIdentity() {
}
@Test
- void requestCanonicalizesSourceFormatBeforeAdapterRouting() {
+ void requestCanonicalizesTextIdentityBeforeCrossBoundaryUse() {
OfficeConversionRequest request = new OfficeConversionRequest(
- "tenant-a",
+ " tenant-a ",
UUID.randomUUID(),
1L,
" DoCx ",
- "policy-v1",
- "trace-1",
+ " policy-v1 ",
+ " trace-1 ",
"source".getBytes(StandardCharsets.UTF_8)
);
+ assertEquals("tenant-a", request.tenantId());
assertEquals("docx", request.sourceFormat());
+ assertEquals("policy-v1", request.policyVersion());
+ assertEquals("trace-1", request.correlationId());
}
@Test
@@ -149,6 +152,22 @@ void failureCodesExposeExplicitRetryPolicy() {
assertTrue(OfficeConversionFailureCode.ENGINE_CRASH.isRetryable());
}
+ @Test
+ void adapterFailuresCarryStableClassAndRetryability() {
+ OfficeConversionException failure = new OfficeConversionException(
+ OfficeConversionFailureCode.TIMEOUT,
+ " conversion deadline exceeded "
+ );
+
+ assertEquals(OfficeConversionFailureCode.TIMEOUT, failure.failureCode());
+ assertTrue(failure.isRetryable());
+ assertEquals("conversion deadline exceeded", failure.getMessage());
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionException(null, "failure"));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionException(OfficeConversionFailureCode.ENGINE_CRASH, " "));
+ }
+
@Test
void adapterContractCanReturnDeterministicFixtureEvidence() {
byte[] source = "fixture-docx".getBytes(StandardCharsets.UTF_8);
From 10201a331641e69bb5e232596da79a819590db99 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:19:07 +0900
Subject: [PATCH 011/219] fix(conversion): canonicalize adapter request
identity
---
.../clearfolio/viewer/conversion/OfficeConversionRequest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index 85c61ed7..ac075218 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -78,13 +78,13 @@ public String sourceSha256() {
}
private static String normalizeSourceFormat(String value) {
- return requireText(value, "sourceFormat").strip().toLowerCase(Locale.ROOT);
+ return requireText(value, "sourceFormat").toLowerCase(Locale.ROOT);
}
private static String requireText(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
- return value;
+ return value.strip();
}
}
From 8850dffbc0d2e07c1fbfce7f90682f06276968e5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:19:18 +0900
Subject: [PATCH 012/219] feat(conversion): add typed Office adapter failure
---
.../conversion/OfficeConversionException.java | 57 +++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java
new file mode 100644
index 00000000..adb486dc
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java
@@ -0,0 +1,57 @@
+package com.clearfolio.viewer.conversion;
+
+/**
+ * Typed failure returned by a qualified Office conversion adapter.
+ *
+ * The stable failure code is the policy authority for retryability. Human-
+ * readable messages remain diagnostic context and must not be parsed to decide
+ * whether a conversion should be retried.
+ */
+public final class OfficeConversionException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final OfficeConversionFailureCode failureCode;
+
+ /**
+ * Creates a typed adapter failure.
+ *
+ * @param failureCode stable failure class
+ * @param message non-empty diagnostic message
+ * @throws IllegalArgumentException when the failure code or message is missing
+ */
+ public OfficeConversionException(
+ OfficeConversionFailureCode failureCode,
+ String message) {
+ super(requireMessage(message));
+ if (failureCode == null) {
+ throw new IllegalArgumentException("failureCode must not be null");
+ }
+ this.failureCode = failureCode;
+ }
+
+ /**
+ * Returns the stable conversion failure class.
+ *
+ * @return failure code used for retry and product error mapping
+ */
+ public OfficeConversionFailureCode failureCode() {
+ return failureCode;
+ }
+
+ /**
+ * Returns whether bounded retry is permitted for this failure class.
+ *
+ * @return {@code true} only for transient adapter/engine failures
+ */
+ public boolean isRetryable() {
+ return failureCode.isRetryable();
+ }
+
+ private static String requireMessage(String message) {
+ if (message == null || message.isBlank()) {
+ throw new IllegalArgumentException("message must not be blank");
+ }
+ return message.strip();
+ }
+}
From 3ba542f86e2c9d2979300e22737fb944f9c602c9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:31:37 +0900
Subject: [PATCH 013/219] test(conversion): require adapter source provenance
binding
---
...OfficeConversionAdapterProvenanceTest.java | 62 +++++++++++++++++++
1 file changed, 62 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java
new file mode 100644
index 00000000..e0b29dc2
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java
@@ -0,0 +1,62 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Security and integrity regressions for adapter result provenance.
+ */
+class OfficeConversionAdapterProvenanceTest {
+
+ @Test
+ void convertRejectsResultBoundToDifferentSourceDigest() {
+ OfficeConversionRequest request = request("source-a");
+ OfficeConversionRequest differentSource = request("source-b");
+ byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
+ OfficeConversionAdapter adapter = ignored -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ differentSource.sourceSha256(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion result source digest mismatch", failure.getMessage());
+ }
+
+ @Test
+ void convertRejectsMissingAdapterResult() {
+ OfficeConversionRequest request = request("source-a");
+ OfficeConversionAdapter adapter = ignored -> null;
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion adapter returned no result", failure.getMessage());
+ }
+
+ private static OfficeConversionRequest request(String sourceText) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.randomUUID(),
+ 1L,
+ "docx",
+ "policy-v1",
+ "trace-1",
+ sourceText.getBytes(StandardCharsets.UTF_8)
+ );
+ }
+}
From 0f80c64205e56c227b66739e05f9845c08b0ff10 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:34:42 +0900
Subject: [PATCH 014/219] fix(conversion): enforce source-bound adapter results
---
.../conversion/OfficeConversionAdapter.java | 34 +++++++++++++++++--
1 file changed, 32 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 00469db1..36bbdc32 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -12,10 +12,40 @@
public interface OfficeConversionAdapter {
/**
- * Converts one immutable Office request into verified PDF evidence.
+ * Converts one immutable Office request and verifies that the result is
+ * present and bound to the exact source digest supplied to the provider.
+ *
+ * This method is the public conversion authority. Implementations supply
+ * only {@link #performConversion(OfficeConversionRequest)}; callers cannot
+ * accidentally accept a result for a different source document.
*
* @param request immutable tenant- and generation-bound conversion request
* @return verified PDF result with source and adapter provenance
+ * @throws OfficeConversionException when the provider returns no result or
+ * provenance for a different source document
+ */
+ default OfficeConversionResult convert(OfficeConversionRequest request) {
+ OfficeConversionResult result = performConversion(request);
+ if (result == null) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion adapter returned no result"
+ );
+ }
+ if (!request.sourceSha256().equals(result.sourceSha256())) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion result source digest mismatch"
+ );
+ }
+ return result;
+ }
+
+ /**
+ * Performs provider-specific conversion before Clearfolio validates result provenance.
+ *
+ * @param request immutable tenant- and generation-bound conversion request
+ * @return provider result, which the default conversion authority validates
*/
- OfficeConversionResult convert(OfficeConversionRequest request);
+ OfficeConversionResult performConversion(OfficeConversionRequest request);
}
From d6c82956a0dac45872bb4bdd5a091ec5ec5e881a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:39:24 +0900
Subject: [PATCH 015/219] test(conversion): require full request generation
binding
---
.../OfficeConversionRequestBindingTest.java | 72 +++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
new file mode 100644
index 00000000..900a3809
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
@@ -0,0 +1,72 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integrity regressions for immutable request identity across converter boundaries.
+ */
+class OfficeConversionRequestBindingTest {
+
+ @Test
+ void convertRejectsStaleGenerationEvenWhenSourceBytesMatch() {
+ UUID jobId = UUID.randomUUID();
+ OfficeConversionRequest current = request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-current", "same");
+ OfficeConversionRequest stale = request("tenant-a", jobId, 1L, "docx", "policy-v2", "trace-current", "same");
+ byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
+ OfficeConversionAdapter adapter = ignored -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ stale.binding(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(current)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion result request binding mismatch", failure.getMessage());
+ }
+
+ @Test
+ void bindingChangesAcrossEveryRequestAuthorityField() {
+ UUID jobId = UUID.randomUUID();
+ OfficeConversionRequest baseline = request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "same");
+ OfficeConversionRequestBinding binding = baseline.binding();
+
+ assertNotEquals(binding, request("tenant-b", jobId, 2L, "docx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding, request("tenant-a", UUID.randomUUID(), 2L, "docx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding, request("tenant-a", jobId, 3L, "docx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding, request("tenant-a", jobId, 2L, "xlsx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding, request("tenant-a", jobId, 2L, "docx", "policy-v3", "trace-a", "same").binding());
+ assertNotEquals(binding, request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-b", "same").binding());
+ assertNotEquals(binding, request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "different").binding());
+ }
+
+ private static OfficeConversionRequest request(
+ String tenantId,
+ UUID jobId,
+ long generation,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ String sourceText) {
+ return new OfficeConversionRequest(
+ tenantId,
+ jobId,
+ generation,
+ sourceFormat,
+ policyVersion,
+ correlationId,
+ sourceText.getBytes(StandardCharsets.UTF_8)
+ );
+ }
+}
From 0a58858a827025657d91fd277427015f3dd9cd25 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:42:40 +0900
Subject: [PATCH 016/219] feat(conversion): bind immutable request authority
---
.../OfficeConversionRequestBinding.java | 59 +++++++++++++++++++
1 file changed, 59 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
new file mode 100644
index 00000000..0af930e3
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
@@ -0,0 +1,59 @@
+package com.clearfolio.viewer.conversion;
+
+import java.util.Locale;
+import java.util.UUID;
+
+/**
+ * Immutable identity tuple that binds converter output to one exact Office request.
+ *
+ * The binding includes every request authority field that may distinguish a
+ * valid conversion generation even when two jobs carry byte-identical source
+ * documents. Equality therefore acts as the stale-generation and cross-request
+ * acceptance boundary after a provider returns candidate output.
+ *
+ * @param tenantId canonical tenant identifier
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration lifecycle generation used for stale-work fencing
+ * @param sourceFormat canonical lowercase source format
+ * @param policyVersion conversion-policy version applied to the request
+ * @param correlationId controlled request correlation identifier
+ * @param sourceSha256 lowercase SHA-256 digest of the immutable source bytes
+ */
+public record OfficeConversionRequestBinding(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ String sourceSha256
+) {
+
+ /**
+ * Validates and canonicalizes the complete immutable request identity.
+ *
+ * @throws IllegalArgumentException when any authority field is invalid
+ */
+ public OfficeConversionRequestBinding {
+ tenantId = requireText(tenantId, "tenantId");
+ if (jobId == null) {
+ throw new IllegalArgumentException("jobId must not be null");
+ }
+ if (jobGeneration < 0L) {
+ throw new IllegalArgumentException("jobGeneration must be non-negative");
+ }
+ sourceFormat = requireText(sourceFormat, "sourceFormat").toLowerCase(Locale.ROOT);
+ policyVersion = requireText(policyVersion, "policyVersion");
+ correlationId = requireText(correlationId, "correlationId");
+ if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) {
+ throw new IllegalArgumentException("sourceSha256 must be lowercase SHA-256 hex");
+ }
+ }
+
+ private static String requireText(String value, String fieldName) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(fieldName + " must not be blank");
+ }
+ return value.strip();
+ }
+}
From 115a5e02da1e48adce1d3e04fe3be68bb97a191e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:43:11 +0900
Subject: [PATCH 017/219] feat(conversion): expose immutable request binding
---
.../conversion/OfficeConversionRequest.java | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index ac075218..a509b743 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -77,6 +77,23 @@ public String sourceSha256() {
}
}
+ /**
+ * Returns the full immutable authority tuple for provider-output validation.
+ *
+ * @return request binding containing identity, generation, policy and source digest
+ */
+ public OfficeConversionRequestBinding binding() {
+ return new OfficeConversionRequestBinding(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ policyVersion,
+ correlationId,
+ sourceSha256()
+ );
+ }
+
private static String normalizeSourceFormat(String value) {
return requireText(value, "sourceFormat").toLowerCase(Locale.ROOT);
}
From 49a8607d5ce01a90cf4095f62ae1b8e80f60cdcb Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:43:37 +0900
Subject: [PATCH 018/219] feat(conversion): carry full request binding in
results
---
.../conversion/OfficeConversionResult.java | 46 +++++++++++++++----
1 file changed, 37 insertions(+), 9 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java
index 54a4be91..9310ab03 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java
@@ -6,26 +6,51 @@
import java.util.HexFormat;
/**
- * Verified PDF result returned by an Office conversion adapter.
+ * Candidate PDF result returned by an Office conversion provider.
*
- * The result carries adapter provenance and the exact source digest supplied
- * to the converter. PDF bytes are copied at construction and on access so the
- * evidence cannot be mutated after acceptance.
+ * The result carries adapter provenance, source provenance, and—when supplied
+ * by the provider—the complete immutable request binding. PDF bytes are copied
+ * at construction and on access. A result is not trusted merely because this
+ * record can be constructed: {@link OfficeConversionAdapter#convert} is the
+ * authority that verifies source and full request binding before acceptance.
*
* @param adapterId stable adapter implementation identifier
* @param adapterVersion qualified adapter/runtime version identifier
* @param sourceSha256 lowercase SHA-256 digest of the source request
- * @param pdfBytes verified PDF bytes, defensively copied
+ * @param requestBinding complete request authority tuple supplied by the provider,
+ * or {@code null} for an unbound candidate that the adapter must reject
+ * @param pdfBytes candidate PDF bytes, defensively copied
*/
public record OfficeConversionResult(
String adapterId,
String adapterVersion,
String sourceSha256,
+ OfficeConversionRequestBinding requestBinding,
byte[] pdfBytes
) {
/**
- * Validates result provenance and a minimal PDF media signature.
+ * Creates a source-only candidate result for low-level result validation.
+ *
+ * Provider implementations should normally supply the full request binding.
+ * A source-only result is intentionally rejected by the public adapter
+ * acceptance boundary.
+ *
+ * @param adapterId stable adapter implementation identifier
+ * @param adapterVersion qualified adapter/runtime version identifier
+ * @param sourceSha256 lowercase SHA-256 source digest
+ * @param pdfBytes candidate PDF bytes
+ */
+ public OfficeConversionResult(
+ String adapterId,
+ String adapterVersion,
+ String sourceSha256,
+ byte[] pdfBytes) {
+ this(adapterId, adapterVersion, sourceSha256, null, pdfBytes);
+ }
+
+ /**
+ * Validates candidate provenance and a minimal PDF media signature.
*
* @throws IllegalArgumentException when provenance or PDF bytes are invalid
*/
@@ -35,6 +60,9 @@ public record OfficeConversionResult(
if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("sourceSha256 must be lowercase SHA-256 hex");
}
+ if (requestBinding != null && !sourceSha256.equals(requestBinding.sourceSha256())) {
+ throw new IllegalArgumentException("request binding source digest mismatch");
+ }
if (pdfBytes == null) {
throw new IllegalArgumentException("pdfBytes must not be null");
}
@@ -46,7 +74,7 @@ public record OfficeConversionResult(
}
/**
- * Returns a defensive copy of the accepted PDF bytes.
+ * Returns a defensive copy of the candidate PDF bytes.
*
* @return copied PDF bytes
*/
@@ -56,7 +84,7 @@ public byte[] pdfBytes() {
}
/**
- * Returns the SHA-256 digest of the accepted PDF bytes.
+ * Returns the SHA-256 digest of the candidate PDF bytes.
*
* @return lowercase hexadecimal SHA-256 digest
*/
@@ -72,6 +100,6 @@ private static String requireText(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
- return value;
+ return value.strip();
}
}
From 9ad4dc0d1ad405764f9a3e4b5197a97c09ba461d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:43:53 +0900
Subject: [PATCH 019/219] fix(conversion): reject stale request-bound output
---
.../conversion/OfficeConversionAdapter.java | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 36bbdc32..4ff3fa3b 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -13,16 +13,17 @@ public interface OfficeConversionAdapter {
/**
* Converts one immutable Office request and verifies that the result is
- * present and bound to the exact source digest supplied to the provider.
+ * present, source-bound, and tied to the exact request generation and policy.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
- * accidentally accept a result for a different source document.
+ * accidentally accept output for a different source, tenant, job, lifecycle
+ * generation, format, policy, or correlation identity.
*
* @param request immutable tenant- and generation-bound conversion request
- * @return verified PDF result with source and adapter provenance
+ * @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result or
- * provenance for a different source document
+ * provenance for a different source or request generation
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -38,6 +39,12 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
"conversion result source digest mismatch"
);
}
+ if (!request.binding().equals(result.requestBinding())) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion result request binding mismatch"
+ );
+ }
return result;
}
From dd7c6dc5581a5a6955e6f09aea46d2105e15db34 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:44:30 +0900
Subject: [PATCH 020/219] test(conversion): cover request-binding validation
---
.../OfficeConversionRequestBindingTest.java | 68 ++++++++++++++++---
1 file changed, 58 insertions(+), 10 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
index 900a3809..00f4d826 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
@@ -17,12 +17,15 @@ class OfficeConversionRequestBindingTest {
@Test
void convertRejectsStaleGenerationEvenWhenSourceBytesMatch() {
UUID jobId = UUID.randomUUID();
- OfficeConversionRequest current = request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-current", "same");
- OfficeConversionRequest stale = request("tenant-a", jobId, 1L, "docx", "policy-v2", "trace-current", "same");
+ OfficeConversionRequest current = request(
+ "tenant-a", jobId, 2L, "docx", "policy-v2", "trace-current", "same");
+ OfficeConversionRequest stale = request(
+ "tenant-a", jobId, 1L, "docx", "policy-v2", "trace-current", "same");
byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
OfficeConversionAdapter adapter = ignored -> new OfficeConversionResult(
"deterministic-fixture",
"1",
+ stale.sourceSha256(),
stale.binding(),
pdf
);
@@ -39,16 +42,61 @@ void convertRejectsStaleGenerationEvenWhenSourceBytesMatch() {
@Test
void bindingChangesAcrossEveryRequestAuthorityField() {
UUID jobId = UUID.randomUUID();
- OfficeConversionRequest baseline = request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "same");
+ OfficeConversionRequest baseline = request(
+ "tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "same");
OfficeConversionRequestBinding binding = baseline.binding();
- assertNotEquals(binding, request("tenant-b", jobId, 2L, "docx", "policy-v2", "trace-a", "same").binding());
- assertNotEquals(binding, request("tenant-a", UUID.randomUUID(), 2L, "docx", "policy-v2", "trace-a", "same").binding());
- assertNotEquals(binding, request("tenant-a", jobId, 3L, "docx", "policy-v2", "trace-a", "same").binding());
- assertNotEquals(binding, request("tenant-a", jobId, 2L, "xlsx", "policy-v2", "trace-a", "same").binding());
- assertNotEquals(binding, request("tenant-a", jobId, 2L, "docx", "policy-v3", "trace-a", "same").binding());
- assertNotEquals(binding, request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-b", "same").binding());
- assertNotEquals(binding, request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "different").binding());
+ assertNotEquals(binding,
+ request("tenant-b", jobId, 2L, "docx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding,
+ request("tenant-a", UUID.randomUUID(), 2L, "docx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding,
+ request("tenant-a", jobId, 3L, "docx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding,
+ request("tenant-a", jobId, 2L, "xlsx", "policy-v2", "trace-a", "same").binding());
+ assertNotEquals(binding,
+ request("tenant-a", jobId, 2L, "docx", "policy-v3", "trace-a", "same").binding());
+ assertNotEquals(binding,
+ request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-b", "same").binding());
+ assertNotEquals(binding,
+ request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "different").binding());
+ }
+
+ @Test
+ void bindingCanonicalizesTextAndRejectsInvalidAuthority() {
+ UUID jobId = UUID.randomUUID();
+ String digest = request(
+ "tenant", jobId, 0L, "docx", "policy", "trace", "source").sourceSha256();
+ OfficeConversionRequestBinding binding = new OfficeConversionRequestBinding(
+ " tenant-a ", jobId, 4L, " DoCx ", " policy-v4 ", " trace-4 ", digest);
+
+ assertEquals("tenant-a", binding.tenantId());
+ assertEquals("docx", binding.sourceFormat());
+ assertEquals("policy-v4", binding.policyVersion());
+ assertEquals("trace-4", binding.correlationId());
+ assertEquals(digest, binding.sourceSha256());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding(null, jobId, 0L, "docx", "policy", "trace", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding(" ", jobId, 0L, "docx", "policy", "trace", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", null, 0L, "docx", "policy", "trace", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", jobId, -1L, "docx", "policy", "trace", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, " ", "policy", "trace", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", " ", "trace", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", "policy", " ", digest));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", "policy", "trace", null));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", "policy", "trace", "bad"));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionRequestBinding(
+ "tenant", jobId, 0L, "docx", "policy", "trace", digest.toUpperCase()));
}
private static OfficeConversionRequest request(
From 6f8fbf5f6aab2cbfb524127c1f517e05247b090f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:45:09 +0900
Subject: [PATCH 021/219] test(conversion): verify full request-bound results
---
.../OfficeConversionAdapterContractTest.java | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
index 3d35e825..b7c6cc12 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -44,6 +44,8 @@ void requestDefensivelyCopiesSourceBytesAndBindsImmutableIdentity() {
assertEquals("trace-123", request.correlationId());
assertArrayEquals("office-source".getBytes(StandardCharsets.UTF_8), request.sourceBytes());
assertEquals(expectedDigest, request.sourceSha256());
+ assertEquals(expectedDigest, request.binding().sourceSha256());
+ assertEquals(7L, request.binding().jobGeneration());
assertEquals(64, expectedDigest.length());
}
@@ -94,8 +96,8 @@ void requestRejectsMissingIdentityAndEmptySource() {
void resultDefensivelyCopiesVerifiedPdfAndCarriesProvenance() {
byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
OfficeConversionResult result = new OfficeConversionResult(
- "fixture-adapter",
- "1.0.0",
+ " fixture-adapter ",
+ " 1.0.0 ",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
pdf
);
@@ -108,6 +110,7 @@ void resultDefensivelyCopiesVerifiedPdfAndCarriesProvenance() {
assertEquals("fixture-adapter", result.adapterId());
assertEquals("1.0.0", result.adapterVersion());
assertEquals(64, result.sourceSha256().length());
+ assertEquals(null, result.requestBinding());
assertArrayEquals("%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII), result.pdfBytes());
assertEquals(outputDigest, result.outputSha256());
assertEquals(64, outputDigest.length());
@@ -118,6 +121,9 @@ void resultRejectsInvalidProvenanceAndNonPdfOutput() {
byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII);
byte[] notPdf = "not-pdf".getBytes(StandardCharsets.US_ASCII);
String digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ String otherDigest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ OfficeConversionRequestBinding otherBinding = new OfficeConversionRequestBinding(
+ "tenant", UUID.randomUUID(), 0L, "docx", "policy", "trace", otherDigest);
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult(null, "1", digest, pdf));
@@ -131,6 +137,8 @@ void resultRejectsInvalidProvenanceAndNonPdfOutput() {
() -> new OfficeConversionResult("adapter", "1", "bad", pdf));
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult("adapter", "1", digest.toUpperCase(), pdf));
+ assertThrows(IllegalArgumentException.class,
+ () -> new OfficeConversionResult("adapter", "1", digest, otherBinding, pdf));
assertThrows(IllegalArgumentException.class,
() -> new OfficeConversionResult("adapter", "1", digest, null));
assertThrows(IllegalArgumentException.class,
@@ -179,12 +187,14 @@ void adapterContractCanReturnDeterministicFixtureEvidence() {
"deterministic-fixture",
"1",
input.sourceSha256(),
+ input.binding(),
pdf
);
OfficeConversionResult result = adapter.convert(request);
assertEquals(request.sourceSha256(), result.sourceSha256());
+ assertEquals(request.binding(), result.requestBinding());
assertArrayEquals(pdf, result.pdfBytes());
}
}
From a10103163e8c9db02595b5323fcec0ec94cd5767 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 01:52:16 +0900
Subject: [PATCH 022/219] test(conversion): require deterministic fixture
adapter
---
.../OfficeConversionRequestBindingTest.java | 34 +++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
index 00f4d826..fa2b4ea8 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
@@ -1,10 +1,12 @@
package com.clearfolio.viewer.conversion;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
+import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -99,6 +101,38 @@ void bindingCanonicalizesTextAndRejectsInvalidAuthority() {
"tenant", jobId, 0L, "docx", "policy", "trace", digest.toUpperCase()));
}
+ @Test
+ void deterministicFixtureAdapterIsExactAndDefensivelyOwned() {
+ UUID jobId = UUID.randomUUID();
+ OfficeConversionRequest current = request(
+ "tenant-a", jobId, 5L, "docx", "policy-v1", "trace-1", "fixture-source");
+ OfficeConversionRequest stale = request(
+ "tenant-a", jobId, 4L, "docx", "policy-v1", "trace-1", "fixture-source");
+ byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
+ DeterministicFixtureOfficeConversionAdapter adapter = new DeterministicFixtureOfficeConversionAdapter(
+ Map.of(current.binding(), pdf)
+ );
+ pdf[0] = 'X';
+
+ OfficeConversionResult first = adapter.convert(current);
+ OfficeConversionResult second = adapter.convert(current);
+ byte[] canonicalPdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
+
+ assertArrayEquals(canonicalPdf, first.pdfBytes());
+ assertArrayEquals(first.pdfBytes(), second.pdfBytes());
+ assertEquals(first.outputSha256(), second.outputSha256());
+ assertEquals(current.binding(), first.requestBinding());
+ assertEquals("deterministic-fixture", first.adapterId());
+ assertEquals("1", first.adapterVersion());
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(stale)
+ );
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("deterministic fixture not registered for request binding", failure.getMessage());
+ }
+
private static OfficeConversionRequest request(
String tenantId,
UUID jobId,
From 189f2352d9e50eaf2de93e51af0cabd0b9b246cb Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:00:02 +0900
Subject: [PATCH 023/219] feat(conversion): add deterministic fixture adapter
---
...inisticFixtureOfficeConversionAdapter.java | 62 +++++++++++++++++++
1 file changed, 62 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java
new file mode 100644
index 00000000..546c1d0a
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java
@@ -0,0 +1,62 @@
+package com.clearfolio.viewer.conversion;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Deterministic offline Office conversion adapter backed by exact request fixtures.
+ *
+ * This adapter is a contract and fidelity test implementation, not a production
+ * Office renderer. It returns only pre-registered PDF bytes for the exact immutable
+ * request binding and therefore cannot silently accept a stale tenant, job,
+ * lifecycle generation, format, policy, correlation identity, or source digest.
+ */
+public final class DeterministicFixtureOfficeConversionAdapter implements OfficeConversionAdapter {
+
+ private static final String ADAPTER_ID = "deterministic-fixture";
+ private static final String ADAPTER_VERSION = "1";
+
+ private final Map fixtures;
+
+ /**
+ * Creates an immutable fixture adapter from exact request bindings to reference PDFs.
+ *
+ * Fixture byte arrays are defensively copied so later caller mutation cannot
+ * change the reference output accepted by the adapter.
+ *
+ * @param fixtures exact request bindings mapped to reference PDF bytes
+ */
+ public DeterministicFixtureOfficeConversionAdapter(
+ Map fixtures) {
+ this.fixtures = fixtures.entrySet().stream()
+ .collect(Collectors.toUnmodifiableMap(
+ Map.Entry::getKey,
+ entry -> entry.getValue().clone()
+ ));
+ }
+
+ /**
+ * Returns the exact reference PDF registered for the request binding.
+ *
+ * @param request immutable tenant- and generation-bound conversion request
+ * @return deterministic reference PDF with matching request provenance
+ * @throws OfficeConversionException when no exact fixture is registered
+ */
+ @Override
+ public OfficeConversionResult performConversion(OfficeConversionRequest request) {
+ byte[] pdfBytes = fixtures.get(request.binding());
+ if (pdfBytes == null) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "deterministic fixture not registered for request binding"
+ );
+ }
+ return new OfficeConversionResult(
+ ADAPTER_ID,
+ ADAPTER_VERSION,
+ request.sourceSha256(),
+ request.binding(),
+ pdfBytes
+ );
+ }
+}
From f7c043716c87cd7ef758d5296113d4d4d30e1c20 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:04:00 +0900
Subject: [PATCH 024/219] test(conversion): require bound output size limit
---
.../OfficeConversionOutputLimitTest.java | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
new file mode 100644
index 00000000..1a25c832
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
@@ -0,0 +1,93 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Resource-boundary regressions for Office conversion output acceptance.
+ */
+class OfficeConversionOutputLimitTest {
+
+ @Test
+ void requestBindsPositiveMaximumOutputBytes() {
+ OfficeConversionRequest request = requestWithLimit(20L);
+
+ assertEquals(20L, request.maxOutputBytes());
+ assertEquals(20L, request.binding().maxOutputBytes());
+ assertThrows(IllegalArgumentException.class, () -> requestWithLimit(0L));
+ assertThrows(IllegalArgumentException.class, () -> requestWithLimit(-1L));
+ }
+
+ @Test
+ void outputLimitChangesImmutableRequestBinding() {
+ OfficeConversionRequest small = requestWithLimit(20L);
+ OfficeConversionRequest large = new OfficeConversionRequest(
+ small.tenantId(),
+ small.jobId(),
+ small.jobGeneration(),
+ small.sourceFormat(),
+ small.policyVersion(),
+ small.correlationId(),
+ small.sourceBytes(),
+ 21L
+ );
+
+ org.junit.jupiter.api.Assertions.assertNotEquals(small.binding(), large.binding());
+ }
+
+ @Test
+ void adapterRejectsPdfThatExceedsBoundOutputLimit() {
+ OfficeConversionRequest request = requestWithLimit(8L);
+ byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED, failure.failureCode());
+ assertEquals("conversion output exceeds maximum bytes", failure.getMessage());
+ }
+
+ @Test
+ void adapterAcceptsPdfAtExactOutputLimit() {
+ byte[] pdf = "%PDF-".getBytes(StandardCharsets.US_ASCII);
+ OfficeConversionRequest request = requestWithLimit(pdf.length);
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionResult result = adapter.convert(request);
+
+ assertEquals(pdf.length, result.pdfBytes().length);
+ }
+
+ private static OfficeConversionRequest requestWithLimit(long maxOutputBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("1eaf3d24-f238-4a14-a909-47c20d264282"),
+ 3L,
+ "docx",
+ "policy-v1",
+ "trace-output-limit",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ maxOutputBytes
+ );
+ }
+}
From 18e5ebf64b169ba81b8d8f0bcc2d0dbfdec9ad46 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:06:12 +0900
Subject: [PATCH 025/219] feat(conversion): bind maximum output bytes
---
.../conversion/OfficeConversionRequest.java | 61 ++++++++++++++++---
1 file changed, 53 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index a509b743..05b5d006 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -10,9 +10,10 @@
* Immutable request passed across the provider-neutral Office conversion boundary.
*
* The request binds untrusted document bytes to tenant, job-generation,
- * policy, format, and correlation identity before any converter implementation
- * can process them. Source bytes are defensively copied at construction and on
- * access so callers cannot mutate the digest-bound payload after validation.
+ * policy, format, correlation identity, and an output-publication size ceiling
+ * before any converter implementation can process them. Source bytes are
+ * defensively copied at construction and on access so callers cannot mutate the
+ * digest-bound payload after validation.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -21,6 +22,7 @@
* @param policyVersion conversion and active-content policy version
* @param correlationId request correlation identifier used for controlled tracing
* @param sourceBytes untrusted source bytes, defensively copied
+ * @param maxOutputBytes positive maximum PDF bytes accepted for publication
*/
public record OfficeConversionRequest(
String tenantId,
@@ -29,13 +31,52 @@ public record OfficeConversionRequest(
String sourceFormat,
String policyVersion,
String correlationId,
- byte[] sourceBytes
+ byte[] sourceBytes,
+ long maxOutputBytes
) {
+ /** Default compatibility ceiling for contract callers that have not supplied a policy-specific limit. */
+ public static final long DEFAULT_MAX_OUTPUT_BYTES = 64L * 1024L * 1024L;
+
/**
- * Validates immutable conversion identity and copies the untrusted source bytes.
+ * Creates a request using the bounded compatibility output ceiling.
+ *
+ * Production adapter integration should supply the policy-specific output
+ * ceiling explicitly. This overload keeps existing contract callers bounded
+ * while the provider runtime remains unintegrated.
*
- * @throws IllegalArgumentException when required identity or source bytes are invalid
+ * @param tenantId tenant that owns the conversion request
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration immutable lifecycle generation
+ * @param sourceFormat normalized source format
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceBytes untrusted source bytes
+ */
+ public OfficeConversionRequest(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ byte[] sourceBytes) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ policyVersion,
+ correlationId,
+ sourceBytes,
+ DEFAULT_MAX_OUTPUT_BYTES
+ );
+ }
+
+ /**
+ * Validates immutable conversion identity, the publication limit, and copies source bytes.
+ *
+ * @throws IllegalArgumentException when required identity, source bytes, or limit are invalid
*/
public OfficeConversionRequest {
tenantId = requireText(tenantId, "tenantId");
@@ -51,6 +92,9 @@ public record OfficeConversionRequest(
if (sourceBytes == null || sourceBytes.length == 0) {
throw new IllegalArgumentException("sourceBytes must not be empty");
}
+ if (maxOutputBytes <= 0L) {
+ throw new IllegalArgumentException("maxOutputBytes must be positive");
+ }
sourceBytes = sourceBytes.clone();
}
@@ -80,7 +124,7 @@ public String sourceSha256() {
/**
* Returns the full immutable authority tuple for provider-output validation.
*
- * @return request binding containing identity, generation, policy and source digest
+ * @return request binding containing identity, generation, policy, output limit, and source digest
*/
public OfficeConversionRequestBinding binding() {
return new OfficeConversionRequestBinding(
@@ -90,7 +134,8 @@ public OfficeConversionRequestBinding binding() {
sourceFormat,
policyVersion,
correlationId,
- sourceSha256()
+ sourceSha256(),
+ maxOutputBytes
);
}
From 2215a5900d7d4a4dafc6d061c48b4a9016e19751 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:06:42 +0900
Subject: [PATCH 026/219] feat(conversion): include output ceiling in binding
---
.../OfficeConversionRequestBinding.java | 40 ++++++++++++++++++-
1 file changed, 38 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
index 0af930e3..9fa5c874 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
@@ -18,6 +18,7 @@
* @param policyVersion conversion-policy version applied to the request
* @param correlationId controlled request correlation identifier
* @param sourceSha256 lowercase SHA-256 digest of the immutable source bytes
+ * @param maxOutputBytes positive maximum PDF bytes accepted for publication
*/
public record OfficeConversionRequestBinding(
String tenantId,
@@ -26,13 +27,45 @@ public record OfficeConversionRequestBinding(
String sourceFormat,
String policyVersion,
String correlationId,
- String sourceSha256
+ String sourceSha256,
+ long maxOutputBytes
) {
+ /**
+ * Creates a binding using the request compatibility output ceiling.
+ *
+ * @param tenantId canonical tenant identifier
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration lifecycle generation
+ * @param sourceFormat canonical source format
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceSha256 lowercase source digest
+ */
+ public OfficeConversionRequestBinding(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ String sourceSha256) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ policyVersion,
+ correlationId,
+ sourceSha256,
+ OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
+ );
+ }
+
/**
* Validates and canonicalizes the complete immutable request identity.
*
- * @throws IllegalArgumentException when any authority field is invalid
+ * @throws IllegalArgumentException when any authority field or limit is invalid
*/
public OfficeConversionRequestBinding {
tenantId = requireText(tenantId, "tenantId");
@@ -48,6 +81,9 @@ public record OfficeConversionRequestBinding(
if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("sourceSha256 must be lowercase SHA-256 hex");
}
+ if (maxOutputBytes <= 0L) {
+ throw new IllegalArgumentException("maxOutputBytes must be positive");
+ }
}
private static String requireText(String value, String fieldName) {
From 850e661586f266ce12ccfa46e724a22808a94667 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:07:14 +0900
Subject: [PATCH 027/219] feat(conversion): classify oversized output
---
.../viewer/conversion/OfficeConversionFailureCode.java | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
index cad30d97..bcfdfeb1 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
@@ -3,8 +3,8 @@
/**
* Stable failure classes returned by qualified Office conversion adapters.
*
- * Retryability is part of the adapter contract so parser, policy, and user
- * input failures cannot be mistaken for transient engine failures.
+ * Retryability is part of the adapter contract so parser, policy, resource,
+ * and user-input failures cannot be mistaken for transient engine failures.
*/
public enum OfficeConversionFailureCode {
@@ -20,6 +20,8 @@ public enum OfficeConversionFailureCode {
CANCELLED(false),
/** Converter returned output that failed PDF validation. */
INVALID_OUTPUT(false),
+ /** Candidate PDF exceeds the request-bound publication size ceiling. */
+ OUTPUT_LIMIT_EXCEEDED(false),
/** Qualified converter service or capacity is temporarily unavailable. */
ENGINE_UNAVAILABLE(true),
/** Conversion exceeded its bounded execution deadline. */
From 368eb5431fecbe7376cd40ecc8bd3344afadad3c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:07:35 +0900
Subject: [PATCH 028/219] feat(conversion): reject oversized candidate PDFs
---
.../conversion/OfficeConversionAdapter.java | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 4ff3fa3b..cc465ebb 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -13,17 +13,18 @@ public interface OfficeConversionAdapter {
/**
* Converts one immutable Office request and verifies that the result is
- * present, source-bound, and tied to the exact request generation and policy.
+ * present, source-bound, tied to the exact request generation and policy,
+ * and within the request-bound publication size ceiling.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
- * generation, format, policy, or correlation identity.
+ * generation, format, policy, correlation identity, or output-size policy.
*
* @param request immutable tenant- and generation-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
- * @throws OfficeConversionException when the provider returns no result or
- * provenance for a different source or request generation
+ * @throws OfficeConversionException when the provider returns no result,
+ * mismatched provenance, or an oversized candidate PDF
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -45,6 +46,12 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
"conversion result request binding mismatch"
);
}
+ if (result.pdfBytes().length > request.maxOutputBytes()) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED,
+ "conversion output exceeds maximum bytes"
+ );
+ }
return result;
}
From cbf06286cda04f95afc5237fa24c37fc9bfbb737 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:13:50 +0900
Subject: [PATCH 029/219] test(conversion): require parseable PDF output
---
.../OfficeConversionPdfValidationTest.java | 80 +++++++++++++++++++
1 file changed, 80 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
new file mode 100644
index 00000000..cc028736
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
@@ -0,0 +1,80 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Output-structure regressions for converter-produced PDF candidates.
+ */
+class OfficeConversionPdfValidationTest {
+
+ @Test
+ void adapterRejectsTruncatedMagicOnlyPdf() {
+ OfficeConversionRequest request = request();
+ byte[] truncated = "%PDF-1.7\nnot-a-complete-document".getBytes(StandardCharsets.US_ASCII);
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ truncated
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion output is not a valid PDF", failure.getMessage());
+ }
+
+ @Test
+ void adapterAcceptsParseablePdf() throws IOException {
+ OfficeConversionRequest request = request();
+ byte[] pdf = onePagePdf();
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionResult result = adapter.convert(request);
+
+ assertArrayEquals(pdf, result.pdfBytes());
+ }
+
+ private static OfficeConversionRequest request() {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("d031f25a-8d92-4c9d-a89f-362e0324c8ef"),
+ 8L,
+ "docx",
+ "policy-v1",
+ "trace-pdf-validation",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
+ );
+ }
+
+ private static byte[] onePagePdf() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+}
From 6201aa18de23afb7bd8f9370b7ee644bb9fbae58 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:19:39 +0900
Subject: [PATCH 030/219] feat(conversion): validate candidate PDF structure
---
.../conversion/OfficeConversionAdapter.java | 28 ++++++++++++++++---
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index cc465ebb..db2aa7dc 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -1,5 +1,10 @@
package com.clearfolio.viewer.conversion;
+import java.io.IOException;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+
/**
* Provider-neutral boundary for sandboxed or remote Office-to-PDF conversion.
*
@@ -14,17 +19,18 @@ public interface OfficeConversionAdapter {
/**
* Converts one immutable Office request and verifies that the result is
* present, source-bound, tied to the exact request generation and policy,
- * and within the request-bound publication size ceiling.
+ * within the request-bound publication size ceiling, and parseable as PDF.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
- * generation, format, policy, correlation identity, or output-size policy.
+ * generation, format, policy, correlation identity, output-size policy, or
+ * a truncated byte sequence that only carries a PDF magic prefix.
*
* @param request immutable tenant- and generation-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
- * mismatched provenance, or an oversized candidate PDF
+ * mismatched provenance, an oversized candidate, or malformed PDF
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -46,12 +52,15 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
"conversion result request binding mismatch"
);
}
- if (result.pdfBytes().length > request.maxOutputBytes()) {
+
+ byte[] pdfBytes = result.pdfBytes();
+ if (pdfBytes.length > request.maxOutputBytes()) {
throw new OfficeConversionException(
OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED,
"conversion output exceeds maximum bytes"
);
}
+ requireParseablePdf(pdfBytes);
return result;
}
@@ -62,4 +71,15 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
* @return provider result, which the default conversion authority validates
*/
OfficeConversionResult performConversion(OfficeConversionRequest request);
+
+ private static void requireParseablePdf(byte[] pdfBytes) {
+ try (PDDocument ignored = Loader.loadPDF(pdfBytes)) {
+ // Loading and closing the bounded candidate proves PDFBox can parse its structure.
+ } catch (IOException ex) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion output is not a valid PDF"
+ );
+ }
+ }
}
From 6d3146abde325682b6ece54dfa5cba72bf8c505b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:20:14 +0900
Subject: [PATCH 031/219] test(conversion): share parseable PDF fixture
---
.../conversion/OfficeConversionTestPdf.java | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java
new file mode 100644
index 00000000..d9b799cb
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java
@@ -0,0 +1,32 @@
+package com.clearfolio.viewer.conversion;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+
+/**
+ * Deterministic parseable PDF fixtures shared by Office conversion contract tests.
+ */
+final class OfficeConversionTestPdf {
+
+ private OfficeConversionTestPdf() {
+ }
+
+ /**
+ * Creates a deterministic one-page PDF suitable for parser acceptance tests.
+ *
+ * @return parseable one-page PDF bytes
+ */
+ static byte[] onePage() {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.save(output);
+ return output.toByteArray();
+ } catch (IOException ex) {
+ throw new IllegalStateException("failed to create test PDF", ex);
+ }
+ }
+}
From 666d8ca262f7a08e5954dd7e54b768a6e693ab3e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:21:15 +0900
Subject: [PATCH 032/219] test(conversion): use parseable PDF for adapter
success
---
.../viewer/conversion/OfficeConversionAdapterContractTest.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
index b7c6cc12..b37130f5 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -155,6 +155,7 @@ void failureCodesExposeExplicitRetryPolicy() {
assertFalse(OfficeConversionFailureCode.MALFORMED_INPUT.isRetryable());
assertFalse(OfficeConversionFailureCode.CANCELLED.isRetryable());
assertFalse(OfficeConversionFailureCode.INVALID_OUTPUT.isRetryable());
+ assertFalse(OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED.isRetryable());
assertTrue(OfficeConversionFailureCode.ENGINE_UNAVAILABLE.isRetryable());
assertTrue(OfficeConversionFailureCode.TIMEOUT.isRetryable());
assertTrue(OfficeConversionFailureCode.ENGINE_CRASH.isRetryable());
@@ -181,7 +182,7 @@ void adapterContractCanReturnDeterministicFixtureEvidence() {
byte[] source = "fixture-docx".getBytes(StandardCharsets.UTF_8);
OfficeConversionRequest request = new OfficeConversionRequest(
"tenant-a", UUID.randomUUID(), 1L, "docx", "policy-v1", "trace-1", source);
- byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
+ byte[] pdf = OfficeConversionTestPdf.onePage();
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
"deterministic-fixture",
From b1e6582c4bb5a10830cb477d440ac4be6b9b7c7b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 02:22:11 +0900
Subject: [PATCH 033/219] test(conversion): use parseable deterministic fixture
output
---
.../viewer/conversion/OfficeConversionRequestBindingTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
index fa2b4ea8..9e99da3b 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
@@ -108,7 +108,8 @@ void deterministicFixtureAdapterIsExactAndDefensivelyOwned() {
"tenant-a", jobId, 5L, "docx", "policy-v1", "trace-1", "fixture-source");
OfficeConversionRequest stale = request(
"tenant-a", jobId, 4L, "docx", "policy-v1", "trace-1", "fixture-source");
- byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
+ byte[] pdf = OfficeConversionTestPdf.onePage();
+ byte[] canonicalPdf = pdf.clone();
DeterministicFixtureOfficeConversionAdapter adapter = new DeterministicFixtureOfficeConversionAdapter(
Map.of(current.binding(), pdf)
);
@@ -116,7 +117,6 @@ void deterministicFixtureAdapterIsExactAndDefensivelyOwned() {
OfficeConversionResult first = adapter.convert(current);
OfficeConversionResult second = adapter.convert(current);
- byte[] canonicalPdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
assertArrayEquals(canonicalPdf, first.pdfBytes());
assertArrayEquals(first.pdfBytes(), second.pdfBytes());
From a7f7f332877685ee424cc2c930be3fd8cf1db50e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:05:56 +0900
Subject: [PATCH 034/219] fix(conversion): eliminate PDF validation compile
warning
---
.../viewer/conversion/OfficeConversionAdapter.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index db2aa7dc..81501efd 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -73,8 +73,9 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult performConversion(OfficeConversionRequest request);
private static void requireParseablePdf(byte[] pdfBytes) {
- try (PDDocument ignored = Loader.loadPDF(pdfBytes)) {
- // Loading and closing the bounded candidate proves PDFBox can parse its structure.
+ try (PDDocument document = Loader.loadPDF(pdfBytes)) {
+ // Force page-tree access so the parsed document resource is both validated and used.
+ document.getNumberOfPages();
} catch (IOException ex) {
throw new OfficeConversionException(
OfficeConversionFailureCode.INVALID_OUTPUT,
From 7e5262e3cbb6f44bbeef29d839caadcdb07c117d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:09:25 +0900
Subject: [PATCH 035/219] test(conversion): use parseable PDF at exact output
limit
---
.../viewer/conversion/OfficeConversionOutputLimitTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
index 1a25c832..e44eeab0 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
@@ -62,8 +62,8 @@ void adapterRejectsPdfThatExceedsBoundOutputLimit() {
}
@Test
- void adapterAcceptsPdfAtExactOutputLimit() {
- byte[] pdf = "%PDF-".getBytes(StandardCharsets.US_ASCII);
+ void adapterAcceptsParseablePdfAtExactOutputLimit() {
+ byte[] pdf = OfficeConversionTestPdf.onePage();
OfficeConversionRequest request = requestWithLimit(pdf.length);
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
"fixture",
From 9549c7437397503306920efc93375d813df11048 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:10:14 +0900
Subject: [PATCH 036/219] test(conversion): reject zero-page PDF output
---
.../OfficeConversionPdfValidationTest.java | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
index cc028736..25376540 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
@@ -39,6 +39,27 @@ void adapterRejectsTruncatedMagicOnlyPdf() {
assertEquals("conversion output is not a valid PDF", failure.getMessage());
}
+ @Test
+ void adapterRejectsParseablePdfWithoutPages() throws IOException {
+ OfficeConversionRequest request = request();
+ byte[] zeroPagePdf = zeroPagePdf();
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ zeroPagePdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion output PDF has no pages", failure.getMessage());
+ }
+
@Test
void adapterAcceptsParseablePdf() throws IOException {
OfficeConversionRequest request = request();
@@ -69,6 +90,14 @@ private static OfficeConversionRequest request() {
);
}
+ private static byte[] zeroPagePdf() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] onePagePdf() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From d806ae344ad1e8fd42371b90e76ff56ae046a66f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:12:36 +0900
Subject: [PATCH 037/219] fix(conversion): reject zero-page PDF output
---
.../conversion/OfficeConversionAdapter.java | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 81501efd..258c78ba 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -19,18 +19,20 @@ public interface OfficeConversionAdapter {
/**
* Converts one immutable Office request and verifies that the result is
* present, source-bound, tied to the exact request generation and policy,
- * within the request-bound publication size ceiling, and parseable as PDF.
+ * within the request-bound publication size ceiling, and parseable as a
+ * non-empty PDF.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
* generation, format, policy, correlation identity, output-size policy, or
- * a truncated byte sequence that only carries a PDF magic prefix.
+ * a truncated/empty PDF container that is not usable document output.
*
* @param request immutable tenant- and generation-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
- * mismatched provenance, an oversized candidate, or malformed PDF
+ * mismatched provenance, an oversized candidate, a malformed PDF,
+ * or a parseable PDF with no pages
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -74,8 +76,12 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
private static void requireParseablePdf(byte[] pdfBytes) {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
- // Force page-tree access so the parsed document resource is both validated and used.
- document.getNumberOfPages();
+ if (document.getNumberOfPages() == 0) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion output PDF has no pages"
+ );
+ }
} catch (IOException ex) {
throw new OfficeConversionException(
OfficeConversionFailureCode.INVALID_OUTPUT,
From 99c89d52e2e7748bb3a14eeb6e4b9455f3295f22 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:15:26 +0900
Subject: [PATCH 038/219] test(conversion): bind output to qualified adapter
identity
---
...eConversionAdapterIdentityBindingTest.java | 87 +++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
new file mode 100644
index 00000000..fb909578
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
@@ -0,0 +1,87 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integrity regressions for binding Office output to the qualified adapter identity.
+ */
+class OfficeConversionAdapterIdentityBindingTest {
+
+ @Test
+ void requestBindingIncludesExpectedAdapterIdentity() {
+ OfficeConversionRequest baseline = request("sandboxed-office-sidecar", "24.8.5");
+ OfficeConversionRequest otherAdapter = request("remote-office-service", "24.8.5");
+ OfficeConversionRequest otherVersion = request("sandboxed-office-sidecar", "24.8.6");
+
+ assertEquals("sandboxed-office-sidecar", baseline.expectedAdapterId());
+ assertEquals("24.8.5", baseline.expectedAdapterVersion());
+ assertEquals("sandboxed-office-sidecar", baseline.binding().expectedAdapterId());
+ assertEquals("24.8.5", baseline.binding().expectedAdapterVersion());
+ assertNotEquals(baseline.binding(), otherAdapter.binding());
+ assertNotEquals(baseline.binding(), otherVersion.binding());
+ }
+
+ @Test
+ void adapterRejectsResultFromUnexpectedAdapterIdentity() {
+ OfficeConversionRequest request = request("sandboxed-office-sidecar", "24.8.5");
+ byte[] pdf = OfficeConversionTestPdf.onePage();
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "remote-office-service",
+ "24.8.5",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion result adapter identity mismatch", failure.getMessage());
+ }
+
+ @Test
+ void adapterRejectsResultFromUnexpectedAdapterVersion() {
+ OfficeConversionRequest request = request("sandboxed-office-sidecar", "24.8.5");
+ byte[] pdf = OfficeConversionTestPdf.onePage();
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "sandboxed-office-sidecar",
+ "24.8.6",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion result adapter identity mismatch", failure.getMessage());
+ }
+
+ private static OfficeConversionRequest request(String adapterId, String adapterVersion) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("ce0a17f5-cdee-44db-9547-c7ed5e6d2f19"),
+ 4L,
+ "docx",
+ adapterId,
+ adapterVersion,
+ "policy-v3",
+ "trace-adapter-binding",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
+ );
+ }
+}
From d163729cb497fdb4a1a252f831ef9ed7f4aa6ae0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:18:10 +0900
Subject: [PATCH 039/219] feat(conversion): bind requests to qualified adapter
identity
---
.../conversion/OfficeConversionRequest.java | 117 ++++++++++++++++--
1 file changed, 107 insertions(+), 10 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index 05b5d006..e9521c27 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -10,15 +10,17 @@
* Immutable request passed across the provider-neutral Office conversion boundary.
*
* The request binds untrusted document bytes to tenant, job-generation,
- * policy, format, correlation identity, and an output-publication size ceiling
- * before any converter implementation can process them. Source bytes are
- * defensively copied at construction and on access so callers cannot mutate the
- * digest-bound payload after validation.
+ * source format, qualified adapter identity, policy, correlation identity, and
+ * an output-publication size ceiling before any converter implementation can
+ * process them. Source bytes are defensively copied at construction and on
+ * access so callers cannot mutate the digest-bound payload after validation.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
* @param jobGeneration immutable lifecycle generation for stale-work fencing
* @param sourceFormat normalized source format such as {@code docx}
+ * @param expectedAdapterId qualified adapter implementation identifier
+ * @param expectedAdapterVersion exact qualified adapter/runtime version
* @param policyVersion conversion and active-content policy version
* @param correlationId request correlation identifier used for controlled tracing
* @param sourceBytes untrusted source bytes, defensively copied
@@ -29,6 +31,8 @@ public record OfficeConversionRequest(
UUID jobId,
long jobGeneration,
String sourceFormat,
+ String expectedAdapterId,
+ String expectedAdapterVersion,
String policyVersion,
String correlationId,
byte[] sourceBytes,
@@ -38,12 +42,97 @@ public record OfficeConversionRequest(
/** Default compatibility ceiling for contract callers that have not supplied a policy-specific limit. */
public static final long DEFAULT_MAX_OUTPUT_BYTES = 64L * 1024L * 1024L;
+ private static final String CONTRACT_FIXTURE_ADAPTER_ID = "deterministic-fixture";
+ private static final String CONTRACT_FIXTURE_ADAPTER_VERSION = "1";
+
+ /**
+ * Creates a qualified-adapter request using the bounded compatibility output ceiling.
+ *
+ * Production integration should prefer this overload when the output
+ * ceiling is inherited from the currently qualified policy. The exact
+ * adapter id and version remain mandatory authority fields.
+ *
+ * @param tenantId tenant that owns the conversion request
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration immutable lifecycle generation
+ * @param sourceFormat normalized source format
+ * @param expectedAdapterId qualified adapter identifier
+ * @param expectedAdapterVersion exact qualified adapter/runtime version
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceBytes untrusted source bytes
+ */
+ public OfficeConversionRequest(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String expectedAdapterId,
+ String expectedAdapterVersion,
+ String policyVersion,
+ String correlationId,
+ byte[] sourceBytes) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ expectedAdapterId,
+ expectedAdapterVersion,
+ policyVersion,
+ correlationId,
+ sourceBytes,
+ DEFAULT_MAX_OUTPUT_BYTES
+ );
+ }
+
+ /**
+ * Creates a deterministic-fixture contract request with an explicit output ceiling.
+ *
+ * This compatibility overload is deliberately bound to the deterministic
+ * fixture adapter. A production sidecar or remote-service integration must
+ * use an overload that supplies its qualified adapter id and exact version;
+ * it cannot silently inherit this fixture identity.
+ *
+ * @param tenantId tenant that owns the conversion request
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration immutable lifecycle generation
+ * @param sourceFormat normalized source format
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceBytes untrusted source bytes
+ * @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ */
+ public OfficeConversionRequest(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ byte[] sourceBytes,
+ long maxOutputBytes) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ CONTRACT_FIXTURE_ADAPTER_ID,
+ CONTRACT_FIXTURE_ADAPTER_VERSION,
+ policyVersion,
+ correlationId,
+ sourceBytes,
+ maxOutputBytes
+ );
+ }
+
/**
- * Creates a request using the bounded compatibility output ceiling.
+ * Creates a deterministic-fixture contract request using the bounded compatibility output ceiling.
*
- * Production adapter integration should supply the policy-specific output
- * ceiling explicitly. This overload keeps existing contract callers bounded
- * while the provider runtime remains unintegrated.
+ * This overload exists for the offline contract fixture only. Production
+ * adapter integration must name the qualified adapter id and exact runtime
+ * version explicitly so provider provenance cannot float independently of
+ * the immutable request binding.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -66,6 +155,8 @@ public OfficeConversionRequest(
jobId,
jobGeneration,
sourceFormat,
+ CONTRACT_FIXTURE_ADAPTER_ID,
+ CONTRACT_FIXTURE_ADAPTER_VERSION,
policyVersion,
correlationId,
sourceBytes,
@@ -74,7 +165,8 @@ public OfficeConversionRequest(
}
/**
- * Validates immutable conversion identity, the publication limit, and copies source bytes.
+ * Validates immutable conversion identity, qualified adapter identity, the
+ * publication limit, and copies source bytes.
*
* @throws IllegalArgumentException when required identity, source bytes, or limit are invalid
*/
@@ -87,6 +179,8 @@ public OfficeConversionRequest(
throw new IllegalArgumentException("jobGeneration must be non-negative");
}
sourceFormat = normalizeSourceFormat(sourceFormat);
+ expectedAdapterId = requireText(expectedAdapterId, "expectedAdapterId");
+ expectedAdapterVersion = requireText(expectedAdapterVersion, "expectedAdapterVersion");
policyVersion = requireText(policyVersion, "policyVersion");
correlationId = requireText(correlationId, "correlationId");
if (sourceBytes == null || sourceBytes.length == 0) {
@@ -124,7 +218,8 @@ public String sourceSha256() {
/**
* Returns the full immutable authority tuple for provider-output validation.
*
- * @return request binding containing identity, generation, policy, output limit, and source digest
+ * @return request binding containing identity, generation, adapter, policy,
+ * output limit, and source digest
*/
public OfficeConversionRequestBinding binding() {
return new OfficeConversionRequestBinding(
@@ -132,6 +227,8 @@ public OfficeConversionRequestBinding binding() {
jobId,
jobGeneration,
sourceFormat,
+ expectedAdapterId,
+ expectedAdapterVersion,
policyVersion,
correlationId,
sourceSha256(),
From c9991a5041f51450301345bb1441a8af30fce193 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:18:55 +0900
Subject: [PATCH 040/219] feat(conversion): include adapter version in request
binding
---
.../OfficeConversionRequestBinding.java | 93 ++++++++++++++++++-
1 file changed, 90 insertions(+), 3 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
index 9fa5c874..3dcb6cbf 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
@@ -8,13 +8,15 @@
*
* The binding includes every request authority field that may distinguish a
* valid conversion generation even when two jobs carry byte-identical source
- * documents. Equality therefore acts as the stale-generation and cross-request
- * acceptance boundary after a provider returns candidate output.
+ * documents. Equality therefore acts as the stale-generation, provider-version,
+ * and cross-request acceptance boundary after a provider returns candidate output.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
* @param jobGeneration lifecycle generation used for stale-work fencing
* @param sourceFormat canonical lowercase source format
+ * @param expectedAdapterId qualified adapter implementation identifier
+ * @param expectedAdapterVersion exact qualified adapter/runtime version
* @param policyVersion conversion-policy version applied to the request
* @param correlationId controlled request correlation identifier
* @param sourceSha256 lowercase SHA-256 digest of the immutable source bytes
@@ -25,14 +27,95 @@ public record OfficeConversionRequestBinding(
UUID jobId,
long jobGeneration,
String sourceFormat,
+ String expectedAdapterId,
+ String expectedAdapterVersion,
String policyVersion,
String correlationId,
String sourceSha256,
long maxOutputBytes
) {
+ private static final String CONTRACT_FIXTURE_ADAPTER_ID = "deterministic-fixture";
+ private static final String CONTRACT_FIXTURE_ADAPTER_VERSION = "1";
+
+ /**
+ * Creates a qualified-adapter binding using the request compatibility output ceiling.
+ *
+ * @param tenantId canonical tenant identifier
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration lifecycle generation
+ * @param sourceFormat canonical source format
+ * @param expectedAdapterId qualified adapter identifier
+ * @param expectedAdapterVersion exact qualified adapter/runtime version
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceSha256 lowercase source digest
+ */
+ public OfficeConversionRequestBinding(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String expectedAdapterId,
+ String expectedAdapterVersion,
+ String policyVersion,
+ String correlationId,
+ String sourceSha256) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ expectedAdapterId,
+ expectedAdapterVersion,
+ policyVersion,
+ correlationId,
+ sourceSha256,
+ OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
+ );
+ }
+
+ /**
+ * Creates a deterministic-fixture contract binding with an explicit output ceiling.
+ *
+ * This compatibility overload is fail-closed for production adapters: it
+ * binds the deterministic fixture id/version. Production sidecar or remote
+ * integrations must supply their qualified adapter identity explicitly.
+ *
+ * @param tenantId canonical tenant identifier
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration lifecycle generation
+ * @param sourceFormat canonical source format
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceSha256 lowercase source digest
+ * @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ */
+ public OfficeConversionRequestBinding(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ String sourceSha256,
+ long maxOutputBytes) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ CONTRACT_FIXTURE_ADAPTER_ID,
+ CONTRACT_FIXTURE_ADAPTER_VERSION,
+ policyVersion,
+ correlationId,
+ sourceSha256,
+ maxOutputBytes
+ );
+ }
+
/**
- * Creates a binding using the request compatibility output ceiling.
+ * Creates a deterministic-fixture contract binding using the request compatibility output ceiling.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -55,6 +138,8 @@ public OfficeConversionRequestBinding(
jobId,
jobGeneration,
sourceFormat,
+ CONTRACT_FIXTURE_ADAPTER_ID,
+ CONTRACT_FIXTURE_ADAPTER_VERSION,
policyVersion,
correlationId,
sourceSha256,
@@ -76,6 +161,8 @@ public OfficeConversionRequestBinding(
throw new IllegalArgumentException("jobGeneration must be non-negative");
}
sourceFormat = requireText(sourceFormat, "sourceFormat").toLowerCase(Locale.ROOT);
+ expectedAdapterId = requireText(expectedAdapterId, "expectedAdapterId");
+ expectedAdapterVersion = requireText(expectedAdapterVersion, "expectedAdapterVersion");
policyVersion = requireText(policyVersion, "policyVersion");
correlationId = requireText(correlationId, "correlationId");
if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) {
From 0b99611db3651cba6958c1318ec63354d3f15f48 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:19:22 +0900
Subject: [PATCH 041/219] fix(conversion): enforce qualified adapter provenance
---
.../conversion/OfficeConversionAdapter.java | 24 ++++++++++++-------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 258c78ba..f445cc94 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -18,21 +18,22 @@ public interface OfficeConversionAdapter {
/**
* Converts one immutable Office request and verifies that the result is
- * present, source-bound, tied to the exact request generation and policy,
- * within the request-bound publication size ceiling, and parseable as a
- * non-empty PDF.
+ * present, source-bound, tied to the exact qualified adapter/runtime,
+ * request generation and policy, within the request-bound publication size
+ * ceiling, and parseable as a non-empty PDF.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
- * generation, format, policy, correlation identity, output-size policy, or
- * a truncated/empty PDF container that is not usable document output.
+ * generation, adapter id/version, format, policy, correlation identity,
+ * output-size policy, or a truncated/empty PDF container that is not usable
+ * document output.
*
- * @param request immutable tenant- and generation-bound conversion request
+ * @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
- * mismatched provenance, an oversized candidate, a malformed PDF,
- * or a parseable PDF with no pages
+ * mismatched provenance, an unexpected adapter id/version, an
+ * oversized candidate, a malformed PDF, or a parseable PDF with no pages
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -48,6 +49,13 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
"conversion result source digest mismatch"
);
}
+ if (!request.expectedAdapterId().equals(result.adapterId())
+ || !request.expectedAdapterVersion().equals(result.adapterVersion())) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion result adapter identity mismatch"
+ );
+ }
if (!request.binding().equals(result.requestBinding())) {
throw new OfficeConversionException(
OfficeConversionFailureCode.INVALID_OUTPUT,
From fe3f65b57195cb8af626f2a21b61c37e8d395fba Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:19:50 +0900
Subject: [PATCH 042/219] test(conversion): align output-limit fixtures with
bound adapter
---
.../viewer/conversion/OfficeConversionOutputLimitTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
index e44eeab0..fda74e8c 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
@@ -45,7 +45,7 @@ void adapterRejectsPdfThatExceedsBoundOutputLimit() {
OfficeConversionRequest request = requestWithLimit(8L);
byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII);
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
- "fixture",
+ "deterministic-fixture",
"1",
input.sourceSha256(),
input.binding(),
@@ -66,7 +66,7 @@ void adapterAcceptsParseablePdfAtExactOutputLimit() {
byte[] pdf = OfficeConversionTestPdf.onePage();
OfficeConversionRequest request = requestWithLimit(pdf.length);
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
- "fixture",
+ "deterministic-fixture",
"1",
input.sourceSha256(),
input.binding(),
From 30bcc1e7a5fa182d9e8de0a543f965127b88af9a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:20:23 +0900
Subject: [PATCH 043/219] test(conversion): align PDF fixtures with bound
adapter
---
.../conversion/OfficeConversionPdfValidationTest.java | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
index 25376540..a6dfdec0 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
@@ -23,7 +23,7 @@ void adapterRejectsTruncatedMagicOnlyPdf() {
OfficeConversionRequest request = request();
byte[] truncated = "%PDF-1.7\nnot-a-complete-document".getBytes(StandardCharsets.US_ASCII);
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
- "fixture",
+ "deterministic-fixture",
"1",
input.sourceSha256(),
input.binding(),
@@ -44,7 +44,7 @@ void adapterRejectsParseablePdfWithoutPages() throws IOException {
OfficeConversionRequest request = request();
byte[] zeroPagePdf = zeroPagePdf();
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
- "fixture",
+ "deterministic-fixture",
"1",
input.sourceSha256(),
input.binding(),
@@ -65,7 +65,7 @@ void adapterAcceptsParseablePdf() throws IOException {
OfficeConversionRequest request = request();
byte[] pdf = onePagePdf();
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
- "fixture",
+ "deterministic-fixture",
"1",
input.sourceSha256(),
input.binding(),
From f20aa092b4a05f08f99d65e7c87354bf9f0cf670 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:24:02 +0900
Subject: [PATCH 044/219] test(conversion): require explicit adapter identity
in public requests
---
.../OfficeConversionAdapterIdentityBindingTest.java | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
index fb909578..d4387c6e 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
@@ -3,8 +3,10 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -28,6 +30,17 @@ void requestBindingIncludesExpectedAdapterIdentity() {
assertNotEquals(baseline.binding(), otherVersion.binding());
}
+ @Test
+ void publicRequestConstructorsRequireExplicitQualifiedAdapterIdentity() {
+ boolean allPublicConstructorsRequireAdapterIdentity = Arrays.stream(OfficeConversionRequest.class.getConstructors())
+ .allMatch(constructor -> constructor.getParameterCount() >= 9);
+
+ assertTrue(
+ allPublicConstructorsRequireAdapterIdentity,
+ "public conversion requests must not silently bind a fixture/default adapter identity"
+ );
+ }
+
@Test
void adapterRejectsResultFromUnexpectedAdapterIdentity() {
OfficeConversionRequest request = request("sandboxed-office-sidecar", "24.8.5");
From f97bfe49ca2ddc082f81259bdca53bf09fe9e5d6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:24:42 +0900
Subject: [PATCH 045/219] test(conversion): require explicit adapter identity
in public bindings
---
.../OfficeConversionAdapterIdentityBindingTest.java | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
index d4387c6e..1d3fdc5e 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
@@ -31,14 +31,20 @@ void requestBindingIncludesExpectedAdapterIdentity() {
}
@Test
- void publicRequestConstructorsRequireExplicitQualifiedAdapterIdentity() {
- boolean allPublicConstructorsRequireAdapterIdentity = Arrays.stream(OfficeConversionRequest.class.getConstructors())
+ void publicAuthorityConstructorsRequireExplicitQualifiedAdapterIdentity() {
+ boolean requestConstructorsAreStrict = Arrays.stream(OfficeConversionRequest.class.getConstructors())
+ .allMatch(constructor -> constructor.getParameterCount() >= 9);
+ boolean bindingConstructorsAreStrict = Arrays.stream(OfficeConversionRequestBinding.class.getConstructors())
.allMatch(constructor -> constructor.getParameterCount() >= 9);
assertTrue(
- allPublicConstructorsRequireAdapterIdentity,
+ requestConstructorsAreStrict,
"public conversion requests must not silently bind a fixture/default adapter identity"
);
+ assertTrue(
+ bindingConstructorsAreStrict,
+ "public conversion bindings must not silently bind a fixture/default adapter identity"
+ );
}
@Test
From 508f653dea2f82d4d998321860f224dfd4364418 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:25:46 +0900
Subject: [PATCH 046/219] fix(conversion): require adapter identity in public
request API
---
.../conversion/OfficeConversionRequest.java | 25 ++++++++++---------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index e9521c27..2f3bcf04 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -87,12 +87,13 @@ public OfficeConversionRequest(
}
/**
- * Creates a deterministic-fixture contract request with an explicit output ceiling.
+ * Creates a package-local deterministic-fixture contract request with an explicit output ceiling.
*
- * This compatibility overload is deliberately bound to the deterministic
- * fixture adapter. A production sidecar or remote-service integration must
- * use an overload that supplies its qualified adapter id and exact version;
- * it cannot silently inherit this fixture identity.
+ * This compatibility overload is deliberately non-public and bound to the
+ * deterministic fixture adapter. A production sidecar or remote-service
+ * integration must use a public overload that supplies its qualified adapter
+ * id and exact version; external callers cannot silently inherit the fixture
+ * identity.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -103,7 +104,7 @@ public OfficeConversionRequest(
* @param sourceBytes untrusted source bytes
* @param maxOutputBytes positive maximum PDF bytes accepted for publication
*/
- public OfficeConversionRequest(
+ OfficeConversionRequest(
String tenantId,
UUID jobId,
long jobGeneration,
@@ -127,12 +128,12 @@ public OfficeConversionRequest(
}
/**
- * Creates a deterministic-fixture contract request using the bounded compatibility output ceiling.
+ * Creates a package-local deterministic-fixture contract request using the bounded compatibility output ceiling.
*
- * This overload exists for the offline contract fixture only. Production
- * adapter integration must name the qualified adapter id and exact runtime
- * version explicitly so provider provenance cannot float independently of
- * the immutable request binding.
+ * This non-public overload exists for the offline contract fixture only.
+ * Production adapter integration must name the qualified adapter id and exact
+ * runtime version explicitly so provider provenance cannot float independently
+ * of the immutable request binding.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -142,7 +143,7 @@ public OfficeConversionRequest(
* @param correlationId controlled correlation identifier
* @param sourceBytes untrusted source bytes
*/
- public OfficeConversionRequest(
+ OfficeConversionRequest(
String tenantId,
UUID jobId,
long jobGeneration,
From 967a709f3128fdc5e11f9ef8d3f2a0bcfdac94fd Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:26:28 +0900
Subject: [PATCH 047/219] fix(conversion): require adapter identity in public
binding API
---
.../OfficeConversionRequestBinding.java | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
index 3dcb6cbf..9aad72e2 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
@@ -76,11 +76,12 @@ public OfficeConversionRequestBinding(
}
/**
- * Creates a deterministic-fixture contract binding with an explicit output ceiling.
+ * Creates a package-local deterministic-fixture contract binding with an explicit output ceiling.
*
- * This compatibility overload is fail-closed for production adapters: it
- * binds the deterministic fixture id/version. Production sidecar or remote
- * integrations must supply their qualified adapter identity explicitly.
+ * This compatibility overload is deliberately non-public and bound to the
+ * deterministic fixture id/version. Production sidecar or remote integrations
+ * must use a public constructor that supplies their qualified adapter identity
+ * explicitly.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -91,7 +92,7 @@ public OfficeConversionRequestBinding(
* @param sourceSha256 lowercase source digest
* @param maxOutputBytes positive maximum PDF bytes accepted for publication
*/
- public OfficeConversionRequestBinding(
+ OfficeConversionRequestBinding(
String tenantId,
UUID jobId,
long jobGeneration,
@@ -115,7 +116,7 @@ public OfficeConversionRequestBinding(
}
/**
- * Creates a deterministic-fixture contract binding using the request compatibility output ceiling.
+ * Creates a package-local deterministic-fixture contract binding using the request compatibility output ceiling.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -125,7 +126,7 @@ public OfficeConversionRequestBinding(
* @param correlationId controlled correlation identifier
* @param sourceSha256 lowercase source digest
*/
- public OfficeConversionRequestBinding(
+ OfficeConversionRequestBinding(
String tenantId,
UUID jobId,
long jobGeneration,
From 5cb089c6dca013fd20c3a2ac5aeae09f3d730a0b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:35:43 +0900
Subject: [PATCH 048/219] test(conversion): reject encrypted Office adapter
PDFs
---
.../OfficeConversionPdfValidationTest.java | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
index a6dfdec0..3b5de788 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
@@ -11,6 +11,8 @@
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
+import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
import org.junit.jupiter.api.Test;
/**
@@ -60,6 +62,27 @@ void adapterRejectsParseablePdfWithoutPages() throws IOException {
assertEquals("conversion output PDF has no pages", failure.getMessage());
}
+ @Test
+ void adapterRejectsEncryptedPdf() throws IOException {
+ OfficeConversionRequest request = request();
+ byte[] encryptedPdf = encryptedOnePagePdf();
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ encryptedPdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode());
+ assertEquals("conversion output PDF must not be encrypted", failure.getMessage());
+ }
+
@Test
void adapterAcceptsParseablePdf() throws IOException {
OfficeConversionRequest request = request();
@@ -98,6 +121,19 @@ private static byte[] zeroPagePdf() throws IOException {
}
}
+ private static byte[] encryptedOnePagePdf() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ AccessPermission permissions = new AccessPermission();
+ StandardProtectionPolicy policy = new StandardProtectionPolicy("owner-secret", "", permissions);
+ policy.setEncryptionKeyLength(128);
+ document.protect(policy);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] onePagePdf() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From 3cbfa3ce1219aec17e05cc2b207480ab62438be6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:37:33 +0900
Subject: [PATCH 049/219] fix(conversion): reject encrypted Office adapter PDFs
---
.../conversion/OfficeConversionAdapter.java | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index f445cc94..a8bde409 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -20,20 +20,21 @@ public interface OfficeConversionAdapter {
* Converts one immutable Office request and verifies that the result is
* present, source-bound, tied to the exact qualified adapter/runtime,
* request generation and policy, within the request-bound publication size
- * ceiling, and parseable as a non-empty PDF.
+ * ceiling, and parseable as a non-empty, unencrypted PDF.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
* generation, adapter id/version, format, policy, correlation identity,
- * output-size policy, or a truncated/empty PDF container that is not usable
- * document output.
+ * output-size policy, or a truncated, empty, or encrypted PDF container that
+ * is not acceptable document output.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
- * oversized candidate, a malformed PDF, or a parseable PDF with no pages
+ * oversized candidate, a malformed or encrypted PDF, or a parseable
+ * PDF with no pages
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -84,6 +85,12 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
private static void requireParseablePdf(byte[] pdfBytes) {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
+ if (document.isEncrypted()) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.INVALID_OUTPUT,
+ "conversion output PDF must not be encrypted"
+ );
+ }
if (document.getNumberOfPages() == 0) {
throw new OfficeConversionException(
OfficeConversionFailureCode.INVALID_OUTPUT,
From 7226e7e75de95e18d82cca5d68d8510d8324296f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:40:49 +0900
Subject: [PATCH 050/219] test(conversion): bind and enforce PDF page ceiling
---
.../OfficeConversionPageLimitTest.java | 102 ++++++++++++++++++
1 file changed, 102 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java
new file mode 100644
index 00000000..ffd208fb
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java
@@ -0,0 +1,102 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Resource-boundary regressions for request-bound PDF page-count acceptance.
+ */
+class OfficeConversionPageLimitTest {
+
+ @Test
+ void requestBindsPositiveMaximumPdfPages() {
+ OfficeConversionRequest request = requestWithLimits(1_000_000L, 2);
+
+ assertEquals(2, request.maxPdfPages());
+ assertEquals(2, request.binding().maxPdfPages());
+ assertThrows(IllegalArgumentException.class, () -> requestWithLimits(1_000_000L, 0));
+ assertThrows(IllegalArgumentException.class, () -> requestWithLimits(1_000_000L, -1));
+ }
+
+ @Test
+ void pageLimitChangesImmutableRequestBinding() {
+ OfficeConversionRequest twoPages = requestWithLimits(1_000_000L, 2);
+ OfficeConversionRequest threePages = requestWithLimits(1_000_000L, 3);
+
+ assertNotEquals(twoPages.binding(), threePages.binding());
+ }
+
+ @Test
+ void adapterRejectsPdfThatExceedsBoundPageLimit() throws IOException {
+ OfficeConversionRequest request = requestWithLimits(1_000_000L, 1);
+ byte[] pdf = pdfWithPages(2);
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.PAGE_LIMIT_EXCEEDED, failure.failureCode());
+ assertEquals("conversion output exceeds maximum pages", failure.getMessage());
+ }
+
+ @Test
+ void adapterAcceptsPdfAtExactPageLimit() throws IOException {
+ OfficeConversionRequest request = requestWithLimits(1_000_000L, 2);
+ byte[] pdf = pdfWithPages(2);
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionResult result = adapter.convert(request);
+
+ assertEquals(2, request.maxPdfPages());
+ assertEquals(pdf.length, result.pdfBytes().length);
+ }
+
+ private static OfficeConversionRequest requestWithLimits(long maxOutputBytes, int maxPdfPages) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("bd7bd272-61d5-4558-937f-2180d00ec4dd"),
+ 4L,
+ "docx",
+ "policy-v1",
+ "trace-page-limit",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ maxOutputBytes,
+ maxPdfPages
+ );
+ }
+
+ private static byte[] pdfWithPages(int pageCount) throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ for (int index = 0; index < pageCount; index++) {
+ document.addPage(new PDPage());
+ }
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+}
From ee17904f6187bf436bfded29c55be5bbcf867c93 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:43:25 +0900
Subject: [PATCH 051/219] feat(conversion): bind PDF page ceiling to Office
request
---
.../conversion/OfficeConversionRequest.java | 94 +++++++++++++------
1 file changed, 67 insertions(+), 27 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index 2f3bcf04..26348a4d 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -11,7 +11,7 @@
*
* The request binds untrusted document bytes to tenant, job-generation,
* source format, qualified adapter identity, policy, correlation identity, and
- * an output-publication size ceiling before any converter implementation can
+ * bounded PDF publication limits before any converter implementation can
* process them. Source bytes are defensively copied at construction and on
* access so callers cannot mutate the digest-bound payload after validation.
*
@@ -25,6 +25,7 @@
* @param correlationId request correlation identifier used for controlled tracing
* @param sourceBytes untrusted source bytes, defensively copied
* @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ * @param maxPdfPages positive maximum PDF pages accepted for publication
*/
public record OfficeConversionRequest(
String tenantId,
@@ -36,21 +37,25 @@ public record OfficeConversionRequest(
String policyVersion,
String correlationId,
byte[] sourceBytes,
- long maxOutputBytes
+ long maxOutputBytes,
+ int maxPdfPages
) {
- /** Default compatibility ceiling for contract callers that have not supplied a policy-specific limit. */
+ /** Default compatibility byte ceiling for contract callers without a policy-specific limit. */
public static final long DEFAULT_MAX_OUTPUT_BYTES = 64L * 1024L * 1024L;
+ /** Default compatibility page ceiling for contract callers without a policy-specific limit. */
+ public static final int DEFAULT_MAX_PDF_PAGES = 1_000;
+
private static final String CONTRACT_FIXTURE_ADAPTER_ID = "deterministic-fixture";
private static final String CONTRACT_FIXTURE_ADAPTER_VERSION = "1";
/**
- * Creates a qualified-adapter request using the bounded compatibility output ceiling.
+ * Creates a qualified-adapter request using bounded compatibility publication limits.
*
- * Production integration should prefer this overload when the output
- * ceiling is inherited from the currently qualified policy. The exact
- * adapter id and version remain mandatory authority fields.
+ * Production integration should use the canonical constructor when policy
+ * supplies explicit byte or page ceilings. The exact adapter id and version
+ * remain mandatory authority fields.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -82,18 +87,18 @@ public OfficeConversionRequest(
policyVersion,
correlationId,
sourceBytes,
- DEFAULT_MAX_OUTPUT_BYTES
+ DEFAULT_MAX_OUTPUT_BYTES,
+ DEFAULT_MAX_PDF_PAGES
);
}
/**
- * Creates a package-local deterministic-fixture contract request with an explicit output ceiling.
+ * Creates a package-local deterministic-fixture request with explicit publication limits.
*
* This compatibility overload is deliberately non-public and bound to the
* deterministic fixture adapter. A production sidecar or remote-service
- * integration must use a public overload that supplies its qualified adapter
- * id and exact version; external callers cannot silently inherit the fixture
- * identity.
+ * integration must use the canonical public constructor and supply qualified
+ * adapter identity explicitly.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -103,6 +108,7 @@ public OfficeConversionRequest(
* @param correlationId controlled correlation identifier
* @param sourceBytes untrusted source bytes
* @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ * @param maxPdfPages positive maximum PDF pages accepted for publication
*/
OfficeConversionRequest(
String tenantId,
@@ -112,7 +118,8 @@ public OfficeConversionRequest(
String policyVersion,
String correlationId,
byte[] sourceBytes,
- long maxOutputBytes) {
+ long maxOutputBytes,
+ int maxPdfPages) {
this(
tenantId,
jobId,
@@ -123,17 +130,47 @@ public OfficeConversionRequest(
policyVersion,
correlationId,
sourceBytes,
- maxOutputBytes
+ maxOutputBytes,
+ maxPdfPages
);
}
/**
- * Creates a package-local deterministic-fixture contract request using the bounded compatibility output ceiling.
+ * Creates a package-local deterministic-fixture request with an explicit byte ceiling.
*
- * This non-public overload exists for the offline contract fixture only.
- * Production adapter integration must name the qualified adapter id and exact
- * runtime version explicitly so provider provenance cannot float independently
- * of the immutable request binding.
+ * @param tenantId tenant that owns the conversion request
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration immutable lifecycle generation
+ * @param sourceFormat normalized source format
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceBytes untrusted source bytes
+ * @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ */
+ OfficeConversionRequest(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String policyVersion,
+ String correlationId,
+ byte[] sourceBytes,
+ long maxOutputBytes) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ policyVersion,
+ correlationId,
+ sourceBytes,
+ maxOutputBytes,
+ DEFAULT_MAX_PDF_PAGES
+ );
+ }
+
+ /**
+ * Creates a package-local deterministic-fixture request using bounded compatibility limits.
*
* @param tenantId tenant that owns the conversion request
* @param jobId immutable conversion job identifier
@@ -156,20 +193,19 @@ public OfficeConversionRequest(
jobId,
jobGeneration,
sourceFormat,
- CONTRACT_FIXTURE_ADAPTER_ID,
- CONTRACT_FIXTURE_ADAPTER_VERSION,
policyVersion,
correlationId,
sourceBytes,
- DEFAULT_MAX_OUTPUT_BYTES
+ DEFAULT_MAX_OUTPUT_BYTES,
+ DEFAULT_MAX_PDF_PAGES
);
}
/**
- * Validates immutable conversion identity, qualified adapter identity, the
- * publication limit, and copies source bytes.
+ * Validates immutable conversion identity, qualified adapter identity,
+ * publication limits, and copies source bytes.
*
- * @throws IllegalArgumentException when required identity, source bytes, or limit are invalid
+ * @throws IllegalArgumentException when required identity, source bytes, or limits are invalid
*/
public OfficeConversionRequest {
tenantId = requireText(tenantId, "tenantId");
@@ -190,6 +226,9 @@ public OfficeConversionRequest(
if (maxOutputBytes <= 0L) {
throw new IllegalArgumentException("maxOutputBytes must be positive");
}
+ if (maxPdfPages <= 0) {
+ throw new IllegalArgumentException("maxPdfPages must be positive");
+ }
sourceBytes = sourceBytes.clone();
}
@@ -220,7 +259,7 @@ public String sourceSha256() {
* Returns the full immutable authority tuple for provider-output validation.
*
* @return request binding containing identity, generation, adapter, policy,
- * output limit, and source digest
+ * publication limits, and source digest
*/
public OfficeConversionRequestBinding binding() {
return new OfficeConversionRequestBinding(
@@ -233,7 +272,8 @@ public OfficeConversionRequestBinding binding() {
policyVersion,
correlationId,
sourceSha256(),
- maxOutputBytes
+ maxOutputBytes,
+ maxPdfPages
);
}
From 416ca4d988ad41726d5f34606c93ae3819c59681 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:43:55 +0900
Subject: [PATCH 052/219] feat(conversion): include PDF page ceiling in request
binding
---
.../OfficeConversionRequestBinding.java | 30 +++++++++++--------
1 file changed, 17 insertions(+), 13 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
index 9aad72e2..b418d4d0 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java
@@ -9,7 +9,8 @@
* The binding includes every request authority field that may distinguish a
* valid conversion generation even when two jobs carry byte-identical source
* documents. Equality therefore acts as the stale-generation, provider-version,
- * and cross-request acceptance boundary after a provider returns candidate output.
+ * policy-limit, and cross-request acceptance boundary after a provider returns
+ * candidate output.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -21,6 +22,7 @@
* @param correlationId controlled request correlation identifier
* @param sourceSha256 lowercase SHA-256 digest of the immutable source bytes
* @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ * @param maxPdfPages positive maximum PDF pages accepted for publication
*/
public record OfficeConversionRequestBinding(
String tenantId,
@@ -32,14 +34,15 @@ public record OfficeConversionRequestBinding(
String policyVersion,
String correlationId,
String sourceSha256,
- long maxOutputBytes
+ long maxOutputBytes,
+ int maxPdfPages
) {
private static final String CONTRACT_FIXTURE_ADAPTER_ID = "deterministic-fixture";
private static final String CONTRACT_FIXTURE_ADAPTER_VERSION = "1";
/**
- * Creates a qualified-adapter binding using the request compatibility output ceiling.
+ * Creates a qualified-adapter binding using bounded compatibility publication limits.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -71,17 +74,13 @@ public OfficeConversionRequestBinding(
policyVersion,
correlationId,
sourceSha256,
- OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
+ OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES,
+ OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES
);
}
/**
- * Creates a package-local deterministic-fixture contract binding with an explicit output ceiling.
- *
- * This compatibility overload is deliberately non-public and bound to the
- * deterministic fixture id/version. Production sidecar or remote integrations
- * must use a public constructor that supplies their qualified adapter identity
- * explicitly.
+ * Creates a package-local deterministic-fixture binding with an explicit byte ceiling.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -111,12 +110,13 @@ public OfficeConversionRequestBinding(
policyVersion,
correlationId,
sourceSha256,
- maxOutputBytes
+ maxOutputBytes,
+ OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES
);
}
/**
- * Creates a package-local deterministic-fixture contract binding using the request compatibility output ceiling.
+ * Creates a package-local deterministic-fixture binding using bounded compatibility limits.
*
* @param tenantId canonical tenant identifier
* @param jobId immutable conversion job identifier
@@ -144,7 +144,8 @@ public OfficeConversionRequestBinding(
policyVersion,
correlationId,
sourceSha256,
- OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
+ OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES,
+ OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES
);
}
@@ -172,6 +173,9 @@ public OfficeConversionRequestBinding(
if (maxOutputBytes <= 0L) {
throw new IllegalArgumentException("maxOutputBytes must be positive");
}
+ if (maxPdfPages <= 0) {
+ throw new IllegalArgumentException("maxPdfPages must be positive");
+ }
}
private static String requireText(String value, String fieldName) {
From 2f7a1bad96af08cee0f817625e0469446a66d437 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:44:20 +0900
Subject: [PATCH 053/219] feat(conversion): classify PDF page-limit failures
---
.../viewer/conversion/OfficeConversionFailureCode.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
index bcfdfeb1..9f0e1497 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java
@@ -22,6 +22,8 @@ public enum OfficeConversionFailureCode {
INVALID_OUTPUT(false),
/** Candidate PDF exceeds the request-bound publication size ceiling. */
OUTPUT_LIMIT_EXCEEDED(false),
+ /** Candidate PDF exceeds the request-bound publication page ceiling. */
+ PAGE_LIMIT_EXCEEDED(false),
/** Qualified converter service or capacity is temporarily unavailable. */
ENGINE_UNAVAILABLE(true),
/** Conversion exceeded its bounded execution deadline. */
From d7a4a0ac4f5dc7a8dfced7903fe6617d29db2228 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:44:52 +0900
Subject: [PATCH 054/219] fix(conversion): enforce request-bound PDF page
ceiling
---
.../conversion/OfficeConversionAdapter.java | 25 ++++++++++++-------
1 file changed, 16 insertions(+), 9 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index a8bde409..9d46348a 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -19,22 +19,22 @@ public interface OfficeConversionAdapter {
/**
* Converts one immutable Office request and verifies that the result is
* present, source-bound, tied to the exact qualified adapter/runtime,
- * request generation and policy, within the request-bound publication size
- * ceiling, and parseable as a non-empty, unencrypted PDF.
+ * request generation and policy, within request-bound byte and page
+ * publication ceilings, and parseable as a non-empty, unencrypted PDF.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
* generation, adapter id/version, format, policy, correlation identity,
- * output-size policy, or a truncated, empty, or encrypted PDF container that
- * is not acceptable document output.
+ * publication policy, or a truncated, empty, encrypted, or over-page-limit
+ * PDF container that is not acceptable document output.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
- * oversized candidate, a malformed or encrypted PDF, or a parseable
- * PDF with no pages
+ * oversized candidate, a malformed or encrypted PDF, a PDF with no
+ * pages, or a PDF that exceeds the request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -71,7 +71,7 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
"conversion output exceeds maximum bytes"
);
}
- requireParseablePdf(pdfBytes);
+ requireParseablePdf(pdfBytes, request.maxPdfPages());
return result;
}
@@ -83,7 +83,7 @@ default OfficeConversionResult convert(OfficeConversionRequest request) {
*/
OfficeConversionResult performConversion(OfficeConversionRequest request);
- private static void requireParseablePdf(byte[] pdfBytes) {
+ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
if (document.isEncrypted()) {
throw new OfficeConversionException(
@@ -91,12 +91,19 @@ private static void requireParseablePdf(byte[] pdfBytes) {
"conversion output PDF must not be encrypted"
);
}
- if (document.getNumberOfPages() == 0) {
+ int pageCount = document.getNumberOfPages();
+ if (pageCount == 0) {
throw new OfficeConversionException(
OfficeConversionFailureCode.INVALID_OUTPUT,
"conversion output PDF has no pages"
);
}
+ if (pageCount > maxPdfPages) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.PAGE_LIMIT_EXCEEDED,
+ "conversion output exceeds maximum pages"
+ );
+ }
} catch (IOException ex) {
throw new OfficeConversionException(
OfficeConversionFailureCode.INVALID_OUTPUT,
From 948a80c31f4351616e80145912aa4f6932793a4c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:47:07 +0900
Subject: [PATCH 055/219] fix(conversion): preserve qualified adapter
byte-limit constructor
---
.../conversion/OfficeConversionRequest.java | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
index 26348a4d..d996016a 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java
@@ -92,6 +92,51 @@ public OfficeConversionRequest(
);
}
+ /**
+ * Creates a qualified-adapter request with an explicit byte ceiling and the
+ * bounded compatibility page ceiling.
+ *
+ * This overload preserves the public authority contract introduced by
+ * the byte-limit slice. New policy integrations that also control page count
+ * should use the canonical constructor.
+ *
+ * @param tenantId tenant that owns the conversion request
+ * @param jobId immutable conversion job identifier
+ * @param jobGeneration immutable lifecycle generation
+ * @param sourceFormat normalized source format
+ * @param expectedAdapterId qualified adapter identifier
+ * @param expectedAdapterVersion exact qualified adapter/runtime version
+ * @param policyVersion conversion-policy version
+ * @param correlationId controlled correlation identifier
+ * @param sourceBytes untrusted source bytes
+ * @param maxOutputBytes positive maximum PDF bytes accepted for publication
+ */
+ public OfficeConversionRequest(
+ String tenantId,
+ UUID jobId,
+ long jobGeneration,
+ String sourceFormat,
+ String expectedAdapterId,
+ String expectedAdapterVersion,
+ String policyVersion,
+ String correlationId,
+ byte[] sourceBytes,
+ long maxOutputBytes) {
+ this(
+ tenantId,
+ jobId,
+ jobGeneration,
+ sourceFormat,
+ expectedAdapterId,
+ expectedAdapterVersion,
+ policyVersion,
+ correlationId,
+ sourceBytes,
+ maxOutputBytes,
+ DEFAULT_MAX_PDF_PAGES
+ );
+ }
+
/**
* Creates a package-local deterministic-fixture request with explicit publication limits.
*
From 2cd4586bc5f7267a7ecee9d69ea0db416beab45a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 03:54:02 +0900
Subject: [PATCH 056/219] test(conversion): reject PDF JavaScript open actions
---
...ficeConversionActiveContentPolicyTest.java | 70 +++++++++++++++++++
1 file changed, 70 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
new file mode 100644
index 00000000..3c499880
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -0,0 +1,70 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Active-content policy regressions for converter-produced PDF candidates.
+ */
+class OfficeConversionActiveContentPolicyTest {
+
+ @Test
+ void adapterRejectsJavaScriptDocumentOpenAction() throws IOException {
+ OfficeConversionRequest request = request();
+ byte[] pdf = pdfWithJavaScriptOpenAction();
+ OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request)
+ );
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ private static OfficeConversionRequest request() {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"),
+ 9L,
+ "docx",
+ "policy-v1",
+ "trace-active-content",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] pdfWithJavaScriptOpenAction() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ COSDictionary javascriptAction = new COSDictionary();
+ javascriptAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("JavaScript"));
+ javascriptAction.setString(COSName.getPDFName("JS"), "app.alert('clearfolio')");
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("OpenAction"), javascriptAction);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+}
From 70a4d67ea5441885bae54ea426b82390fd7f676d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:06:02 +0900
Subject: [PATCH 057/219] fix(conversion): reject PDF document-open actions
---
.../conversion/OfficeConversionAdapter.java | 22 ++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 9d46348a..6c6e737d 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -3,6 +3,8 @@
import java.io.IOException;
import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
/**
@@ -20,21 +22,23 @@ public interface OfficeConversionAdapter {
* Converts one immutable Office request and verifies that the result is
* present, source-bound, tied to the exact qualified adapter/runtime,
* request generation and policy, within request-bound byte and page
- * publication ceilings, and parseable as a non-empty, unencrypted PDF.
+ * publication ceilings, and parseable as a non-empty, unencrypted PDF
+ * without a document-open action.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
* generation, adapter id/version, format, policy, correlation identity,
- * publication policy, or a truncated, empty, encrypted, or over-page-limit
- * PDF container that is not acceptable document output.
+ * publication policy, or a truncated, empty, encrypted, active-on-open, or
+ * over-page-limit PDF container that is not acceptable document output.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
- * oversized candidate, a malformed or encrypted PDF, a PDF with no
- * pages, or a PDF that exceeds the request-bound page ceiling
+ * oversized candidate, a malformed or encrypted PDF, a PDF with a
+ * document-open action or no pages, or a PDF that exceeds the
+ * request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -91,6 +95,14 @@ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
"conversion output PDF must not be encrypted"
);
}
+ COSBase openAction = document.getDocumentCatalog().getCOSObject()
+ .getDictionaryObject(COSName.getPDFName("OpenAction"));
+ if (openAction != null) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.POLICY_DENIED,
+ "conversion output contains prohibited active content"
+ );
+ }
int pageCount = document.getNumberOfPages();
if (pageCount == 0) {
throw new OfficeConversionException(
From 30dc09379de24bb971c748725ab3470f13b019ec Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:10:45 +0900
Subject: [PATCH 058/219] test(conversion): reject document JavaScript name
trees
---
...ficeConversionActiveContentPolicyTest.java | 56 ++++++++++++++++---
1 file changed, 48 insertions(+), 8 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 3c499880..0c814657 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -8,8 +8,10 @@
import java.nio.charset.StandardCharsets;
import java.util.UUID;
+import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.Test;
@@ -21,8 +23,22 @@ class OfficeConversionActiveContentPolicyTest {
@Test
void adapterRejectsJavaScriptDocumentOpenAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithJavaScriptOpenAction());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ @Test
+ void adapterRejectsDocumentJavaScriptNameTreeWithoutOpenAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithJavaScriptNameTree());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ private static OfficeConversionException assertPolicyDenied(byte[] pdf) {
OfficeConversionRequest request = request();
- byte[] pdf = pdfWithJavaScriptOpenAction();
OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
"deterministic-fixture",
"1",
@@ -31,13 +47,10 @@ void adapterRejectsJavaScriptDocumentOpenAction() throws IOException {
pdf
);
- OfficeConversionException failure = assertThrows(
+ return assertThrows(
OfficeConversionException.class,
() -> adapter.convert(request)
);
-
- assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
- assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
private static OfficeConversionRequest request() {
@@ -58,13 +71,40 @@ private static byte[] pdfWithJavaScriptOpenAction() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
document.addPage(new PDPage());
- COSDictionary javascriptAction = new COSDictionary();
- javascriptAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("JavaScript"));
- javascriptAction.setString(COSName.getPDFName("JS"), "app.alert('clearfolio')");
+ COSDictionary javascriptAction = javascriptAction();
document.getDocumentCatalog().getCOSObject()
.setItem(COSName.getPDFName("OpenAction"), javascriptAction);
document.save(output);
return output.toByteArray();
}
}
+
+ private static byte[] pdfWithJavaScriptNameTree() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+
+ COSArray entries = new COSArray();
+ entries.add(new COSString("clearfolio-startup"));
+ entries.add(javascriptAction());
+
+ COSDictionary javaScriptTree = new COSDictionary();
+ javaScriptTree.setItem(COSName.getPDFName("Names"), entries);
+
+ COSDictionary names = new COSDictionary();
+ names.setItem(COSName.getPDFName("JavaScript"), javaScriptTree);
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("Names"), names);
+
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static COSDictionary javascriptAction() {
+ COSDictionary javascriptAction = new COSDictionary();
+ javascriptAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("JavaScript"));
+ javascriptAction.setString(COSName.getPDFName("JS"), "app.alert('clearfolio')");
+ return javascriptAction;
+ }
}
From 99882ab027441f74686bc8e33970d20be82e6698 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:14:33 +0900
Subject: [PATCH 059/219] fix(conversion): reject document JavaScript name
trees
---
.../conversion/OfficeConversionAdapter.java | 28 +++++++++++++------
1 file changed, 20 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 6c6e737d..bcb1f1d7 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -4,6 +4,7 @@
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -23,22 +24,22 @@ public interface OfficeConversionAdapter {
* present, source-bound, tied to the exact qualified adapter/runtime,
* request generation and policy, within request-bound byte and page
* publication ceilings, and parseable as a non-empty, unencrypted PDF
- * without a document-open action.
+ * without prohibited document-level active content.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
* accidentally accept output for a different source, tenant, job, lifecycle
* generation, adapter id/version, format, policy, correlation identity,
- * publication policy, or a truncated, empty, encrypted, active-on-open, or
- * over-page-limit PDF container that is not acceptable document output.
+ * publication policy, or a truncated, empty, encrypted, actively executable,
+ * or over-page-limit PDF container that is not acceptable document output.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
- * document-open action or no pages, or a PDF that exceeds the
- * request-bound page ceiling
+ * document-open action or JavaScript name tree, a PDF with no pages,
+ * or a PDF that exceeds the request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -95,9 +96,7 @@ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
"conversion output PDF must not be encrypted"
);
}
- COSBase openAction = document.getDocumentCatalog().getCOSObject()
- .getDictionaryObject(COSName.getPDFName("OpenAction"));
- if (openAction != null) {
+ if (containsProhibitedActiveContent(document)) {
throw new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
"conversion output contains prohibited active content"
@@ -123,4 +122,17 @@ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
);
}
}
+
+ private static boolean containsProhibitedActiveContent(PDDocument document) {
+ COSDictionary catalog = document.getDocumentCatalog().getCOSObject();
+ if (catalog.getDictionaryObject(COSName.getPDFName("OpenAction")) != null) {
+ return true;
+ }
+
+ COSBase namesBase = catalog.getDictionaryObject(COSName.getPDFName("Names"));
+ if (!(namesBase instanceof COSDictionary names)) {
+ return false;
+ }
+ return names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null;
+ }
}
From fdfc65e2740532948db728adde6e5ec27036b03f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:19:04 +0900
Subject: [PATCH 060/219] test(conversion): reject embedded PDF file name trees
---
...ficeConversionActiveContentPolicyTest.java | 62 ++++++++++++++++---
1 file changed, 55 insertions(+), 7 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 0c814657..5979e08f 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -1,5 +1,6 @@
package com.clearfolio.viewer.conversion;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -37,20 +38,38 @@ void adapterRejectsDocumentJavaScriptNameTreeWithoutOpenAction() throws IOExcept
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsEmbeddedFileNameTreeWithoutExecutableAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithEmbeddedFilesNameTree());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ @Test
+ void adapterAcceptsBenignEmptyDocumentNameDictionary() throws IOException {
+ byte[] pdf = pdfWithEmptyNameDictionary();
+ OfficeConversionAdapter adapter = adapterReturning(pdf);
+
+ assertDoesNotThrow(() -> adapter.convert(request()));
+ }
+
private static OfficeConversionException assertPolicyDenied(byte[] pdf) {
- OfficeConversionRequest request = request();
- OfficeConversionAdapter adapter = input -> new OfficeConversionResult(
+ OfficeConversionAdapter adapter = adapterReturning(pdf);
+ return assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request())
+ );
+ }
+
+ private static OfficeConversionAdapter adapterReturning(byte[] pdf) {
+ return input -> new OfficeConversionResult(
"deterministic-fixture",
"1",
input.sourceSha256(),
input.binding(),
pdf
);
-
- return assertThrows(
- OfficeConversionException.class,
- () -> adapter.convert(request)
- );
}
private static OfficeConversionRequest request() {
@@ -101,6 +120,35 @@ private static byte[] pdfWithJavaScriptNameTree() throws IOException {
}
}
+ private static byte[] pdfWithEmbeddedFilesNameTree() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+
+ COSDictionary embeddedFilesTree = new COSDictionary();
+ embeddedFilesTree.setItem(COSName.getPDFName("Names"), new COSArray());
+
+ COSDictionary names = new COSDictionary();
+ names.setItem(COSName.getPDFName("EmbeddedFiles"), embeddedFilesTree);
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("Names"), names);
+
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static byte[] pdfWithEmptyNameDictionary() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("Names"), new COSDictionary());
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static COSDictionary javascriptAction() {
COSDictionary javascriptAction = new COSDictionary();
javascriptAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("JavaScript"));
From 87c48f8f9ba9b22efe74c4aa11ccd2cc4d09a9b4 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:22:12 +0900
Subject: [PATCH 061/219] fix(conversion): reject embedded PDF file name trees
---
.../viewer/conversion/OfficeConversionAdapter.java | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index bcb1f1d7..f9fb779c 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -31,15 +31,17 @@ public interface OfficeConversionAdapter {
* accidentally accept output for a different source, tenant, job, lifecycle
* generation, adapter id/version, format, policy, correlation identity,
* publication policy, or a truncated, empty, encrypted, actively executable,
- * or over-page-limit PDF container that is not acceptable document output.
+ * embedded-file-bearing, or over-page-limit PDF container that is not
+ * acceptable document output.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
- * document-open action or JavaScript name tree, a PDF with no pages,
- * or a PDF that exceeds the request-bound page ceiling
+ * document-open action, JavaScript name tree, or embedded-file name
+ * tree, a PDF with no pages, or a PDF that exceeds the request-bound
+ * page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -133,6 +135,7 @@ private static boolean containsProhibitedActiveContent(PDDocument document) {
if (!(namesBase instanceof COSDictionary names)) {
return false;
}
- return names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null;
+ return names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null
+ || names.getDictionaryObject(COSName.getPDFName("EmbeddedFiles")) != null;
}
}
From bd2e6285a4d5aadafff97e34ec548d7ace57e398 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:24:11 +0900
Subject: [PATCH 062/219] test(conversion): reject PDF page additional actions
---
...ficeConversionActiveContentPolicyTest.java | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 5979e08f..e4ef0b19 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -46,6 +46,14 @@ void adapterRejectsEmbeddedFileNameTreeWithoutExecutableAction() throws IOExcept
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsPageAdditionalActions() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithPageAdditionalActions());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterAcceptsBenignEmptyDocumentNameDictionary() throws IOException {
byte[] pdf = pdfWithEmptyNameDictionary();
@@ -138,6 +146,19 @@ private static byte[] pdfWithEmbeddedFilesNameTree() throws IOException {
}
}
+ private static byte[] pdfWithPageAdditionalActions() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ PDPage page = new PDPage();
+ COSDictionary additionalActions = new COSDictionary();
+ additionalActions.setItem(COSName.getPDFName("O"), javascriptAction());
+ page.getCOSObject().setItem(COSName.getPDFName("AA"), additionalActions);
+ document.addPage(page);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] pdfWithEmptyNameDictionary() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From fc29c32c27a4bca47bc25be0a01d440227963047 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 04:25:51 +0900
Subject: [PATCH 063/219] fix(conversion): reject PDF page additional actions
---
.../conversion/OfficeConversionAdapter.java | 22 +++++++++++++------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index f9fb779c..27ea339e 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -7,6 +7,7 @@
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
/**
* Provider-neutral boundary for sandboxed or remote Office-to-PDF conversion.
@@ -39,9 +40,9 @@ public interface OfficeConversionAdapter {
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
- * document-open action, JavaScript name tree, or embedded-file name
- * tree, a PDF with no pages, or a PDF that exceeds the request-bound
- * page ceiling
+ * document-open action, document JavaScript/embedded-file name tree,
+ * or page additional-actions dictionary, a PDF with no pages, or a
+ * PDF that exceeds the request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -132,10 +133,17 @@ private static boolean containsProhibitedActiveContent(PDDocument document) {
}
COSBase namesBase = catalog.getDictionaryObject(COSName.getPDFName("Names"));
- if (!(namesBase instanceof COSDictionary names)) {
- return false;
+ if (namesBase instanceof COSDictionary names
+ && (names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null
+ || names.getDictionaryObject(COSName.getPDFName("EmbeddedFiles")) != null)) {
+ return true;
+ }
+
+ for (PDPage page : document.getPages()) {
+ if (page.getCOSObject().getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ return true;
+ }
}
- return names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null
- || names.getDictionaryObject(COSName.getPDFName("EmbeddedFiles")) != null;
+ return false;
}
}
From 18a7f64d768a8b2f6c03173ef3dc2a98f96c9483 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:01:42 +0900
Subject: [PATCH 064/219] test(conversion): reject PDF catalog additional
actions
---
...ficeConversionActiveContentPolicyTest.java | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index e4ef0b19..df90de75 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -46,6 +46,14 @@ void adapterRejectsEmbeddedFileNameTreeWithoutExecutableAction() throws IOExcept
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsCatalogAdditionalActions() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAdditionalActions());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterRejectsPageAdditionalActions() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithPageAdditionalActions());
@@ -146,6 +154,19 @@ private static byte[] pdfWithEmbeddedFilesNameTree() throws IOException {
}
}
+ private static byte[] pdfWithCatalogAdditionalActions() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ COSDictionary additionalActions = new COSDictionary();
+ additionalActions.setItem(COSName.getPDFName("WC"), javascriptAction());
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("AA"), additionalActions);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] pdfWithPageAdditionalActions() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From 89b61370dca779d1ae2310f0c1f6ce7efafafb7d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:03:36 +0900
Subject: [PATCH 065/219] fix(conversion): reject PDF catalog additional
actions
---
.../viewer/conversion/OfficeConversionAdapter.java | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 27ea339e..56d211ef 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -40,9 +40,9 @@ public interface OfficeConversionAdapter {
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
- * document-open action, document JavaScript/embedded-file name tree,
- * or page additional-actions dictionary, a PDF with no pages, or a
- * PDF that exceeds the request-bound page ceiling
+ * document-open action, catalog/page additional-actions dictionary,
+ * document JavaScript/embedded-file name tree, a PDF with no pages,
+ * or a PDF that exceeds the request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -128,7 +128,8 @@ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
private static boolean containsProhibitedActiveContent(PDDocument document) {
COSDictionary catalog = document.getDocumentCatalog().getCOSObject();
- if (catalog.getDictionaryObject(COSName.getPDFName("OpenAction")) != null) {
+ if (catalog.getDictionaryObject(COSName.getPDFName("OpenAction")) != null
+ || catalog.getDictionaryObject(COSName.getPDFName("AA")) != null) {
return true;
}
From 6add9bf0bc48bc5ce0911218dad506935e183895 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:06:39 +0900
Subject: [PATCH 066/219] test(conversion): reject PDF annotation actions
---
...ficeConversionActiveContentPolicyTest.java | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index df90de75..cbf4aa0c 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -62,6 +62,14 @@ void adapterRejectsPageAdditionalActions() throws IOException {
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsAnnotationUriAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationUriAction());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterAcceptsBenignEmptyDocumentNameDictionary() throws IOException {
byte[] pdf = pdfWithEmptyNameDictionary();
@@ -180,6 +188,29 @@ private static byte[] pdfWithPageAdditionalActions() throws IOException {
}
}
+ private static byte[] pdfWithAnnotationUriAction() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ PDPage page = new PDPage();
+
+ COSDictionary uriAction = new COSDictionary();
+ uriAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI"));
+ uriAction.setString(COSName.getPDFName("URI"), "https://example.invalid/clearfolio");
+
+ COSDictionary annotation = new COSDictionary();
+ annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
+ annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
+ annotation.setItem(COSName.getPDFName("A"), uriAction);
+
+ COSArray annotations = new COSArray();
+ annotations.add(annotation);
+ page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations);
+ document.addPage(page);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] pdfWithEmptyNameDictionary() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From 41c053d0917c027d298b530601a908c4b112801f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:08:14 +0900
Subject: [PATCH 067/219] test(conversion): target executable annotation
actions
---
.../OfficeConversionActiveContentPolicyTest.java | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index cbf4aa0c..23c4fd61 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -63,8 +63,8 @@ void adapterRejectsPageAdditionalActions() throws IOException {
}
@Test
- void adapterRejectsAnnotationUriAction() throws IOException {
- OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationUriAction());
+ void adapterRejectsAnnotationJavaScriptAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationJavaScriptAction());
assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
assertEquals("conversion output contains prohibited active content", failure.getMessage());
@@ -188,19 +188,15 @@ private static byte[] pdfWithPageAdditionalActions() throws IOException {
}
}
- private static byte[] pdfWithAnnotationUriAction() throws IOException {
+ private static byte[] pdfWithAnnotationJavaScriptAction() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDPage page = new PDPage();
- COSDictionary uriAction = new COSDictionary();
- uriAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI"));
- uriAction.setString(COSName.getPDFName("URI"), "https://example.invalid/clearfolio");
-
COSDictionary annotation = new COSDictionary();
annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
- annotation.setItem(COSName.getPDFName("A"), uriAction);
+ annotation.setItem(COSName.getPDFName("A"), javascriptAction());
COSArray annotations = new COSArray();
annotations.add(annotation);
From 3654cdde3838b04920b744ec31ea664b8899bbb4 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:11:29 +0900
Subject: [PATCH 068/219] fix(conversion): reject executable PDF annotation
actions
---
.../conversion/OfficeConversionAdapter.java | 42 +++++++++++++++++--
1 file changed, 38 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 56d211ef..bc5d5e6b 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -3,6 +3,7 @@
import java.io.IOException;
import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
@@ -25,7 +26,7 @@ public interface OfficeConversionAdapter {
* present, source-bound, tied to the exact qualified adapter/runtime,
* request generation and policy, within request-bound byte and page
* publication ceilings, and parseable as a non-empty, unencrypted PDF
- * without prohibited document-level active content.
+ * without prohibited active content.
*
* This method is the public conversion authority. Implementations supply
* only {@link #performConversion(OfficeConversionRequest)}; callers cannot
@@ -41,8 +42,9 @@ public interface OfficeConversionAdapter {
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
* document-open action, catalog/page additional-actions dictionary,
- * document JavaScript/embedded-file name tree, a PDF with no pages,
- * or a PDF that exceeds the request-bound page ceiling
+ * document JavaScript/embedded-file name tree, annotation
+ * additional actions or annotation JavaScript action, a PDF with no
+ * pages, or a PDF that exceeds the request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -141,10 +143,42 @@ private static boolean containsProhibitedActiveContent(PDDocument document) {
}
for (PDPage page : document.getPages()) {
- if (page.getCOSObject().getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ if (pageContainsProhibitedActiveContent(page)) {
return true;
}
}
return false;
}
+
+ private static boolean pageContainsProhibitedActiveContent(PDPage page) {
+ COSDictionary pageDictionary = page.getCOSObject();
+ if (pageDictionary.getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ return true;
+ }
+
+ COSBase annotationsBase = pageDictionary.getDictionaryObject(COSName.getPDFName("Annots"));
+ if (!(annotationsBase instanceof COSArray annotations)) {
+ return false;
+ }
+ for (int index = 0; index < annotations.size(); index++) {
+ COSBase annotationBase = annotations.getObject(index);
+ if (annotationBase instanceof COSDictionary annotation
+ && annotationContainsProhibitedActiveContent(annotation)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean annotationContainsProhibitedActiveContent(COSDictionary annotation) {
+ if (annotation.getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ return true;
+ }
+ COSBase actionBase = annotation.getDictionaryObject(COSName.getPDFName("A"));
+ if (!(actionBase instanceof COSDictionary action)) {
+ return false;
+ }
+ COSBase actionType = action.getDictionaryObject(COSName.getPDFName("S"));
+ return COSName.getPDFName("JavaScript").equals(actionType);
+ }
}
From 7d8b59712d9468536ade31774adc9f2becc6241e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:12:20 +0900
Subject: [PATCH 069/219] test(conversion): preserve inert PDF hyperlinks
---
...ficeConversionActiveContentPolicyTest.java | 59 +++++++++++++++++--
1 file changed, 53 insertions(+), 6 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 23c4fd61..403ba4be 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -64,12 +64,28 @@ void adapterRejectsPageAdditionalActions() throws IOException {
@Test
void adapterRejectsAnnotationJavaScriptAction() throws IOException {
- OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationJavaScriptAction());
+ OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAction(javascriptAction()));
assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsAnnotationAdditionalActions() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAdditionalActions());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ @Test
+ void adapterPreservesBenignAnnotationUriAction() throws IOException {
+ byte[] pdf = pdfWithAnnotationAction(uriAction());
+ OfficeConversionAdapter adapter = adapterReturning(pdf);
+
+ assertDoesNotThrow(() -> adapter.convert(request()));
+ }
+
@Test
void adapterAcceptsBenignEmptyDocumentNameDictionary() throws IOException {
byte[] pdf = pdfWithEmptyNameDictionary();
@@ -188,15 +204,13 @@ private static byte[] pdfWithPageAdditionalActions() throws IOException {
}
}
- private static byte[] pdfWithAnnotationJavaScriptAction() throws IOException {
+ private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDPage page = new PDPage();
- COSDictionary annotation = new COSDictionary();
- annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
- annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
- annotation.setItem(COSName.getPDFName("A"), javascriptAction());
+ COSDictionary annotation = linkAnnotation();
+ annotation.setItem(COSName.getPDFName("A"), action);
COSArray annotations = new COSArray();
annotations.add(annotation);
@@ -207,6 +221,39 @@ private static byte[] pdfWithAnnotationJavaScriptAction() throws IOException {
}
}
+ private static byte[] pdfWithAnnotationAdditionalActions() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ PDPage page = new PDPage();
+
+ COSDictionary additionalActions = new COSDictionary();
+ additionalActions.setItem(COSName.getPDFName("E"), javascriptAction());
+ COSDictionary annotation = linkAnnotation();
+ annotation.setItem(COSName.getPDFName("AA"), additionalActions);
+
+ COSArray annotations = new COSArray();
+ annotations.add(annotation);
+ page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations);
+ document.addPage(page);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static COSDictionary linkAnnotation() {
+ COSDictionary annotation = new COSDictionary();
+ annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
+ annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
+ return annotation;
+ }
+
+ private static COSDictionary uriAction() {
+ COSDictionary uriAction = new COSDictionary();
+ uriAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI"));
+ uriAction.setString(COSName.getPDFName("URI"), "https://example.invalid/clearfolio");
+ return uriAction;
+ }
+
private static byte[] pdfWithEmptyNameDictionary() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From dc1498dd14f992ba5fef285957efbff8b961a124 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:13:34 +0900
Subject: [PATCH 070/219] test(conversion): reject PDF associated files
---
...ficeConversionActiveContentPolicyTest.java | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 403ba4be..0bdedb72 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -46,6 +46,14 @@ void adapterRejectsEmbeddedFileNameTreeWithoutExecutableAction() throws IOExcept
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsCatalogAssociatedFiles() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAssociatedFiles());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterRejectsCatalogAdditionalActions() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAdditionalActions());
@@ -178,6 +186,28 @@ private static byte[] pdfWithEmbeddedFilesNameTree() throws IOException {
}
}
+ private static byte[] pdfWithCatalogAssociatedFiles() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+
+ COSDictionary fileSpecification = new COSDictionary();
+ fileSpecification.setItem(COSName.TYPE, COSName.getPDFName("Filespec"));
+ fileSpecification.setString(COSName.getPDFName("F"), "attachment.txt");
+ fileSpecification.setItem(
+ COSName.getPDFName("AFRelationship"),
+ COSName.getPDFName("Data")
+ );
+ COSArray associatedFiles = new COSArray();
+ associatedFiles.add(fileSpecification);
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("AF"), associatedFiles);
+
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] pdfWithCatalogAdditionalActions() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From dccc9630da3d2a8e05608d9bd41e9dada2da176c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:16:00 +0900
Subject: [PATCH 071/219] fix(conversion): reject PDF associated files
---
.../viewer/conversion/OfficeConversionAdapter.java | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index bc5d5e6b..41fd0d4f 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -42,9 +42,10 @@ public interface OfficeConversionAdapter {
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
* document-open action, catalog/page additional-actions dictionary,
- * document JavaScript/embedded-file name tree, annotation
- * additional actions or annotation JavaScript action, a PDF with no
- * pages, or a PDF that exceeds the request-bound page ceiling
+ * catalog associated files, document JavaScript/embedded-file name
+ * tree, annotation additional actions or annotation JavaScript
+ * action, a PDF with no pages, or a PDF that exceeds the
+ * request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeConversionResult result = performConversion(request);
@@ -131,7 +132,8 @@ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
private static boolean containsProhibitedActiveContent(PDDocument document) {
COSDictionary catalog = document.getDocumentCatalog().getCOSObject();
if (catalog.getDictionaryObject(COSName.getPDFName("OpenAction")) != null
- || catalog.getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ || catalog.getDictionaryObject(COSName.getPDFName("AA")) != null
+ || catalog.getDictionaryObject(COSName.getPDFName("AF")) != null) {
return true;
}
From 977fad832718853967ab7d1cdbdcab4ccbbd1ee8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:18:51 +0900
Subject: [PATCH 072/219] test(conversion): reject PDF launch actions
---
.../OfficeConversionActiveContentPolicyTest.java | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 0bdedb72..60c379b9 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -78,6 +78,14 @@ void adapterRejectsAnnotationJavaScriptAction() throws IOException {
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsAnnotationLaunchAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAction(launchAction()));
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterRejectsAnnotationAdditionalActions() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAdditionalActions());
@@ -284,6 +292,13 @@ private static COSDictionary uriAction() {
return uriAction;
}
+ private static COSDictionary launchAction() {
+ COSDictionary launchAction = new COSDictionary();
+ launchAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("Launch"));
+ launchAction.setString(COSName.getPDFName("F"), "clearfolio-helper.exe");
+ return launchAction;
+ }
+
private static byte[] pdfWithEmptyNameDictionary() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From bc262752f7cc3a06a02b84ebd3e07db216812a8e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 05:21:13 +0900
Subject: [PATCH 073/219] fix(conversion): reject PDF launch actions
---
.../viewer/conversion/OfficeConversionAdapter.java | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 41fd0d4f..b5ff1553 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -43,8 +43,8 @@ public interface OfficeConversionAdapter {
* oversized candidate, a malformed or encrypted PDF, a PDF with a
* document-open action, catalog/page additional-actions dictionary,
* catalog associated files, document JavaScript/embedded-file name
- * tree, annotation additional actions or annotation JavaScript
- * action, a PDF with no pages, or a PDF that exceeds the
+ * tree, annotation additional actions, annotation JavaScript or
+ * launch actions, a PDF with no pages, or a PDF that exceeds the
* request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
@@ -181,6 +181,7 @@ private static boolean annotationContainsProhibitedActiveContent(COSDictionary a
return false;
}
COSBase actionType = action.getDictionaryObject(COSName.getPDFName("S"));
- return COSName.getPDFName("JavaScript").equals(actionType);
+ return COSName.getPDFName("JavaScript").equals(actionType)
+ || COSName.getPDFName("Launch").equals(actionType);
}
}
From e7a88eb61bc1038db26ae35ed068be3c1726ece6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 06:03:36 +0900
Subject: [PATCH 074/219] test(conversion): classify benign and active PDF
actions
---
...ConversionPdfActionClassificationTest.java | 183 ++++++++++++++++++
1 file changed, 183 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
new file mode 100644
index 00000000..57fef1fd
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
@@ -0,0 +1,183 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Behavior-level PDF action-policy regressions for converter output.
+ *
+ * Network-independent conversion forbids dereferencing remote resources during
+ * conversion, but it does not make inert navigation metadata executable. The
+ * publication boundary therefore preserves benign internal navigation and
+ * user-activated URI links while rejecting executable, automatic, malformed,
+ * chained-active, and unknown action behavior.
+ */
+class OfficeConversionPdfActionClassificationTest {
+
+ @Test
+ void adapterPreservesInternalDocumentGoToOpenAction() throws IOException {
+ byte[] pdf = pdfWithDocumentOpenAction(goToAction());
+
+ assertDoesNotThrow(() -> adapterReturning(pdf).convert(request()));
+ }
+
+ @Test
+ void adapterPreservesInternalPageAdditionalGoToAction() throws IOException {
+ byte[] pdf = pdfWithPageAdditionalAction(goToAction());
+
+ assertDoesNotThrow(() -> adapterReturning(pdf).convert(request()));
+ }
+
+ @Test
+ void adapterPreservesInternalAnnotationAdditionalGoToAction() throws IOException {
+ byte[] pdf = pdfWithAnnotationAdditionalAction(goToAction());
+
+ assertDoesNotThrow(() -> adapterReturning(pdf).convert(request()));
+ }
+
+ @Test
+ void adapterRejectsAnnotationSubmitFormAction() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationAction(action("SubmitForm")));
+ }
+
+ @Test
+ void adapterRejectsAnnotationImportDataAction() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationAction(action("ImportData")));
+ }
+
+ @Test
+ void adapterRejectsUnknownAnnotationActionType() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationAction(action("ClearfolioUnknown")));
+ }
+
+ @Test
+ void adapterRejectsBenignPrimaryActionChainedToJavaScript() throws IOException {
+ COSDictionary chainedAction = goToAction();
+ chainedAction.setItem(COSName.getPDFName("Next"), action("JavaScript"));
+
+ assertPolicyDenied(pdfWithAnnotationAction(chainedAction));
+ }
+
+ private static void assertPolicyDenied(byte[] pdf) {
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapterReturning(pdf).convert(request())
+ );
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ private static OfficeConversionAdapter adapterReturning(byte[] pdf) {
+ return input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+ }
+
+ private static OfficeConversionRequest request() {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"),
+ 9L,
+ "docx",
+ "policy-v1",
+ "trace-action-policy",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] pdfWithDocumentOpenAction(COSDictionary action) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("OpenAction"), action);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static byte[] pdfWithPageAdditionalAction(COSDictionary action) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ COSDictionary additionalActions = new COSDictionary();
+ additionalActions.setItem(COSName.getPDFName("O"), action);
+ document.getPage(0).getCOSObject()
+ .setItem(COSName.getPDFName("AA"), additionalActions);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static byte[] pdfWithAnnotationAdditionalAction(COSDictionary action) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ COSDictionary additionalActions = new COSDictionary();
+ additionalActions.setItem(COSName.getPDFName("E"), action);
+ COSDictionary annotation = linkAnnotation();
+ annotation.setItem(COSName.getPDFName("AA"), additionalActions);
+ attachAnnotation(document.getPage(0), annotation);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ COSDictionary annotation = linkAnnotation();
+ annotation.setItem(COSName.getPDFName("A"), action);
+ attachAnnotation(document.getPage(0), annotation);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static PDDocument onePageDocument() {
+ PDDocument document = new PDDocument();
+ document.addPage(new PDPage());
+ return document;
+ }
+
+ private static void attachAnnotation(PDPage page, COSDictionary annotation) {
+ COSArray annotations = new COSArray();
+ annotations.add(annotation);
+ page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations);
+ }
+
+ private static COSDictionary linkAnnotation() {
+ COSDictionary annotation = new COSDictionary();
+ annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
+ annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
+ return annotation;
+ }
+
+ private static COSDictionary goToAction() {
+ COSDictionary action = action("GoTo");
+ action.setItem(COSName.getPDFName("D"), COSName.getPDFName("section-one"));
+ return action;
+ }
+
+ private static COSDictionary action(String actionType) {
+ COSDictionary action = new COSDictionary();
+ action.setItem(COSName.getPDFName("S"), COSName.getPDFName(actionType));
+ return action;
+ }
+}
From 79c65ce87f165b1837ba397142af4885e90bd151 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 06:09:09 +0900
Subject: [PATCH 075/219] fix(conversion): classify PDF actions by behavior
---
.../conversion/OfficeConversionAdapter.java | 110 ++++++++++++++++--
1 file changed, 98 insertions(+), 12 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index b5ff1553..0c039534 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -1,12 +1,16 @@
package com.clearfolio.viewer.conversion;
import java.io.IOException;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.Set;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -21,6 +25,8 @@
@FunctionalInterface
public interface OfficeConversionAdapter {
+ int MAX_ACTION_CHAIN_DEPTH = 32;
+
/**
* Converts one immutable Office request and verifies that the result is
* present, source-bound, tied to the exact qualified adapter/runtime,
@@ -41,10 +47,9 @@ public interface OfficeConversionAdapter {
* @throws OfficeConversionException when the provider returns no result,
* mismatched provenance, an unexpected adapter id/version, an
* oversized candidate, a malformed or encrypted PDF, a PDF with a
- * document-open action, catalog/page additional-actions dictionary,
- * catalog associated files, document JavaScript/embedded-file name
- * tree, annotation additional actions, annotation JavaScript or
- * launch actions, a PDF with no pages, or a PDF that exceeds the
+ * prohibited automatic or executable action, catalog associated
+ * files, document JavaScript/embedded-file name trees, a prohibited
+ * annotation action, a PDF with no pages, or a PDF that exceeds the
* request-bound page ceiling
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
@@ -131,9 +136,15 @@ private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) {
private static boolean containsProhibitedActiveContent(PDDocument document) {
COSDictionary catalog = document.getDocumentCatalog().getCOSObject();
- if (catalog.getDictionaryObject(COSName.getPDFName("OpenAction")) != null
- || catalog.getDictionaryObject(COSName.getPDFName("AA")) != null
- || catalog.getDictionaryObject(COSName.getPDFName("AF")) != null) {
+ COSBase openAction = catalog.getDictionaryObject(COSName.getPDFName("OpenAction"));
+ if (openAction != null && isProhibitedOpenAction(openAction)) {
+ return true;
+ }
+ if (containsProhibitedAdditionalActions(
+ catalog.getDictionaryObject(COSName.getPDFName("AA")))) {
+ return true;
+ }
+ if (catalog.getDictionaryObject(COSName.getPDFName("AF")) != null) {
return true;
}
@@ -152,9 +163,23 @@ private static boolean containsProhibitedActiveContent(PDDocument document) {
return false;
}
+ private static boolean isProhibitedOpenAction(COSBase openAction) {
+ if (isInternalDestination(openAction)) {
+ return false;
+ }
+ return isProhibitedAction(openAction, false, newIdentitySet(), 0);
+ }
+
+ private static boolean isInternalDestination(COSBase destination) {
+ return destination instanceof COSArray
+ || destination instanceof COSName
+ || destination instanceof COSString;
+ }
+
private static boolean pageContainsProhibitedActiveContent(PDPage page) {
COSDictionary pageDictionary = page.getCOSObject();
- if (pageDictionary.getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ if (containsProhibitedAdditionalActions(
+ pageDictionary.getDictionaryObject(COSName.getPDFName("AA")))) {
return true;
}
@@ -173,15 +198,76 @@ && annotationContainsProhibitedActiveContent(annotation)) {
}
private static boolean annotationContainsProhibitedActiveContent(COSDictionary annotation) {
- if (annotation.getDictionaryObject(COSName.getPDFName("AA")) != null) {
+ if (containsProhibitedAdditionalActions(
+ annotation.getDictionaryObject(COSName.getPDFName("AA")))) {
return true;
}
COSBase actionBase = annotation.getDictionaryObject(COSName.getPDFName("A"));
- if (!(actionBase instanceof COSDictionary action)) {
+ if (actionBase == null) {
+ return false;
+ }
+ return isProhibitedAction(actionBase, true, newIdentitySet(), 0);
+ }
+
+ private static boolean containsProhibitedAdditionalActions(COSBase additionalActionsBase) {
+ if (additionalActionsBase == null) {
return false;
}
+ if (!(additionalActionsBase instanceof COSDictionary additionalActions)) {
+ return true;
+ }
+ for (COSName trigger : additionalActions.keySet()) {
+ COSBase action = additionalActions.getDictionaryObject(trigger);
+ if (isProhibitedAction(action, false, newIdentitySet(), 0)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isProhibitedAction(
+ COSBase actionBase,
+ boolean allowUri,
+ Set visited,
+ int depth
+ ) {
+ if (!(actionBase instanceof COSDictionary action)
+ || depth >= MAX_ACTION_CHAIN_DEPTH
+ || !visited.add(action)) {
+ return true;
+ }
+
COSBase actionType = action.getDictionaryObject(COSName.getPDFName("S"));
- return COSName.getPDFName("JavaScript").equals(actionType)
- || COSName.getPDFName("Launch").equals(actionType);
+ boolean allowedType = COSName.getPDFName("GoTo").equals(actionType)
+ || (allowUri && COSName.getPDFName("URI").equals(actionType));
+ if (!allowedType) {
+ return true;
+ }
+ if (COSName.getPDFName("GoTo").equals(actionType)
+ && action.getDictionaryObject(COSName.getPDFName("D")) == null) {
+ return true;
+ }
+ if (COSName.getPDFName("URI").equals(actionType)
+ && !(action.getDictionaryObject(COSName.getPDFName("URI")) instanceof COSString)) {
+ return true;
+ }
+
+ COSBase next = action.getDictionaryObject(COSName.getPDFName("Next"));
+ if (next == null) {
+ return false;
+ }
+ if (next instanceof COSArray chainedActions) {
+ for (int index = 0; index < chainedActions.size(); index++) {
+ if (isProhibitedAction(chainedActions.getObject(index), allowUri, visited, depth + 1)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return isProhibitedAction(next, allowUri, visited, depth + 1);
+ }
+
+ private static Set newIdentitySet() {
+ return Collections.newSetFromMap(new IdentityHashMap<>());
}
}
From 0f34c36a3093d905682fab0503a4da9a8442b3c2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 06:15:39 +0900
Subject: [PATCH 076/219] test(conversion): cover PDF action boundary edge
cases
---
.../OfficeConversionActionBoundaryTest.java | 217 ++++++++++++++++++
1 file changed, 217 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
new file mode 100644
index 00000000..39e87014
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
@@ -0,0 +1,217 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSString;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Edge-case regressions for the fail-closed PDF action publication boundary.
+ */
+class OfficeConversionActionBoundaryTest {
+
+ @Test
+ void preservesNamedDocumentDestination() throws IOException {
+ assertDoesNotThrow(() -> convert(pdfWithOpenAction(COSName.getPDFName("section-one"))));
+ }
+
+ @Test
+ void preservesStringDocumentDestination() throws IOException {
+ assertDoesNotThrow(() -> convert(pdfWithOpenAction(new COSString("section-one"))));
+ }
+
+ @Test
+ void preservesBenignChainedGoToDictionary() throws IOException {
+ COSDictionary primary = goToAction();
+ primary.setItem(COSName.getPDFName("Next"), goToAction());
+
+ assertDoesNotThrow(() -> convert(pdfWithAnnotationAction(primary)));
+ }
+
+ @Test
+ void preservesBenignChainedGoToArray() throws IOException {
+ COSArray next = new COSArray();
+ next.add(goToAction());
+ next.add(goToAction());
+ COSDictionary primary = goToAction();
+ primary.setItem(COSName.getPDFName("Next"), next);
+
+ assertDoesNotThrow(() -> convert(pdfWithAnnotationAction(primary)));
+ }
+
+ @Test
+ void rejectsActionWithoutType() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationAction(new COSDictionary()));
+ }
+
+ @Test
+ void rejectsGoToWithoutDestination() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationAction(action("GoTo")));
+ }
+
+ @Test
+ void rejectsUriWithoutStringTarget() throws IOException {
+ COSDictionary uri = action("URI");
+ uri.setItem(COSName.getPDFName("URI"), COSName.getPDFName("not-a-string"));
+
+ assertPolicyDenied(pdfWithAnnotationAction(uri));
+ }
+
+ @Test
+ void rejectsUriWhenConfiguredAsAutomaticPageAction() throws IOException {
+ assertPolicyDenied(pdfWithPageAdditionalAction(uriAction()));
+ }
+
+ @Test
+ void rejectsMalformedAdditionalActionContainer() throws IOException {
+ assertPolicyDenied(pdfWithPageAdditionalActions(new COSString("not-an-action-dictionary")));
+ }
+
+ @Test
+ void rejectsMalformedNextActionValue() throws IOException {
+ COSDictionary primary = goToAction();
+ primary.setItem(COSName.getPDFName("Next"), new COSString("not-an-action"));
+
+ assertPolicyDenied(pdfWithAnnotationAction(primary));
+ }
+
+ @Test
+ void rejectsChainedArrayContainingProhibitedAction() throws IOException {
+ COSArray next = new COSArray();
+ next.add(goToAction());
+ next.add(action("SubmitForm"));
+ COSDictionary primary = goToAction();
+ primary.setItem(COSName.getPDFName("Next"), next);
+
+ assertPolicyDenied(pdfWithAnnotationAction(primary));
+ }
+
+ @Test
+ void rejectsActionChainBeyondPublicationDepthLimit() throws IOException {
+ COSDictionary primary = goToAction();
+ COSDictionary cursor = primary;
+ for (int index = 1; index < 33; index++) {
+ COSDictionary next = goToAction();
+ cursor.setItem(COSName.getPDFName("Next"), next);
+ cursor = next;
+ }
+
+ assertPolicyDenied(pdfWithAnnotationAction(primary));
+ }
+
+ private static void convert(byte[] pdf) {
+ adapterReturning(pdf).convert(request());
+ }
+
+ private static void assertPolicyDenied(byte[] pdf) {
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> convert(pdf)
+ );
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ private static OfficeConversionAdapter adapterReturning(byte[] pdf) {
+ return input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+ }
+
+ private static OfficeConversionRequest request() {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"),
+ 9L,
+ "docx",
+ "policy-v1",
+ "trace-action-boundary",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] pdfWithOpenAction(COSBase openAction) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("OpenAction"), openAction);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ COSDictionary annotation = new COSDictionary();
+ annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
+ annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
+ annotation.setItem(COSName.getPDFName("A"), action);
+ COSArray annotations = new COSArray();
+ annotations.add(annotation);
+ document.getPage(0).getCOSObject()
+ .setItem(COSName.getPDFName("Annots"), annotations);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static byte[] pdfWithPageAdditionalAction(COSDictionary action) throws IOException {
+ COSDictionary additionalActions = new COSDictionary();
+ additionalActions.setItem(COSName.getPDFName("O"), action);
+ return pdfWithPageAdditionalActions(additionalActions);
+ }
+
+ private static byte[] pdfWithPageAdditionalActions(COSBase additionalActions) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.getPage(0).getCOSObject()
+ .setItem(COSName.getPDFName("AA"), additionalActions);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
+ private static PDDocument onePageDocument() {
+ PDDocument document = new PDDocument();
+ document.addPage(new PDPage());
+ return document;
+ }
+
+ private static COSDictionary goToAction() {
+ COSDictionary action = action("GoTo");
+ action.setItem(COSName.getPDFName("D"), COSName.getPDFName("section-one"));
+ return action;
+ }
+
+ private static COSDictionary uriAction() {
+ COSDictionary action = action("URI");
+ action.setString(COSName.getPDFName("URI"), "https://example.invalid/clearfolio");
+ return action;
+ }
+
+ private static COSDictionary action(String actionType) {
+ COSDictionary action = new COSDictionary();
+ action.setItem(COSName.getPDFName("S"), COSName.getPDFName(actionType));
+ return action;
+ }
+}
From 8fdbf3be1b7ca57779faf7317c7c2af9a267798b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 06:24:04 +0900
Subject: [PATCH 077/219] test(conversion): reject unsafe PDF URI action
schemes
---
.../OfficeConversionUriActionPolicyTest.java | 134 ++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java
new file mode 100644
index 00000000..08df549f
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java
@@ -0,0 +1,134 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * URI-scheme regressions for user-activated PDF link preservation.
+ *
+ * Ordinary web and mail hyperlinks are inert navigation metadata at the
+ * conversion boundary. Executable, local-file, embedded-data, malformed, and
+ * custom-protocol URI actions fail closed because a PDF viewer may dispatch
+ * those schemes to behavior outside ordinary hyperlink navigation.
+ */
+class OfficeConversionUriActionPolicyTest {
+
+ @Test
+ void adapterPreservesHttpsAnnotationUri() throws IOException {
+ assertDoesNotThrow(() -> convert(pdfWithUri("https://example.invalid/report")));
+ }
+
+ @Test
+ void adapterPreservesHttpAnnotationUri() throws IOException {
+ assertDoesNotThrow(() -> convert(pdfWithUri("http://example.invalid/report")));
+ }
+
+ @Test
+ void adapterPreservesMailtoAnnotationUri() throws IOException {
+ assertDoesNotThrow(() -> convert(pdfWithUri("mailto:security@example.invalid")));
+ }
+
+ @Test
+ void adapterRejectsJavaScriptUriScheme() throws IOException {
+ assertPolicyDenied(pdfWithUri("javascript:alert(1)"));
+ }
+
+ @Test
+ void adapterRejectsLocalFileUriScheme() throws IOException {
+ assertPolicyDenied(pdfWithUri("file:///etc/passwd"));
+ }
+
+ @Test
+ void adapterRejectsEmbeddedDataUriScheme() throws IOException {
+ assertPolicyDenied(pdfWithUri("data:text/html,%3Cscript%3Ealert(1)%3C/script%3E"));
+ }
+
+ @Test
+ void adapterRejectsUnknownCustomUriScheme() throws IOException {
+ assertPolicyDenied(pdfWithUri("clearfolio-custom:payload"));
+ }
+
+ @Test
+ void adapterRejectsRelativeUriAction() throws IOException {
+ assertPolicyDenied(pdfWithUri("relative/path"));
+ }
+
+ @Test
+ void adapterRejectsMalformedUriAction() throws IOException {
+ assertPolicyDenied(pdfWithUri("https://example.invalid/has space"));
+ }
+
+ private static void convert(byte[] pdf) {
+ adapterReturning(pdf).convert(request());
+ }
+
+ private static void assertPolicyDenied(byte[] pdf) {
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> convert(pdf)
+ );
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
+ private static OfficeConversionAdapter adapterReturning(byte[] pdf) {
+ return input -> new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ pdf
+ );
+ }
+
+ private static OfficeConversionRequest request() {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"),
+ 9L,
+ "docx",
+ "policy-v1",
+ "trace-uri-action-policy",
+ "fixture-source".getBytes(StandardCharsets.UTF_8),
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] pdfWithUri(String uri) throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ PDPage page = new PDPage();
+ document.addPage(page);
+
+ COSDictionary action = new COSDictionary();
+ action.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI"));
+ action.setString(COSName.getPDFName("URI"), uri);
+
+ COSDictionary annotation = new COSDictionary();
+ annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
+ annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
+ annotation.setItem(COSName.getPDFName("A"), action);
+
+ COSArray annotations = new COSArray();
+ annotations.add(annotation);
+ page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations);
+
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+}
From e5680a07939290a829a3fce30498050d339eedac Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:06:54 +0900
Subject: [PATCH 078/219] fix(conversion): validate URI annotation schemes
---
.../conversion/OfficeConversionAdapter.java | 21 ++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 0c039534..7fbc48ba 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -1,6 +1,8 @@
package com.clearfolio.viewer.conversion;
import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
@@ -248,7 +250,7 @@ private static boolean isProhibitedAction(
return true;
}
if (COSName.getPDFName("URI").equals(actionType)
- && !(action.getDictionaryObject(COSName.getPDFName("URI")) instanceof COSString)) {
+ && !isAllowedUriAction(action)) {
return true;
}
@@ -267,6 +269,23 @@ private static boolean isProhibitedAction(
return isProhibitedAction(next, allowUri, visited, depth + 1);
}
+ private static boolean isAllowedUriAction(COSDictionary action) {
+ COSBase uriBase = action.getDictionaryObject(COSName.getPDFName("URI"));
+ if (!(uriBase instanceof COSString uriString)) {
+ return false;
+ }
+ try {
+ URI uri = new URI(uriString.getString());
+ String scheme = uri.getScheme();
+ return scheme != null
+ && ("http".equalsIgnoreCase(scheme)
+ || "https".equalsIgnoreCase(scheme)
+ || "mailto".equalsIgnoreCase(scheme));
+ } catch (URISyntaxException ex) {
+ return false;
+ }
+ }
+
private static Set newIdentitySet() {
return Collections.newSetFromMap(new IdentityHashMap<>());
}
From 370830d8ac6b7a6218d799b753f603fdc6075f08 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:11:07 +0900
Subject: [PATCH 079/219] test(conversion): reject malformed annotation
containers
---
.../OfficeConversionActionBoundaryTest.java | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
index 39e87014..a485fc30 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
@@ -80,6 +80,11 @@ void rejectsMalformedAdditionalActionContainer() throws IOException {
assertPolicyDenied(pdfWithPageAdditionalActions(new COSString("not-an-action-dictionary")));
}
+ @Test
+ void rejectsMalformedAnnotationContainer() throws IOException {
+ assertPolicyDenied(pdfWithMalformedAnnotations(new COSString("not-an-annotation-array")));
+ }
+
@Test
void rejectsMalformedNextActionValue() throws IOException {
COSDictionary primary = goToAction();
@@ -191,6 +196,16 @@ private static byte[] pdfWithPageAdditionalActions(COSBase additionalActions) th
}
}
+ private static byte[] pdfWithMalformedAnnotations(COSBase annotations) throws IOException {
+ try (PDDocument document = onePageDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.getPage(0).getCOSObject()
+ .setItem(COSName.getPDFName("Annots"), annotations);
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static PDDocument onePageDocument() {
PDDocument document = new PDDocument();
document.addPage(new PDPage());
From 778f3c72b6cb24d3b6ec3dcd13d9589f420be969 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:14:12 +0900
Subject: [PATCH 080/219] fix(conversion): fail closed on malformed annotations
---
.../viewer/conversion/OfficeConversionAdapter.java | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 7fbc48ba..d5fba943 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -186,9 +186,12 @@ private static boolean pageContainsProhibitedActiveContent(PDPage page) {
}
COSBase annotationsBase = pageDictionary.getDictionaryObject(COSName.getPDFName("Annots"));
- if (!(annotationsBase instanceof COSArray annotations)) {
+ if (annotationsBase == null) {
return false;
}
+ if (!(annotationsBase instanceof COSArray annotations)) {
+ return true;
+ }
for (int index = 0; index < annotations.size(); index++) {
COSBase annotationBase = annotations.getObject(index);
if (annotationBase instanceof COSDictionary annotation
From 47ae88e2f6bfbdc054f42217cf1809d312271860 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:16:13 +0900
Subject: [PATCH 081/219] test(conversion): reject malformed document names
---
...ficeConversionActiveContentPolicyTest.java | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 60c379b9..eae82e1f 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -46,6 +46,14 @@ void adapterRejectsEmbeddedFileNameTreeWithoutExecutableAction() throws IOExcept
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsMalformedDocumentNameContainer() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithMalformedNameContainer());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterRejectsCatalogAssociatedFiles() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAssociatedFiles());
@@ -194,6 +202,17 @@ private static byte[] pdfWithEmbeddedFilesNameTree() throws IOException {
}
}
+ private static byte[] pdfWithMalformedNameContainer() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("Names"), new COSString("not-a-name-dictionary"));
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] pdfWithCatalogAssociatedFiles() throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From 6c6c6ef950604e91c9d18e29d08bf8ada5c91841 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:19:13 +0900
Subject: [PATCH 082/219] fix(conversion): fail closed on malformed document
names
---
.../viewer/conversion/OfficeConversionAdapter.java | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index d5fba943..e879b786 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -151,10 +151,14 @@ private static boolean containsProhibitedActiveContent(PDDocument document) {
}
COSBase namesBase = catalog.getDictionaryObject(COSName.getPDFName("Names"));
- if (namesBase instanceof COSDictionary names
- && (names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null
- || names.getDictionaryObject(COSName.getPDFName("EmbeddedFiles")) != null)) {
- return true;
+ if (namesBase != null) {
+ if (!(namesBase instanceof COSDictionary names)) {
+ return true;
+ }
+ if (names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null
+ || names.getDictionaryObject(COSName.getPDFName("EmbeddedFiles")) != null) {
+ return true;
+ }
}
for (PDPage page : document.getPages()) {
From 2b355d0303832f37995e7ea905da30a0aeb2515c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:22:10 +0900
Subject: [PATCH 083/219] test(conversion): reject malformed annotation entries
---
.../OfficeConversionActionBoundaryTest.java | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
index a485fc30..9f9bedda 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
@@ -85,6 +85,11 @@ void rejectsMalformedAnnotationContainer() throws IOException {
assertPolicyDenied(pdfWithMalformedAnnotations(new COSString("not-an-annotation-array")));
}
+ @Test
+ void rejectsMalformedAnnotationEntry() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationEntry(new COSString("not-an-annotation-dictionary")));
+ }
+
@Test
void rejectsMalformedNextActionValue() throws IOException {
COSDictionary primary = goToAction();
@@ -165,14 +170,18 @@ private static byte[] pdfWithOpenAction(COSBase openAction) throws IOException {
}
private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException {
+ COSDictionary annotation = new COSDictionary();
+ annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
+ annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
+ annotation.setItem(COSName.getPDFName("A"), action);
+ return pdfWithAnnotationEntry(annotation);
+ }
+
+ private static byte[] pdfWithAnnotationEntry(COSBase annotationEntry) throws IOException {
try (PDDocument document = onePageDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
- COSDictionary annotation = new COSDictionary();
- annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot"));
- annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link"));
- annotation.setItem(COSName.getPDFName("A"), action);
COSArray annotations = new COSArray();
- annotations.add(annotation);
+ annotations.add(annotationEntry);
document.getPage(0).getCOSObject()
.setItem(COSName.getPDFName("Annots"), annotations);
document.save(output);
From 9882fa2aa96132901f624283273a63fc8cb9447f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:24:08 +0900
Subject: [PATCH 084/219] fix(conversion): fail closed on malformed annotation
entries
---
.../viewer/conversion/OfficeConversionAdapter.java | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index e879b786..3eeeb7b1 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -198,8 +198,10 @@ private static boolean pageContainsProhibitedActiveContent(PDPage page) {
}
for (int index = 0; index < annotations.size(); index++) {
COSBase annotationBase = annotations.getObject(index);
- if (annotationBase instanceof COSDictionary annotation
- && annotationContainsProhibitedActiveContent(annotation)) {
+ if (!(annotationBase instanceof COSDictionary annotation)) {
+ return true;
+ }
+ if (annotationContainsProhibitedActiveContent(annotation)) {
return true;
}
}
From 17f77ab16f1eec57035413719b885f5a50141718 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:33:09 +0900
Subject: [PATCH 085/219] test(conversion): reject automatic GoTo additional
actions
---
...ficeConversionActiveContentPolicyTest.java | 61 ++++++++++++++++++-
1 file changed, 58 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index eae82e1f..ec3cfec7 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -70,6 +70,14 @@ void adapterRejectsCatalogAdditionalActions() throws IOException {
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsCatalogAutomaticGoToAdditionalAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAutomaticGoToAdditionalAction());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterRejectsPageAdditionalActions() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithPageAdditionalActions());
@@ -78,6 +86,14 @@ void adapterRejectsPageAdditionalActions() throws IOException {
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsPageAutomaticGoToAdditionalAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithPageAutomaticGoToAdditionalAction());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterRejectsAnnotationJavaScriptAction() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAction(javascriptAction()));
@@ -102,6 +118,14 @@ void adapterRejectsAnnotationAdditionalActions() throws IOException {
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterRejectsAnnotationAutomaticGoToAdditionalAction() throws IOException {
+ OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAutomaticGoToAdditionalAction());
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("conversion output contains prohibited active content", failure.getMessage());
+ }
+
@Test
void adapterPreservesBenignAnnotationUriAction() throws IOException {
byte[] pdf = pdfWithAnnotationAction(uriAction());
@@ -236,11 +260,19 @@ private static byte[] pdfWithCatalogAssociatedFiles() throws IOException {
}
private static byte[] pdfWithCatalogAdditionalActions() throws IOException {
+ return pdfWithCatalogAdditionalAction(javascriptAction());
+ }
+
+ private static byte[] pdfWithCatalogAutomaticGoToAdditionalAction() throws IOException {
+ return pdfWithCatalogAdditionalAction(goToAction());
+ }
+
+ private static byte[] pdfWithCatalogAdditionalAction(COSDictionary action) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
document.addPage(new PDPage());
COSDictionary additionalActions = new COSDictionary();
- additionalActions.setItem(COSName.getPDFName("WC"), javascriptAction());
+ additionalActions.setItem(COSName.getPDFName("WC"), action);
document.getDocumentCatalog().getCOSObject()
.setItem(COSName.getPDFName("AA"), additionalActions);
document.save(output);
@@ -249,11 +281,19 @@ private static byte[] pdfWithCatalogAdditionalActions() throws IOException {
}
private static byte[] pdfWithPageAdditionalActions() throws IOException {
+ return pdfWithPageAdditionalAction(javascriptAction());
+ }
+
+ private static byte[] pdfWithPageAutomaticGoToAdditionalAction() throws IOException {
+ return pdfWithPageAdditionalAction(goToAction());
+ }
+
+ private static byte[] pdfWithPageAdditionalAction(COSDictionary action) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDPage page = new PDPage();
COSDictionary additionalActions = new COSDictionary();
- additionalActions.setItem(COSName.getPDFName("O"), javascriptAction());
+ additionalActions.setItem(COSName.getPDFName("O"), action);
page.getCOSObject().setItem(COSName.getPDFName("AA"), additionalActions);
document.addPage(page);
document.save(output);
@@ -279,12 +319,20 @@ private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOExc
}
private static byte[] pdfWithAnnotationAdditionalActions() throws IOException {
+ return pdfWithAnnotationAdditionalAction(javascriptAction());
+ }
+
+ private static byte[] pdfWithAnnotationAutomaticGoToAdditionalAction() throws IOException {
+ return pdfWithAnnotationAdditionalAction(goToAction());
+ }
+
+ private static byte[] pdfWithAnnotationAdditionalAction(COSDictionary action) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDPage page = new PDPage();
COSDictionary additionalActions = new COSDictionary();
- additionalActions.setItem(COSName.getPDFName("E"), javascriptAction());
+ additionalActions.setItem(COSName.getPDFName("E"), action);
COSDictionary annotation = linkAnnotation();
annotation.setItem(COSName.getPDFName("AA"), additionalActions);
@@ -311,6 +359,13 @@ private static COSDictionary uriAction() {
return uriAction;
}
+ private static COSDictionary goToAction() {
+ COSDictionary goToAction = new COSDictionary();
+ goToAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("GoTo"));
+ goToAction.setString(COSName.getPDFName("D"), "destination-one");
+ return goToAction;
+ }
+
private static COSDictionary launchAction() {
COSDictionary launchAction = new COSDictionary();
launchAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("Launch"));
From 3f4ec7a1d8836910cddfa973fc75b1ccb791d9f0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:35:54 +0900
Subject: [PATCH 086/219] test(conversion): preserve empty additional-action
dictionaries
---
...ficeConversionActiveContentPolicyTest.java | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index ec3cfec7..26fe96de 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -78,6 +78,14 @@ void adapterRejectsCatalogAutomaticGoToAdditionalAction() throws IOException {
assertEquals("conversion output contains prohibited active content", failure.getMessage());
}
+ @Test
+ void adapterAcceptsEmptyCatalogAdditionalActionDictionary() throws IOException {
+ byte[] pdf = pdfWithEmptyCatalogAdditionalActions();
+ OfficeConversionAdapter adapter = adapterReturning(pdf);
+
+ assertDoesNotThrow(() -> adapter.convert(request()));
+ }
+
@Test
void adapterRejectsPageAdditionalActions() throws IOException {
OfficeConversionException failure = assertPolicyDenied(pdfWithPageAdditionalActions());
@@ -267,6 +275,17 @@ private static byte[] pdfWithCatalogAutomaticGoToAdditionalAction() throws IOExc
return pdfWithCatalogAdditionalAction(goToAction());
}
+ private static byte[] pdfWithEmptyCatalogAdditionalActions() throws IOException {
+ try (PDDocument document = new PDDocument();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.getDocumentCatalog().getCOSObject()
+ .setItem(COSName.getPDFName("AA"), new COSDictionary());
+ document.save(output);
+ return output.toByteArray();
+ }
+ }
+
private static byte[] pdfWithCatalogAdditionalAction(COSDictionary action) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
From be9dfb13bb992eade32866ddf16e872331a466c3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:36:29 +0900
Subject: [PATCH 087/219] fix(conversion): fail closed on automatic PDF actions
---
.../viewer/conversion/OfficeConversionAdapter.java | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 3eeeb7b1..7de01381 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -227,13 +227,13 @@ private static boolean containsProhibitedAdditionalActions(COSBase additionalAct
if (!(additionalActionsBase instanceof COSDictionary additionalActions)) {
return true;
}
- for (COSName trigger : additionalActions.keySet()) {
- COSBase action = additionalActions.getDictionaryObject(trigger);
- if (isProhibitedAction(action, false, newIdentitySet(), 0)) {
- return true;
- }
- }
- return false;
+
+ // PDF /AA entries are event-triggered actions rather than explicit user
+ // navigation. Preserve an empty dictionary for interoperability, but fail
+ // closed when any trigger is configured regardless of the nested action
+ // type. A benign direct /A GoTo may remain fidelity-preserving; an /AA
+ // GoTo can execute automatically on page/document/annotation events.
+ return !additionalActions.keySet().isEmpty();
}
private static boolean isProhibitedAction(
From 5e40b3d34df54589351e6dd7662c0c6198b32daf Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:42:49 +0900
Subject: [PATCH 088/219] test(conversion): align automatic action policy with
PDF triggers
---
...ConversionPdfActionClassificationTest.java | 22 +++++++++++--------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
index 57fef1fd..123c557c 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
@@ -20,10 +20,11 @@
* Behavior-level PDF action-policy regressions for converter output.
*
* Network-independent conversion forbids dereferencing remote resources during
- * conversion, but it does not make inert navigation metadata executable. The
- * publication boundary therefore preserves benign internal navigation and
- * user-activated URI links while rejecting executable, automatic, malformed,
- * chained-active, and unknown action behavior.
+ * conversion, but it does not make explicit user navigation metadata executable.
+ * The publication boundary therefore preserves direct internal navigation and
+ * approved user-activated URI links while rejecting event-triggered additional
+ * actions, executable behavior, malformed actions, chained-active actions, and
+ * unknown action behavior.
*/
class OfficeConversionPdfActionClassificationTest {
@@ -35,17 +36,20 @@ void adapterPreservesInternalDocumentGoToOpenAction() throws IOException {
}
@Test
- void adapterPreservesInternalPageAdditionalGoToAction() throws IOException {
- byte[] pdf = pdfWithPageAdditionalAction(goToAction());
+ void adapterPreservesExplicitInternalAnnotationGoToAction() throws IOException {
+ byte[] pdf = pdfWithAnnotationAction(goToAction());
assertDoesNotThrow(() -> adapterReturning(pdf).convert(request()));
}
@Test
- void adapterPreservesInternalAnnotationAdditionalGoToAction() throws IOException {
- byte[] pdf = pdfWithAnnotationAdditionalAction(goToAction());
+ void adapterRejectsInternalPageAdditionalGoToAction() throws IOException {
+ assertPolicyDenied(pdfWithPageAdditionalAction(goToAction()));
+ }
- assertDoesNotThrow(() -> adapterReturning(pdf).convert(request()));
+ @Test
+ void adapterRejectsInternalAnnotationAdditionalGoToAction() throws IOException {
+ assertPolicyDenied(pdfWithAnnotationAdditionalAction(goToAction()));
}
@Test
From d4bb8502115e6b860367b4895b021d2774f7da64 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:50:23 +0900
Subject: [PATCH 089/219] test(conversion): require source container preflight
---
.../OfficeSourceContainerPreflightTest.java | 121 ++++++++++++++++++
1 file changed, 121 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
new file mode 100644
index 00000000..7d4af5ac
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -0,0 +1,121 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Source-container regressions for the Office adapter trust boundary.
+ *
+ * These tests deliberately cover only the common pre-conversion authority:
+ * candidate format qualification and declared-format/container-signature
+ * agreement. They do not treat a matching ZIP or compound-file signature as a
+ * complete safety or fidelity qualification.
+ */
+class OfficeSourceContainerPreflightTest {
+
+ private static final byte[] ZIP_LOCAL_HEADER = new byte[] {
+ 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00
+ };
+ private static final byte[] COMPOUND_FILE_HEADER = new byte[] {
+ (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
+ (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
+ };
+
+ @Test
+ void adapterRejectsUnknownFormatBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request("pdf", "%PDF-1.7".getBytes(StandardCharsets.US_ASCII)))
+ );
+
+ assertEquals(OfficeConversionFailureCode.UNSUPPORTED_FORMAT, failure.failureCode());
+ assertEquals("source format is not an Office conversion candidate", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterRejectsZipFamilyWithCompoundFileSignatureBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request("docx", COMPOUND_FILE_HEADER))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source container signature does not match declared format", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterRejectsLegacyFamilyWithZipSignatureBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request("xls", ZIP_LOCAL_HEADER))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source container signature does not match declared format", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterInvokesProviderForQualifiedZipFamilySignature() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ adapter.convert(request("pptx", ZIP_LOCAL_HEADER));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ @Test
+ void adapterInvokesProviderForQualifiedLegacyCompoundFileSignature() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ adapter.convert(request("doc", COMPOUND_FILE_HEADER));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(String sourceFormat, byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"),
+ 9L,
+ sourceFormat,
+ "policy-v1",
+ "trace-source-preflight",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+}
From 11844d34050a9e89b5fb46796eb1a18025dc9f8c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:53:33 +0900
Subject: [PATCH 090/219] feat(conversion): validate Office source container
signatures
---
.../OfficeSourceContainerPreflight.java | 79 +++++++++++++++++++
1 file changed, 79 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
new file mode 100644
index 00000000..7d6f0fd3
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -0,0 +1,79 @@
+package com.clearfolio.viewer.conversion;
+
+import java.util.Set;
+
+/**
+ * Performs the format-neutral source-container checks shared by qualified Office converters.
+ *
+ * This preflight intentionally proves only two facts before untrusted bytes reach a
+ * sidecar or remote converter: the declared source format belongs to the current Office
+ * conversion candidate set, and the leading container signature matches that format
+ * family. A matching signature is not a complete structure, macro,
+ * embedded-object, archive-expansion, malware, or fidelity qualification. Those deeper
+ * controls remain separate sandbox/content-policy acceptance gates.
+ */
+final class OfficeSourceContainerPreflight {
+
+ private static final Set ZIP_PACKAGE_FORMATS = Set.of(
+ "docx", "xlsx", "pptx", "odt", "ods", "odp"
+ );
+ private static final Set COMPOUND_FILE_FORMATS = Set.of(
+ "doc", "xls", "ppt"
+ );
+ private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
+ 0x50, 0x4b, 0x03, 0x04
+ };
+ private static final byte[] COMPOUND_FILE_HEADER = new byte[] {
+ (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
+ (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
+ };
+
+ private OfficeSourceContainerPreflight() {
+ }
+
+ /**
+ * Rejects unknown candidate formats and obvious declared-format/container mismatches.
+ *
+ * @param request immutable conversion request containing declared format and source bytes
+ * @throws OfficeConversionException when the format is not a current candidate or the
+ * source does not begin with that format family's required container signature
+ */
+ static void requireQualifiedContainer(OfficeConversionRequest request) {
+ String sourceFormat = request.sourceFormat();
+ byte[] sourceBytes = request.sourceBytes();
+
+ if (ZIP_PACKAGE_FORMATS.contains(sourceFormat)) {
+ requireSignature(sourceBytes, ZIP_LOCAL_FILE_HEADER);
+ return;
+ }
+ if (COMPOUND_FILE_FORMATS.contains(sourceFormat)) {
+ requireSignature(sourceBytes, COMPOUND_FILE_HEADER);
+ return;
+ }
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.UNSUPPORTED_FORMAT,
+ "source format is not an Office conversion candidate"
+ );
+ }
+
+ private static void requireSignature(byte[] sourceBytes, byte[] expectedSignature) {
+ if (!startsWith(sourceBytes, expectedSignature)) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source container signature does not match declared format"
+ );
+ }
+ }
+
+ private static boolean startsWith(byte[] sourceBytes, byte[] expectedSignature) {
+ if (sourceBytes.length < expectedSignature.length) {
+ return false;
+ }
+ for (int index = 0; index < expectedSignature.length; index++) {
+ if (sourceBytes[index] != expectedSignature[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
From 443cf1181ba5cd0bb803f9b6b0def06bb7483f66 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 07:55:43 +0900
Subject: [PATCH 091/219] fix(conversion): gate providers on source container
preflight
---
.../conversion/OfficeConversionAdapter.java | 35 +++++++++----------
1 file changed, 16 insertions(+), 19 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index 7de01381..fde0bbc4 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -30,31 +30,28 @@ public interface OfficeConversionAdapter {
int MAX_ACTION_CHAIN_DEPTH = 32;
/**
- * Converts one immutable Office request and verifies that the result is
- * present, source-bound, tied to the exact qualified adapter/runtime,
- * request generation and policy, within request-bound byte and page
- * publication ceilings, and parseable as a non-empty, unencrypted PDF
- * without prohibited active content.
+ * Converts one immutable Office request and verifies its source and result.
*
- * This method is the public conversion authority. Implementations supply
- * only {@link #performConversion(OfficeConversionRequest)}; callers cannot
- * accidentally accept output for a different source, tenant, job, lifecycle
- * generation, adapter id/version, format, policy, correlation identity,
- * publication policy, or a truncated, empty, encrypted, actively executable,
- * embedded-file-bearing, or over-page-limit PDF container that is not
- * acceptable document output.
+ * Before provider invocation, Clearfolio requires the declared source
+ * format to be a current Office conversion candidate and requires its leading
+ * container signature to match the declared format family. This common
+ * preflight is intentionally narrower than complete archive, macro, OLE,
+ * malware, or fidelity qualification, which remain sandbox/content-policy
+ * responsibilities. After provider execution, the result must be present,
+ * source-bound, tied to the exact qualified adapter/runtime, request
+ * generation and policy, within request-bound byte/page publication ceilings,
+ * and parseable as a non-empty, unencrypted PDF without prohibited active
+ * content.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
- * @throws OfficeConversionException when the provider returns no result,
- * mismatched provenance, an unexpected adapter id/version, an
- * oversized candidate, a malformed or encrypted PDF, a PDF with a
- * prohibited automatic or executable action, catalog associated
- * files, document JavaScript/embedded-file name trees, a prohibited
- * annotation action, a PDF with no pages, or a PDF that exceeds the
- * request-bound page ceiling
+ * @throws OfficeConversionException when source preflight fails, the provider
+ * returns no result, provenance mismatches, adapter identity is
+ * unexpected, output exceeds limits, output is malformed/encrypted,
+ * prohibited active content is present, or page limits are exceeded
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
+ OfficeSourceContainerPreflight.requireQualifiedContainer(request);
OfficeConversionResult result = performConversion(request);
if (result == null) {
throw new OfficeConversionException(
From ac92f8a20cc68ba7b15c6eeef0982ea14c75d608 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:01:03 +0900
Subject: [PATCH 092/219] test(conversion): add signature-qualified Office
source fixtures
---
.../OfficeConversionTestSource.java | 80 +++++++++++++++++++
1 file changed, 80 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
new file mode 100644
index 00000000..f22e3257
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
@@ -0,0 +1,80 @@
+package com.clearfolio.viewer.conversion;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Creates deterministic test-only Office source bytes with a qualified container signature.
+ *
+ * The returned bytes are intentionally only sufficient for the common source-container
+ * signature preflight. They are not valid complete OOXML, ODF, or compound-file documents
+ * and must never be used as fidelity, archive-structure, macro, malware, or production
+ * converter fixtures. Real document-fidelity qualification uses separate authorized or
+ * redistributable Office fixtures.
+ */
+final class OfficeConversionTestSource {
+
+ private static final Set ZIP_PACKAGE_FORMATS = Set.of(
+ "docx", "xlsx", "pptx", "odt", "ods", "odp"
+ );
+ private static final Set COMPOUND_FILE_FORMATS = Set.of(
+ "doc", "xls", "ppt"
+ );
+ private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
+ 0x50, 0x4b, 0x03, 0x04
+ };
+ private static final byte[] COMPOUND_FILE_HEADER = new byte[] {
+ (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
+ (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
+ };
+
+ private OfficeConversionTestSource() {
+ }
+
+ /**
+ * Creates one signature-qualified source fixture for the declared candidate format.
+ *
+ * @param sourceFormat Office candidate format
+ * @param marker deterministic marker appended after the family signature
+ * @return test-only source bytes
+ */
+ static byte[] forFormat(String sourceFormat, String marker) {
+ String normalized = sourceFormat.strip().toLowerCase(Locale.ROOT);
+ byte[] payload = marker.getBytes(StandardCharsets.UTF_8);
+ if (ZIP_PACKAGE_FORMATS.contains(normalized)) {
+ return concatenate(ZIP_LOCAL_FILE_HEADER, payload);
+ }
+ if (COMPOUND_FILE_FORMATS.contains(normalized)) {
+ return concatenate(COMPOUND_FILE_HEADER, payload);
+ }
+ return payload;
+ }
+
+ /**
+ * Creates a signature-qualified ZIP-package source fixture.
+ *
+ * @param marker deterministic marker
+ * @return test-only ZIP-family source bytes
+ */
+ static byte[] zipPackage(String marker) {
+ return concatenate(ZIP_LOCAL_FILE_HEADER, marker.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Creates a signature-qualified compound-file source fixture.
+ *
+ * @param marker deterministic marker
+ * @return test-only legacy Office source bytes
+ */
+ static byte[] compoundFile(String marker) {
+ return concatenate(COMPOUND_FILE_HEADER, marker.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static byte[] concatenate(byte[] prefix, byte[] suffix) {
+ byte[] combined = new byte[prefix.length + suffix.length];
+ System.arraycopy(prefix, 0, combined, 0, prefix.length);
+ System.arraycopy(suffix, 0, combined, prefix.length, suffix.length);
+ return combined;
+ }
+}
From 0c47f26a15557b5c78e03379217f175beef21160 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:02:20 +0900
Subject: [PATCH 093/219] test(conversion): qualify action-policy source
fixture
---
.../viewer/conversion/OfficeConversionActionBoundaryTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
index 9f9bedda..eb69f0b4 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java
@@ -6,7 +6,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.apache.pdfbox.cos.COSArray;
@@ -153,7 +152,7 @@ private static OfficeConversionRequest request() {
"docx",
"policy-v1",
"trace-action-boundary",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
1_000_000L,
10
);
From 7d93165fac74f528cd3f9efa9f43638aa397495a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:03:10 +0900
Subject: [PATCH 094/219] test(conversion): qualify active-content source
fixture
---
.../conversion/OfficeConversionActiveContentPolicyTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
index 26fe96de..22f7aedf 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java
@@ -6,7 +6,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.apache.pdfbox.cos.COSArray;
@@ -176,7 +175,7 @@ private static OfficeConversionRequest request() {
"docx",
"policy-v1",
"trace-active-content",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
1_000_000L,
10
);
From 4b016eb39d623c3dbf611025434cfa28efe018f5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:03:40 +0900
Subject: [PATCH 095/219] test(conversion): qualify adapter-identity source
fixture
---
.../conversion/OfficeConversionAdapterIdentityBindingTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
index 1d3fdc5e..f07b1cfc 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java
@@ -5,7 +5,6 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;
@@ -99,7 +98,7 @@ private static OfficeConversionRequest request(String adapterId, String adapterV
adapterVersion,
"policy-v3",
"trace-adapter-binding",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
);
}
From fe6bd39aa2ff8a6256c0b10b94f741874f65b07a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:03:56 +0900
Subject: [PATCH 096/219] test(conversion): qualify provenance source fixtures
---
.../conversion/OfficeConversionAdapterProvenanceTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java
index e0b29dc2..30420cec 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java
@@ -56,7 +56,7 @@ private static OfficeConversionRequest request(String sourceText) {
"docx",
"policy-v1",
"trace-1",
- sourceText.getBytes(StandardCharsets.UTF_8)
+ OfficeConversionTestSource.zipPackage(sourceText)
);
}
}
From 40b5f7558aa0ae6146f93e9ec99d40430a49272d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:04:15 +0900
Subject: [PATCH 097/219] test(conversion): qualify output-limit source fixture
---
.../viewer/conversion/OfficeConversionOutputLimitTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
index fda74e8c..967e361b 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java
@@ -86,7 +86,7 @@ private static OfficeConversionRequest requestWithLimit(long maxOutputBytes) {
"docx",
"policy-v1",
"trace-output-limit",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
maxOutputBytes
);
}
From 1bdedc0f5ff5504d2683bee7de5d00571056e1ec Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:04:36 +0900
Subject: [PATCH 098/219] test(conversion): qualify page-limit source fixture
---
.../viewer/conversion/OfficeConversionPageLimitTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java
index ffd208fb..7a1b41db 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java
@@ -6,7 +6,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -83,7 +82,7 @@ private static OfficeConversionRequest requestWithLimits(long maxOutputBytes, in
"docx",
"policy-v1",
"trace-page-limit",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
maxOutputBytes,
maxPdfPages
);
From 0b2e3e9fc2368426b72552cc9ec39d059cf66fd3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:06:03 +0900
Subject: [PATCH 099/219] test(conversion): qualify PDF-validation source
fixture
---
.../viewer/conversion/OfficeConversionPdfValidationTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
index 3b5de788..6fef3251 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java
@@ -108,7 +108,7 @@ private static OfficeConversionRequest request() {
"docx",
"policy-v1",
"trace-pdf-validation",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES
);
}
From 8a2ec3bff3220786fe4fc4c7153f2596808753a2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:06:25 +0900
Subject: [PATCH 100/219] test(conversion): qualify URI-policy source fixture
---
.../viewer/conversion/OfficeConversionUriActionPolicyTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java
index 08df549f..1c2c534e 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java
@@ -6,7 +6,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.apache.pdfbox.cos.COSArray;
@@ -102,7 +101,7 @@ private static OfficeConversionRequest request() {
"docx",
"policy-v1",
"trace-uri-action-policy",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
1_000_000L,
10
);
From 45eee35386c07eebb3ea968edbcbc02dbf248ed1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:06:50 +0900
Subject: [PATCH 101/219] test(conversion): qualify action-classification
source fixture
---
.../OfficeConversionPdfActionClassificationTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
index 123c557c..86e2d2aa 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java
@@ -6,7 +6,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.apache.pdfbox.cos.COSArray;
@@ -102,7 +101,7 @@ private static OfficeConversionRequest request() {
"docx",
"policy-v1",
"trace-action-policy",
- "fixture-source".getBytes(StandardCharsets.UTF_8),
+ OfficeConversionTestSource.zipPackage("fixture-source"),
1_000_000L,
10
);
From c39db6e9b3c113f952835f098d59259082fdf4ad Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:07:22 +0900
Subject: [PATCH 102/219] test(conversion): qualify binding source fixtures
---
.../viewer/conversion/OfficeConversionRequestBindingTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
index 9e99da3b..fe35dd7d 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java
@@ -148,7 +148,7 @@ private static OfficeConversionRequest request(
sourceFormat,
policyVersion,
correlationId,
- sourceText.getBytes(StandardCharsets.UTF_8)
+ OfficeConversionTestSource.forFormat(sourceFormat, sourceText)
);
}
}
From bc3cc46e351ecb7db112a49e4dc16dc7e9dfc41d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:07:53 +0900
Subject: [PATCH 103/219] test(conversion): qualify deterministic adapter
fixture source
---
.../viewer/conversion/OfficeConversionAdapterContractTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
index b37130f5..60d52d58 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java
@@ -179,7 +179,7 @@ void adapterFailuresCarryStableClassAndRetryability() {
@Test
void adapterContractCanReturnDeterministicFixtureEvidence() {
- byte[] source = "fixture-docx".getBytes(StandardCharsets.UTF_8);
+ byte[] source = OfficeConversionTestSource.zipPackage("fixture-docx");
OfficeConversionRequest request = new OfficeConversionRequest(
"tenant-a", UUID.randomUUID(), 1L, "docx", "policy-v1", "trace-1", source);
byte[] pdf = OfficeConversionTestPdf.onePage();
From 1813cac3f6a5d810501fe8d3b1b20e36e8522d5d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:08:16 +0900
Subject: [PATCH 104/219] test(conversion): cover truncated source signature
---
.../OfficeSourceContainerPreflightTest.java | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
index 7d4af5ac..775fd092 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -72,6 +72,21 @@ void adapterRejectsLegacyFamilyWithZipSignatureBeforeProviderInvocation() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsTruncatedZipSignatureBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request("docx", new byte[] {0x50, 0x4b, 0x03}))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source container signature does not match declared format", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterInvokesProviderForQualifiedZipFamilySignature() {
AtomicInteger providerCalls = new AtomicInteger();
From 3ba58bfa7721bcdef67bfd75fa904ff921aab767 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:16:33 +0900
Subject: [PATCH 105/219] test(conversion): require bounded ZIP
central-directory framing
---
.../OfficeSourceContainerPreflightTest.java | 64 +++++++++++++++++--
1 file changed, 58 insertions(+), 6 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
index 775fd092..b717390b 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -13,9 +13,10 @@
* Source-container regressions for the Office adapter trust boundary.
*
* These tests deliberately cover only the common pre-conversion authority:
- * candidate format qualification and declared-format/container-signature
- * agreement. They do not treat a matching ZIP or compound-file signature as a
- * complete safety or fidelity qualification.
+ * candidate format qualification, declared-format/container-family agreement,
+ * and bounded ZIP central-directory framing. They do not treat passing this
+ * preflight as complete safety, Office-package, archive-expansion, macro,
+ * malware, or fidelity qualification.
*/
class OfficeSourceContainerPreflightTest {
@@ -64,7 +65,7 @@ void adapterRejectsLegacyFamilyWithZipSignatureBeforeProviderInvocation() {
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
- () -> adapter.convert(request("xls", ZIP_LOCAL_HEADER))
+ () -> adapter.convert(request("xls", framedZip()))
);
assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
@@ -88,11 +89,26 @@ void adapterRejectsTruncatedZipSignatureBeforeProviderInvocation() {
}
@Test
- void adapterInvokesProviderForQualifiedZipFamilySignature() {
+ void adapterRejectsZipPrefixWithoutCentralDirectoryFramingBeforeProviderInvocation() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
- adapter.convert(request("pptx", ZIP_LOCAL_HEADER));
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request("docx", ZIP_LOCAL_HEADER))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP container framing is invalid", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterInvokesProviderForBoundedZipFamilyFraming() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ adapter.convert(request("pptx", framedZip()));
assertEquals(1, providerCalls.get());
}
@@ -133,4 +149,40 @@ private static OfficeConversionRequest request(String sourceFormat, byte[] sourc
10
);
}
+
+ private static byte[] framedZip() {
+ // Signature-level fixture with one local-header marker, one central-directory
+ // marker, and a standard single-disk EOCD record. It is deliberately not a
+ // complete Office package and is used only for this bounded framing contract.
+ byte[] bytes = new byte[34];
+ System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
+ int centralOffset = 8;
+ bytes[centralOffset] = 0x50;
+ bytes[centralOffset + 1] = 0x4b;
+ bytes[centralOffset + 2] = 0x01;
+ bytes[centralOffset + 3] = 0x02;
+ int eocdOffset = 12;
+ bytes[eocdOffset] = 0x50;
+ bytes[eocdOffset + 1] = 0x4b;
+ bytes[eocdOffset + 2] = 0x05;
+ bytes[eocdOffset + 3] = 0x06;
+ putUnsignedShort(bytes, eocdOffset + 8, 1);
+ putUnsignedShort(bytes, eocdOffset + 10, 1);
+ putUnsignedInt(bytes, eocdOffset + 12, 4);
+ putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
+ putUnsignedShort(bytes, eocdOffset + 20, 0);
+ return bytes;
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
}
From 3b0281e1cecf1196d76030c0fb4e7462701ee6d4 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:21:21 +0900
Subject: [PATCH 106/219] fix(conversion): require bounded ZIP framing before
provider
---
.../OfficeSourceContainerPreflight.java | 116 +++++++++++++++---
1 file changed, 102 insertions(+), 14 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 7d6f0fd3..58ac174d 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -3,14 +3,16 @@
import java.util.Set;
/**
- * Performs the format-neutral source-container checks shared by qualified Office converters.
+ * Performs format-neutral source-container checks shared by qualified Office converters.
*
- * This preflight intentionally proves only two facts before untrusted bytes reach a
- * sidecar or remote converter: the declared source format belongs to the current Office
- * conversion candidate set, and the leading container signature matches that format
- * family. A matching signature is not a complete structure, macro,
- * embedded-object, archive-expansion, malware, or fidelity qualification. Those deeper
- * controls remain separate sandbox/content-policy acceptance gates.
+ * This preflight intentionally proves only a bounded set of facts before untrusted
+ * bytes reach a sidecar or remote converter: the declared source format belongs to the
+ * current Office conversion candidate set, the leading container signature matches that
+ * format family, and ZIP-family candidates contain a self-consistent standard single-disk
+ * central-directory/end-of-central-directory frame. Passing this preflight is
+ * not complete package, macro, embedded-object, archive-expansion,
+ * malware, or fidelity qualification. Those deeper controls remain separate
+ * sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -23,10 +25,20 @@ final class OfficeSourceContainerPreflight {
private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
0x50, 0x4b, 0x03, 0x04
};
+ private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] {
+ 0x50, 0x4b, 0x01, 0x02
+ };
+ private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] {
+ 0x50, 0x4b, 0x05, 0x06
+ };
private static final byte[] COMPOUND_FILE_HEADER = new byte[] {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
+ private static final int ZIP_EOCD_MINIMUM_LENGTH = 22;
+ private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535;
+ private static final int ZIP16_SENTINEL = 0xffff;
+ private static final long ZIP32_SENTINEL = 0xffff_ffffL;
private OfficeSourceContainerPreflight() {
}
@@ -35,8 +47,9 @@ private OfficeSourceContainerPreflight() {
* Rejects unknown candidate formats and obvious declared-format/container mismatches.
*
* @param request immutable conversion request containing declared format and source bytes
- * @throws OfficeConversionException when the format is not a current candidate or the
- * source does not begin with that format family's required container signature
+ * @throws OfficeConversionException when the format is not a current candidate, the
+ * source does not match that format family's required container signature, or a
+ * ZIP-family source lacks bounded standard single-disk central-directory framing
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -44,6 +57,7 @@ static void requireQualifiedContainer(OfficeConversionRequest request) {
if (ZIP_PACKAGE_FORMATS.contains(sourceFormat)) {
requireSignature(sourceBytes, ZIP_LOCAL_FILE_HEADER);
+ requireStandardZipFraming(sourceBytes);
return;
}
if (COMPOUND_FILE_FORMATS.contains(sourceFormat)) {
@@ -57,7 +71,7 @@ static void requireQualifiedContainer(OfficeConversionRequest request) {
}
private static void requireSignature(byte[] sourceBytes, byte[] expectedSignature) {
- if (!startsWith(sourceBytes, expectedSignature)) {
+ if (!matchesAt(sourceBytes, 0, expectedSignature)) {
throw new OfficeConversionException(
OfficeConversionFailureCode.MALFORMED_INPUT,
"source container signature does not match declared format"
@@ -65,15 +79,89 @@ private static void requireSignature(byte[] sourceBytes, byte[] expectedSignatur
}
}
- private static boolean startsWith(byte[] sourceBytes, byte[] expectedSignature) {
- if (sourceBytes.length < expectedSignature.length) {
+ private static void requireStandardZipFraming(byte[] sourceBytes) {
+ int eocdOffset = findEocdOffset(sourceBytes);
+ if (eocdOffset < 0 || !isStandardSingleDiskEocd(sourceBytes, eocdOffset)) {
+ throw invalidZipFraming();
+ }
+
+ int entryCount = unsignedShort(sourceBytes, eocdOffset + 10);
+ long centralDirectorySize = unsignedInt(sourceBytes, eocdOffset + 12);
+ long centralDirectoryOffset = unsignedInt(sourceBytes, eocdOffset + 16);
+ if (entryCount == ZIP16_SENTINEL
+ || centralDirectorySize == ZIP32_SENTINEL
+ || centralDirectoryOffset == ZIP32_SENTINEL
+ || centralDirectorySize == 0L
+ || centralDirectoryOffset > Integer.MAX_VALUE) {
+ throw invalidZipFraming();
+ }
+
+ long centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize;
+ if (centralDirectoryEnd > eocdOffset
+ || !matchesAt(sourceBytes, (int) centralDirectoryOffset, ZIP_CENTRAL_DIRECTORY_HEADER)) {
+ throw invalidZipFraming();
+ }
+ }
+
+ private static int findEocdOffset(byte[] sourceBytes) {
+ if (sourceBytes.length < ZIP_EOCD_MINIMUM_LENGTH) {
+ return -1;
+ }
+ int latest = sourceBytes.length - ZIP_EOCD_MINIMUM_LENGTH;
+ int earliest = Math.max(0, latest - ZIP_MAXIMUM_COMMENT_LENGTH);
+ for (int offset = latest; offset >= earliest; offset--) {
+ if (!matchesAt(sourceBytes, offset, ZIP_END_OF_CENTRAL_DIRECTORY)) {
+ continue;
+ }
+ int commentLength = unsignedShort(sourceBytes, offset + 20);
+ if (offset + ZIP_EOCD_MINIMUM_LENGTH + commentLength == sourceBytes.length) {
+ return offset;
+ }
+ }
+ return -1;
+ }
+
+ private static boolean isStandardSingleDiskEocd(byte[] sourceBytes, int eocdOffset) {
+ int diskNumber = unsignedShort(sourceBytes, eocdOffset + 4);
+ int centralDirectoryDisk = unsignedShort(sourceBytes, eocdOffset + 6);
+ int entriesOnDisk = unsignedShort(sourceBytes, eocdOffset + 8);
+ int totalEntries = unsignedShort(sourceBytes, eocdOffset + 10);
+ return diskNumber == 0
+ && centralDirectoryDisk == 0
+ && entriesOnDisk > 0
+ && entriesOnDisk == totalEntries;
+ }
+
+ private static boolean matchesAt(byte[] sourceBytes, int offset, byte[] signature) {
+ if (offset < 0 || offset > sourceBytes.length - signature.length) {
return false;
}
- for (int index = 0; index < expectedSignature.length; index++) {
- if (sourceBytes[index] != expectedSignature[index]) {
+ for (int index = 0; index < signature.length; index++) {
+ if (sourceBytes[offset + index] != signature[index]) {
return false;
}
}
return true;
}
+
+ private static int unsignedShort(byte[] sourceBytes, int offset) {
+ return Byte.toUnsignedInt(sourceBytes[offset])
+ | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8);
+ }
+
+ private static long unsignedInt(byte[] sourceBytes, int offset) {
+ return Integer.toUnsignedLong(
+ Byte.toUnsignedInt(sourceBytes[offset])
+ | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8)
+ | (Byte.toUnsignedInt(sourceBytes[offset + 2]) << 16)
+ | (Byte.toUnsignedInt(sourceBytes[offset + 3]) << 24)
+ );
+ }
+
+ private static OfficeConversionException invalidZipFraming() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ZIP container framing is invalid"
+ );
+ }
}
From c5a302789918093a192b1cc0ab576738ca2572af Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:21:51 +0900
Subject: [PATCH 107/219] test(conversion): frame ZIP-family source fixtures
---
.../OfficeConversionTestSource.java | 69 ++++++++++++++++---
1 file changed, 58 insertions(+), 11 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
index f22e3257..8b1cd4a4 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
@@ -5,10 +5,13 @@
import java.util.Set;
/**
- * Creates deterministic test-only Office source bytes with a qualified container signature.
+ * Creates deterministic test-only Office source bytes for conversion-boundary tests.
*
- * The returned bytes are intentionally only sufficient for the common source-container
- * signature preflight. They are not valid complete OOXML, ODF, or compound-file documents
+ *
ZIP-family fixtures contain only enough framing to satisfy the common source
+ * preflight: a local-file signature, deterministic marker bytes, a central-directory
+ * signature, and a self-consistent standard single-disk end-of-central-directory record.
+ * Legacy fixtures contain only the compound-file family signature plus marker bytes.
+ * These are not valid complete OOXML, ODF, or compound-file documents
* and must never be used as fidelity, archive-structure, macro, malware, or production
* converter fixtures. Real document-fidelity qualification uses separate authorized or
* redistributable Office fixtures.
@@ -24,41 +27,73 @@ final class OfficeConversionTestSource {
private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
0x50, 0x4b, 0x03, 0x04
};
+ private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] {
+ 0x50, 0x4b, 0x01, 0x02
+ };
+ private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] {
+ 0x50, 0x4b, 0x05, 0x06
+ };
private static final byte[] COMPOUND_FILE_HEADER = new byte[] {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
+ private static final int ZIP_EOCD_LENGTH = 22;
private OfficeConversionTestSource() {
}
/**
- * Creates one signature-qualified source fixture for the declared candidate format.
+ * Creates one preflight-qualified source fixture for the declared candidate format.
*
* @param sourceFormat Office candidate format
- * @param marker deterministic marker appended after the family signature
+ * @param marker deterministic marker kept inside the test container framing
* @return test-only source bytes
*/
static byte[] forFormat(String sourceFormat, String marker) {
String normalized = sourceFormat.strip().toLowerCase(Locale.ROOT);
- byte[] payload = marker.getBytes(StandardCharsets.UTF_8);
if (ZIP_PACKAGE_FORMATS.contains(normalized)) {
- return concatenate(ZIP_LOCAL_FILE_HEADER, payload);
+ return zipPackage(marker);
}
if (COMPOUND_FILE_FORMATS.contains(normalized)) {
- return concatenate(COMPOUND_FILE_HEADER, payload);
+ return compoundFile(marker);
}
- return payload;
+ return marker.getBytes(StandardCharsets.UTF_8);
}
/**
- * Creates a signature-qualified ZIP-package source fixture.
+ * Creates a bounded-framing ZIP-package source fixture.
*
* @param marker deterministic marker
* @return test-only ZIP-family source bytes
*/
static byte[] zipPackage(String marker) {
- return concatenate(ZIP_LOCAL_FILE_HEADER, marker.getBytes(StandardCharsets.UTF_8));
+ byte[] markerBytes = marker.getBytes(StandardCharsets.UTF_8);
+ int centralDirectoryOffset = ZIP_LOCAL_FILE_HEADER.length + markerBytes.length;
+ int eocdOffset = centralDirectoryOffset + ZIP_CENTRAL_DIRECTORY_HEADER.length;
+ byte[] bytes = new byte[eocdOffset + ZIP_EOCD_LENGTH];
+
+ System.arraycopy(ZIP_LOCAL_FILE_HEADER, 0, bytes, 0, ZIP_LOCAL_FILE_HEADER.length);
+ System.arraycopy(markerBytes, 0, bytes, ZIP_LOCAL_FILE_HEADER.length, markerBytes.length);
+ System.arraycopy(
+ ZIP_CENTRAL_DIRECTORY_HEADER,
+ 0,
+ bytes,
+ centralDirectoryOffset,
+ ZIP_CENTRAL_DIRECTORY_HEADER.length
+ );
+ System.arraycopy(
+ ZIP_END_OF_CENTRAL_DIRECTORY,
+ 0,
+ bytes,
+ eocdOffset,
+ ZIP_END_OF_CENTRAL_DIRECTORY.length
+ );
+ putUnsignedShort(bytes, eocdOffset + 8, 1);
+ putUnsignedShort(bytes, eocdOffset + 10, 1);
+ putUnsignedInt(bytes, eocdOffset + 12, ZIP_CENTRAL_DIRECTORY_HEADER.length);
+ putUnsignedInt(bytes, eocdOffset + 16, centralDirectoryOffset);
+ putUnsignedShort(bytes, eocdOffset + 20, 0);
+ return bytes;
}
/**
@@ -71,6 +106,18 @@ static byte[] compoundFile(String marker) {
return concatenate(COMPOUND_FILE_HEADER, marker.getBytes(StandardCharsets.UTF_8));
}
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+
private static byte[] concatenate(byte[] prefix, byte[] suffix) {
byte[] combined = new byte[prefix.length + suffix.length];
System.arraycopy(prefix, 0, combined, 0, prefix.length);
From 7113c32b769878553b59a1de9a9d0cd9bcb9db62 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:23:22 +0900
Subject: [PATCH 108/219] test(conversion): exhaust ZIP framing failure
branches
---
.../OfficeSourceContainerPreflightTest.java | 177 +++++++++++++++---
1 file changed, 150 insertions(+), 27 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
index b717390b..04f1f36e 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -4,6 +4,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
@@ -27,6 +28,8 @@ class OfficeSourceContainerPreflightTest {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
+ private static final int CENTRAL_OFFSET = 8;
+ private static final int EOCD_OFFSET = 12;
@Test
void adapterRejectsUnknownFormatBeforeProviderInvocation() {
@@ -90,17 +93,113 @@ void adapterRejectsTruncatedZipSignatureBeforeProviderInvocation() {
@Test
void adapterRejectsZipPrefixWithoutCentralDirectoryFramingBeforeProviderInvocation() {
- AtomicInteger providerCalls = new AtomicInteger();
- OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+ assertMalformedZipBeforeProvider(ZIP_LOCAL_HEADER);
+ }
- OfficeConversionException failure = assertThrows(
- OfficeConversionException.class,
- () -> adapter.convert(request("docx", ZIP_LOCAL_HEADER))
- );
+ @Test
+ void adapterRejectsLongZipCandidateWithoutEocdBeforeProviderInvocation() {
+ byte[] bytes = new byte[40];
+ System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
- assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
- assertEquals("source ZIP container framing is invalid", failure.getMessage());
- assertEquals(0, providerCalls.get());
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsEocdWithCommentLengthBeyondBuffer() {
+ byte[] bytes = framedZip();
+ putUnsignedShort(bytes, EOCD_OFFSET + 20, 1);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsMultiDiskZipFraming() {
+ byte[] bytes = framedZip();
+ putUnsignedShort(bytes, EOCD_OFFSET + 4, 1);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsCentralDirectoryOnDifferentDisk() {
+ byte[] bytes = framedZip();
+ putUnsignedShort(bytes, EOCD_OFFSET + 6, 1);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsZeroEntryZipFraming() {
+ byte[] bytes = framedZip();
+ putUnsignedShort(bytes, EOCD_OFFSET + 8, 0);
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, 0);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsMismatchedEntryCounts() {
+ byte[] bytes = framedZip();
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, 2);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsZip64EntrySentinelWithoutSeparateQualification() {
+ byte[] bytes = framedZip();
+ putUnsignedShort(bytes, EOCD_OFFSET + 8, 0xffff);
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, 0xffff);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsZip64CentralDirectorySizeSentinel() {
+ byte[] bytes = framedZip();
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, 0xffff_ffffL);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsZip64CentralDirectoryOffsetSentinel() {
+ byte[] bytes = framedZip();
+ putUnsignedInt(bytes, EOCD_OFFSET + 16, 0xffff_ffffL);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsEmptyCentralDirectorySize() {
+ byte[] bytes = framedZip();
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, 0L);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsCentralDirectoryOffsetOutsideAddressableInput() {
+ byte[] bytes = framedZip();
+ putUnsignedInt(bytes, EOCD_OFFSET + 16, 0x8000_0000L);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsCentralDirectoryThatOverlapsEocd() {
+ byte[] bytes = framedZip();
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, 8L);
+
+ assertMalformedZipBeforeProvider(bytes);
+ }
+
+ @Test
+ void adapterRejectsMissingCentralDirectorySignature() {
+ byte[] bytes = framedZip();
+ bytes[CENTRAL_OFFSET] = 0x00;
+
+ assertMalformedZipBeforeProvider(bytes);
}
@Test
@@ -113,6 +212,21 @@ void adapterInvokesProviderForBoundedZipFamilyFraming() {
assertEquals(1, providerCalls.get());
}
+ @Test
+ void adapterInvokesProviderForBoundedZipFramingWithComment() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+ byte[] base = framedZip();
+ byte[] withComment = Arrays.copyOf(base, base.length + 2);
+ putUnsignedShort(withComment, EOCD_OFFSET + 20, 2);
+ withComment[withComment.length - 2] = 'o';
+ withComment[withComment.length - 1] = 'k';
+
+ adapter.convert(request("docx", withComment));
+
+ assertEquals(1, providerCalls.get());
+ }
+
@Test
void adapterInvokesProviderForQualifiedLegacyCompoundFileSignature() {
AtomicInteger providerCalls = new AtomicInteger();
@@ -123,6 +237,20 @@ void adapterInvokesProviderForQualifiedLegacyCompoundFileSignature() {
assertEquals(1, providerCalls.get());
}
+ private static void assertMalformedZipBeforeProvider(byte[] bytes) {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request("docx", bytes))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP container framing is invalid", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
return input -> {
providerCalls.incrementAndGet();
@@ -151,26 +279,21 @@ private static OfficeConversionRequest request(String sourceFormat, byte[] sourc
}
private static byte[] framedZip() {
- // Signature-level fixture with one local-header marker, one central-directory
- // marker, and a standard single-disk EOCD record. It is deliberately not a
- // complete Office package and is used only for this bounded framing contract.
byte[] bytes = new byte[34];
System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
- int centralOffset = 8;
- bytes[centralOffset] = 0x50;
- bytes[centralOffset + 1] = 0x4b;
- bytes[centralOffset + 2] = 0x01;
- bytes[centralOffset + 3] = 0x02;
- int eocdOffset = 12;
- bytes[eocdOffset] = 0x50;
- bytes[eocdOffset + 1] = 0x4b;
- bytes[eocdOffset + 2] = 0x05;
- bytes[eocdOffset + 3] = 0x06;
- putUnsignedShort(bytes, eocdOffset + 8, 1);
- putUnsignedShort(bytes, eocdOffset + 10, 1);
- putUnsignedInt(bytes, eocdOffset + 12, 4);
- putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
- putUnsignedShort(bytes, eocdOffset + 20, 0);
+ bytes[CENTRAL_OFFSET] = 0x50;
+ bytes[CENTRAL_OFFSET + 1] = 0x4b;
+ bytes[CENTRAL_OFFSET + 2] = 0x01;
+ bytes[CENTRAL_OFFSET + 3] = 0x02;
+ bytes[EOCD_OFFSET] = 0x50;
+ bytes[EOCD_OFFSET + 1] = 0x4b;
+ bytes[EOCD_OFFSET + 2] = 0x05;
+ bytes[EOCD_OFFSET + 3] = 0x06;
+ putUnsignedShort(bytes, EOCD_OFFSET + 8, 1);
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, 1);
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, 4);
+ putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET);
+ putUnsignedShort(bytes, EOCD_OFFSET + 20, 0);
return bytes;
}
From 24a5163c920ee86f5eb8df40f8c0ab57771b9278 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:23:50 +0900
Subject: [PATCH 109/219] refactor(conversion): remove unreachable negative ZIP
offset branch
---
.../viewer/conversion/OfficeSourceContainerPreflight.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 58ac174d..b0d703f2 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -133,7 +133,7 @@ private static boolean isStandardSingleDiskEocd(byte[] sourceBytes, int eocdOffs
}
private static boolean matchesAt(byte[] sourceBytes, int offset, byte[] signature) {
- if (offset < 0 || offset > sourceBytes.length - signature.length) {
+ if (offset > sourceBytes.length - signature.length) {
return false;
}
for (int index = 0; index < signature.length; index++) {
From 70612ed6832282498d26afec1c3a868b5dd9798f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:29:25 +0900
Subject: [PATCH 110/219] test(conversion): reject encrypted and inconsistent
ZIP entries
---
...fficeSourceCentralDirectoryPolicyTest.java | 113 ++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
new file mode 100644
index 00000000..5d9a14d9
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -0,0 +1,113 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Central-directory metadata regressions for ZIP-family Office candidates.
+ *
+ * The pre-provider boundary must not trust the EOCD entry count or a leading
+ * central-directory signature alone. These tests do not decompress entry data;
+ * they only require structurally present central records and fail closed when a
+ * record advertises ZIP encryption.
+ */
+class OfficeSourceCentralDirectoryPolicyTest {
+
+ private static final int LOCAL_HEADER_OFFSET = 0;
+ private static final int CENTRAL_DIRECTORY_OFFSET = 4;
+ private static final int CENTRAL_DIRECTORY_RECORD_LENGTH = 46;
+ private static final int EOCD_OFFSET = CENTRAL_DIRECTORY_OFFSET + CENTRAL_DIRECTORY_RECORD_LENGTH;
+ private static final int EOCD_LENGTH = 22;
+
+ @Test
+ void adapterRejectsEncryptedCentralDirectoryEntryBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(1, 1);
+ putUnsignedShort(source, CENTRAL_DIRECTORY_OFFSET + 8, 0x0001);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.PASSWORD_PROTECTED, failure.failureCode());
+ assertEquals("source ZIP entry is encrypted", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterRejectsEocdCountThatExceedsPresentCentralRecords() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(2, 2);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP central directory is invalid", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"),
+ 10L,
+ "docx",
+ "policy-v1",
+ "trace-central-directory-policy",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] oneEntryZip(int entriesOnDisk, int totalEntries) {
+ byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH];
+ putSignature(bytes, LOCAL_HEADER_OFFSET, 0x04034b50L);
+ putSignature(bytes, CENTRAL_DIRECTORY_OFFSET, 0x02014b50L);
+ putUnsignedInt(bytes, CENTRAL_DIRECTORY_OFFSET + 42, LOCAL_HEADER_OFFSET);
+ putSignature(bytes, EOCD_OFFSET, 0x06054b50L);
+ putUnsignedShort(bytes, EOCD_OFFSET + 8, entriesOnDisk);
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, totalEntries);
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_DIRECTORY_RECORD_LENGTH);
+ putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_DIRECTORY_OFFSET);
+ return bytes;
+ }
+
+ private static void putSignature(byte[] bytes, int offset, long value) {
+ putUnsignedInt(bytes, offset, value);
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+}
From f245c830e544a865ba1b76cbde235c478a053957 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:31:56 +0900
Subject: [PATCH 111/219] fix(conversion): validate ZIP central-directory
entries
---
.../OfficeSourceContainerPreflight.java | 81 +++++++++++++++++--
1 file changed, 75 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index b0d703f2..5c680e2c 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -8,9 +8,9 @@
* This preflight intentionally proves only a bounded set of facts before untrusted
* bytes reach a sidecar or remote converter: the declared source format belongs to the
* current Office conversion candidate set, the leading container signature matches that
- * format family, and ZIP-family candidates contain a self-consistent standard single-disk
- * central-directory/end-of-central-directory frame. Passing this preflight is
- * not complete package, macro, embedded-object, archive-expansion,
+ * format family, and ZIP-family candidates contain self-consistent standard single-disk
+ * central-directory records and end-of-central-directory framing. Passing this preflight
+ * is not complete package, macro, embedded-object, archive-expansion,
* malware, or fidelity qualification. Those deeper controls remain separate
* sandbox/content-policy acceptance gates.
*/
@@ -36,20 +36,23 @@ final class OfficeSourceContainerPreflight {
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private static final int ZIP_EOCD_MINIMUM_LENGTH = 22;
+ private static final int ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH = 46;
private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535;
private static final int ZIP16_SENTINEL = 0xffff;
private static final long ZIP32_SENTINEL = 0xffff_ffffL;
+ private static final int ZIP_ENCRYPTED_FLAG = 0x0001;
private OfficeSourceContainerPreflight() {
}
/**
- * Rejects unknown candidate formats and obvious declared-format/container mismatches.
+ * Rejects unknown candidate formats and invalid source-container framing.
*
* @param request immutable conversion request containing declared format and source bytes
* @throws OfficeConversionException when the format is not a current candidate, the
- * source does not match that format family's required container signature, or a
- * ZIP-family source lacks bounded standard single-disk central-directory framing
+ * source does not match that format family's required container signature, a
+ * ZIP-family source has invalid central-directory framing, or a ZIP entry is
+ * encrypted
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -101,6 +104,65 @@ private static void requireStandardZipFraming(byte[] sourceBytes) {
|| !matchesAt(sourceBytes, (int) centralDirectoryOffset, ZIP_CENTRAL_DIRECTORY_HEADER)) {
throw invalidZipFraming();
}
+ requireCentralDirectoryRecords(
+ sourceBytes,
+ (int) centralDirectoryOffset,
+ (int) centralDirectoryEnd,
+ entryCount
+ );
+ }
+
+ private static void requireCentralDirectoryRecords(
+ byte[] sourceBytes,
+ int centralDirectoryOffset,
+ int centralDirectoryEnd,
+ int entryCount
+ ) {
+ int cursor = centralDirectoryOffset;
+ for (int entryIndex = 0; entryIndex < entryCount; entryIndex++) {
+ if (cursor > centralDirectoryEnd - ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH
+ || !matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER)) {
+ throw invalidCentralDirectory();
+ }
+
+ int flags = unsignedShort(sourceBytes, cursor + 8);
+ if ((flags & ZIP_ENCRYPTED_FLAG) != 0) {
+ throw new OfficeConversionException(
+ OfficeConversionFailureCode.PASSWORD_PROTECTED,
+ "source ZIP entry is encrypted"
+ );
+ }
+
+ long compressedSize = unsignedInt(sourceBytes, cursor + 20);
+ long uncompressedSize = unsignedInt(sourceBytes, cursor + 24);
+ int fileNameLength = unsignedShort(sourceBytes, cursor + 28);
+ int extraFieldLength = unsignedShort(sourceBytes, cursor + 30);
+ int fileCommentLength = unsignedShort(sourceBytes, cursor + 32);
+ int diskStart = unsignedShort(sourceBytes, cursor + 34);
+ long localHeaderOffset = unsignedInt(sourceBytes, cursor + 42);
+ if (compressedSize == ZIP32_SENTINEL
+ || uncompressedSize == ZIP32_SENTINEL
+ || diskStart == ZIP16_SENTINEL
+ || diskStart != 0
+ || localHeaderOffset == ZIP32_SENTINEL
+ || localHeaderOffset >= centralDirectoryOffset
+ || !matchesAt(sourceBytes, (int) localHeaderOffset, ZIP_LOCAL_FILE_HEADER)) {
+ throw invalidCentralDirectory();
+ }
+
+ long recordLength = (long) ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH
+ + fileNameLength
+ + extraFieldLength
+ + fileCommentLength;
+ long nextCursor = (long) cursor + recordLength;
+ if (nextCursor > centralDirectoryEnd) {
+ throw invalidCentralDirectory();
+ }
+ cursor = (int) nextCursor;
+ }
+ if (cursor != centralDirectoryEnd) {
+ throw invalidCentralDirectory();
+ }
}
private static int findEocdOffset(byte[] sourceBytes) {
@@ -164,4 +226,11 @@ private static OfficeConversionException invalidZipFraming() {
"source ZIP container framing is invalid"
);
}
+
+ private static OfficeConversionException invalidCentralDirectory() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ZIP central directory is invalid"
+ );
+ }
}
From e83a29e6f0ff68dda418faa086b9e69014aeb2cb Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:32:18 +0900
Subject: [PATCH 112/219] test(conversion): emit complete central-directory
fixture records
---
.../OfficeConversionTestSource.java | 21 +++++++++++--------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
index 8b1cd4a4..1fa9b85f 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
@@ -8,13 +8,14 @@
* Creates deterministic test-only Office source bytes for conversion-boundary tests.
*
* ZIP-family fixtures contain only enough framing to satisfy the common source
- * preflight: a local-file signature, deterministic marker bytes, a central-directory
- * signature, and a self-consistent standard single-disk end-of-central-directory record.
- * Legacy fixtures contain only the compound-file family signature plus marker bytes.
- * These are not valid complete OOXML, ODF, or compound-file documents
- * and must never be used as fidelity, archive-structure, macro, malware, or production
- * converter fixtures. Real document-fidelity qualification uses separate authorized or
- * redistributable Office fixtures.
+ * preflight: a local-file signature, deterministic marker bytes, one fixed-length
+ * central-directory record, and a self-consistent standard single-disk
+ * end-of-central-directory record. Legacy fixtures contain only the compound-file
+ * family signature plus marker bytes. These are not valid complete
+ * OOXML, ODF, or compound-file documents and must never be used as fidelity,
+ * archive-structure, macro, malware, or production converter fixtures. Real
+ * document-fidelity qualification uses separate authorized or redistributable Office
+ * fixtures.
*/
final class OfficeConversionTestSource {
@@ -37,6 +38,7 @@ final class OfficeConversionTestSource {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
+ private static final int ZIP_CENTRAL_DIRECTORY_RECORD_LENGTH = 46;
private static final int ZIP_EOCD_LENGTH = 22;
private OfficeConversionTestSource() {
@@ -69,7 +71,7 @@ static byte[] forFormat(String sourceFormat, String marker) {
static byte[] zipPackage(String marker) {
byte[] markerBytes = marker.getBytes(StandardCharsets.UTF_8);
int centralDirectoryOffset = ZIP_LOCAL_FILE_HEADER.length + markerBytes.length;
- int eocdOffset = centralDirectoryOffset + ZIP_CENTRAL_DIRECTORY_HEADER.length;
+ int eocdOffset = centralDirectoryOffset + ZIP_CENTRAL_DIRECTORY_RECORD_LENGTH;
byte[] bytes = new byte[eocdOffset + ZIP_EOCD_LENGTH];
System.arraycopy(ZIP_LOCAL_FILE_HEADER, 0, bytes, 0, ZIP_LOCAL_FILE_HEADER.length);
@@ -81,6 +83,7 @@ static byte[] zipPackage(String marker) {
centralDirectoryOffset,
ZIP_CENTRAL_DIRECTORY_HEADER.length
);
+ putUnsignedInt(bytes, centralDirectoryOffset + 42, 0L);
System.arraycopy(
ZIP_END_OF_CENTRAL_DIRECTORY,
0,
@@ -90,7 +93,7 @@ static byte[] zipPackage(String marker) {
);
putUnsignedShort(bytes, eocdOffset + 8, 1);
putUnsignedShort(bytes, eocdOffset + 10, 1);
- putUnsignedInt(bytes, eocdOffset + 12, ZIP_CENTRAL_DIRECTORY_HEADER.length);
+ putUnsignedInt(bytes, eocdOffset + 12, ZIP_CENTRAL_DIRECTORY_RECORD_LENGTH);
putUnsignedInt(bytes, eocdOffset + 16, centralDirectoryOffset);
putUnsignedShort(bytes, eocdOffset + 20, 0);
return bytes;
From 38ff39caf14eaebcac5ae1e7ca3e1d7a7646ef58 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:32:47 +0900
Subject: [PATCH 113/219] test(conversion): use complete central-directory
framing fixtures
---
.../OfficeSourceContainerPreflightTest.java | 40 ++++---------------
1 file changed, 7 insertions(+), 33 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
index 04f1f36e..8e52c8c6 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -29,18 +29,18 @@ class OfficeSourceContainerPreflightTest {
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private static final int CENTRAL_OFFSET = 8;
- private static final int EOCD_OFFSET = 12;
+ private static final int CENTRAL_RECORD_LENGTH = 46;
+ private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH;
+ private static final int EOCD_LENGTH = 22;
@Test
void adapterRejectsUnknownFormatBeforeProviderInvocation() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
() -> adapter.convert(request("pdf", "%PDF-1.7".getBytes(StandardCharsets.US_ASCII)))
);
-
assertEquals(OfficeConversionFailureCode.UNSUPPORTED_FORMAT, failure.failureCode());
assertEquals("source format is not an Office conversion candidate", failure.getMessage());
assertEquals(0, providerCalls.get());
@@ -50,12 +50,10 @@ void adapterRejectsUnknownFormatBeforeProviderInvocation() {
void adapterRejectsZipFamilyWithCompoundFileSignatureBeforeProviderInvocation() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
() -> adapter.convert(request("docx", COMPOUND_FILE_HEADER))
);
-
assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
assertEquals("source container signature does not match declared format", failure.getMessage());
assertEquals(0, providerCalls.get());
@@ -65,12 +63,10 @@ void adapterRejectsZipFamilyWithCompoundFileSignatureBeforeProviderInvocation()
void adapterRejectsLegacyFamilyWithZipSignatureBeforeProviderInvocation() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
() -> adapter.convert(request("xls", framedZip()))
);
-
assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
assertEquals("source container signature does not match declared format", failure.getMessage());
assertEquals(0, providerCalls.get());
@@ -80,12 +76,10 @@ void adapterRejectsLegacyFamilyWithZipSignatureBeforeProviderInvocation() {
void adapterRejectsTruncatedZipSignatureBeforeProviderInvocation() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
() -> adapter.convert(request("docx", new byte[] {0x50, 0x4b, 0x03}))
);
-
assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
assertEquals("source container signature does not match declared format", failure.getMessage());
assertEquals(0, providerCalls.get());
@@ -100,7 +94,6 @@ void adapterRejectsZipPrefixWithoutCentralDirectoryFramingBeforeProviderInvocati
void adapterRejectsLongZipCandidateWithoutEocdBeforeProviderInvocation() {
byte[] bytes = new byte[40];
System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -108,7 +101,6 @@ void adapterRejectsLongZipCandidateWithoutEocdBeforeProviderInvocation() {
void adapterRejectsEocdWithCommentLengthBeyondBuffer() {
byte[] bytes = framedZip();
putUnsignedShort(bytes, EOCD_OFFSET + 20, 1);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -116,7 +108,6 @@ void adapterRejectsEocdWithCommentLengthBeyondBuffer() {
void adapterRejectsMultiDiskZipFraming() {
byte[] bytes = framedZip();
putUnsignedShort(bytes, EOCD_OFFSET + 4, 1);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -124,7 +115,6 @@ void adapterRejectsMultiDiskZipFraming() {
void adapterRejectsCentralDirectoryOnDifferentDisk() {
byte[] bytes = framedZip();
putUnsignedShort(bytes, EOCD_OFFSET + 6, 1);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -133,7 +123,6 @@ void adapterRejectsZeroEntryZipFraming() {
byte[] bytes = framedZip();
putUnsignedShort(bytes, EOCD_OFFSET + 8, 0);
putUnsignedShort(bytes, EOCD_OFFSET + 10, 0);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -141,7 +130,6 @@ void adapterRejectsZeroEntryZipFraming() {
void adapterRejectsMismatchedEntryCounts() {
byte[] bytes = framedZip();
putUnsignedShort(bytes, EOCD_OFFSET + 10, 2);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -150,7 +138,6 @@ void adapterRejectsZip64EntrySentinelWithoutSeparateQualification() {
byte[] bytes = framedZip();
putUnsignedShort(bytes, EOCD_OFFSET + 8, 0xffff);
putUnsignedShort(bytes, EOCD_OFFSET + 10, 0xffff);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -158,7 +145,6 @@ void adapterRejectsZip64EntrySentinelWithoutSeparateQualification() {
void adapterRejectsZip64CentralDirectorySizeSentinel() {
byte[] bytes = framedZip();
putUnsignedInt(bytes, EOCD_OFFSET + 12, 0xffff_ffffL);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -166,7 +152,6 @@ void adapterRejectsZip64CentralDirectorySizeSentinel() {
void adapterRejectsZip64CentralDirectoryOffsetSentinel() {
byte[] bytes = framedZip();
putUnsignedInt(bytes, EOCD_OFFSET + 16, 0xffff_ffffL);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -174,7 +159,6 @@ void adapterRejectsZip64CentralDirectoryOffsetSentinel() {
void adapterRejectsEmptyCentralDirectorySize() {
byte[] bytes = framedZip();
putUnsignedInt(bytes, EOCD_OFFSET + 12, 0L);
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -182,15 +166,13 @@ void adapterRejectsEmptyCentralDirectorySize() {
void adapterRejectsCentralDirectoryOffsetOutsideAddressableInput() {
byte[] bytes = framedZip();
putUnsignedInt(bytes, EOCD_OFFSET + 16, 0x8000_0000L);
-
assertMalformedZipBeforeProvider(bytes);
}
@Test
void adapterRejectsCentralDirectoryThatOverlapsEocd() {
byte[] bytes = framedZip();
- putUnsignedInt(bytes, EOCD_OFFSET + 12, 8L);
-
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH + 1L);
assertMalformedZipBeforeProvider(bytes);
}
@@ -198,7 +180,6 @@ void adapterRejectsCentralDirectoryThatOverlapsEocd() {
void adapterRejectsMissingCentralDirectorySignature() {
byte[] bytes = framedZip();
bytes[CENTRAL_OFFSET] = 0x00;
-
assertMalformedZipBeforeProvider(bytes);
}
@@ -206,9 +187,7 @@ void adapterRejectsMissingCentralDirectorySignature() {
void adapterInvokesProviderForBoundedZipFamilyFraming() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
adapter.convert(request("pptx", framedZip()));
-
assertEquals(1, providerCalls.get());
}
@@ -221,9 +200,7 @@ void adapterInvokesProviderForBoundedZipFramingWithComment() {
putUnsignedShort(withComment, EOCD_OFFSET + 20, 2);
withComment[withComment.length - 2] = 'o';
withComment[withComment.length - 1] = 'k';
-
adapter.convert(request("docx", withComment));
-
assertEquals(1, providerCalls.get());
}
@@ -231,21 +208,17 @@ void adapterInvokesProviderForBoundedZipFramingWithComment() {
void adapterInvokesProviderForQualifiedLegacyCompoundFileSignature() {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
adapter.convert(request("doc", COMPOUND_FILE_HEADER));
-
assertEquals(1, providerCalls.get());
}
private static void assertMalformedZipBeforeProvider(byte[] bytes) {
AtomicInteger providerCalls = new AtomicInteger();
OfficeConversionAdapter adapter = countingAdapter(providerCalls);
-
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
() -> adapter.convert(request("docx", bytes))
);
-
assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
assertEquals("source ZIP container framing is invalid", failure.getMessage());
assertEquals(0, providerCalls.get());
@@ -279,19 +252,20 @@ private static OfficeConversionRequest request(String sourceFormat, byte[] sourc
}
private static byte[] framedZip() {
- byte[] bytes = new byte[34];
+ byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH];
System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
bytes[CENTRAL_OFFSET] = 0x50;
bytes[CENTRAL_OFFSET + 1] = 0x4b;
bytes[CENTRAL_OFFSET + 2] = 0x01;
bytes[CENTRAL_OFFSET + 3] = 0x02;
+ putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L);
bytes[EOCD_OFFSET] = 0x50;
bytes[EOCD_OFFSET + 1] = 0x4b;
bytes[EOCD_OFFSET + 2] = 0x05;
bytes[EOCD_OFFSET + 3] = 0x06;
putUnsignedShort(bytes, EOCD_OFFSET + 8, 1);
putUnsignedShort(bytes, EOCD_OFFSET + 10, 1);
- putUnsignedInt(bytes, EOCD_OFFSET + 12, 4);
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH);
putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET);
putUnsignedShort(bytes, EOCD_OFFSET + 20, 0);
return bytes;
From 5292c6718b9daa5cdb0fc11c957c2ef6ebcc8f7f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 08:45:13 +0900
Subject: [PATCH 114/219] test(conversion): reject unsafe ZIP entry paths
---
.../OfficeSourceEntryPathPolicyTest.java | 134 ++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java
new file mode 100644
index 00000000..57b64ddb
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java
@@ -0,0 +1,134 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * ZIP-entry path policy regressions for Office source candidates.
+ *
+ * The converter may eventually use a filesystem-backed sandbox internally, so
+ * path traversal and platform-absolute entry names must fail before provider
+ * invocation. These tests inspect only central-directory metadata and do not
+ * extract archive contents.
+ */
+class OfficeSourceEntryPathPolicyTest {
+
+ @Test
+ void adapterRejectsParentTraversalEntryBeforeProviderInvocation() {
+ assertUnsafeEntry("../outside.bin");
+ }
+
+ @Test
+ void adapterRejectsNestedParentTraversalEntryBeforeProviderInvocation() {
+ assertUnsafeEntry("word/../../outside.bin");
+ }
+
+ @Test
+ void adapterRejectsLeadingSlashEntryBeforeProviderInvocation() {
+ assertUnsafeEntry("/absolute.bin");
+ }
+
+ @Test
+ void adapterRejectsBackslashEntryBeforeProviderInvocation() {
+ assertUnsafeEntry("word\\..\\outside.bin");
+ }
+
+ @Test
+ void adapterRejectsNulEntryNameBeforeProviderInvocation() {
+ assertUnsafeEntry("word/document.xml\u0000.exe");
+ }
+
+ @Test
+ void adapterAllowsRelativeOfficeStyleEntryName() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ adapter.convert(request(zipWithEntry("word/document.xml")));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ private static void assertUnsafeEntry(String entryName) {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request(zipWithEntry(entryName)))
+ );
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("source ZIP entry path is unsafe", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("b715d31f-e26f-451d-86fb-d95fb11e8e63"),
+ 11L,
+ "docx",
+ "policy-v1",
+ "trace-entry-path-policy",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] zipWithEntry(String entryName) {
+ byte[] nameBytes = entryName.getBytes(StandardCharsets.UTF_8);
+ int localHeaderLength = 30 + nameBytes.length;
+ int centralOffset = localHeaderLength;
+ int centralLength = 46 + nameBytes.length;
+ int eocdOffset = centralOffset + centralLength;
+ byte[] bytes = new byte[eocdOffset + 22];
+
+ putUnsignedInt(bytes, 0, 0x04034b50L);
+ putUnsignedShort(bytes, 26, nameBytes.length);
+ System.arraycopy(nameBytes, 0, bytes, 30, nameBytes.length);
+
+ putUnsignedInt(bytes, centralOffset, 0x02014b50L);
+ putUnsignedShort(bytes, centralOffset + 28, nameBytes.length);
+ putUnsignedInt(bytes, centralOffset + 42, 0L);
+ System.arraycopy(nameBytes, 0, bytes, centralOffset + 46, nameBytes.length);
+
+ putUnsignedInt(bytes, eocdOffset, 0x06054b50L);
+ putUnsignedShort(bytes, eocdOffset + 8, 1);
+ putUnsignedShort(bytes, eocdOffset + 10, 1);
+ putUnsignedInt(bytes, eocdOffset + 12, centralLength);
+ putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
+ return bytes;
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+}
From d285b4de64a2842d8ebaace0ec025c6fe3231d9d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:13:34 +0900
Subject: [PATCH 115/219] fix(conversion): reject unsafe ZIP entry paths
---
.../OfficeSourceContainerPreflight.java | 68 +++++++++++++++++--
1 file changed, 62 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 5c680e2c..8534e5e2 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -9,10 +9,12 @@
* bytes reach a sidecar or remote converter: the declared source format belongs to the
* current Office conversion candidate set, the leading container signature matches that
* format family, and ZIP-family candidates contain self-consistent standard single-disk
- * central-directory records and end-of-central-directory framing. Passing this preflight
- * is not complete package, macro, embedded-object, archive-expansion,
- * malware, or fidelity qualification. Those deeper controls remain separate
- * sandbox/content-policy acceptance gates.
+ * central-directory records and end-of-central-directory framing. ZIP entry names are
+ * also rejected when they are absolute, contain parent traversal, use backslash path
+ * separators, or contain NUL bytes. Passing this preflight is not
+ * complete package, macro, embedded-object, archive-expansion, malware, or fidelity
+ * qualification. Those deeper controls remain separate sandbox/content-policy acceptance
+ * gates.
*/
final class OfficeSourceContainerPreflight {
@@ -41,6 +43,11 @@ final class OfficeSourceContainerPreflight {
private static final int ZIP16_SENTINEL = 0xffff;
private static final long ZIP32_SENTINEL = 0xffff_ffffL;
private static final int ZIP_ENCRYPTED_FLAG = 0x0001;
+ private static final byte ZIP_PATH_SEPARATOR = (byte) '/';
+ private static final byte ZIP_WINDOWS_PATH_SEPARATOR = (byte) '\\';
+ private static final byte ZIP_NUL = 0;
+ private static final byte ZIP_DOT = (byte) '.';
+ private static final byte ZIP_COLON = (byte) ':';
private OfficeSourceContainerPreflight() {
}
@@ -51,8 +58,8 @@ private OfficeSourceContainerPreflight() {
* @param request immutable conversion request containing declared format and source bytes
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
- * ZIP-family source has invalid central-directory framing, or a ZIP entry is
- * encrypted
+ * ZIP-family source has invalid central-directory framing, a ZIP entry is
+ * encrypted, or a ZIP entry path is unsafe
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -158,6 +165,7 @@ private static void requireCentralDirectoryRecords(
if (nextCursor > centralDirectoryEnd) {
throw invalidCentralDirectory();
}
+ requireSafeEntryPath(sourceBytes, cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH, fileNameLength);
cursor = (int) nextCursor;
}
if (cursor != centralDirectoryEnd) {
@@ -165,6 +173,47 @@ private static void requireCentralDirectoryRecords(
}
}
+ private static void requireSafeEntryPath(byte[] sourceBytes, int nameOffset, int nameLength) {
+ if (nameLength == 0) {
+ throw unsafeEntryPath();
+ }
+ int nameEnd = nameOffset + nameLength;
+ byte first = sourceBytes[nameOffset];
+ if (first == ZIP_PATH_SEPARATOR || first == ZIP_WINDOWS_PATH_SEPARATOR) {
+ throw unsafeEntryPath();
+ }
+ if (nameLength >= 2 && isAsciiLetter(first) && sourceBytes[nameOffset + 1] == ZIP_COLON) {
+ throw unsafeEntryPath();
+ }
+
+ int segmentStart = nameOffset;
+ for (int cursor = nameOffset; cursor < nameEnd; cursor++) {
+ byte current = sourceBytes[cursor];
+ if (current == ZIP_NUL || current == ZIP_WINDOWS_PATH_SEPARATOR) {
+ throw unsafeEntryPath();
+ }
+ if (current == ZIP_PATH_SEPARATOR) {
+ if (isParentSegment(sourceBytes, segmentStart, cursor)) {
+ throw unsafeEntryPath();
+ }
+ segmentStart = cursor + 1;
+ }
+ }
+ if (isParentSegment(sourceBytes, segmentStart, nameEnd)) {
+ throw unsafeEntryPath();
+ }
+ }
+
+ private static boolean isParentSegment(byte[] sourceBytes, int start, int end) {
+ return end - start == 2
+ && sourceBytes[start] == ZIP_DOT
+ && sourceBytes[start + 1] == ZIP_DOT;
+ }
+
+ private static boolean isAsciiLetter(byte value) {
+ return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z');
+ }
+
private static int findEocdOffset(byte[] sourceBytes) {
if (sourceBytes.length < ZIP_EOCD_MINIMUM_LENGTH) {
return -1;
@@ -233,4 +282,11 @@ private static OfficeConversionException invalidCentralDirectory() {
"source ZIP central directory is invalid"
);
}
+
+ private static OfficeConversionException unsafeEntryPath() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.POLICY_DENIED,
+ "source ZIP entry path is unsafe"
+ );
+ }
}
From ac88b3ec1440fa7f89ce35dd917d1c52d56dfba9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:17:23 +0900
Subject: [PATCH 116/219] test(conversion): add safe ZIP fixture entry name
---
.../conversion/OfficeSourceContainerPreflightTest.java | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
index 8e52c8c6..0e4d236b 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -28,8 +28,10 @@ class OfficeSourceContainerPreflightTest {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
+ private static final byte[] SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
private static final int CENTRAL_OFFSET = 8;
- private static final int CENTRAL_RECORD_LENGTH = 46;
+ private static final int CENTRAL_FIXED_LENGTH = 46;
+ private static final int CENTRAL_RECORD_LENGTH = CENTRAL_FIXED_LENGTH + SAFE_ENTRY_NAME.length;
private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH;
private static final int EOCD_LENGTH = 22;
@@ -258,7 +260,9 @@ private static byte[] framedZip() {
bytes[CENTRAL_OFFSET + 1] = 0x4b;
bytes[CENTRAL_OFFSET + 2] = 0x01;
bytes[CENTRAL_OFFSET + 3] = 0x02;
+ putUnsignedShort(bytes, CENTRAL_OFFSET + 28, SAFE_ENTRY_NAME.length);
putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L);
+ System.arraycopy(SAFE_ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_FIXED_LENGTH, SAFE_ENTRY_NAME.length);
bytes[EOCD_OFFSET] = 0x50;
bytes[EOCD_OFFSET + 1] = 0x4b;
bytes[EOCD_OFFSET + 2] = 0x05;
From 81ef4d20959da80b21aafa119d3505a23dee1ee9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:17:50 +0900
Subject: [PATCH 117/219] test(conversion): qualify shared ZIP fixture paths
---
.../OfficeConversionTestSource.java | 31 ++++++++++++-------
1 file changed, 20 insertions(+), 11 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
index 1fa9b85f..a623b5fd 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
@@ -8,14 +8,13 @@
* Creates deterministic test-only Office source bytes for conversion-boundary tests.
*
* ZIP-family fixtures contain only enough framing to satisfy the common source
- * preflight: a local-file signature, deterministic marker bytes, one fixed-length
- * central-directory record, and a self-consistent standard single-disk
- * end-of-central-directory record. Legacy fixtures contain only the compound-file
- * family signature plus marker bytes. These are not valid complete
- * OOXML, ODF, or compound-file documents and must never be used as fidelity,
- * archive-structure, macro, malware, or production converter fixtures. Real
- * document-fidelity qualification uses separate authorized or redistributable Office
- * fixtures.
+ * preflight: a local-file signature, deterministic marker bytes, one central-directory
+ * record with a safe relative entry name, and a self-consistent standard single-disk
+ * end-of-central-directory record. Legacy fixtures contain only the compound-file family
+ * signature plus marker bytes. These are not valid complete OOXML, ODF,
+ * or compound-file documents and must never be used as fidelity, archive-structure,
+ * macro, malware, or production converter fixtures. Real document-fidelity qualification
+ * uses separate authorized or redistributable Office fixtures.
*/
final class OfficeConversionTestSource {
@@ -38,7 +37,8 @@ final class OfficeConversionTestSource {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
- private static final int ZIP_CENTRAL_DIRECTORY_RECORD_LENGTH = 46;
+ private static final byte[] ZIP_SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
+ private static final int ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH = 46;
private static final int ZIP_EOCD_LENGTH = 22;
private OfficeConversionTestSource() {
@@ -71,7 +71,8 @@ static byte[] forFormat(String sourceFormat, String marker) {
static byte[] zipPackage(String marker) {
byte[] markerBytes = marker.getBytes(StandardCharsets.UTF_8);
int centralDirectoryOffset = ZIP_LOCAL_FILE_HEADER.length + markerBytes.length;
- int eocdOffset = centralDirectoryOffset + ZIP_CENTRAL_DIRECTORY_RECORD_LENGTH;
+ int centralDirectoryLength = ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH + ZIP_SAFE_ENTRY_NAME.length;
+ int eocdOffset = centralDirectoryOffset + centralDirectoryLength;
byte[] bytes = new byte[eocdOffset + ZIP_EOCD_LENGTH];
System.arraycopy(ZIP_LOCAL_FILE_HEADER, 0, bytes, 0, ZIP_LOCAL_FILE_HEADER.length);
@@ -83,7 +84,15 @@ static byte[] zipPackage(String marker) {
centralDirectoryOffset,
ZIP_CENTRAL_DIRECTORY_HEADER.length
);
+ putUnsignedShort(bytes, centralDirectoryOffset + 28, ZIP_SAFE_ENTRY_NAME.length);
putUnsignedInt(bytes, centralDirectoryOffset + 42, 0L);
+ System.arraycopy(
+ ZIP_SAFE_ENTRY_NAME,
+ 0,
+ bytes,
+ centralDirectoryOffset + ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH,
+ ZIP_SAFE_ENTRY_NAME.length
+ );
System.arraycopy(
ZIP_END_OF_CENTRAL_DIRECTORY,
0,
@@ -93,7 +102,7 @@ static byte[] zipPackage(String marker) {
);
putUnsignedShort(bytes, eocdOffset + 8, 1);
putUnsignedShort(bytes, eocdOffset + 10, 1);
- putUnsignedInt(bytes, eocdOffset + 12, ZIP_CENTRAL_DIRECTORY_RECORD_LENGTH);
+ putUnsignedInt(bytes, eocdOffset + 12, centralDirectoryLength);
putUnsignedInt(bytes, eocdOffset + 16, centralDirectoryOffset);
putUnsignedShort(bytes, eocdOffset + 20, 0);
return bytes;
From 18ccc3f0ac73355c100efe904d534aa8ff65a40b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:18:21 +0900
Subject: [PATCH 118/219] test(conversion): qualify central-directory fixtures
---
...OfficeSourceCentralDirectoryPolicyTest.java | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index 5d9a14d9..efcf02b7 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
@@ -13,14 +14,17 @@
*
* The pre-provider boundary must not trust the EOCD entry count or a leading
* central-directory signature alone. These tests do not decompress entry data;
- * they only require structurally present central records and fail closed when a
- * record advertises ZIP encryption.
+ * they only require structurally present central records with a safe relative
+ * entry name and fail closed when a record advertises ZIP encryption.
*/
class OfficeSourceCentralDirectoryPolicyTest {
+ private static final byte[] SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
private static final int LOCAL_HEADER_OFFSET = 0;
private static final int CENTRAL_DIRECTORY_OFFSET = 4;
- private static final int CENTRAL_DIRECTORY_RECORD_LENGTH = 46;
+ private static final int CENTRAL_DIRECTORY_FIXED_LENGTH = 46;
+ private static final int CENTRAL_DIRECTORY_RECORD_LENGTH =
+ CENTRAL_DIRECTORY_FIXED_LENGTH + SAFE_ENTRY_NAME.length;
private static final int EOCD_OFFSET = CENTRAL_DIRECTORY_OFFSET + CENTRAL_DIRECTORY_RECORD_LENGTH;
private static final int EOCD_LENGTH = 22;
@@ -86,7 +90,15 @@ private static byte[] oneEntryZip(int entriesOnDisk, int totalEntries) {
byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH];
putSignature(bytes, LOCAL_HEADER_OFFSET, 0x04034b50L);
putSignature(bytes, CENTRAL_DIRECTORY_OFFSET, 0x02014b50L);
+ putUnsignedShort(bytes, CENTRAL_DIRECTORY_OFFSET + 28, SAFE_ENTRY_NAME.length);
putUnsignedInt(bytes, CENTRAL_DIRECTORY_OFFSET + 42, LOCAL_HEADER_OFFSET);
+ System.arraycopy(
+ SAFE_ENTRY_NAME,
+ 0,
+ bytes,
+ CENTRAL_DIRECTORY_OFFSET + CENTRAL_DIRECTORY_FIXED_LENGTH,
+ SAFE_ENTRY_NAME.length
+ );
putSignature(bytes, EOCD_OFFSET, 0x06054b50L);
putUnsignedShort(bytes, EOCD_OFFSET + 8, entriesOnDisk);
putUnsignedShort(bytes, EOCD_OFFSET + 10, totalEntries);
From 8a3ed99b72494bc4b2318d6337fc6ec6d025a1f8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:20:53 +0900
Subject: [PATCH 119/219] test(conversion): require local-central ZIP name
consistency
---
.../OfficeSourceEntryPathPolicyTest.java | 39 ++++++++++++++-----
1 file changed, 30 insertions(+), 9 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java
index 57b64ddb..0d96871f 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java
@@ -14,8 +14,9 @@
*
* The converter may eventually use a filesystem-backed sandbox internally, so
* path traversal and platform-absolute entry names must fail before provider
- * invocation. These tests inspect only central-directory metadata and do not
- * extract archive contents.
+ * invocation. Local-header and central-directory entry names must also agree so
+ * different ZIP consumers cannot be given conflicting path metadata. These tests
+ * inspect archive metadata and do not extract archive contents.
*/
class OfficeSourceEntryPathPolicyTest {
@@ -44,6 +45,21 @@ void adapterRejectsNulEntryNameBeforeProviderInvocation() {
assertUnsafeEntry("word/document.xml\u0000.exe");
}
+ @Test
+ void adapterRejectsLocalHeaderNameThatDiffersFromCentralDirectoryBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ OfficeConversionAdapter adapter = countingAdapter(providerCalls);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> adapter.convert(request(zipWithEntryNames("../outside.bin", "word/document.xml")))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP local header does not match central directory", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterAllowsRelativeOfficeStyleEntryName() {
AtomicInteger providerCalls = new AtomicInteger();
@@ -96,21 +112,26 @@ private static OfficeConversionRequest request(byte[] sourceBytes) {
}
private static byte[] zipWithEntry(String entryName) {
- byte[] nameBytes = entryName.getBytes(StandardCharsets.UTF_8);
- int localHeaderLength = 30 + nameBytes.length;
+ return zipWithEntryNames(entryName, entryName);
+ }
+
+ private static byte[] zipWithEntryNames(String localEntryName, String centralEntryName) {
+ byte[] localNameBytes = localEntryName.getBytes(StandardCharsets.UTF_8);
+ byte[] centralNameBytes = centralEntryName.getBytes(StandardCharsets.UTF_8);
+ int localHeaderLength = 30 + localNameBytes.length;
int centralOffset = localHeaderLength;
- int centralLength = 46 + nameBytes.length;
+ int centralLength = 46 + centralNameBytes.length;
int eocdOffset = centralOffset + centralLength;
byte[] bytes = new byte[eocdOffset + 22];
putUnsignedInt(bytes, 0, 0x04034b50L);
- putUnsignedShort(bytes, 26, nameBytes.length);
- System.arraycopy(nameBytes, 0, bytes, 30, nameBytes.length);
+ putUnsignedShort(bytes, 26, localNameBytes.length);
+ System.arraycopy(localNameBytes, 0, bytes, 30, localNameBytes.length);
putUnsignedInt(bytes, centralOffset, 0x02014b50L);
- putUnsignedShort(bytes, centralOffset + 28, nameBytes.length);
+ putUnsignedShort(bytes, centralOffset + 28, centralNameBytes.length);
putUnsignedInt(bytes, centralOffset + 42, 0L);
- System.arraycopy(nameBytes, 0, bytes, centralOffset + 46, nameBytes.length);
+ System.arraycopy(centralNameBytes, 0, bytes, centralOffset + 46, centralNameBytes.length);
putUnsignedInt(bytes, eocdOffset, 0x06054b50L);
putUnsignedShort(bytes, eocdOffset + 8, 1);
From 387b35f15371e4feda60ed60b929b076953846d1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:22:39 +0900
Subject: [PATCH 120/219] fix(conversion): bind ZIP local and central entry
names
---
.../OfficeSourceContainerPreflight.java | 48 +++++++++++++++++--
1 file changed, 44 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 8534e5e2..5e1115da 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -9,8 +9,8 @@
* bytes reach a sidecar or remote converter: the declared source format belongs to the
* current Office conversion candidate set, the leading container signature matches that
* format family, and ZIP-family candidates contain self-consistent standard single-disk
- * central-directory records and end-of-central-directory framing. ZIP entry names are
- * also rejected when they are absolute, contain parent traversal, use backslash path
+ * local-header, central-directory, and end-of-central-directory framing. ZIP entry names
+ * are also rejected when they are absolute, contain parent traversal, use backslash path
* separators, or contain NUL bytes. Passing this preflight is not
* complete package, macro, embedded-object, archive-expansion, malware, or fidelity
* qualification. Those deeper controls remain separate sandbox/content-policy acceptance
@@ -38,6 +38,7 @@ final class OfficeSourceContainerPreflight {
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private static final int ZIP_EOCD_MINIMUM_LENGTH = 22;
+ private static final int ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH = 30;
private static final int ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH = 46;
private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535;
private static final int ZIP16_SENTINEL = 0xffff;
@@ -58,7 +59,7 @@ private OfficeSourceContainerPreflight() {
* @param request immutable conversion request containing declared format and source bytes
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
- * ZIP-family source has invalid central-directory framing, a ZIP entry is
+ * ZIP-family source has invalid local/central-directory framing, a ZIP entry is
* encrypted, or a ZIP entry path is unsafe
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
@@ -165,7 +166,15 @@ private static void requireCentralDirectoryRecords(
if (nextCursor > centralDirectoryEnd) {
throw invalidCentralDirectory();
}
- requireSafeEntryPath(sourceBytes, cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH, fileNameLength);
+ int centralNameOffset = cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH;
+ requireMatchingLocalHeaderName(
+ sourceBytes,
+ (int) localHeaderOffset,
+ centralDirectoryOffset,
+ centralNameOffset,
+ fileNameLength
+ );
+ requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
cursor = (int) nextCursor;
}
if (cursor != centralDirectoryEnd) {
@@ -173,6 +182,30 @@ private static void requireCentralDirectoryRecords(
}
}
+ private static void requireMatchingLocalHeaderName(
+ byte[] sourceBytes,
+ int localHeaderOffset,
+ int centralDirectoryOffset,
+ int centralNameOffset,
+ int centralNameLength
+ ) {
+ if (localHeaderOffset > centralDirectoryOffset - ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH) {
+ throw invalidLocalHeader();
+ }
+ int localNameLength = unsignedShort(sourceBytes, localHeaderOffset + 26);
+ int localExtraFieldLength = unsignedShort(sourceBytes, localHeaderOffset + 28);
+ long localNameOffset = (long) localHeaderOffset + ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH;
+ long localHeaderMetadataEnd = localNameOffset + localNameLength + localExtraFieldLength;
+ if (localNameLength != centralNameLength || localHeaderMetadataEnd > centralDirectoryOffset) {
+ throw invalidLocalHeader();
+ }
+ for (int index = 0; index < centralNameLength; index++) {
+ if (sourceBytes[(int) localNameOffset + index] != sourceBytes[centralNameOffset + index]) {
+ throw invalidLocalHeader();
+ }
+ }
+ }
+
private static void requireSafeEntryPath(byte[] sourceBytes, int nameOffset, int nameLength) {
if (nameLength == 0) {
throw unsafeEntryPath();
@@ -283,6 +316,13 @@ private static OfficeConversionException invalidCentralDirectory() {
);
}
+ private static OfficeConversionException invalidLocalHeader() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ZIP local header does not match central directory"
+ );
+ }
+
private static OfficeConversionException unsafeEntryPath() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 9b35e827c472906be5509486c0c86d6fd90cffc9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:23:48 +0900
Subject: [PATCH 121/219] test(conversion): qualify local ZIP header fixture
---
.../OfficeSourceContainerPreflightTest.java | 23 +++++++++++++------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
index 0e4d236b..d3abaf86 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java
@@ -15,13 +15,13 @@
*
* These tests deliberately cover only the common pre-conversion authority:
* candidate format qualification, declared-format/container-family agreement,
- * and bounded ZIP central-directory framing. They do not treat passing this
- * preflight as complete safety, Office-package, archive-expansion, macro,
+ * and bounded ZIP local/central-directory framing. They do not treat passing
+ * this preflight as complete safety, Office-package, archive-expansion, macro,
* malware, or fidelity qualification.
*/
class OfficeSourceContainerPreflightTest {
- private static final byte[] ZIP_LOCAL_HEADER = new byte[] {
+ private static final byte[] ZIP_SIGNATURE_PREFIX = new byte[] {
0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00
};
private static final byte[] COMPOUND_FILE_HEADER = new byte[] {
@@ -29,7 +29,8 @@ class OfficeSourceContainerPreflightTest {
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private static final byte[] SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
- private static final int CENTRAL_OFFSET = 8;
+ private static final int LOCAL_FIXED_LENGTH = 30;
+ private static final int CENTRAL_OFFSET = LOCAL_FIXED_LENGTH + SAFE_ENTRY_NAME.length;
private static final int CENTRAL_FIXED_LENGTH = 46;
private static final int CENTRAL_RECORD_LENGTH = CENTRAL_FIXED_LENGTH + SAFE_ENTRY_NAME.length;
private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH;
@@ -89,13 +90,13 @@ void adapterRejectsTruncatedZipSignatureBeforeProviderInvocation() {
@Test
void adapterRejectsZipPrefixWithoutCentralDirectoryFramingBeforeProviderInvocation() {
- assertMalformedZipBeforeProvider(ZIP_LOCAL_HEADER);
+ assertMalformedZipBeforeProvider(ZIP_SIGNATURE_PREFIX);
}
@Test
void adapterRejectsLongZipCandidateWithoutEocdBeforeProviderInvocation() {
byte[] bytes = new byte[40];
- System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
+ System.arraycopy(ZIP_SIGNATURE_PREFIX, 0, bytes, 0, ZIP_SIGNATURE_PREFIX.length);
assertMalformedZipBeforeProvider(bytes);
}
@@ -255,7 +256,14 @@ private static OfficeConversionRequest request(String sourceFormat, byte[] sourc
private static byte[] framedZip() {
byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH];
- System.arraycopy(ZIP_LOCAL_HEADER, 0, bytes, 0, ZIP_LOCAL_HEADER.length);
+ bytes[0] = 0x50;
+ bytes[1] = 0x4b;
+ bytes[2] = 0x03;
+ bytes[3] = 0x04;
+ putUnsignedShort(bytes, 4, 20);
+ putUnsignedShort(bytes, 26, SAFE_ENTRY_NAME.length);
+ System.arraycopy(SAFE_ENTRY_NAME, 0, bytes, LOCAL_FIXED_LENGTH, SAFE_ENTRY_NAME.length);
+
bytes[CENTRAL_OFFSET] = 0x50;
bytes[CENTRAL_OFFSET + 1] = 0x4b;
bytes[CENTRAL_OFFSET + 2] = 0x01;
@@ -263,6 +271,7 @@ private static byte[] framedZip() {
putUnsignedShort(bytes, CENTRAL_OFFSET + 28, SAFE_ENTRY_NAME.length);
putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L);
System.arraycopy(SAFE_ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_FIXED_LENGTH, SAFE_ENTRY_NAME.length);
+
bytes[EOCD_OFFSET] = 0x50;
bytes[EOCD_OFFSET + 1] = 0x4b;
bytes[EOCD_OFFSET + 2] = 0x05;
From 4369d77d40d0e4610a5f0c8453f0c6771bdc0f54 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:24:17 +0900
Subject: [PATCH 122/219] test(conversion): qualify shared ZIP local headers
---
.../OfficeConversionTestSource.java | 25 ++++++++++++-------
1 file changed, 16 insertions(+), 9 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
index a623b5fd..c307366f 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java
@@ -8,13 +8,13 @@
* Creates deterministic test-only Office source bytes for conversion-boundary tests.
*
* ZIP-family fixtures contain only enough framing to satisfy the common source
- * preflight: a local-file signature, deterministic marker bytes, one central-directory
- * record with a safe relative entry name, and a self-consistent standard single-disk
- * end-of-central-directory record. Legacy fixtures contain only the compound-file family
- * signature plus marker bytes. These are not valid complete OOXML, ODF,
- * or compound-file documents and must never be used as fidelity, archive-structure,
- * macro, malware, or production converter fixtures. Real document-fidelity qualification
- * uses separate authorized or redistributable Office fixtures.
+ * preflight: matching local/central entry metadata, deterministic marker bytes, and a
+ * self-consistent standard single-disk end-of-central-directory record. Legacy fixtures
+ * contain only the compound-file family signature plus marker bytes. These are
+ * not valid complete OOXML, ODF, or compound-file documents and must
+ * never be used as fidelity, archive-expansion, macro, malware, or production converter
+ * fixtures. Real document-fidelity qualification uses separate authorized or
+ * redistributable Office fixtures.
*/
final class OfficeConversionTestSource {
@@ -38,6 +38,7 @@ final class OfficeConversionTestSource {
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private static final byte[] ZIP_SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
+ private static final int ZIP_LOCAL_FILE_HEADER_FIXED_LENGTH = 30;
private static final int ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH = 46;
private static final int ZIP_EOCD_LENGTH = 22;
@@ -70,13 +71,19 @@ static byte[] forFormat(String sourceFormat, String marker) {
*/
static byte[] zipPackage(String marker) {
byte[] markerBytes = marker.getBytes(StandardCharsets.UTF_8);
- int centralDirectoryOffset = ZIP_LOCAL_FILE_HEADER.length + markerBytes.length;
+ int localNameOffset = ZIP_LOCAL_FILE_HEADER_FIXED_LENGTH;
+ int markerOffset = localNameOffset + ZIP_SAFE_ENTRY_NAME.length;
+ int centralDirectoryOffset = markerOffset + markerBytes.length;
int centralDirectoryLength = ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH + ZIP_SAFE_ENTRY_NAME.length;
int eocdOffset = centralDirectoryOffset + centralDirectoryLength;
byte[] bytes = new byte[eocdOffset + ZIP_EOCD_LENGTH];
System.arraycopy(ZIP_LOCAL_FILE_HEADER, 0, bytes, 0, ZIP_LOCAL_FILE_HEADER.length);
- System.arraycopy(markerBytes, 0, bytes, ZIP_LOCAL_FILE_HEADER.length, markerBytes.length);
+ putUnsignedShort(bytes, 4, 20);
+ putUnsignedShort(bytes, 26, ZIP_SAFE_ENTRY_NAME.length);
+ System.arraycopy(ZIP_SAFE_ENTRY_NAME, 0, bytes, localNameOffset, ZIP_SAFE_ENTRY_NAME.length);
+ System.arraycopy(markerBytes, 0, bytes, markerOffset, markerBytes.length);
+
System.arraycopy(
ZIP_CENTRAL_DIRECTORY_HEADER,
0,
From 28c702743b93aa46f6358f97c4aa9e14214cd98e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:24:59 +0900
Subject: [PATCH 123/219] test(conversion): qualify central policy local
headers
---
.../OfficeSourceCentralDirectoryPolicyTest.java | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index efcf02b7..2868a832 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -14,14 +14,15 @@
*
* The pre-provider boundary must not trust the EOCD entry count or a leading
* central-directory signature alone. These tests do not decompress entry data;
- * they only require structurally present central records with a safe relative
- * entry name and fail closed when a record advertises ZIP encryption.
+ * they require matching local/central metadata with a safe relative entry name
+ * and fail closed when a central record advertises ZIP encryption.
*/
class OfficeSourceCentralDirectoryPolicyTest {
private static final byte[] SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
private static final int LOCAL_HEADER_OFFSET = 0;
- private static final int CENTRAL_DIRECTORY_OFFSET = 4;
+ private static final int LOCAL_HEADER_FIXED_LENGTH = 30;
+ private static final int CENTRAL_DIRECTORY_OFFSET = LOCAL_HEADER_FIXED_LENGTH + SAFE_ENTRY_NAME.length;
private static final int CENTRAL_DIRECTORY_FIXED_LENGTH = 46;
private static final int CENTRAL_DIRECTORY_RECORD_LENGTH =
CENTRAL_DIRECTORY_FIXED_LENGTH + SAFE_ENTRY_NAME.length;
@@ -89,6 +90,16 @@ private static OfficeConversionRequest request(byte[] sourceBytes) {
private static byte[] oneEntryZip(int entriesOnDisk, int totalEntries) {
byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH];
putSignature(bytes, LOCAL_HEADER_OFFSET, 0x04034b50L);
+ putUnsignedShort(bytes, LOCAL_HEADER_OFFSET + 4, 20);
+ putUnsignedShort(bytes, LOCAL_HEADER_OFFSET + 26, SAFE_ENTRY_NAME.length);
+ System.arraycopy(
+ SAFE_ENTRY_NAME,
+ 0,
+ bytes,
+ LOCAL_HEADER_OFFSET + LOCAL_HEADER_FIXED_LENGTH,
+ SAFE_ENTRY_NAME.length
+ );
+
putSignature(bytes, CENTRAL_DIRECTORY_OFFSET, 0x02014b50L);
putUnsignedShort(bytes, CENTRAL_DIRECTORY_OFFSET + 28, SAFE_ENTRY_NAME.length);
putUnsignedInt(bytes, CENTRAL_DIRECTORY_OFFSET + 42, LOCAL_HEADER_OFFSET);
From f693a54147c192199280ab15bba97c9cffced318 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 09:26:36 +0900
Subject: [PATCH 124/219] test(conversion): reject local-header encryption
mismatch
---
...fficeSourceCentralDirectoryPolicyTest.java | 26 +++++++++++++++----
1 file changed, 21 insertions(+), 5 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index 2868a832..3b18aec8 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -10,12 +10,12 @@
import org.junit.jupiter.api.Test;
/**
- * Central-directory metadata regressions for ZIP-family Office candidates.
+ * Local/central-directory metadata regressions for ZIP-family Office candidates.
*
- * The pre-provider boundary must not trust the EOCD entry count or a leading
- * central-directory signature alone. These tests do not decompress entry data;
- * they require matching local/central metadata with a safe relative entry name
- * and fail closed when a central record advertises ZIP encryption.
+ * The pre-provider boundary must not trust the EOCD entry count or only one of
+ * the duplicated local/central metadata authorities. These tests do not decompress
+ * entry data; they require matching local/central metadata with a safe relative
+ * entry name and fail closed when either record advertises ZIP encryption.
*/
class OfficeSourceCentralDirectoryPolicyTest {
@@ -45,6 +45,22 @@ void adapterRejectsEncryptedCentralDirectoryEntryBeforeProviderInvocation() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsEncryptedLocalHeaderWhenCentralDirectoryLooksUnencrypted() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(1, 1);
+ putUnsignedShort(source, LOCAL_HEADER_OFFSET + 6, 0x0001);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.PASSWORD_PROTECTED, failure.failureCode());
+ assertEquals("source ZIP entry is encrypted", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterRejectsEocdCountThatExceedsPresentCentralRecords() {
AtomicInteger providerCalls = new AtomicInteger();
From e1b720f9c61c17b20667f1f706bc0f43d3e81c32 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 11:10:23 +0900
Subject: [PATCH 125/219] fix(conversion): reject encrypted ZIP local headers
---
.../OfficeSourceContainerPreflight.java | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 5e1115da..2ebe3b19 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -135,10 +135,7 @@ private static void requireCentralDirectoryRecords(
int flags = unsignedShort(sourceBytes, cursor + 8);
if ((flags & ZIP_ENCRYPTED_FLAG) != 0) {
- throw new OfficeConversionException(
- OfficeConversionFailureCode.PASSWORD_PROTECTED,
- "source ZIP entry is encrypted"
- );
+ throw encryptedZipEntry();
}
long compressedSize = unsignedInt(sourceBytes, cursor + 20);
@@ -192,6 +189,10 @@ private static void requireMatchingLocalHeaderName(
if (localHeaderOffset > centralDirectoryOffset - ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH) {
throw invalidLocalHeader();
}
+ int localFlags = unsignedShort(sourceBytes, localHeaderOffset + 6);
+ if ((localFlags & ZIP_ENCRYPTED_FLAG) != 0) {
+ throw encryptedZipEntry();
+ }
int localNameLength = unsignedShort(sourceBytes, localHeaderOffset + 26);
int localExtraFieldLength = unsignedShort(sourceBytes, localHeaderOffset + 28);
long localNameOffset = (long) localHeaderOffset + ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH;
@@ -302,6 +303,13 @@ private static long unsignedInt(byte[] sourceBytes, int offset) {
);
}
+ private static OfficeConversionException encryptedZipEntry() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.PASSWORD_PROTECTED,
+ "source ZIP entry is encrypted"
+ );
+ }
+
private static OfficeConversionException invalidZipFraming() {
return new OfficeConversionException(
OfficeConversionFailureCode.MALFORMED_INPUT,
From dd3bbf28e63e29510c2848d2060e47374b4dc8f9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 11:20:21 +0900
Subject: [PATCH 126/219] test(conversion): reject local-central compression
mismatch
---
...fficeSourceCentralDirectoryPolicyTest.java | 20 ++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index 3b18aec8..562d3b04 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -15,7 +15,8 @@
* The pre-provider boundary must not trust the EOCD entry count or only one of
* the duplicated local/central metadata authorities. These tests do not decompress
* entry data; they require matching local/central metadata with a safe relative
- * entry name and fail closed when either record advertises ZIP encryption.
+ * entry name and fail closed when either record advertises ZIP encryption or the
+ * duplicated compression-method metadata disagrees.
*/
class OfficeSourceCentralDirectoryPolicyTest {
@@ -61,6 +62,23 @@ void adapterRejectsEncryptedLocalHeaderWhenCentralDirectoryLooksUnencrypted() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsCompressionMethodMismatchBetweenLocalAndCentralRecords() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(1, 1);
+ putUnsignedShort(source, LOCAL_HEADER_OFFSET + 8, 8);
+ putUnsignedShort(source, CENTRAL_DIRECTORY_OFFSET + 10, 0);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP local header does not match central directory", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterRejectsEocdCountThatExceedsPresentCentralRecords() {
AtomicInteger providerCalls = new AtomicInteger();
From 48596e979f32dcebaebdfe0c489e6ceb8802d90d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 11:24:13 +0900
Subject: [PATCH 127/219] fix(conversion): bind ZIP compression metadata
---
.../OfficeSourceContainerPreflight.java | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 2ebe3b19..ba4e6446 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -137,6 +137,7 @@ private static void requireCentralDirectoryRecords(
if ((flags & ZIP_ENCRYPTED_FLAG) != 0) {
throw encryptedZipEntry();
}
+ int compressionMethod = unsignedShort(sourceBytes, cursor + 10);
long compressedSize = unsignedInt(sourceBytes, cursor + 20);
long uncompressedSize = unsignedInt(sourceBytes, cursor + 24);
@@ -164,12 +165,13 @@ private static void requireCentralDirectoryRecords(
throw invalidCentralDirectory();
}
int centralNameOffset = cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH;
- requireMatchingLocalHeaderName(
+ requireMatchingLocalHeaderMetadata(
sourceBytes,
(int) localHeaderOffset,
centralDirectoryOffset,
centralNameOffset,
- fileNameLength
+ fileNameLength,
+ compressionMethod
);
requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
cursor = (int) nextCursor;
@@ -179,12 +181,13 @@ private static void requireCentralDirectoryRecords(
}
}
- private static void requireMatchingLocalHeaderName(
+ private static void requireMatchingLocalHeaderMetadata(
byte[] sourceBytes,
int localHeaderOffset,
int centralDirectoryOffset,
int centralNameOffset,
- int centralNameLength
+ int centralNameLength,
+ int centralCompressionMethod
) {
if (localHeaderOffset > centralDirectoryOffset - ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH) {
throw invalidLocalHeader();
@@ -193,11 +196,14 @@ private static void requireMatchingLocalHeaderName(
if ((localFlags & ZIP_ENCRYPTED_FLAG) != 0) {
throw encryptedZipEntry();
}
+ int localCompressionMethod = unsignedShort(sourceBytes, localHeaderOffset + 8);
int localNameLength = unsignedShort(sourceBytes, localHeaderOffset + 26);
int localExtraFieldLength = unsignedShort(sourceBytes, localHeaderOffset + 28);
long localNameOffset = (long) localHeaderOffset + ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH;
long localHeaderMetadataEnd = localNameOffset + localNameLength + localExtraFieldLength;
- if (localNameLength != centralNameLength || localHeaderMetadataEnd > centralDirectoryOffset) {
+ if (localCompressionMethod != centralCompressionMethod
+ || localNameLength != centralNameLength
+ || localHeaderMetadataEnd > centralDirectoryOffset) {
throw invalidLocalHeader();
}
for (int index = 0; index < centralNameLength; index++) {
From 8352eb7a62ca0673266a80b97a7b8ddea11cc1d1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 12:10:29 +0900
Subject: [PATCH 128/219] test(conversion): reject unsupported ZIP compression
methods
---
...fficeSourceCentralDirectoryPolicyTest.java | 22 +++++++++++++++++--
1 file changed, 20 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index 562d3b04..d5d7cfe7 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -15,8 +15,9 @@
* The pre-provider boundary must not trust the EOCD entry count or only one of
* the duplicated local/central metadata authorities. These tests do not decompress
* entry data; they require matching local/central metadata with a safe relative
- * entry name and fail closed when either record advertises ZIP encryption or the
- * duplicated compression-method metadata disagrees.
+ * entry name and fail closed when either record advertises ZIP encryption, the
+ * duplicated compression-method metadata disagrees, or the agreed compression
+ * method falls outside the current Stored/Deflate qualification boundary.
*/
class OfficeSourceCentralDirectoryPolicyTest {
@@ -79,6 +80,23 @@ void adapterRejectsCompressionMethodMismatchBetweenLocalAndCentralRecords() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsUnsupportedCompressionMethodBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(1, 1);
+ putUnsignedShort(source, LOCAL_HEADER_OFFSET + 8, 12);
+ putUnsignedShort(source, CENTRAL_DIRECTORY_OFFSET + 10, 12);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode());
+ assertEquals("source ZIP compression method is not allowed", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterRejectsEocdCountThatExceedsPresentCentralRecords() {
AtomicInteger providerCalls = new AtomicInteger();
From 3a20318d90aeaa1962a9aa16be776f10564a9b97 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 12:15:00 +0900
Subject: [PATCH 129/219] fix(conversion): bound ZIP compression methods
---
.../OfficeSourceContainerPreflight.java | 26 ++++++++++++++++---
1 file changed, 22 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index ba4e6446..f04c4e5c 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -9,8 +9,9 @@
* bytes reach a sidecar or remote converter: the declared source format belongs to the
* current Office conversion candidate set, the leading container signature matches that
* format family, and ZIP-family candidates contain self-consistent standard single-disk
- * local-header, central-directory, and end-of-central-directory framing. ZIP entry names
- * are also rejected when they are absolute, contain parent traversal, use backslash path
+ * local-header, central-directory, and end-of-central-directory framing. ZIP entries are
+ * limited to the current Stored/Deflate compression qualification boundary, and entry
+ * names are rejected when they are absolute, contain parent traversal, use backslash path
* separators, or contain NUL bytes. Passing this preflight is not
* complete package, macro, embedded-object, archive-expansion, malware, or fidelity
* qualification. Those deeper controls remain separate sandbox/content-policy acceptance
@@ -44,6 +45,8 @@ final class OfficeSourceContainerPreflight {
private static final int ZIP16_SENTINEL = 0xffff;
private static final long ZIP32_SENTINEL = 0xffff_ffffL;
private static final int ZIP_ENCRYPTED_FLAG = 0x0001;
+ private static final int ZIP_STORED_METHOD = 0;
+ private static final int ZIP_DEFLATED_METHOD = 8;
private static final byte ZIP_PATH_SEPARATOR = (byte) '/';
private static final byte ZIP_WINDOWS_PATH_SEPARATOR = (byte) '\\';
private static final byte ZIP_NUL = 0;
@@ -59,8 +62,9 @@ private OfficeSourceContainerPreflight() {
* @param request immutable conversion request containing declared format and source bytes
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
- * ZIP-family source has invalid local/central-directory framing, a ZIP entry is
- * encrypted, or a ZIP entry path is unsafe
+ * ZIP-family source has invalid local/central-directory framing, uses a ZIP
+ * compression method outside the current Stored/Deflate qualification boundary,
+ * contains an encrypted entry, or has an unsafe ZIP entry path
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -138,6 +142,9 @@ private static void requireCentralDirectoryRecords(
throw encryptedZipEntry();
}
int compressionMethod = unsignedShort(sourceBytes, cursor + 10);
+ if (!isAllowedCompressionMethod(compressionMethod)) {
+ throw unsupportedCompressionMethod();
+ }
long compressedSize = unsignedInt(sourceBytes, cursor + 20);
long uncompressedSize = unsignedInt(sourceBytes, cursor + 24);
@@ -213,6 +220,10 @@ private static void requireMatchingLocalHeaderMetadata(
}
}
+ private static boolean isAllowedCompressionMethod(int compressionMethod) {
+ return compressionMethod == ZIP_STORED_METHOD || compressionMethod == ZIP_DEFLATED_METHOD;
+ }
+
private static void requireSafeEntryPath(byte[] sourceBytes, int nameOffset, int nameLength) {
if (nameLength == 0) {
throw unsafeEntryPath();
@@ -337,6 +348,13 @@ private static OfficeConversionException invalidLocalHeader() {
);
}
+ private static OfficeConversionException unsupportedCompressionMethod() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.POLICY_DENIED,
+ "source ZIP compression method is not allowed"
+ );
+ }
+
private static OfficeConversionException unsafeEntryPath() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 83ec6f7fe2b04bdcd28bf98ec350e41e55730a18 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:22:31 +0900
Subject: [PATCH 130/219] deps(deps): bump com.github.junrar:junrar from 7.6.0
to 8.1.0 (#311)
Bumps [com.github.junrar:junrar](https://github.com/junrar/junrar) from 7.6.0 to 8.1.0.
- [Release notes](https://github.com/junrar/junrar/releases)
- [Changelog](https://github.com/junrar/junrar/blob/master/CHANGELOG.md)
- [Commits](https://github.com/junrar/junrar/compare/v7.6.0...v8.1.0)
---
updated-dependencies:
- dependency-name: com.github.junrar:junrar
dependency-version: 8.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pom.xml b/pom.xml
index 51e22d13..b5bf49d4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -64,7 +64,7 @@
com.github.junrar
junrar
- 7.6.0
+ 8.1.0
From 9adf1620445420ab761da6eb9c95c5462c82dc50 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 12:42:27 +0900
Subject: [PATCH 131/219] test(conversion): reject fixture fidelity overclaim
---
...xtureOfficeConversionAdapterClaimTest.java | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java b/src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java
new file mode 100644
index 00000000..ac4805ea
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java
@@ -0,0 +1,31 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Protects the public documentation boundary of the deterministic Office fixture adapter.
+ */
+class DeterministicFixtureOfficeConversionAdapterClaimTest {
+
+ /**
+ * Prevents a byte-replay fixture from being described as evidence of Office rendering fidelity.
+ *
+ * @throws IOException when the production source cannot be read by the contract test
+ */
+ @Test
+ void deterministicFixtureIsDocumentedAsContractOracleNotFidelityImplementation() throws IOException {
+ String source = Files.readString(Path.of(
+ "src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java"
+ ));
+
+ assertThat(source)
+ .contains("contract oracle")
+ .doesNotContain("contract and fidelity test implementation");
+ }
+}
From 8a9b1cd8ce3d006c7dc5b788580d13c870f1e31b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 12:44:09 +0900
Subject: [PATCH 132/219] docs(conversion): stop treating fixture replay as
fidelity evidence
---
.../DeterministicFixtureOfficeConversionAdapter.java | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java
index 546c1d0a..51beb103 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java
@@ -6,10 +6,10 @@
/**
* Deterministic offline Office conversion adapter backed by exact request fixtures.
*
- * This adapter is a contract and fidelity test implementation, not a production
- * Office renderer. It returns only pre-registered PDF bytes for the exact immutable
- * request binding and therefore cannot silently accept a stale tenant, job,
- * lifecycle generation, format, policy, correlation identity, or source digest.
+ * This adapter is a contract oracle, not evidence of Office rendering fidelity and
+ * not a production Office renderer. It returns only pre-registered PDF bytes for the
+ * exact immutable request binding and therefore cannot silently accept a stale tenant,
+ * job, lifecycle generation, format, policy, correlation identity, or source digest.
*/
public final class DeterministicFixtureOfficeConversionAdapter implements OfficeConversionAdapter {
From b2aa85d2387074e59046ee5509281cb9b7064122 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 12:52:39 +0900
Subject: [PATCH 133/219] test(conversion): reject impossible ZIP compressed
spans
---
...fficeSourceCentralDirectoryPolicyTest.java | 22 +++++++++++++++++--
1 file changed, 20 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index d5d7cfe7..2e653639 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -16,8 +16,9 @@
* the duplicated local/central metadata authorities. These tests do not decompress
* entry data; they require matching local/central metadata with a safe relative
* entry name and fail closed when either record advertises ZIP encryption, the
- * duplicated compression-method metadata disagrees, or the agreed compression
- * method falls outside the current Stored/Deflate qualification boundary.
+ * duplicated compression-method metadata disagrees, the agreed compression
+ * method falls outside the current Stored/Deflate qualification boundary, or a
+ * central-directory entry claims compressed bytes outside the local data region.
*/
class OfficeSourceCentralDirectoryPolicyTest {
@@ -97,6 +98,23 @@ void adapterRejectsUnsupportedCompressionMethodBeforeProviderInvocation() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsCompressedSizeThatExtendsBeyondLocalDataRegion() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(1, 1);
+ putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 20, 1L);
+ putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 24, 1L);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP entry data exceeds local data region", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterRejectsEocdCountThatExceedsPresentCentralRecords() {
AtomicInteger providerCalls = new AtomicInteger();
From 89b201e0775ae80f99093e2b1448148932159d9f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 12:55:36 +0900
Subject: [PATCH 134/219] fix(conversion): reject impossible ZIP compressed
spans
---
.../OfficeSourceContainerPreflight.java | 35 +++++++++++++------
1 file changed, 24 insertions(+), 11 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index f04c4e5c..f0381eb8 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -10,12 +10,13 @@
* current Office conversion candidate set, the leading container signature matches that
* format family, and ZIP-family candidates contain self-consistent standard single-disk
* local-header, central-directory, and end-of-central-directory framing. ZIP entries are
- * limited to the current Stored/Deflate compression qualification boundary, and entry
- * names are rejected when they are absolute, contain parent traversal, use backslash path
- * separators, or contain NUL bytes. Passing this preflight is not
- * complete package, macro, embedded-object, archive-expansion, malware, or fidelity
- * qualification. Those deeper controls remain separate sandbox/content-policy acceptance
- * gates.
+ * limited to the current Stored/Deflate compression qualification boundary, advertised
+ * compressed bytes cannot extend beyond the local-data area before the central directory,
+ * and entry names are rejected when they are absolute, contain parent traversal, use
+ * backslash path separators, or contain NUL bytes. Passing this preflight is
+ * not complete package, macro, embedded-object, archive-expansion,
+ * malware, or fidelity qualification. Those deeper controls remain separate
+ * sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -62,9 +63,10 @@ private OfficeSourceContainerPreflight() {
* @param request immutable conversion request containing declared format and source bytes
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
- * ZIP-family source has invalid local/central-directory framing, uses a ZIP
- * compression method outside the current Stored/Deflate qualification boundary,
- * contains an encrypted entry, or has an unsafe ZIP entry path
+ * ZIP-family source has invalid local/central-directory framing, advertises
+ * compressed bytes beyond its local-data region, uses a ZIP compression method
+ * outside the current Stored/Deflate qualification boundary, contains an
+ * encrypted entry, or has an unsafe ZIP entry path
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -172,7 +174,7 @@ private static void requireCentralDirectoryRecords(
throw invalidCentralDirectory();
}
int centralNameOffset = cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH;
- requireMatchingLocalHeaderMetadata(
+ long localDataOffset = requireMatchingLocalHeaderMetadata(
sourceBytes,
(int) localHeaderOffset,
centralDirectoryOffset,
@@ -180,6 +182,9 @@ private static void requireCentralDirectoryRecords(
fileNameLength,
compressionMethod
);
+ if (localDataOffset + compressedSize > centralDirectoryOffset) {
+ throw invalidEntryDataRange();
+ }
requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
cursor = (int) nextCursor;
}
@@ -188,7 +193,7 @@ private static void requireCentralDirectoryRecords(
}
}
- private static void requireMatchingLocalHeaderMetadata(
+ private static long requireMatchingLocalHeaderMetadata(
byte[] sourceBytes,
int localHeaderOffset,
int centralDirectoryOffset,
@@ -218,6 +223,7 @@ private static void requireMatchingLocalHeaderMetadata(
throw invalidLocalHeader();
}
}
+ return localHeaderMetadataEnd;
}
private static boolean isAllowedCompressionMethod(int compressionMethod) {
@@ -348,6 +354,13 @@ private static OfficeConversionException invalidLocalHeader() {
);
}
+ private static OfficeConversionException invalidEntryDataRange() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ZIP entry data exceeds local data region"
+ );
+ }
+
private static OfficeConversionException unsupportedCompressionMethod() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 0aea85bdcf1be82d955cae4dcbb2ef899f4f85b9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:00:07 +0900
Subject: [PATCH 135/219] test(conversion): reject inconsistent stored ZIP
sizes
---
...fficeSourceCentralDirectoryPolicyTest.java | 22 +++++++++++++++++--
1 file changed, 20 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index 2e653639..b4b73fcd 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -17,8 +17,9 @@
* entry data; they require matching local/central metadata with a safe relative
* entry name and fail closed when either record advertises ZIP encryption, the
* duplicated compression-method metadata disagrees, the agreed compression
- * method falls outside the current Stored/Deflate qualification boundary, or a
- * central-directory entry claims compressed bytes outside the local data region.
+ * method falls outside the current Stored/Deflate qualification boundary, a Stored
+ * entry reports different compressed/uncompressed sizes, or a central-directory
+ * entry claims compressed bytes outside the local data region.
*/
class OfficeSourceCentralDirectoryPolicyTest {
@@ -98,6 +99,23 @@ void adapterRejectsUnsupportedCompressionMethodBeforeProviderInvocation() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsStoredEntryWithDifferentCompressedAndUncompressedSizes() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZip(1, 1);
+ putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 20, 0L);
+ putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 24, 1L);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP stored entry sizes are inconsistent", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterRejectsCompressedSizeThatExtendsBeyondLocalDataRegion() {
AtomicInteger providerCalls = new AtomicInteger();
From 31703f54bb8a8d06f5100e1236fa1223dd0802e0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:03:24 +0900
Subject: [PATCH 136/219] fix(conversion): reject inconsistent stored ZIP sizes
---
.../OfficeSourceContainerPreflight.java | 32 ++++++++++++-------
1 file changed, 21 insertions(+), 11 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index f0381eb8..d9dbc854 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -10,13 +10,13 @@
* current Office conversion candidate set, the leading container signature matches that
* format family, and ZIP-family candidates contain self-consistent standard single-disk
* local-header, central-directory, and end-of-central-directory framing. ZIP entries are
- * limited to the current Stored/Deflate compression qualification boundary, advertised
- * compressed bytes cannot extend beyond the local-data area before the central directory,
- * and entry names are rejected when they are absolute, contain parent traversal, use
- * backslash path separators, or contain NUL bytes. Passing this preflight is
- * not complete package, macro, embedded-object, archive-expansion,
- * malware, or fidelity qualification. Those deeper controls remain separate
- * sandbox/content-policy acceptance gates.
+ * limited to the current Stored/Deflate compression qualification boundary, Stored entry
+ * sizes must be internally consistent, advertised compressed bytes cannot extend beyond
+ * the local-data area before the central directory, and entry names are rejected when
+ * they are absolute, contain parent traversal, use backslash path separators, or contain
+ * NUL bytes. Passing this preflight is not complete package, macro,
+ * embedded-object, archive-expansion, malware, or fidelity qualification. Those deeper
+ * controls remain separate sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -63,10 +63,10 @@ private OfficeSourceContainerPreflight() {
* @param request immutable conversion request containing declared format and source bytes
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
- * ZIP-family source has invalid local/central-directory framing, advertises
- * compressed bytes beyond its local-data region, uses a ZIP compression method
- * outside the current Stored/Deflate qualification boundary, contains an
- * encrypted entry, or has an unsafe ZIP entry path
+ * ZIP-family source has invalid local/central-directory framing, has inconsistent
+ * Stored entry sizes, advertises compressed bytes beyond its local-data region,
+ * uses a ZIP compression method outside the current Stored/Deflate qualification
+ * boundary, contains an encrypted entry, or has an unsafe ZIP entry path
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -164,6 +164,9 @@ private static void requireCentralDirectoryRecords(
|| !matchesAt(sourceBytes, (int) localHeaderOffset, ZIP_LOCAL_FILE_HEADER)) {
throw invalidCentralDirectory();
}
+ if (compressionMethod == ZIP_STORED_METHOD && compressedSize != uncompressedSize) {
+ throw inconsistentStoredEntrySizes();
+ }
long recordLength = (long) ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH
+ fileNameLength
@@ -354,6 +357,13 @@ private static OfficeConversionException invalidLocalHeader() {
);
}
+ private static OfficeConversionException inconsistentStoredEntrySizes() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ZIP stored entry sizes are inconsistent"
+ );
+ }
+
private static OfficeConversionException invalidEntryDataRange() {
return new OfficeConversionException(
OfficeConversionFailureCode.MALFORMED_INPUT,
From e5701dcb771f6241caf251d447991cec6ed2e8e4 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:08:03 +0900
Subject: [PATCH 137/219] test(conversion): reject local ZIP size metadata
drift
---
...ceSourceLocalHeaderMetadataPolicyTest.java | 98 +++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
new file mode 100644
index 00000000..7f909438
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
@@ -0,0 +1,98 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies duplicated ZIP local-header size metadata before an Office provider is invoked.
+ */
+class OfficeSourceLocalHeaderMetadataPolicyTest {
+
+ private static final byte[] ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
+ private static final int LOCAL_HEADER_LENGTH = 30;
+ private static final int CENTRAL_HEADER_LENGTH = 46;
+ private static final int CENTRAL_OFFSET = LOCAL_HEADER_LENGTH + ENTRY_NAME.length;
+ private static final int CENTRAL_RECORD_LENGTH = CENTRAL_HEADER_LENGTH + ENTRY_NAME.length;
+ private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH;
+
+ @Test
+ void adapterRejectsLocalCompressedSizeMismatchWithoutDataDescriptor() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryStoredZip();
+ putUnsignedInt(source, 18, 1L);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP local header does not match central directory", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"),
+ 10L,
+ "docx",
+ "policy-v1",
+ "trace-local-header-metadata",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] oneEntryStoredZip() {
+ byte[] bytes = new byte[EOCD_OFFSET + 22];
+ putUnsignedInt(bytes, 0, 0x04034b50L);
+ putUnsignedShort(bytes, 4, 20);
+ putUnsignedShort(bytes, 26, ENTRY_NAME.length);
+ System.arraycopy(ENTRY_NAME, 0, bytes, LOCAL_HEADER_LENGTH, ENTRY_NAME.length);
+
+ putUnsignedInt(bytes, CENTRAL_OFFSET, 0x02014b50L);
+ putUnsignedShort(bytes, CENTRAL_OFFSET + 28, ENTRY_NAME.length);
+ putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L);
+ System.arraycopy(ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_HEADER_LENGTH, ENTRY_NAME.length);
+
+ putUnsignedInt(bytes, EOCD_OFFSET, 0x06054b50L);
+ putUnsignedShort(bytes, EOCD_OFFSET + 8, 1);
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, 1);
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH);
+ putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET);
+ return bytes;
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+}
From 4e006cc89512b966bf36828e9764f288fcfeb694 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:12:12 +0900
Subject: [PATCH 138/219] fix(conversion): reject local ZIP size metadata drift
---
.../OfficeSourceContainerPreflight.java | 36 ++++++++++++++-----
1 file changed, 27 insertions(+), 9 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index d9dbc854..ecc1e5c5 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -11,10 +11,11 @@
* format family, and ZIP-family candidates contain self-consistent standard single-disk
* local-header, central-directory, and end-of-central-directory framing. ZIP entries are
* limited to the current Stored/Deflate compression qualification boundary, Stored entry
- * sizes must be internally consistent, advertised compressed bytes cannot extend beyond
- * the local-data area before the central directory, and entry names are rejected when
- * they are absolute, contain parent traversal, use backslash path separators, or contain
- * NUL bytes. Passing this preflight is not complete package, macro,
+ * sizes must be internally consistent, local and central size metadata must agree when a
+ * data descriptor is not in use, advertised compressed bytes cannot extend beyond the
+ * local-data area before the central directory, and entry names are rejected when they
+ * are absolute, contain parent traversal, use backslash path separators, or contain NUL
+ * bytes. Passing this preflight is not complete package, macro,
* embedded-object, archive-expansion, malware, or fidelity qualification. Those deeper
* controls remain separate sandbox/content-policy acceptance gates.
*/
@@ -46,6 +47,7 @@ final class OfficeSourceContainerPreflight {
private static final int ZIP16_SENTINEL = 0xffff;
private static final long ZIP32_SENTINEL = 0xffff_ffffL;
private static final int ZIP_ENCRYPTED_FLAG = 0x0001;
+ private static final int ZIP_DATA_DESCRIPTOR_FLAG = 0x0008;
private static final int ZIP_STORED_METHOD = 0;
private static final int ZIP_DEFLATED_METHOD = 8;
private static final byte ZIP_PATH_SEPARATOR = (byte) '/';
@@ -64,9 +66,10 @@ private OfficeSourceContainerPreflight() {
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
* ZIP-family source has invalid local/central-directory framing, has inconsistent
- * Stored entry sizes, advertises compressed bytes beyond its local-data region,
- * uses a ZIP compression method outside the current Stored/Deflate qualification
- * boundary, contains an encrypted entry, or has an unsafe ZIP entry path
+ * Stored entry sizes or duplicated size metadata, advertises compressed bytes
+ * beyond its local-data region, uses a ZIP compression method outside the current
+ * Stored/Deflate qualification boundary, contains an encrypted entry, or has an
+ * unsafe ZIP entry path
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -183,7 +186,10 @@ private static void requireCentralDirectoryRecords(
centralDirectoryOffset,
centralNameOffset,
fileNameLength,
- compressionMethod
+ flags,
+ compressionMethod,
+ compressedSize,
+ uncompressedSize
);
if (localDataOffset + compressedSize > centralDirectoryOffset) {
throw invalidEntryDataRange();
@@ -202,7 +208,10 @@ private static long requireMatchingLocalHeaderMetadata(
int centralDirectoryOffset,
int centralNameOffset,
int centralNameLength,
- int centralCompressionMethod
+ int centralFlags,
+ int centralCompressionMethod,
+ long centralCompressedSize,
+ long centralUncompressedSize
) {
if (localHeaderOffset > centralDirectoryOffset - ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH) {
throw invalidLocalHeader();
@@ -221,6 +230,15 @@ private static long requireMatchingLocalHeaderMetadata(
|| localHeaderMetadataEnd > centralDirectoryOffset) {
throw invalidLocalHeader();
}
+ boolean usesDataDescriptor = ((centralFlags | localFlags) & ZIP_DATA_DESCRIPTOR_FLAG) != 0;
+ if (!usesDataDescriptor) {
+ long localCompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 18);
+ long localUncompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 22);
+ if (localCompressedSize != centralCompressedSize
+ || localUncompressedSize != centralUncompressedSize) {
+ throw invalidLocalHeader();
+ }
+ }
for (int index = 0; index < centralNameLength; index++) {
if (sourceBytes[(int) localNameOffset + index] != sourceBytes[centralNameOffset + index]) {
throw invalidLocalHeader();
From d054356ddd966afe1d8a8d18bc2cde326c7eda01 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:12:45 +0900
Subject: [PATCH 139/219] test(conversion): preserve compressed-span fixture
intent
---
.../conversion/OfficeSourceCentralDirectoryPolicyTest.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
index b4b73fcd..b472dc3d 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java
@@ -120,6 +120,8 @@ void adapterRejectsStoredEntryWithDifferentCompressedAndUncompressedSizes() {
void adapterRejectsCompressedSizeThatExtendsBeyondLocalDataRegion() {
AtomicInteger providerCalls = new AtomicInteger();
byte[] source = oneEntryZip(1, 1);
+ putUnsignedInt(source, LOCAL_HEADER_OFFSET + 18, 1L);
+ putUnsignedInt(source, LOCAL_HEADER_OFFSET + 22, 1L);
putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 20, 1L);
putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 24, 1L);
From bb1cd454e8b403457390ae5c36b3234db38def1f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:17:20 +0900
Subject: [PATCH 140/219] test(conversion): reject ZIP data-descriptor flag
drift
---
...iceSourceLocalHeaderMetadataPolicyTest.java | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
index 7f909438..a265de78 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
@@ -10,7 +10,7 @@
import org.junit.jupiter.api.Test;
/**
- * Verifies duplicated ZIP local-header size metadata before an Office provider is invoked.
+ * Verifies duplicated ZIP local-header metadata before an Office provider is invoked.
*/
class OfficeSourceLocalHeaderMetadataPolicyTest {
@@ -37,6 +37,22 @@ void adapterRejectsLocalCompressedSizeMismatchWithoutDataDescriptor() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsDataDescriptorFlagMismatchBetweenLocalAndCentralRecords() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryStoredZip();
+ putUnsignedShort(source, CENTRAL_OFFSET + 8, 0x0008);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP local header does not match central directory", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
return input -> {
providerCalls.incrementAndGet();
From f002e3b4c8ebef66d0543b7a8776eddaeaedc8b3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:21:32 +0900
Subject: [PATCH 141/219] fix(conversion): reject ZIP data-descriptor flag
drift
---
.../OfficeSourceContainerPreflight.java | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index ecc1e5c5..ca27ac5c 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -11,9 +11,9 @@
* format family, and ZIP-family candidates contain self-consistent standard single-disk
* local-header, central-directory, and end-of-central-directory framing. ZIP entries are
* limited to the current Stored/Deflate compression qualification boundary, Stored entry
- * sizes must be internally consistent, local and central size metadata must agree when a
- * data descriptor is not in use, advertised compressed bytes cannot extend beyond the
- * local-data area before the central directory, and entry names are rejected when they
+ * sizes must be internally consistent, local and central data-descriptor flags and size
+ * metadata must agree where applicable, advertised compressed bytes cannot extend beyond
+ * the local-data area before the central directory, and entry names are rejected when they
* are absolute, contain parent traversal, use backslash path separators, or contain NUL
* bytes. Passing this preflight is not complete package, macro,
* embedded-object, archive-expansion, malware, or fidelity qualification. Those deeper
@@ -66,8 +66,8 @@ private OfficeSourceContainerPreflight() {
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
* ZIP-family source has invalid local/central-directory framing, has inconsistent
- * Stored entry sizes or duplicated size metadata, advertises compressed bytes
- * beyond its local-data region, uses a ZIP compression method outside the current
+ * Stored entry sizes or duplicated metadata, advertises compressed bytes beyond
+ * its local-data region, uses a ZIP compression method outside the current
* Stored/Deflate qualification boundary, contains an encrypted entry, or has an
* unsafe ZIP entry path
*/
@@ -230,8 +230,12 @@ private static long requireMatchingLocalHeaderMetadata(
|| localHeaderMetadataEnd > centralDirectoryOffset) {
throw invalidLocalHeader();
}
- boolean usesDataDescriptor = ((centralFlags | localFlags) & ZIP_DATA_DESCRIPTOR_FLAG) != 0;
- if (!usesDataDescriptor) {
+ boolean centralUsesDataDescriptor = (centralFlags & ZIP_DATA_DESCRIPTOR_FLAG) != 0;
+ boolean localUsesDataDescriptor = (localFlags & ZIP_DATA_DESCRIPTOR_FLAG) != 0;
+ if (centralUsesDataDescriptor != localUsesDataDescriptor) {
+ throw invalidLocalHeader();
+ }
+ if (!centralUsesDataDescriptor) {
long localCompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 18);
long localUncompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 22);
if (localCompressedSize != centralCompressedSize
From 807312cc3367e6305f6dd4a5c6aedeff00c8cedf Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:29:23 +0900
Subject: [PATCH 142/219] test(conversion): reject local ZIP CRC metadata drift
---
...fficeSourceLocalHeaderMetadataPolicyTest.java | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
index a265de78..112c6330 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java
@@ -53,6 +53,22 @@ void adapterRejectsDataDescriptorFlagMismatchBetweenLocalAndCentralRecords() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsLocalCrcMismatchWithoutDataDescriptor() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryStoredZip();
+ putUnsignedInt(source, 14, 1L);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP local header does not match central directory", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
return input -> {
providerCalls.incrementAndGet();
From 1c4293897eb57ef7dbf9a4dfe6a682e007063c4b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:33:12 +0900
Subject: [PATCH 143/219] fix(conversion): reject local ZIP CRC metadata drift
---
.../OfficeSourceContainerPreflight.java | 21 ++++++++++++-------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index ca27ac5c..74f2cc11 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -11,13 +11,13 @@
* format family, and ZIP-family candidates contain self-consistent standard single-disk
* local-header, central-directory, and end-of-central-directory framing. ZIP entries are
* limited to the current Stored/Deflate compression qualification boundary, Stored entry
- * sizes must be internally consistent, local and central data-descriptor flags and size
- * metadata must agree where applicable, advertised compressed bytes cannot extend beyond
- * the local-data area before the central directory, and entry names are rejected when they
- * are absolute, contain parent traversal, use backslash path separators, or contain NUL
- * bytes. Passing this preflight is not complete package, macro,
- * embedded-object, archive-expansion, malware, or fidelity qualification. Those deeper
- * controls remain separate sandbox/content-policy acceptance gates.
+ * sizes must be internally consistent, local and central data-descriptor flags plus CRC
+ * and size metadata must agree where applicable, advertised compressed bytes cannot extend
+ * beyond the local-data area before the central directory, and entry names are rejected
+ * when they are absolute, contain parent traversal, use backslash path separators, or
+ * contain NUL bytes. Passing this preflight is not complete package,
+ * macro, embedded-object, archive-expansion, malware, or fidelity qualification. Those
+ * deeper controls remain separate sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -151,6 +151,7 @@ private static void requireCentralDirectoryRecords(
throw unsupportedCompressionMethod();
}
+ long crc32 = unsignedInt(sourceBytes, cursor + 16);
long compressedSize = unsignedInt(sourceBytes, cursor + 20);
long uncompressedSize = unsignedInt(sourceBytes, cursor + 24);
int fileNameLength = unsignedShort(sourceBytes, cursor + 28);
@@ -188,6 +189,7 @@ private static void requireCentralDirectoryRecords(
fileNameLength,
flags,
compressionMethod,
+ crc32,
compressedSize,
uncompressedSize
);
@@ -210,6 +212,7 @@ private static long requireMatchingLocalHeaderMetadata(
int centralNameLength,
int centralFlags,
int centralCompressionMethod,
+ long centralCrc32,
long centralCompressedSize,
long centralUncompressedSize
) {
@@ -236,9 +239,11 @@ private static long requireMatchingLocalHeaderMetadata(
throw invalidLocalHeader();
}
if (!centralUsesDataDescriptor) {
+ long localCrc32 = unsignedInt(sourceBytes, localHeaderOffset + 14);
long localCompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 18);
long localUncompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 22);
- if (localCompressedSize != centralCompressedSize
+ if (localCrc32 != centralCrc32
+ || localCompressedSize != centralCompressedSize
|| localUncompressedSize != centralUncompressedSize) {
throw invalidLocalHeader();
}
From e1c6ba1a827268914fe9b2eaff3ccee9be4a7f4a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:39:07 +0900
Subject: [PATCH 144/219] test(conversion): require ODF package manifest
---
.../OfficeOdfPackagePolicyTest.java | 97 +++++++++++++++++++
1 file changed, 97 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java
new file mode 100644
index 00000000..24d0b6ae
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java
@@ -0,0 +1,97 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Enforces OpenDocument package structure before invoking an Office provider.
+ */
+class OfficeOdfPackagePolicyTest {
+
+ private static final byte[] ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8);
+ private static final int LOCAL_HEADER_LENGTH = 30;
+ private static final int CENTRAL_HEADER_LENGTH = 46;
+ private static final int CENTRAL_OFFSET = LOCAL_HEADER_LENGTH + ENTRY_NAME.length;
+ private static final int CENTRAL_RECORD_LENGTH = CENTRAL_HEADER_LENGTH + ENTRY_NAME.length;
+ private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH;
+
+ @Test
+ void adapterRejectsOdfPackageWithoutManifestBeforeProviderInvocation() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = oneEntryZipWithoutOdfManifest();
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF package manifest is missing", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"),
+ 10L,
+ "odt",
+ "policy-v1",
+ "trace-odf-package-policy",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] oneEntryZipWithoutOdfManifest() {
+ byte[] bytes = new byte[EOCD_OFFSET + 22];
+ putUnsignedInt(bytes, 0, 0x04034b50L);
+ putUnsignedShort(bytes, 4, 20);
+ putUnsignedShort(bytes, 26, ENTRY_NAME.length);
+ System.arraycopy(ENTRY_NAME, 0, bytes, LOCAL_HEADER_LENGTH, ENTRY_NAME.length);
+
+ putUnsignedInt(bytes, CENTRAL_OFFSET, 0x02014b50L);
+ putUnsignedShort(bytes, CENTRAL_OFFSET + 28, ENTRY_NAME.length);
+ putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L);
+ System.arraycopy(ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_HEADER_LENGTH, ENTRY_NAME.length);
+
+ putUnsignedInt(bytes, EOCD_OFFSET, 0x06054b50L);
+ putUnsignedShort(bytes, EOCD_OFFSET + 8, 1);
+ putUnsignedShort(bytes, EOCD_OFFSET + 10, 1);
+ putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH);
+ putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET);
+ return bytes;
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+}
From d569365cc3e91205bdb5c8efe7d5b40e024b729f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:45:47 +0900
Subject: [PATCH 145/219] fix(conversion): require ODF package manifest
---
.../OfficeSourceContainerPreflight.java | 64 ++++++++++++++++---
1 file changed, 55 insertions(+), 9 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 74f2cc11..4f2a0ea4 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -1,5 +1,6 @@
package com.clearfolio.viewer.conversion;
+import java.nio.charset.StandardCharsets;
import java.util.Set;
/**
@@ -15,18 +16,25 @@
* and size metadata must agree where applicable, advertised compressed bytes cannot extend
* beyond the local-data area before the central directory, and entry names are rejected
* when they are absolute, contain parent traversal, use backslash path separators, or
- * contain NUL bytes. Passing this preflight is not complete package,
- * macro, embedded-object, archive-expansion, malware, or fidelity qualification. Those
- * deeper controls remain separate sandbox/content-policy acceptance gates.
+ * contain NUL bytes. OpenDocument candidates additionally require the package manifest
+ * entry mandated by the ODF package specification. Passing this preflight is
+ * not complete package, macro, embedded-object, archive-expansion,
+ * malware, or fidelity qualification. Those deeper controls remain separate
+ * sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
private static final Set ZIP_PACKAGE_FORMATS = Set.of(
"docx", "xlsx", "pptx", "odt", "ods", "odp"
);
+ private static final Set ODF_PACKAGE_FORMATS = Set.of(
+ "odt", "ods", "odp"
+ );
private static final Set COMPOUND_FILE_FORMATS = Set.of(
"doc", "xls", "ppt"
);
+ private static final byte[] ODF_MANIFEST_ENTRY_NAME =
+ "META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
0x50, 0x4b, 0x03, 0x04
};
@@ -68,8 +76,8 @@ private OfficeSourceContainerPreflight() {
* ZIP-family source has invalid local/central-directory framing, has inconsistent
* Stored entry sizes or duplicated metadata, advertises compressed bytes beyond
* its local-data region, uses a ZIP compression method outside the current
- * Stored/Deflate qualification boundary, contains an encrypted entry, or has an
- * unsafe ZIP entry path
+ * Stored/Deflate qualification boundary, contains an encrypted entry, has an
+ * unsafe ZIP entry path, or an ODF candidate omits its required package manifest
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -77,7 +85,7 @@ static void requireQualifiedContainer(OfficeConversionRequest request) {
if (ZIP_PACKAGE_FORMATS.contains(sourceFormat)) {
requireSignature(sourceBytes, ZIP_LOCAL_FILE_HEADER);
- requireStandardZipFraming(sourceBytes);
+ requireStandardZipFraming(sourceBytes, ODF_PACKAGE_FORMATS.contains(sourceFormat));
return;
}
if (COMPOUND_FILE_FORMATS.contains(sourceFormat)) {
@@ -99,7 +107,7 @@ private static void requireSignature(byte[] sourceBytes, byte[] expectedSignatur
}
}
- private static void requireStandardZipFraming(byte[] sourceBytes) {
+ private static void requireStandardZipFraming(byte[] sourceBytes, boolean requireOdfManifest) {
int eocdOffset = findEocdOffset(sourceBytes);
if (eocdOffset < 0 || !isStandardSingleDiskEocd(sourceBytes, eocdOffset)) {
throw invalidZipFraming();
@@ -125,7 +133,8 @@ private static void requireStandardZipFraming(byte[] sourceBytes) {
sourceBytes,
(int) centralDirectoryOffset,
(int) centralDirectoryEnd,
- entryCount
+ entryCount,
+ requireOdfManifest
);
}
@@ -133,9 +142,11 @@ private static void requireCentralDirectoryRecords(
byte[] sourceBytes,
int centralDirectoryOffset,
int centralDirectoryEnd,
- int entryCount
+ int entryCount,
+ boolean requireOdfManifest
) {
int cursor = centralDirectoryOffset;
+ boolean odfManifestFound = false;
for (int entryIndex = 0; entryIndex < entryCount; entryIndex++) {
if (cursor > centralDirectoryEnd - ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH
|| !matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER)) {
@@ -197,11 +208,22 @@ private static void requireCentralDirectoryRecords(
throw invalidEntryDataRange();
}
requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
+ if (entryNameMatches(
+ sourceBytes,
+ centralNameOffset,
+ fileNameLength,
+ ODF_MANIFEST_ENTRY_NAME
+ )) {
+ odfManifestFound = true;
+ }
cursor = (int) nextCursor;
}
if (cursor != centralDirectoryEnd) {
throw invalidCentralDirectory();
}
+ if (requireOdfManifest && !odfManifestFound) {
+ throw missingOdfManifest();
+ }
}
private static long requireMatchingLocalHeaderMetadata(
@@ -256,6 +278,23 @@ private static long requireMatchingLocalHeaderMetadata(
return localHeaderMetadataEnd;
}
+ private static boolean entryNameMatches(
+ byte[] sourceBytes,
+ int nameOffset,
+ int nameLength,
+ byte[] expectedName
+ ) {
+ if (nameLength != expectedName.length) {
+ return false;
+ }
+ for (int index = 0; index < expectedName.length; index++) {
+ if (sourceBytes[nameOffset + index] != expectedName[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private static boolean isAllowedCompressionMethod(int compressionMethod) {
return compressionMethod == ZIP_STORED_METHOD || compressionMethod == ZIP_DEFLATED_METHOD;
}
@@ -398,6 +437,13 @@ private static OfficeConversionException invalidEntryDataRange() {
);
}
+ private static OfficeConversionException missingOdfManifest() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF package manifest is missing"
+ );
+ }
+
private static OfficeConversionException unsupportedCompressionMethod() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From d2d1bd98d91c22dd7c020a6bbd2ada4b2fa1f445 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:49:55 +0900
Subject: [PATCH 146/219] test(conversion): require ODF mimetype first-entry
placement
---
.../OfficeOdfMimetypePolicyTest.java | 119 ++++++++++++++++++
1 file changed, 119 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
new file mode 100644
index 00000000..f21928d1
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
@@ -0,0 +1,119 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Enforces deterministic placement rules for an optional OpenDocument mimetype entry.
+ */
+class OfficeOdfMimetypePolicyTest {
+
+ private static final byte[] MANIFEST_NAME =
+ "META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] MIMETYPE_NAME = "mimetype".getBytes(StandardCharsets.UTF_8);
+ private static final int LOCAL_HEADER_LENGTH = 30;
+ private static final int CENTRAL_HEADER_LENGTH = 46;
+
+ @Test
+ void adapterRejectsOdfMimetypeEntryWhenItIsNotFirst() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfZipWithMimetypeSecond();
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF mimetype entry must be first", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"),
+ 10L,
+ "odt",
+ "policy-v1",
+ "trace-odf-mimetype-policy",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] odfZipWithMimetypeSecond() {
+ int manifestLocalOffset = 0;
+ int mimetypeLocalOffset = LOCAL_HEADER_LENGTH + MANIFEST_NAME.length;
+ int centralOffset = mimetypeLocalOffset + LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length;
+ int firstCentralLength = CENTRAL_HEADER_LENGTH + MANIFEST_NAME.length;
+ int secondCentralOffset = centralOffset + firstCentralLength;
+ int secondCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length;
+ int centralLength = firstCentralLength + secondCentralLength;
+ int eocdOffset = centralOffset + centralLength;
+ byte[] bytes = new byte[eocdOffset + 22];
+
+ writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME);
+ writeLocalHeader(bytes, mimetypeLocalOffset, MIMETYPE_NAME);
+ writeCentralHeader(bytes, centralOffset, MANIFEST_NAME, manifestLocalOffset);
+ writeCentralHeader(bytes, secondCentralOffset, MIMETYPE_NAME, mimetypeLocalOffset);
+
+ putUnsignedInt(bytes, eocdOffset, 0x06054b50L);
+ putUnsignedShort(bytes, eocdOffset + 8, 2);
+ putUnsignedShort(bytes, eocdOffset + 10, 2);
+ putUnsignedInt(bytes, eocdOffset + 12, centralLength);
+ putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
+ return bytes;
+ }
+
+ private static void writeLocalHeader(byte[] bytes, int offset, byte[] entryName) {
+ putUnsignedInt(bytes, offset, 0x04034b50L);
+ putUnsignedShort(bytes, offset + 4, 20);
+ putUnsignedShort(bytes, offset + 26, entryName.length);
+ System.arraycopy(entryName, 0, bytes, offset + LOCAL_HEADER_LENGTH, entryName.length);
+ }
+
+ private static void writeCentralHeader(
+ byte[] bytes,
+ int offset,
+ byte[] entryName,
+ int localHeaderOffset
+ ) {
+ putUnsignedInt(bytes, offset, 0x02014b50L);
+ putUnsignedShort(bytes, offset + 28, entryName.length);
+ putUnsignedInt(bytes, offset + 42, localHeaderOffset);
+ System.arraycopy(entryName, 0, bytes, offset + CENTRAL_HEADER_LENGTH, entryName.length);
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+}
From 6f979ff75dd5c5daa20c869c528dc8c19d72b53b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 13:55:19 +0900
Subject: [PATCH 147/219] fix(conversion): enforce ODF mimetype first-entry
placement
---
.../OfficeSourceContainerPreflight.java | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 4f2a0ea4..e6bbfca8 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -17,7 +17,8 @@
* beyond the local-data area before the central directory, and entry names are rejected
* when they are absolute, contain parent traversal, use backslash path separators, or
* contain NUL bytes. OpenDocument candidates additionally require the package manifest
- * entry mandated by the ODF package specification. Passing this preflight is
+ * entry mandated by the ODF package specification and, when a mimetype entry is present,
+ * require it to be the first local ZIP entry. Passing this preflight is
* not complete package, macro, embedded-object, archive-expansion,
* malware, or fidelity qualification. Those deeper controls remain separate
* sandbox/content-policy acceptance gates.
@@ -35,6 +36,8 @@ final class OfficeSourceContainerPreflight {
);
private static final byte[] ODF_MANIFEST_ENTRY_NAME =
"META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] ODF_MIMETYPE_ENTRY_NAME =
+ "mimetype".getBytes(StandardCharsets.UTF_8);
private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
0x50, 0x4b, 0x03, 0x04
};
@@ -77,7 +80,7 @@ private OfficeSourceContainerPreflight() {
* Stored entry sizes or duplicated metadata, advertises compressed bytes beyond
* its local-data region, uses a ZIP compression method outside the current
* Stored/Deflate qualification boundary, contains an encrypted entry, has an
- * unsafe ZIP entry path, or an ODF candidate omits its required package manifest
+ * unsafe ZIP entry path, or an ODF candidate violates required package structure
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -208,6 +211,11 @@ private static void requireCentralDirectoryRecords(
throw invalidEntryDataRange();
}
requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
+ if (requireOdfManifest
+ && entryNameMatches(sourceBytes, centralNameOffset, fileNameLength, ODF_MIMETYPE_ENTRY_NAME)
+ && localHeaderOffset != 0L) {
+ throw invalidOdfMimetypePlacement();
+ }
if (entryNameMatches(
sourceBytes,
centralNameOffset,
@@ -444,6 +452,13 @@ private static OfficeConversionException missingOdfManifest() {
);
}
+ private static OfficeConversionException invalidOdfMimetypePlacement() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF mimetype entry must be first"
+ );
+ }
+
private static OfficeConversionException unsupportedCompressionMethod() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From f792687410508017c5076298d2f23a4495330d6b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 14:04:55 +0900
Subject: [PATCH 148/219] test(conversion): reject compressed ODF mimetype
entry
---
.../OfficeOdfMimetypePolicyTest.java | 105 ++++++++++++++++--
1 file changed, 93 insertions(+), 12 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
index f21928d1..56db90d3 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
@@ -10,13 +10,15 @@
import org.junit.jupiter.api.Test;
/**
- * Enforces deterministic placement rules for an optional OpenDocument mimetype entry.
+ * Enforces deterministic placement and storage rules for an optional OpenDocument mimetype entry.
*/
class OfficeOdfMimetypePolicyTest {
private static final byte[] MANIFEST_NAME =
"META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
private static final byte[] MIMETYPE_NAME = "mimetype".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] ODT_MIMETYPE =
+ "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII);
private static final int LOCAL_HEADER_LENGTH = 30;
private static final int CENTRAL_HEADER_LENGTH = 46;
@@ -35,6 +37,21 @@ void adapterRejectsOdfMimetypeEntryWhenItIsNotFirst() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsOdfMimetypeEntryWhenCompressed() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfZipWithFirstMimetype(8, 0);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF mimetype entry must be stored without compression", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
return input -> {
providerCalls.incrementAndGet();
@@ -73,23 +90,67 @@ private static byte[] odfZipWithMimetypeSecond() {
int eocdOffset = centralOffset + centralLength;
byte[] bytes = new byte[eocdOffset + 22];
- writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME);
- writeLocalHeader(bytes, mimetypeLocalOffset, MIMETYPE_NAME);
- writeCentralHeader(bytes, centralOffset, MANIFEST_NAME, manifestLocalOffset);
- writeCentralHeader(bytes, secondCentralOffset, MIMETYPE_NAME, mimetypeLocalOffset);
+ writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, 0, 0, 0);
+ writeLocalHeader(bytes, mimetypeLocalOffset, MIMETYPE_NAME, 0, 0, 0);
+ writeCentralHeader(bytes, centralOffset, MANIFEST_NAME, manifestLocalOffset, 0, 0, 0);
+ writeCentralHeader(bytes, secondCentralOffset, MIMETYPE_NAME, mimetypeLocalOffset, 0, 0, 0);
- putUnsignedInt(bytes, eocdOffset, 0x06054b50L);
- putUnsignedShort(bytes, eocdOffset + 8, 2);
- putUnsignedShort(bytes, eocdOffset + 10, 2);
- putUnsignedInt(bytes, eocdOffset + 12, centralLength);
- putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
+ writeEocd(bytes, eocdOffset, 2, centralLength, centralOffset);
return bytes;
}
- private static void writeLocalHeader(byte[] bytes, int offset, byte[] entryName) {
+ private static byte[] odfZipWithFirstMimetype(int compressionMethod, int localExtraFieldLength) {
+ int mimetypeLocalOffset = 0;
+ int mimetypeDataOffset = LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length + localExtraFieldLength;
+ int manifestLocalOffset = mimetypeDataOffset + ODT_MIMETYPE.length;
+ int centralOffset = manifestLocalOffset + LOCAL_HEADER_LENGTH + MANIFEST_NAME.length;
+ int mimetypeCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length;
+ int manifestCentralOffset = centralOffset + mimetypeCentralLength;
+ int manifestCentralLength = CENTRAL_HEADER_LENGTH + MANIFEST_NAME.length;
+ int centralLength = mimetypeCentralLength + manifestCentralLength;
+ int eocdOffset = centralOffset + centralLength;
+ byte[] bytes = new byte[eocdOffset + 22];
+
+ writeLocalHeader(
+ bytes,
+ mimetypeLocalOffset,
+ MIMETYPE_NAME,
+ compressionMethod,
+ ODT_MIMETYPE.length,
+ localExtraFieldLength
+ );
+ System.arraycopy(ODT_MIMETYPE, 0, bytes, mimetypeDataOffset, ODT_MIMETYPE.length);
+ writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, 0, 0, 0);
+ writeCentralHeader(
+ bytes,
+ centralOffset,
+ MIMETYPE_NAME,
+ mimetypeLocalOffset,
+ compressionMethod,
+ ODT_MIMETYPE.length,
+ ODT_MIMETYPE.length
+ );
+ writeCentralHeader(bytes, manifestCentralOffset, MANIFEST_NAME, manifestLocalOffset, 0, 0, 0);
+
+ writeEocd(bytes, eocdOffset, 2, centralLength, centralOffset);
+ return bytes;
+ }
+
+ private static void writeLocalHeader(
+ byte[] bytes,
+ int offset,
+ byte[] entryName,
+ int compressionMethod,
+ int size,
+ int extraFieldLength
+ ) {
putUnsignedInt(bytes, offset, 0x04034b50L);
putUnsignedShort(bytes, offset + 4, 20);
+ putUnsignedShort(bytes, offset + 8, compressionMethod);
+ putUnsignedInt(bytes, offset + 18, size);
+ putUnsignedInt(bytes, offset + 22, size);
putUnsignedShort(bytes, offset + 26, entryName.length);
+ putUnsignedShort(bytes, offset + 28, extraFieldLength);
System.arraycopy(entryName, 0, bytes, offset + LOCAL_HEADER_LENGTH, entryName.length);
}
@@ -97,14 +158,34 @@ private static void writeCentralHeader(
byte[] bytes,
int offset,
byte[] entryName,
- int localHeaderOffset
+ int localHeaderOffset,
+ int compressionMethod,
+ int compressedSize,
+ int uncompressedSize
) {
putUnsignedInt(bytes, offset, 0x02014b50L);
+ putUnsignedShort(bytes, offset + 10, compressionMethod);
+ putUnsignedInt(bytes, offset + 20, compressedSize);
+ putUnsignedInt(bytes, offset + 24, uncompressedSize);
putUnsignedShort(bytes, offset + 28, entryName.length);
putUnsignedInt(bytes, offset + 42, localHeaderOffset);
System.arraycopy(entryName, 0, bytes, offset + CENTRAL_HEADER_LENGTH, entryName.length);
}
+ private static void writeEocd(
+ byte[] bytes,
+ int eocdOffset,
+ int entryCount,
+ int centralLength,
+ int centralOffset
+ ) {
+ putUnsignedInt(bytes, eocdOffset, 0x06054b50L);
+ putUnsignedShort(bytes, eocdOffset + 8, entryCount);
+ putUnsignedShort(bytes, eocdOffset + 10, entryCount);
+ putUnsignedInt(bytes, eocdOffset + 12, centralLength);
+ putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
+ }
+
private static void putUnsignedShort(byte[] bytes, int offset, int value) {
bytes[offset] = (byte) value;
bytes[offset + 1] = (byte) (value >>> 8);
From a4bad566252cc32dea06b799a6a34e83b6350b4a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 14:09:18 +0900
Subject: [PATCH 149/219] fix(conversion): require stored ODF mimetype entry
---
.../OfficeSourceContainerPreflight.java | 29 ++++++++++++++-----
1 file changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index e6bbfca8..5fe2ddfe 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -18,10 +18,10 @@
* when they are absolute, contain parent traversal, use backslash path separators, or
* contain NUL bytes. OpenDocument candidates additionally require the package manifest
* entry mandated by the ODF package specification and, when a mimetype entry is present,
- * require it to be the first local ZIP entry. Passing this preflight is
- * not complete package, macro, embedded-object, archive-expansion,
- * malware, or fidelity qualification. Those deeper controls remain separate
- * sandbox/content-policy acceptance gates.
+ * require it to be the first local ZIP entry and stored without compression. Passing this
+ * preflight is not complete package, macro, embedded-object,
+ * archive-expansion, malware, or fidelity qualification. Those deeper controls remain
+ * separate sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -211,11 +211,19 @@ private static void requireCentralDirectoryRecords(
throw invalidEntryDataRange();
}
requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
- if (requireOdfManifest
- && entryNameMatches(sourceBytes, centralNameOffset, fileNameLength, ODF_MIMETYPE_ENTRY_NAME)
- && localHeaderOffset != 0L) {
+ boolean odfMimetypeEntry = requireOdfManifest
+ && entryNameMatches(
+ sourceBytes,
+ centralNameOffset,
+ fileNameLength,
+ ODF_MIMETYPE_ENTRY_NAME
+ );
+ if (odfMimetypeEntry && localHeaderOffset != 0L) {
throw invalidOdfMimetypePlacement();
}
+ if (odfMimetypeEntry && compressionMethod != ZIP_STORED_METHOD) {
+ throw compressedOdfMimetype();
+ }
if (entryNameMatches(
sourceBytes,
centralNameOffset,
@@ -459,6 +467,13 @@ private static OfficeConversionException invalidOdfMimetypePlacement() {
);
}
+ private static OfficeConversionException compressedOdfMimetype() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF mimetype entry must be stored without compression"
+ );
+ }
+
private static OfficeConversionException unsupportedCompressionMethod() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 8c886e07496ae4a8f3fc32e242e95b6390af53f2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 14:12:17 +0900
Subject: [PATCH 150/219] test(conversion): reject ODF mimetype local extra
field
---
.../conversion/OfficeOdfMimetypePolicyTest.java | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
index 56db90d3..db40a566 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
@@ -52,6 +52,21 @@ void adapterRejectsOdfMimetypeEntryWhenCompressed() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsOdfMimetypeEntryWithLocalExtraField() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfZipWithFirstMimetype(0, 1);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF mimetype entry must not use a local extra field", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
return input -> {
providerCalls.incrementAndGet();
From 6daacad5bdb6f07a668e324407278011c845c2e8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 14:16:55 +0900
Subject: [PATCH 151/219] fix(conversion): reject ODF mimetype local extra
field
---
.../OfficeSourceContainerPreflight.java | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 5fe2ddfe..583a116f 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -18,10 +18,10 @@
* when they are absolute, contain parent traversal, use backslash path separators, or
* contain NUL bytes. OpenDocument candidates additionally require the package manifest
* entry mandated by the ODF package specification and, when a mimetype entry is present,
- * require it to be the first local ZIP entry and stored without compression. Passing this
- * preflight is not complete package, macro, embedded-object,
- * archive-expansion, malware, or fidelity qualification. Those deeper controls remain
- * separate sandbox/content-policy acceptance gates.
+ * require it to be the first local ZIP entry, stored without compression, and free of a
+ * local-header extra field. Passing this preflight is not complete package,
+ * macro, embedded-object, archive-expansion, malware, or fidelity qualification. Those
+ * deeper controls remain separate sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -224,6 +224,10 @@ && entryNameMatches(
if (odfMimetypeEntry && compressionMethod != ZIP_STORED_METHOD) {
throw compressedOdfMimetype();
}
+ if (odfMimetypeEntry
+ && unsignedShort(sourceBytes, (int) localHeaderOffset + 28) != 0) {
+ throw invalidOdfMimetypeExtraField();
+ }
if (entryNameMatches(
sourceBytes,
centralNameOffset,
@@ -474,6 +478,13 @@ private static OfficeConversionException compressedOdfMimetype() {
);
}
+ private static OfficeConversionException invalidOdfMimetypeExtraField() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF mimetype entry must not use a local extra field"
+ );
+ }
+
private static OfficeConversionException unsupportedCompressionMethod() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From ee88961a354b3b96aa01328584b4424ea050cb41 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 14:27:09 +0900
Subject: [PATCH 152/219] test(conversion): reject mismatched ODF mimetype
payload
---
.../OfficeOdfMimetypePolicyTest.java | 39 ++++++++++++++-----
1 file changed, 30 insertions(+), 9 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
index db40a566..bc5be1e8 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java
@@ -10,7 +10,7 @@
import org.junit.jupiter.api.Test;
/**
- * Enforces deterministic placement and storage rules for an optional OpenDocument mimetype entry.
+ * Enforces deterministic placement, storage, and media-type rules for an optional OpenDocument mimetype entry.
*/
class OfficeOdfMimetypePolicyTest {
@@ -19,6 +19,8 @@ class OfficeOdfMimetypePolicyTest {
private static final byte[] MIMETYPE_NAME = "mimetype".getBytes(StandardCharsets.UTF_8);
private static final byte[] ODT_MIMETYPE =
"application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] ODS_MIMETYPE =
+ "application/vnd.oasis.opendocument.spreadsheet".getBytes(StandardCharsets.US_ASCII);
private static final int LOCAL_HEADER_LENGTH = 30;
private static final int CENTRAL_HEADER_LENGTH = 46;
@@ -40,7 +42,7 @@ void adapterRejectsOdfMimetypeEntryWhenItIsNotFirst() {
@Test
void adapterRejectsOdfMimetypeEntryWhenCompressed() {
AtomicInteger providerCalls = new AtomicInteger();
- byte[] source = odfZipWithFirstMimetype(8, 0);
+ byte[] source = odfZipWithFirstMimetype(8, 0, ODT_MIMETYPE);
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
@@ -55,7 +57,7 @@ void adapterRejectsOdfMimetypeEntryWhenCompressed() {
@Test
void adapterRejectsOdfMimetypeEntryWithLocalExtraField() {
AtomicInteger providerCalls = new AtomicInteger();
- byte[] source = odfZipWithFirstMimetype(0, 1);
+ byte[] source = odfZipWithFirstMimetype(0, 1, ODT_MIMETYPE);
OfficeConversionException failure = assertThrows(
OfficeConversionException.class,
@@ -67,6 +69,21 @@ void adapterRejectsOdfMimetypeEntryWithLocalExtraField() {
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsOdfMimetypeEntryThatDoesNotMatchDeclaredFormat() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfZipWithFirstMimetype(0, 0, ODS_MIMETYPE);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF mimetype does not match declared format", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
return input -> {
providerCalls.incrementAndGet();
@@ -114,10 +131,14 @@ private static byte[] odfZipWithMimetypeSecond() {
return bytes;
}
- private static byte[] odfZipWithFirstMimetype(int compressionMethod, int localExtraFieldLength) {
+ private static byte[] odfZipWithFirstMimetype(
+ int compressionMethod,
+ int localExtraFieldLength,
+ byte[] mimetypePayload
+ ) {
int mimetypeLocalOffset = 0;
int mimetypeDataOffset = LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length + localExtraFieldLength;
- int manifestLocalOffset = mimetypeDataOffset + ODT_MIMETYPE.length;
+ int manifestLocalOffset = mimetypeDataOffset + mimetypePayload.length;
int centralOffset = manifestLocalOffset + LOCAL_HEADER_LENGTH + MANIFEST_NAME.length;
int mimetypeCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length;
int manifestCentralOffset = centralOffset + mimetypeCentralLength;
@@ -131,10 +152,10 @@ private static byte[] odfZipWithFirstMimetype(int compressionMethod, int localEx
mimetypeLocalOffset,
MIMETYPE_NAME,
compressionMethod,
- ODT_MIMETYPE.length,
+ mimetypePayload.length,
localExtraFieldLength
);
- System.arraycopy(ODT_MIMETYPE, 0, bytes, mimetypeDataOffset, ODT_MIMETYPE.length);
+ System.arraycopy(mimetypePayload, 0, bytes, mimetypeDataOffset, mimetypePayload.length);
writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, 0, 0, 0);
writeCentralHeader(
bytes,
@@ -142,8 +163,8 @@ private static byte[] odfZipWithFirstMimetype(int compressionMethod, int localEx
MIMETYPE_NAME,
mimetypeLocalOffset,
compressionMethod,
- ODT_MIMETYPE.length,
- ODT_MIMETYPE.length
+ mimetypePayload.length,
+ mimetypePayload.length
);
writeCentralHeader(bytes, manifestCentralOffset, MANIFEST_NAME, manifestLocalOffset, 0, 0, 0);
From a5e92faf747cf6c2372700e4cbf91aeaac529ba7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 15:08:57 +0900
Subject: [PATCH 153/219] fix(conversion): validate ODF mimetype against
declared format
---
.../OfficeSourceContainerPreflight.java | 38 +++++++++++++++----
1 file changed, 30 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 583a116f..8eda8f87 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -1,6 +1,7 @@
package com.clearfolio.viewer.conversion;
import java.nio.charset.StandardCharsets;
+import java.util.Map;
import java.util.Set;
/**
@@ -18,10 +19,11 @@
* when they are absolute, contain parent traversal, use backslash path separators, or
* contain NUL bytes. OpenDocument candidates additionally require the package manifest
* entry mandated by the ODF package specification and, when a mimetype entry is present,
- * require it to be the first local ZIP entry, stored without compression, and free of a
- * local-header extra field. Passing this preflight is not complete package,
- * macro, embedded-object, archive-expansion, malware, or fidelity qualification. Those
- * deeper controls remain separate sandbox/content-policy acceptance gates.
+ * require it to be the first local ZIP entry, stored without compression, free of a
+ * local-header extra field, and equal to the media type implied by the declared ODF format.
+ * Passing this preflight is not complete package, macro, embedded-object,
+ * archive-expansion, malware, or fidelity qualification. Those deeper controls remain
+ * separate sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -34,6 +36,11 @@ final class OfficeSourceContainerPreflight {
private static final Set COMPOUND_FILE_FORMATS = Set.of(
"doc", "xls", "ppt"
);
+ private static final Map ODF_MIMETYPE_BY_FORMAT = Map.of(
+ "odt", "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII),
+ "ods", "application/vnd.oasis.opendocument.spreadsheet".getBytes(StandardCharsets.US_ASCII),
+ "odp", "application/vnd.oasis.opendocument.presentation".getBytes(StandardCharsets.US_ASCII)
+ );
private static final byte[] ODF_MANIFEST_ENTRY_NAME =
"META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
private static final byte[] ODF_MIMETYPE_ENTRY_NAME =
@@ -88,7 +95,7 @@ static void requireQualifiedContainer(OfficeConversionRequest request) {
if (ZIP_PACKAGE_FORMATS.contains(sourceFormat)) {
requireSignature(sourceBytes, ZIP_LOCAL_FILE_HEADER);
- requireStandardZipFraming(sourceBytes, ODF_PACKAGE_FORMATS.contains(sourceFormat));
+ requireStandardZipFraming(sourceBytes, sourceFormat);
return;
}
if (COMPOUND_FILE_FORMATS.contains(sourceFormat)) {
@@ -110,7 +117,7 @@ private static void requireSignature(byte[] sourceBytes, byte[] expectedSignatur
}
}
- private static void requireStandardZipFraming(byte[] sourceBytes, boolean requireOdfManifest) {
+ private static void requireStandardZipFraming(byte[] sourceBytes, String sourceFormat) {
int eocdOffset = findEocdOffset(sourceBytes);
if (eocdOffset < 0 || !isStandardSingleDiskEocd(sourceBytes, eocdOffset)) {
throw invalidZipFraming();
@@ -132,12 +139,14 @@ private static void requireStandardZipFraming(byte[] sourceBytes, boolean requir
|| !matchesAt(sourceBytes, (int) centralDirectoryOffset, ZIP_CENTRAL_DIRECTORY_HEADER)) {
throw invalidZipFraming();
}
+ boolean requireOdfManifest = ODF_PACKAGE_FORMATS.contains(sourceFormat);
requireCentralDirectoryRecords(
sourceBytes,
(int) centralDirectoryOffset,
(int) centralDirectoryEnd,
entryCount,
- requireOdfManifest
+ requireOdfManifest,
+ ODF_MIMETYPE_BY_FORMAT.get(sourceFormat)
);
}
@@ -146,7 +155,8 @@ private static void requireCentralDirectoryRecords(
int centralDirectoryOffset,
int centralDirectoryEnd,
int entryCount,
- boolean requireOdfManifest
+ boolean requireOdfManifest,
+ byte[] expectedOdfMimetype
) {
int cursor = centralDirectoryOffset;
boolean odfManifestFound = false;
@@ -228,6 +238,11 @@ && entryNameMatches(
&& unsignedShort(sourceBytes, (int) localHeaderOffset + 28) != 0) {
throw invalidOdfMimetypeExtraField();
}
+ if (odfMimetypeEntry
+ && (compressedSize != expectedOdfMimetype.length
+ || !matchesAt(sourceBytes, (int) localDataOffset, expectedOdfMimetype))) {
+ throw invalidOdfMimetypePayload();
+ }
if (entryNameMatches(
sourceBytes,
centralNameOffset,
@@ -485,6 +500,13 @@ private static OfficeConversionException invalidOdfMimetypeExtraField() {
);
}
+ private static OfficeConversionException invalidOdfMimetypePayload() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF mimetype does not match declared format"
+ );
+ }
+
private static OfficeConversionException unsupportedCompressionMethod() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 5867750f451b06292befd26cf28bceb1a557178b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 15:12:04 +0900
Subject: [PATCH 154/219] test(conversion): reject unexpected ODF META-INF
entries
---
.../OfficeOdfMetaInfPolicyTest.java | 89 +++++++++++++++++++
1 file changed, 89 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
new file mode 100644
index 00000000..e80edd95
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
@@ -0,0 +1,89 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Enforces the OpenDocument META-INF package namespace before provider invocation.
+ */
+class OfficeOdfMetaInfPolicyTest {
+
+ @Test
+ void adapterRejectsUnexpectedMetaInfEntryBeforeProviderInvocation() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfPackage("META-INF/manifest.xml", "META-INF/evil.xml");
+
+ OfficeConversionException failure = org.junit.jupiter.api.Assertions.assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF META-INF entry is not allowed", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterAllowsSignatureNamedMetaInfEntry() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfPackage(
+ "META-INF/manifest.xml",
+ "META-INF/documentsignatures.xml"
+ );
+
+ countingAdapter(providerCalls).convert(request(source));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("679a23eb-e2b2-4760-8d0f-df50e60c7158"),
+ 12L,
+ "odt",
+ "policy-v1",
+ "trace-odf-meta-inf-policy",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] odfPackage(String... entryNames) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ for (String entryName : entryNames) {
+ ZipEntry entry = new ZipEntry(entryName);
+ entry.setMethod(ZipEntry.STORED);
+ entry.setSize(0L);
+ entry.setCompressedSize(0L);
+ entry.setCrc(0L);
+ zip.putNextEntry(entry);
+ zip.closeEntry();
+ }
+ }
+ return output.toByteArray();
+ }
+}
From 2d52079bc660b34d34e28b2ec73c26b3b129012c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 15:14:34 +0900
Subject: [PATCH 155/219] fix(conversion): reject unexpected ODF META-INF
entries
---
.../OfficeSourceContainerPreflight.java | 97 ++++++++++++++++---
1 file changed, 84 insertions(+), 13 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 8eda8f87..7b139ff8 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -17,13 +17,13 @@
* and size metadata must agree where applicable, advertised compressed bytes cannot extend
* beyond the local-data area before the central directory, and entry names are rejected
* when they are absolute, contain parent traversal, use backslash path separators, or
- * contain NUL bytes. OpenDocument candidates additionally require the package manifest
- * entry mandated by the ODF package specification and, when a mimetype entry is present,
- * require it to be the first local ZIP entry, stored without compression, free of a
- * local-header extra field, and equal to the media type implied by the declared ODF format.
- * Passing this preflight is not complete package, macro, embedded-object,
- * archive-expansion, malware, or fidelity qualification. Those deeper controls remain
- * separate sandbox/content-policy acceptance gates.
+ * contain NUL bytes. OpenDocument candidates additionally require the package manifest,
+ * restrict META-INF content to the manifest plus signature-named entries, and, when a
+ * mimetype entry is present, require it to be the first local ZIP entry, stored without
+ * compression, free of a local-header extra field, and equal to the media type implied by
+ * the declared ODF format. Passing this preflight is not complete package,
+ * macro, embedded-object, archive-expansion, malware, or fidelity qualification. Those
+ * deeper controls remain separate sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -45,6 +45,10 @@ final class OfficeSourceContainerPreflight {
"META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
private static final byte[] ODF_MIMETYPE_ENTRY_NAME =
"mimetype".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] ODF_META_INF_PREFIX =
+ "META-INF/".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] ODF_SIGNATURES_NAME_FRAGMENT =
+ "signatures".getBytes(StandardCharsets.UTF_8);
private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] {
0x50, 0x4b, 0x03, 0x04
};
@@ -221,6 +225,29 @@ private static void requireCentralDirectoryRecords(
throw invalidEntryDataRange();
}
requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength);
+ boolean odfManifestEntry = requireOdfManifest
+ && entryNameMatches(
+ sourceBytes,
+ centralNameOffset,
+ fileNameLength,
+ ODF_MANIFEST_ENTRY_NAME
+ );
+ if (requireOdfManifest
+ && entryNameStartsWith(
+ sourceBytes,
+ centralNameOffset,
+ fileNameLength,
+ ODF_META_INF_PREFIX
+ )
+ && !odfManifestEntry
+ && !entryNameContains(
+ sourceBytes,
+ centralNameOffset,
+ fileNameLength,
+ ODF_SIGNATURES_NAME_FRAGMENT
+ )) {
+ throw invalidOdfMetaInfEntry();
+ }
boolean odfMimetypeEntry = requireOdfManifest
&& entryNameMatches(
sourceBytes,
@@ -243,12 +270,7 @@ && unsignedShort(sourceBytes, (int) localHeaderOffset + 28) != 0) {
|| !matchesAt(sourceBytes, (int) localDataOffset, expectedOdfMimetype))) {
throw invalidOdfMimetypePayload();
}
- if (entryNameMatches(
- sourceBytes,
- centralNameOffset,
- fileNameLength,
- ODF_MANIFEST_ENTRY_NAME
- )) {
+ if (odfManifestEntry) {
odfManifestFound = true;
}
cursor = (int) nextCursor;
@@ -330,6 +352,48 @@ private static boolean entryNameMatches(
return true;
}
+ private static boolean entryNameStartsWith(
+ byte[] sourceBytes,
+ int nameOffset,
+ int nameLength,
+ byte[] expectedPrefix
+ ) {
+ if (nameLength < expectedPrefix.length) {
+ return false;
+ }
+ for (int index = 0; index < expectedPrefix.length; index++) {
+ if (sourceBytes[nameOffset + index] != expectedPrefix[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean entryNameContains(
+ byte[] sourceBytes,
+ int nameOffset,
+ int nameLength,
+ byte[] expectedFragment
+ ) {
+ if (nameLength < expectedFragment.length) {
+ return false;
+ }
+ int lastStart = nameLength - expectedFragment.length;
+ for (int start = 0; start <= lastStart; start++) {
+ boolean match = true;
+ for (int index = 0; index < expectedFragment.length; index++) {
+ if (sourceBytes[nameOffset + start + index] != expectedFragment[index]) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static boolean isAllowedCompressionMethod(int compressionMethod) {
return compressionMethod == ZIP_STORED_METHOD || compressionMethod == ZIP_DEFLATED_METHOD;
}
@@ -479,6 +543,13 @@ private static OfficeConversionException missingOdfManifest() {
);
}
+ private static OfficeConversionException invalidOdfMetaInfEntry() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF META-INF entry is not allowed"
+ );
+ }
+
private static OfficeConversionException invalidOdfMimetypePlacement() {
return new OfficeConversionException(
OfficeConversionFailureCode.MALFORMED_INPUT,
From 4b6419f47c86a3fe6473fd6c7fc6011bb79d102d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 15:21:36 +0900
Subject: [PATCH 156/219] test(conversion): reject duplicate ZIP entry names
---
.../OfficeSourceDuplicateEntryPolicyTest.java | 107 ++++++++++++++++++
1 file changed, 107 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java
new file mode 100644
index 00000000..2926f16c
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java
@@ -0,0 +1,107 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Prevents duplicate logical ZIP entry names from reaching an Office provider.
+ */
+class OfficeSourceDuplicateEntryPolicyTest {
+
+ private static final byte[] FIRST_NAME = "content.xml".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] SECOND_NAME = "stylesx.xml".getBytes(StandardCharsets.US_ASCII);
+
+ @Test
+ void adapterRejectsDuplicateZipEntryNameBeforeProviderInvocation() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = duplicateNamePackage();
+
+ OfficeConversionException failure = org.junit.jupiter.api.Assertions.assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ZIP contains duplicate entry name", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("516f661f-06a7-49dc-9c14-4f2e4161758c"),
+ 13L,
+ "docx",
+ "policy-v1",
+ "trace-duplicate-entry-policy",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] duplicateNamePackage() throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ addStoredEmptyEntry(zip, new String(FIRST_NAME, StandardCharsets.US_ASCII));
+ addStoredEmptyEntry(zip, new String(SECOND_NAME, StandardCharsets.US_ASCII));
+ }
+ byte[] bytes = output.toByteArray();
+ replaceAll(bytes, SECOND_NAME, FIRST_NAME);
+ return bytes;
+ }
+
+ private static void addStoredEmptyEntry(ZipOutputStream zip, String entryName) throws IOException {
+ ZipEntry entry = new ZipEntry(entryName);
+ entry.setMethod(ZipEntry.STORED);
+ entry.setSize(0L);
+ entry.setCompressedSize(0L);
+ entry.setCrc(0L);
+ zip.putNextEntry(entry);
+ zip.closeEntry();
+ }
+
+ private static void replaceAll(byte[] bytes, byte[] source, byte[] replacement) {
+ if (source.length != replacement.length) {
+ throw new IllegalArgumentException("replacement must preserve ZIP filename length");
+ }
+ for (int offset = 0; offset <= bytes.length - source.length; offset++) {
+ if (!matchesAt(bytes, offset, source)) {
+ continue;
+ }
+ System.arraycopy(replacement, 0, bytes, offset, replacement.length);
+ offset += source.length - 1;
+ }
+ }
+
+ private static boolean matchesAt(byte[] bytes, int offset, byte[] expected) {
+ for (int index = 0; index < expected.length; index++) {
+ if (bytes[offset + index] != expected[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
From 319a73e82d61ba35ae79717ac10ff7cbd3b0bdf1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 15:26:35 +0900
Subject: [PATCH 157/219] fix(conversion): reject duplicate ZIP entry names
---
.../OfficeSourceContainerPreflight.java | 46 +++++++++++++------
1 file changed, 33 insertions(+), 13 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
index 7b139ff8..d97d7049 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java
@@ -1,6 +1,7 @@
package com.clearfolio.viewer.conversion;
import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@@ -15,15 +16,16 @@
* limited to the current Stored/Deflate compression qualification boundary, Stored entry
* sizes must be internally consistent, local and central data-descriptor flags plus CRC
* and size metadata must agree where applicable, advertised compressed bytes cannot extend
- * beyond the local-data area before the central directory, and entry names are rejected
- * when they are absolute, contain parent traversal, use backslash path separators, or
- * contain NUL bytes. OpenDocument candidates additionally require the package manifest,
- * restrict META-INF content to the manifest plus signature-named entries, and, when a
- * mimetype entry is present, require it to be the first local ZIP entry, stored without
- * compression, free of a local-header extra field, and equal to the media type implied by
- * the declared ODF format. Passing this preflight is not complete package,
- * macro, embedded-object, archive-expansion, malware, or fidelity qualification. Those
- * deeper controls remain separate sandbox/content-policy acceptance gates.
+ * beyond the local-data area before the central directory, duplicate raw entry names are
+ * rejected, and entry names are rejected when they are absolute, contain parent traversal,
+ * use backslash path separators, or contain NUL bytes. OpenDocument candidates additionally
+ * require the package manifest, restrict META-INF content to the manifest plus
+ * signature-named entries, and, when a mimetype entry is present, require it to be the
+ * first local ZIP entry, stored without compression, free of a local-header extra field,
+ * and equal to the media type implied by the declared ODF format. Passing this preflight
+ * is not complete package, macro, embedded-object, archive-expansion,
+ * malware, or fidelity qualification. Those deeper controls remain separate
+ * sandbox/content-policy acceptance gates.
*/
final class OfficeSourceContainerPreflight {
@@ -88,10 +90,11 @@ private OfficeSourceContainerPreflight() {
* @throws OfficeConversionException when the format is not a current candidate, the
* source does not match that format family's required container signature, a
* ZIP-family source has invalid local/central-directory framing, has inconsistent
- * Stored entry sizes or duplicated metadata, advertises compressed bytes beyond
- * its local-data region, uses a ZIP compression method outside the current
- * Stored/Deflate qualification boundary, contains an encrypted entry, has an
- * unsafe ZIP entry path, or an ODF candidate violates required package structure
+ * Stored entry sizes or duplicated metadata, contains duplicate raw entry names,
+ * advertises compressed bytes beyond its local-data region, uses a ZIP compression
+ * method outside the current Stored/Deflate qualification boundary, contains an
+ * encrypted entry, has an unsafe ZIP entry path, or an ODF candidate violates
+ * required package structure
*/
static void requireQualifiedContainer(OfficeConversionRequest request) {
String sourceFormat = request.sourceFormat();
@@ -164,6 +167,7 @@ private static void requireCentralDirectoryRecords(
) {
int cursor = centralDirectoryOffset;
boolean odfManifestFound = false;
+ Set rawEntryNames = new HashSet<>();
for (int entryIndex = 0; entryIndex < entryCount; entryIndex++) {
if (cursor > centralDirectoryEnd - ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH
|| !matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER)) {
@@ -209,6 +213,15 @@ private static void requireCentralDirectoryRecords(
throw invalidCentralDirectory();
}
int centralNameOffset = cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH;
+ String rawEntryName = new String(
+ sourceBytes,
+ centralNameOffset,
+ fileNameLength,
+ StandardCharsets.ISO_8859_1
+ );
+ if (!rawEntryNames.add(rawEntryName)) {
+ throw duplicateEntryName();
+ }
long localDataOffset = requireMatchingLocalHeaderMetadata(
sourceBytes,
(int) localHeaderOffset,
@@ -536,6 +549,13 @@ private static OfficeConversionException invalidEntryDataRange() {
);
}
+ private static OfficeConversionException duplicateEntryName() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ZIP contains duplicate entry name"
+ );
+ }
+
private static OfficeConversionException missingOdfManifest() {
return new OfficeConversionException(
OfficeConversionFailureCode.MALFORMED_INPUT,
From 1a18b494cf120ded657796c634dcb17e09d19a52 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:32:15 +0900
Subject: [PATCH 158/219] test(conversion): reject ODF manifest media-type
mismatch
---
.../OfficeOdfManifestMediaTypePolicyTest.java | 177 ++++++++++++++++++
1 file changed, 177 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java
new file mode 100644
index 00000000..c240b73d
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java
@@ -0,0 +1,177 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies the OpenDocument manifest root media type against the package mimetype entry.
+ */
+class OfficeOdfManifestMediaTypePolicyTest {
+
+ private static final byte[] MANIFEST_NAME =
+ "META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] MIMETYPE_NAME = "mimetype".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] ODT_MIMETYPE =
+ "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII);
+ private static final String ODS_MIMETYPE = "application/vnd.oasis.opendocument.spreadsheet";
+ private static final int LOCAL_HEADER_LENGTH = 30;
+ private static final int CENTRAL_HEADER_LENGTH = 46;
+
+ @Test
+ void adapterRejectsManifestRootMediaTypeThatDisagreesWithMimetype() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfZipWithRootMediaType(ODS_MIMETYPE);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF manifest root media type does not match mimetype", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterAcceptsManifestRootMediaTypeThatMatchesMimetype() {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfZipWithRootMediaType(new String(ODT_MIMETYPE, StandardCharsets.US_ASCII));
+
+ countingAdapter(providerCalls).convert(request(source));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"),
+ 10L,
+ "odt",
+ "policy-v1",
+ "trace-odf-manifest-media-type",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] odfZipWithRootMediaType(String rootMediaType) {
+ byte[] manifestPayload = (""
+ + ""
+ + ""
+ + "").getBytes(StandardCharsets.UTF_8);
+
+ int mimetypeLocalOffset = 0;
+ int mimetypeDataOffset = mimetypeLocalOffset + LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length;
+ int manifestLocalOffset = mimetypeDataOffset + ODT_MIMETYPE.length;
+ int manifestDataOffset = manifestLocalOffset + LOCAL_HEADER_LENGTH + MANIFEST_NAME.length;
+ int centralOffset = manifestDataOffset + manifestPayload.length;
+ int mimetypeCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length;
+ int manifestCentralOffset = centralOffset + mimetypeCentralLength;
+ int manifestCentralLength = CENTRAL_HEADER_LENGTH + MANIFEST_NAME.length;
+ int centralLength = mimetypeCentralLength + manifestCentralLength;
+ int eocdOffset = centralOffset + centralLength;
+ byte[] bytes = new byte[eocdOffset + 22];
+
+ writeLocalHeader(bytes, mimetypeLocalOffset, MIMETYPE_NAME, ODT_MIMETYPE.length);
+ System.arraycopy(ODT_MIMETYPE, 0, bytes, mimetypeDataOffset, ODT_MIMETYPE.length);
+ writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, manifestPayload.length);
+ System.arraycopy(manifestPayload, 0, bytes, manifestDataOffset, manifestPayload.length);
+
+ writeCentralHeader(
+ bytes,
+ centralOffset,
+ MIMETYPE_NAME,
+ mimetypeLocalOffset,
+ ODT_MIMETYPE.length
+ );
+ writeCentralHeader(
+ bytes,
+ manifestCentralOffset,
+ MANIFEST_NAME,
+ manifestLocalOffset,
+ manifestPayload.length
+ );
+ writeEocd(bytes, eocdOffset, 2, centralLength, centralOffset);
+ return bytes;
+ }
+
+ private static void writeLocalHeader(byte[] bytes, int offset, byte[] entryName, int size) {
+ putUnsignedInt(bytes, offset, 0x04034b50L);
+ putUnsignedShort(bytes, offset + 4, 20);
+ putUnsignedShort(bytes, offset + 8, 0);
+ putUnsignedInt(bytes, offset + 14, 0L);
+ putUnsignedInt(bytes, offset + 18, size);
+ putUnsignedInt(bytes, offset + 22, size);
+ putUnsignedShort(bytes, offset + 26, entryName.length);
+ putUnsignedShort(bytes, offset + 28, 0);
+ System.arraycopy(entryName, 0, bytes, offset + LOCAL_HEADER_LENGTH, entryName.length);
+ }
+
+ private static void writeCentralHeader(
+ byte[] bytes,
+ int offset,
+ byte[] entryName,
+ int localHeaderOffset,
+ int size
+ ) {
+ putUnsignedInt(bytes, offset, 0x02014b50L);
+ putUnsignedShort(bytes, offset + 10, 0);
+ putUnsignedInt(bytes, offset + 16, 0L);
+ putUnsignedInt(bytes, offset + 20, size);
+ putUnsignedInt(bytes, offset + 24, size);
+ putUnsignedShort(bytes, offset + 28, entryName.length);
+ putUnsignedInt(bytes, offset + 42, localHeaderOffset);
+ System.arraycopy(entryName, 0, bytes, offset + CENTRAL_HEADER_LENGTH, entryName.length);
+ }
+
+ private static void writeEocd(
+ byte[] bytes,
+ int eocdOffset,
+ int entryCount,
+ int centralLength,
+ int centralOffset
+ ) {
+ putUnsignedInt(bytes, eocdOffset, 0x06054b50L);
+ putUnsignedShort(bytes, eocdOffset + 8, entryCount);
+ putUnsignedShort(bytes, eocdOffset + 10, entryCount);
+ putUnsignedInt(bytes, eocdOffset + 12, centralLength);
+ putUnsignedInt(bytes, eocdOffset + 16, centralOffset);
+ }
+
+ private static void putUnsignedShort(byte[] bytes, int offset, int value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ }
+
+ private static void putUnsignedInt(byte[] bytes, int offset, long value) {
+ bytes[offset] = (byte) value;
+ bytes[offset + 1] = (byte) (value >>> 8);
+ bytes[offset + 2] = (byte) (value >>> 16);
+ bytes[offset + 3] = (byte) (value >>> 24);
+ }
+}
From 9f7afd27b59e17b9a86480da26423feb3b3965b5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:37:20 +0900
Subject: [PATCH 159/219] feat(conversion): validate ODF manifest media type
---
.../OfficeOdfManifestPreflight.java | 340 ++++++++++++++++++
1 file changed, 340 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
new file mode 100644
index 00000000..7a302fc6
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
@@ -0,0 +1,340 @@
+package com.clearfolio.viewer.conversion;
+
+import java.io.ByteArrayInputStream;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+/**
+ * Performs bounded, non-networked semantic checks on the OpenDocument package manifest.
+ *
+ * The common ZIP preflight runs first and proves the package framing, allowed compression
+ * methods, entry-name safety, duplicated local/central metadata, required manifest presence,
+ * and optional {@code mimetype} placement. This second boundary extracts only the manifest
+ * payload, bounds its expanded size, parses it as non-validating namespace-aware XML with
+ * DTD and external-entity support disabled, and enforces the ODF root media-type contract.
+ * It intentionally does not attempt full Relax NG manifest-schema validation.
+ */
+final class OfficeOdfManifestPreflight {
+
+ private static final Map ODF_MIMETYPE_BY_FORMAT = Map.of(
+ "odt", "application/vnd.oasis.opendocument.text",
+ "ods", "application/vnd.oasis.opendocument.spreadsheet",
+ "odp", "application/vnd.oasis.opendocument.presentation"
+ );
+ private static final String MANIFEST_NAMESPACE =
+ "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+ private static final byte[] MANIFEST_ENTRY_NAME =
+ "META-INF/manifest.xml".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ private static final byte[] MIMETYPE_ENTRY_NAME =
+ "mimetype".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] {
+ 0x50, 0x4b, 0x01, 0x02
+ };
+ private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] {
+ 0x50, 0x4b, 0x05, 0x06
+ };
+ private static final int ZIP_LOCAL_HEADER_FIXED_LENGTH = 30;
+ private static final int ZIP_CENTRAL_HEADER_FIXED_LENGTH = 46;
+ private static final int ZIP_EOCD_MINIMUM_LENGTH = 22;
+ private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535;
+ private static final int ZIP_STORED_METHOD = 0;
+ private static final int ZIP_DEFLATED_METHOD = 8;
+ private static final int MAX_MANIFEST_BYTES = 1_048_576;
+
+ private OfficeOdfManifestPreflight() {
+ }
+
+ /**
+ * Validates the ODF manifest root entry when the request is an ODF package candidate.
+ *
+ * @param request immutable conversion request that already passed common container preflight
+ * @throws OfficeConversionException when the manifest cannot be safely extracted or parsed,
+ * or when its root-document media type disagrees with the package {@code mimetype}
+ */
+ static void requireQualifiedManifest(OfficeConversionRequest request) {
+ String expectedMediaType = ODF_MIMETYPE_BY_FORMAT.get(request.sourceFormat());
+ if (expectedMediaType == null) {
+ return;
+ }
+
+ LocatedEntries entries = locateEntries(request.sourceBytes());
+ byte[] manifestBytes = extractManifest(request.sourceBytes(), entries.manifestEntry());
+ requireManifestContract(manifestBytes, entries.mimetypeFound(), expectedMediaType);
+ }
+
+ private static LocatedEntries locateEntries(byte[] sourceBytes) {
+ int eocdOffset = findEocdOffset(sourceBytes);
+ if (eocdOffset < 0) {
+ throw invalidManifest();
+ }
+ int entryCount = unsignedShort(sourceBytes, eocdOffset + 10);
+ long centralOffsetLong = unsignedInt(sourceBytes, eocdOffset + 16);
+ if (centralOffsetLong > Integer.MAX_VALUE) {
+ throw invalidManifest();
+ }
+ int cursor = (int) centralOffsetLong;
+ ManifestEntry manifestEntry = null;
+ boolean mimetypeFound = false;
+
+ for (int index = 0; index < entryCount; index++) {
+ if (!matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER)
+ || cursor > sourceBytes.length - ZIP_CENTRAL_HEADER_FIXED_LENGTH) {
+ throw invalidManifest();
+ }
+ int compressionMethod = unsignedShort(sourceBytes, cursor + 10);
+ long compressedSize = unsignedInt(sourceBytes, cursor + 20);
+ long uncompressedSize = unsignedInt(sourceBytes, cursor + 24);
+ int fileNameLength = unsignedShort(sourceBytes, cursor + 28);
+ int extraFieldLength = unsignedShort(sourceBytes, cursor + 30);
+ int fileCommentLength = unsignedShort(sourceBytes, cursor + 32);
+ long localHeaderOffset = unsignedInt(sourceBytes, cursor + 42);
+ int nameOffset = cursor + ZIP_CENTRAL_HEADER_FIXED_LENGTH;
+ long nextCursor = (long) nameOffset + fileNameLength + extraFieldLength + fileCommentLength;
+ if (nextCursor > sourceBytes.length || localHeaderOffset > Integer.MAX_VALUE) {
+ throw invalidManifest();
+ }
+
+ if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MIMETYPE_ENTRY_NAME)) {
+ mimetypeFound = true;
+ }
+ if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MANIFEST_ENTRY_NAME)) {
+ int localOffset = (int) localHeaderOffset;
+ if (localOffset > sourceBytes.length - ZIP_LOCAL_HEADER_FIXED_LENGTH) {
+ throw invalidManifest();
+ }
+ int localNameLength = unsignedShort(sourceBytes, localOffset + 26);
+ int localExtraLength = unsignedShort(sourceBytes, localOffset + 28);
+ long dataOffset = (long) localOffset
+ + ZIP_LOCAL_HEADER_FIXED_LENGTH
+ + localNameLength
+ + localExtraLength;
+ if (dataOffset > Integer.MAX_VALUE) {
+ throw invalidManifest();
+ }
+ manifestEntry = new ManifestEntry(
+ compressionMethod,
+ compressedSize,
+ uncompressedSize,
+ (int) dataOffset
+ );
+ }
+ cursor = (int) nextCursor;
+ }
+ if (manifestEntry == null) {
+ throw invalidManifest();
+ }
+ return new LocatedEntries(manifestEntry, mimetypeFound);
+ }
+
+ private static byte[] extractManifest(byte[] sourceBytes, ManifestEntry entry) {
+ if (entry.uncompressedSize() > MAX_MANIFEST_BYTES) {
+ throw manifestTooLarge();
+ }
+ if (entry.compressedSize() > Integer.MAX_VALUE || entry.uncompressedSize() > Integer.MAX_VALUE) {
+ throw invalidManifest();
+ }
+ int compressedSize = (int) entry.compressedSize();
+ int uncompressedSize = (int) entry.uncompressedSize();
+ long dataEnd = (long) entry.dataOffset() + compressedSize;
+ if (dataEnd > sourceBytes.length) {
+ throw invalidManifest();
+ }
+ if (entry.compressionMethod() == ZIP_STORED_METHOD) {
+ if (compressedSize != uncompressedSize) {
+ throw invalidManifest();
+ }
+ return Arrays.copyOfRange(sourceBytes, entry.dataOffset(), (int) dataEnd);
+ }
+ if (entry.compressionMethod() != ZIP_DEFLATED_METHOD) {
+ throw invalidManifest();
+ }
+
+ byte[] result = new byte[uncompressedSize];
+ Inflater inflater = new Inflater(true);
+ try {
+ inflater.setInput(sourceBytes, entry.dataOffset(), compressedSize);
+ int written = 0;
+ while (!inflater.finished() && written < result.length) {
+ int produced = inflater.inflate(result, written, result.length - written);
+ if (produced == 0) {
+ break;
+ }
+ written += produced;
+ }
+ if (!inflater.finished() || written != result.length || inflater.getRemaining() != 0) {
+ throw invalidManifest();
+ }
+ return result;
+ } catch (DataFormatException ex) {
+ throw invalidManifest();
+ } finally {
+ inflater.end();
+ }
+ }
+
+ private static void requireManifestContract(
+ byte[] manifestBytes,
+ boolean mimetypeFound,
+ String expectedMediaType
+ ) {
+ XMLInputFactory factory = XMLInputFactory.newFactory();
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+ factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ factory.setXMLResolver((publicId, systemId, baseUri, namespace) -> {
+ throw new XMLStreamException("external XML resolution is disabled");
+ });
+
+ boolean rootElementSeen = false;
+ String rootDocumentMediaType = null;
+ try (ByteArrayInputStream input = new ByteArrayInputStream(manifestBytes)) {
+ XMLStreamReader reader = factory.createXMLStreamReader(input);
+ try {
+ while (reader.hasNext()) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.DTD || event == XMLStreamConstants.ENTITY_REFERENCE) {
+ throw invalidManifest();
+ }
+ if (event != XMLStreamConstants.START_ELEMENT) {
+ continue;
+ }
+ if (!rootElementSeen) {
+ rootElementSeen = true;
+ if (!MANIFEST_NAMESPACE.equals(reader.getNamespaceURI())
+ || !"manifest".equals(reader.getLocalName())) {
+ throw invalidManifest();
+ }
+ }
+ if (MANIFEST_NAMESPACE.equals(reader.getNamespaceURI())
+ && "file-entry".equals(reader.getLocalName())
+ && "/".equals(reader.getAttributeValue(MANIFEST_NAMESPACE, "full-path"))) {
+ if (rootDocumentMediaType != null) {
+ throw invalidManifest();
+ }
+ rootDocumentMediaType = reader.getAttributeValue(MANIFEST_NAMESPACE, "media-type");
+ }
+ }
+ } finally {
+ reader.close();
+ }
+ } catch (XMLStreamException | java.io.IOException ex) {
+ throw invalidManifest();
+ }
+
+ if (!rootElementSeen) {
+ throw invalidManifest();
+ }
+ if (mimetypeFound && rootDocumentMediaType == null) {
+ throw missingManifestRootEntry();
+ }
+ if (rootDocumentMediaType != null && !mimetypeFound) {
+ throw missingMimetypeForManifestRoot();
+ }
+ if (rootDocumentMediaType != null && !expectedMediaType.equals(rootDocumentMediaType)) {
+ throw manifestMediaTypeMismatch();
+ }
+ }
+
+ private static int findEocdOffset(byte[] sourceBytes) {
+ if (sourceBytes.length < ZIP_EOCD_MINIMUM_LENGTH) {
+ return -1;
+ }
+ int latest = sourceBytes.length - ZIP_EOCD_MINIMUM_LENGTH;
+ int earliest = Math.max(0, latest - ZIP_MAXIMUM_COMMENT_LENGTH);
+ for (int offset = latest; offset >= earliest; offset--) {
+ if (matchesAt(sourceBytes, offset, ZIP_END_OF_CENTRAL_DIRECTORY)
+ && offset + ZIP_EOCD_MINIMUM_LENGTH + unsignedShort(sourceBytes, offset + 20)
+ == sourceBytes.length) {
+ return offset;
+ }
+ }
+ return -1;
+ }
+
+ private static boolean entryNameMatches(
+ byte[] sourceBytes,
+ int nameOffset,
+ int nameLength,
+ byte[] expectedName
+ ) {
+ return nameLength == expectedName.length && matchesAt(sourceBytes, nameOffset, expectedName);
+ }
+
+ private static boolean matchesAt(byte[] sourceBytes, int offset, byte[] expected) {
+ if (offset < 0 || offset > sourceBytes.length - expected.length) {
+ return false;
+ }
+ for (int index = 0; index < expected.length; index++) {
+ if (sourceBytes[offset + index] != expected[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static int unsignedShort(byte[] sourceBytes, int offset) {
+ return Byte.toUnsignedInt(sourceBytes[offset])
+ | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8);
+ }
+
+ private static long unsignedInt(byte[] sourceBytes, int offset) {
+ return Integer.toUnsignedLong(
+ Byte.toUnsignedInt(sourceBytes[offset])
+ | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8)
+ | (Byte.toUnsignedInt(sourceBytes[offset + 2]) << 16)
+ | (Byte.toUnsignedInt(sourceBytes[offset + 3]) << 24)
+ );
+ }
+
+ private static OfficeConversionException invalidManifest() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF manifest is invalid"
+ );
+ }
+
+ private static OfficeConversionException manifestTooLarge() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.POLICY_DENIED,
+ "source ODF manifest exceeds maximum bytes"
+ );
+ }
+
+ private static OfficeConversionException missingManifestRootEntry() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF manifest root entry is missing"
+ );
+ }
+
+ private static OfficeConversionException missingMimetypeForManifestRoot() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF mimetype entry is missing for manifest root"
+ );
+ }
+
+ private static OfficeConversionException manifestMediaTypeMismatch() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF manifest root media type does not match mimetype"
+ );
+ }
+
+ private record ManifestEntry(
+ int compressionMethod,
+ long compressedSize,
+ long uncompressedSize,
+ int dataOffset
+ ) {
+ }
+
+ private record LocatedEntries(ManifestEntry manifestEntry, boolean mimetypeFound) {
+ }
+}
From 16797747dac922f7bceebeb8f0e859e4b87152a3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:38:10 +0900
Subject: [PATCH 160/219] feat(conversion): enforce ODF manifest semantics
before provider
---
.../conversion/OfficeConversionAdapter.java | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
index fde0bbc4..3c87c602 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java
@@ -34,14 +34,15 @@ public interface OfficeConversionAdapter {
*
* Before provider invocation, Clearfolio requires the declared source
* format to be a current Office conversion candidate and requires its leading
- * container signature to match the declared format family. This common
- * preflight is intentionally narrower than complete archive, macro, OLE,
- * malware, or fidelity qualification, which remain sandbox/content-policy
- * responsibilities. After provider execution, the result must be present,
- * source-bound, tied to the exact qualified adapter/runtime, request
- * generation and policy, within request-bound byte/page publication ceilings,
- * and parseable as a non-empty, unencrypted PDF without prohibited active
- * content.
+ * container signature to match the declared format family. ODF candidates
+ * additionally pass bounded, non-networked manifest parsing and root media-type
+ * validation. This common preflight is intentionally narrower than complete
+ * archive, macro, OLE, malware, or fidelity qualification, which remain
+ * sandbox/content-policy responsibilities. After provider execution, the
+ * result must be present, source-bound, tied to the exact qualified
+ * adapter/runtime, request generation and policy, within request-bound
+ * byte/page publication ceilings, and parseable as a non-empty, unencrypted
+ * PDF without prohibited active content.
*
* @param request immutable tenant-, generation-, and adapter-bound conversion request
* @return verified PDF result with source, request, and adapter provenance
@@ -52,6 +53,7 @@ public interface OfficeConversionAdapter {
*/
default OfficeConversionResult convert(OfficeConversionRequest request) {
OfficeSourceContainerPreflight.requireQualifiedContainer(request);
+ OfficeOdfManifestPreflight.requireQualifiedManifest(request);
OfficeConversionResult result = performConversion(request);
if (result == null) {
throw new OfficeConversionException(
From fae8097b564956def0bdd9619ddc52e39f3b84de Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:39:29 +0900
Subject: [PATCH 161/219] test(conversion): keep ODF META-INF fixture
structurally valid
---
.../OfficeOdfMetaInfPolicyTest.java | 34 +++++++++++++++++--
1 file changed, 31 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
index e80edd95..9bb0366e 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
@@ -4,8 +4,10 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -16,6 +18,18 @@
*/
class OfficeOdfMetaInfPolicyTest {
+ private static final byte[] MANIFEST_XML = (
+ ""
+ + ""
+ ).getBytes(StandardCharsets.UTF_8);
+ private static final byte[] SIGNATURE_XML = (
+ ""
+ + ""
+ ).getBytes(StandardCharsets.UTF_8);
+
@Test
void adapterRejectsUnexpectedMetaInfEntryBeforeProviderInvocation() throws IOException {
AtomicInteger providerCalls = new AtomicInteger();
@@ -75,15 +89,29 @@ private static byte[] odfPackage(String... entryNames) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output)) {
for (String entryName : entryNames) {
+ byte[] payload = payloadFor(entryName);
+ CRC32 crc32 = new CRC32();
+ crc32.update(payload);
ZipEntry entry = new ZipEntry(entryName);
entry.setMethod(ZipEntry.STORED);
- entry.setSize(0L);
- entry.setCompressedSize(0L);
- entry.setCrc(0L);
+ entry.setSize(payload.length);
+ entry.setCompressedSize(payload.length);
+ entry.setCrc(crc32.getValue());
zip.putNextEntry(entry);
+ zip.write(payload);
zip.closeEntry();
}
}
return output.toByteArray();
}
+
+ private static byte[] payloadFor(String entryName) {
+ if ("META-INF/manifest.xml".equals(entryName)) {
+ return MANIFEST_XML;
+ }
+ if (entryName.contains("signatures")) {
+ return SIGNATURE_XML;
+ }
+ return new byte[0];
+ }
}
From b8b81d5e9ce51c3ae86508f2c96845f06c695d19 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:42:04 +0900
Subject: [PATCH 162/219] test(conversion): require ODF manifest entry coverage
---
.../OfficeOdfManifestInventoryPolicyTest.java | 110 ++++++++++++++++++
1 file changed, 110 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java
new file mode 100644
index 00000000..6c5b5d10
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java
@@ -0,0 +1,110 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that the ODF manifest enumerates each ordinary package file exactly once.
+ */
+class OfficeOdfManifestInventoryPolicyTest {
+
+ private static final String MANIFEST_NAMESPACE =
+ "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+
+ @Test
+ void adapterRejectsOrdinaryPackageFileMissingFromManifest() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfPackage(false);
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(source))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF manifest does not match package file inventory", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterAcceptsOrdinaryPackageFileEnumeratedExactlyOnce() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+ byte[] source = odfPackage(true);
+
+ countingAdapter(providerCalls).convert(request(source));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("41bcd923-780f-4f7a-b142-e00fed5ce05e"),
+ 13L,
+ "odt",
+ "policy-v1",
+ "trace-odf-manifest-inventory",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] odfPackage(boolean listContentXml) throws IOException {
+ String fileEntry = listContentXml
+ ? ""
+ : "";
+ byte[] manifest = (""
+ + ""
+ + fileEntry
+ + "").getBytes(StandardCharsets.UTF_8);
+ byte[] content = "".getBytes(StandardCharsets.UTF_8);
+
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ writeStored(zip, "META-INF/manifest.xml", manifest);
+ writeStored(zip, "content.xml", content);
+ }
+ return output.toByteArray();
+ }
+
+ private static void writeStored(ZipOutputStream zip, String name, byte[] payload) throws IOException {
+ CRC32 crc32 = new CRC32();
+ crc32.update(payload);
+ ZipEntry entry = new ZipEntry(name);
+ entry.setMethod(ZipEntry.STORED);
+ entry.setSize(payload.length);
+ entry.setCompressedSize(payload.length);
+ entry.setCrc(crc32.getValue());
+ zip.putNextEntry(entry);
+ zip.write(payload);
+ zip.closeEntry();
+ }
+}
From 9f06eb5e35e22c09e88d9271611210dec0339695 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:45:07 +0900
Subject: [PATCH 163/219] feat(conversion): bind ODF manifest to package
inventory
---
.../OfficeOdfManifestPreflight.java | 77 +++++++++++++++----
1 file changed, 62 insertions(+), 15 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
index 7a302fc6..2db7bbb8 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
@@ -1,8 +1,12 @@
package com.clearfolio.viewer.conversion;
import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Map;
+import java.util.Set;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
@@ -18,8 +22,9 @@
* methods, entry-name safety, duplicated local/central metadata, required manifest presence,
* and optional {@code mimetype} placement. This second boundary extracts only the manifest
* payload, bounds its expanded size, parses it as non-validating namespace-aware XML with
- * DTD and external-entity support disabled, and enforces the ODF root media-type contract.
- * It intentionally does not attempt full Relax NG manifest-schema validation.
+ * DTD and external-entity support disabled, binds ordinary ZIP files to exactly one manifest
+ * entry, and enforces the ODF root media-type contract. It intentionally does not attempt
+ * full Relax NG manifest-schema validation.
*/
final class OfficeOdfManifestPreflight {
@@ -30,10 +35,12 @@ final class OfficeOdfManifestPreflight {
);
private static final String MANIFEST_NAMESPACE =
"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+ private static final String MANIFEST_ENTRY_PATH = "META-INF/manifest.xml";
+ private static final String MIMETYPE_ENTRY_PATH = "mimetype";
private static final byte[] MANIFEST_ENTRY_NAME =
- "META-INF/manifest.xml".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ MANIFEST_ENTRY_PATH.getBytes(StandardCharsets.UTF_8);
private static final byte[] MIMETYPE_ENTRY_NAME =
- "mimetype".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ MIMETYPE_ENTRY_PATH.getBytes(StandardCharsets.UTF_8);
private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] {
0x50, 0x4b, 0x01, 0x02
};
@@ -52,11 +59,12 @@ private OfficeOdfManifestPreflight() {
}
/**
- * Validates the ODF manifest root entry when the request is an ODF package candidate.
+ * Validates the ODF manifest when the request is an ODF package candidate.
*
* @param request immutable conversion request that already passed common container preflight
* @throws OfficeConversionException when the manifest cannot be safely extracted or parsed,
- * or when its root-document media type disagrees with the package {@code mimetype}
+ * ordinary package-file inventory is inconsistent, or the root-document media type
+ * disagrees with the package {@code mimetype}
*/
static void requireQualifiedManifest(OfficeConversionRequest request) {
String expectedMediaType = ODF_MIMETYPE_BY_FORMAT.get(request.sourceFormat());
@@ -66,7 +74,12 @@ static void requireQualifiedManifest(OfficeConversionRequest request) {
LocatedEntries entries = locateEntries(request.sourceBytes());
byte[] manifestBytes = extractManifest(request.sourceBytes(), entries.manifestEntry());
- requireManifestContract(manifestBytes, entries.mimetypeFound(), expectedMediaType);
+ requireManifestContract(
+ manifestBytes,
+ entries.mimetypeFound(),
+ expectedMediaType,
+ entries.ordinaryPackageFiles()
+ );
}
private static LocatedEntries locateEntries(byte[] sourceBytes) {
@@ -82,6 +95,7 @@ private static LocatedEntries locateEntries(byte[] sourceBytes) {
int cursor = (int) centralOffsetLong;
ManifestEntry manifestEntry = null;
boolean mimetypeFound = false;
+ Set ordinaryPackageFiles = new HashSet<>();
for (int index = 0; index < entryCount; index++) {
if (!matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER)
@@ -101,10 +115,10 @@ private static LocatedEntries locateEntries(byte[] sourceBytes) {
throw invalidManifest();
}
+ String entryName = new String(sourceBytes, nameOffset, fileNameLength, StandardCharsets.UTF_8);
if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MIMETYPE_ENTRY_NAME)) {
mimetypeFound = true;
- }
- if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MANIFEST_ENTRY_NAME)) {
+ } else if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MANIFEST_ENTRY_NAME)) {
int localOffset = (int) localHeaderOffset;
if (localOffset > sourceBytes.length - ZIP_LOCAL_HEADER_FIXED_LENGTH) {
throw invalidManifest();
@@ -124,13 +138,15 @@ private static LocatedEntries locateEntries(byte[] sourceBytes) {
uncompressedSize,
(int) dataOffset
);
+ } else if (!entryName.startsWith("META-INF/") && !entryName.endsWith("/")) {
+ ordinaryPackageFiles.add(entryName);
}
cursor = (int) nextCursor;
}
if (manifestEntry == null) {
throw invalidManifest();
}
- return new LocatedEntries(manifestEntry, mimetypeFound);
+ return new LocatedEntries(manifestEntry, mimetypeFound, Set.copyOf(ordinaryPackageFiles));
}
private static byte[] extractManifest(byte[] sourceBytes, ManifestEntry entry) {
@@ -182,7 +198,8 @@ private static byte[] extractManifest(byte[] sourceBytes, ManifestEntry entry) {
private static void requireManifestContract(
byte[] manifestBytes,
boolean mimetypeFound,
- String expectedMediaType
+ String expectedMediaType,
+ Set ordinaryPackageFiles
) {
XMLInputFactory factory = XMLInputFactory.newFactory();
factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
@@ -193,6 +210,7 @@ private static void requireManifestContract(
boolean rootElementSeen = false;
String rootDocumentMediaType = null;
+ Map ordinaryManifestEntryCounts = new HashMap<>();
try (ByteArrayInputStream input = new ByteArrayInputStream(manifestBytes)) {
XMLStreamReader reader = factory.createXMLStreamReader(input);
try {
@@ -211,13 +229,27 @@ private static void requireManifestContract(
throw invalidManifest();
}
}
- if (MANIFEST_NAMESPACE.equals(reader.getNamespaceURI())
- && "file-entry".equals(reader.getLocalName())
- && "/".equals(reader.getAttributeValue(MANIFEST_NAMESPACE, "full-path"))) {
+ if (!MANIFEST_NAMESPACE.equals(reader.getNamespaceURI())
+ || !"file-entry".equals(reader.getLocalName())) {
+ continue;
+ }
+
+ String fullPath = reader.getAttributeValue(MANIFEST_NAMESPACE, "full-path");
+ if (fullPath == null || fullPath.isEmpty()) {
+ throw invalidManifest();
+ }
+ if ("/".equals(fullPath)) {
if (rootDocumentMediaType != null) {
throw invalidManifest();
}
rootDocumentMediaType = reader.getAttributeValue(MANIFEST_NAMESPACE, "media-type");
+ continue;
+ }
+ if (MANIFEST_ENTRY_PATH.equals(fullPath) || MIMETYPE_ENTRY_PATH.equals(fullPath)) {
+ throw manifestInventoryMismatch();
+ }
+ if (!fullPath.startsWith("META-INF/") && !fullPath.endsWith("/")) {
+ ordinaryManifestEntryCounts.merge(fullPath, 1, Integer::sum);
}
}
} finally {
@@ -239,6 +271,10 @@ private static void requireManifestContract(
if (rootDocumentMediaType != null && !expectedMediaType.equals(rootDocumentMediaType)) {
throw manifestMediaTypeMismatch();
}
+ if (!ordinaryManifestEntryCounts.keySet().equals(ordinaryPackageFiles)
+ || ordinaryManifestEntryCounts.values().stream().anyMatch(count -> count != 1)) {
+ throw manifestInventoryMismatch();
+ }
}
private static int findEocdOffset(byte[] sourceBytes) {
@@ -327,6 +363,13 @@ private static OfficeConversionException manifestMediaTypeMismatch() {
);
}
+ private static OfficeConversionException manifestInventoryMismatch() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF manifest does not match package file inventory"
+ );
+ }
+
private record ManifestEntry(
int compressionMethod,
long compressedSize,
@@ -335,6 +378,10 @@ private record ManifestEntry(
) {
}
- private record LocatedEntries(ManifestEntry manifestEntry, boolean mimetypeFound) {
+ private record LocatedEntries(
+ ManifestEntry manifestEntry,
+ boolean mimetypeFound,
+ Set ordinaryPackageFiles
+ ) {
}
}
From d96a3676f4d57c4b8934652fdfd9fb5f43441409 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 16:49:32 +0900
Subject: [PATCH 164/219] test(conversion): require ODF 1.4 manifest version
---
.../OfficeOdfManifestVersionPolicyTest.java | 98 +++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java
new file mode 100644
index 00000000..8ee5d73d
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java
@@ -0,0 +1,98 @@
+package com.clearfolio.viewer.conversion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that the OpenDocument manifest advertises the supported ODF package version.
+ */
+class OfficeOdfManifestVersionPolicyTest {
+
+ private static final String MANIFEST_NAMESPACE =
+ "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+
+ @Test
+ void adapterRejectsManifestVersionOutsideSupportedOdfVersion() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(odfPackage("1.3")))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF manifest version is not allowed", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
+ @Test
+ void adapterAcceptsManifestVersionFourteen() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+
+ countingAdapter(providerCalls).convert(request(odfPackage("1.4")));
+
+ assertEquals(1, providerCalls.get());
+ }
+
+ private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) {
+ return input -> {
+ providerCalls.incrementAndGet();
+ return new OfficeConversionResult(
+ "deterministic-fixture",
+ "1",
+ input.sourceSha256(),
+ input.binding(),
+ OfficeConversionTestPdf.onePage()
+ );
+ };
+ }
+
+ private static OfficeConversionRequest request(byte[] sourceBytes) {
+ return new OfficeConversionRequest(
+ "tenant-a",
+ UUID.fromString("931b7157-7840-4435-b65b-0d01fae5b141"),
+ 14L,
+ "odt",
+ "policy-v1",
+ "trace-odf-manifest-version",
+ sourceBytes,
+ 1_000_000L,
+ 10
+ );
+ }
+
+ private static byte[] odfPackage(String version) throws IOException {
+ byte[] manifest = (""
+ + "").getBytes(StandardCharsets.UTF_8);
+
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ CRC32 crc32 = new CRC32();
+ crc32.update(manifest);
+ ZipEntry entry = new ZipEntry("META-INF/manifest.xml");
+ entry.setMethod(ZipEntry.STORED);
+ entry.setSize(manifest.length);
+ entry.setCompressedSize(manifest.length);
+ entry.setCrc(crc32.getValue());
+ zip.putNextEntry(entry);
+ zip.write(manifest);
+ zip.closeEntry();
+ }
+ return output.toByteArray();
+ }
+}
From 914d1680e83a9935dd0375430bb1f9c0b79ac4ff Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 20:11:31 +0900
Subject: [PATCH 165/219] fix(conversion): enforce ODF 1.4 manifest version
---
.../OfficeOdfManifestPreflight.java | 23 +++++++++++++++----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
index 2db7bbb8..dad93d7d 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
@@ -22,9 +22,9 @@
* methods, entry-name safety, duplicated local/central metadata, required manifest presence,
* and optional {@code mimetype} placement. This second boundary extracts only the manifest
* payload, bounds its expanded size, parses it as non-validating namespace-aware XML with
- * DTD and external-entity support disabled, binds ordinary ZIP files to exactly one manifest
- * entry, and enforces the ODF root media-type contract. It intentionally does not attempt
- * full Relax NG manifest-schema validation.
+ * DTD and external-entity support disabled, requires the OpenDocument 1.4 manifest version,
+ * binds ordinary ZIP files to exactly one manifest entry, and enforces the ODF root media-type
+ * contract. It intentionally does not attempt full Relax NG manifest-schema validation.
*/
final class OfficeOdfManifestPreflight {
@@ -35,6 +35,7 @@ final class OfficeOdfManifestPreflight {
);
private static final String MANIFEST_NAMESPACE =
"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+ private static final String SUPPORTED_MANIFEST_VERSION = "1.4";
private static final String MANIFEST_ENTRY_PATH = "META-INF/manifest.xml";
private static final String MIMETYPE_ENTRY_PATH = "mimetype";
private static final byte[] MANIFEST_ENTRY_NAME =
@@ -63,8 +64,9 @@ private OfficeOdfManifestPreflight() {
*
* @param request immutable conversion request that already passed common container preflight
* @throws OfficeConversionException when the manifest cannot be safely extracted or parsed,
- * ordinary package-file inventory is inconsistent, or the root-document media type
- * disagrees with the package {@code mimetype}
+ * does not advertise the supported OpenDocument manifest version, ordinary package-file
+ * inventory is inconsistent, or the root-document media type disagrees with the package
+ * {@code mimetype}
*/
static void requireQualifiedManifest(OfficeConversionRequest request) {
String expectedMediaType = ODF_MIMETYPE_BY_FORMAT.get(request.sourceFormat());
@@ -228,6 +230,10 @@ private static void requireManifestContract(
|| !"manifest".equals(reader.getLocalName())) {
throw invalidManifest();
}
+ String manifestVersion = reader.getAttributeValue(MANIFEST_NAMESPACE, "version");
+ if (!SUPPORTED_MANIFEST_VERSION.equals(manifestVersion)) {
+ throw unsupportedManifestVersion();
+ }
}
if (!MANIFEST_NAMESPACE.equals(reader.getNamespaceURI())
|| !"file-entry".equals(reader.getLocalName())) {
@@ -335,6 +341,13 @@ private static OfficeConversionException invalidManifest() {
);
}
+ private static OfficeConversionException unsupportedManifestVersion() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF manifest version is not allowed"
+ );
+ }
+
private static OfficeConversionException manifestTooLarge() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 466a5ede72d3df04140f3732df727953788562d9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 20:18:22 +0900
Subject: [PATCH 166/219] test(conversion): require ODF manifest file entries
---
.../OfficeOdfManifestVersionPolicyTest.java | 31 +++++++++++++++++--
1 file changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java
index 8ee5d73d..2051d889 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java
@@ -15,7 +15,7 @@
import org.junit.jupiter.api.Test;
/**
- * Verifies that the OpenDocument manifest advertises the supported ODF package version.
+ * Verifies the supported OpenDocument manifest version and minimum manifest content.
*/
class OfficeOdfManifestVersionPolicyTest {
@@ -36,6 +36,20 @@ void adapterRejectsManifestVersionOutsideSupportedOdfVersion() throws IOExceptio
assertEquals(0, providerCalls.get());
}
+ @Test
+ void adapterRejectsManifestWithoutAnyFileEntry() throws IOException {
+ AtomicInteger providerCalls = new AtomicInteger();
+
+ OfficeConversionException failure = assertThrows(
+ OfficeConversionException.class,
+ () -> countingAdapter(providerCalls).convert(request(odfPackageWithoutFileEntry()))
+ );
+
+ assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode());
+ assertEquals("source ODF manifest has no file entries", failure.getMessage());
+ assertEquals(0, providerCalls.get());
+ }
+
@Test
void adapterAcceptsManifestVersionFourteen() throws IOException {
AtomicInteger providerCalls = new AtomicInteger();
@@ -73,13 +87,24 @@ private static OfficeConversionRequest request(byte[] sourceBytes) {
}
private static byte[] odfPackage(String version) throws IOException {
- byte[] manifest = (""
+ return packageWithManifest((""
+ "").getBytes(StandardCharsets.UTF_8);
+ + "\">"
+ + ""
+ + "").getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static byte[] odfPackageWithoutFileEntry() throws IOException {
+ return packageWithManifest((""
+ + "").getBytes(StandardCharsets.UTF_8));
+ }
+ private static byte[] packageWithManifest(byte[] manifest) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output)) {
CRC32 crc32 = new CRC32();
From a5c86719b8adef1c2957a29bc6de6042c59cdd9a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 20:22:08 +0900
Subject: [PATCH 167/219] fix(conversion): require ODF manifest file entry
---
.../OfficeOdfManifestPreflight.java | 25 ++++++++++++++-----
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
index dad93d7d..7b0299eb 100644
--- a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
+++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java
@@ -22,9 +22,10 @@
* methods, entry-name safety, duplicated local/central metadata, required manifest presence,
* and optional {@code mimetype} placement. This second boundary extracts only the manifest
* payload, bounds its expanded size, parses it as non-validating namespace-aware XML with
- * DTD and external-entity support disabled, requires the OpenDocument 1.4 manifest version,
- * binds ordinary ZIP files to exactly one manifest entry, and enforces the ODF root media-type
- * contract. It intentionally does not attempt full Relax NG manifest-schema validation.
+ * DTD and external-entity support disabled, requires the OpenDocument 1.4 manifest version
+ * and at least one manifest file entry, binds ordinary ZIP files to exactly one manifest entry,
+ * and enforces the ODF root media-type contract. It intentionally does not attempt full Relax
+ * NG manifest-schema validation.
*/
final class OfficeOdfManifestPreflight {
@@ -64,9 +65,9 @@ private OfficeOdfManifestPreflight() {
*
* @param request immutable conversion request that already passed common container preflight
* @throws OfficeConversionException when the manifest cannot be safely extracted or parsed,
- * does not advertise the supported OpenDocument manifest version, ordinary package-file
- * inventory is inconsistent, or the root-document media type disagrees with the package
- * {@code mimetype}
+ * does not advertise the supported OpenDocument manifest version or any file entry,
+ * ordinary package-file inventory is inconsistent, or the root-document media type
+ * disagrees with the package {@code mimetype}
*/
static void requireQualifiedManifest(OfficeConversionRequest request) {
String expectedMediaType = ODF_MIMETYPE_BY_FORMAT.get(request.sourceFormat());
@@ -211,6 +212,7 @@ private static void requireManifestContract(
});
boolean rootElementSeen = false;
+ int manifestFileEntryCount = 0;
String rootDocumentMediaType = null;
Map ordinaryManifestEntryCounts = new HashMap<>();
try (ByteArrayInputStream input = new ByteArrayInputStream(manifestBytes)) {
@@ -239,6 +241,7 @@ private static void requireManifestContract(
|| !"file-entry".equals(reader.getLocalName())) {
continue;
}
+ manifestFileEntryCount++;
String fullPath = reader.getAttributeValue(MANIFEST_NAMESPACE, "full-path");
if (fullPath == null || fullPath.isEmpty()) {
@@ -268,6 +271,9 @@ private static void requireManifestContract(
if (!rootElementSeen) {
throw invalidManifest();
}
+ if (manifestFileEntryCount == 0) {
+ throw missingManifestFileEntry();
+ }
if (mimetypeFound && rootDocumentMediaType == null) {
throw missingManifestRootEntry();
}
@@ -348,6 +354,13 @@ private static OfficeConversionException unsupportedManifestVersion() {
);
}
+ private static OfficeConversionException missingManifestFileEntry() {
+ return new OfficeConversionException(
+ OfficeConversionFailureCode.MALFORMED_INPUT,
+ "source ODF manifest has no file entries"
+ );
+ }
+
private static OfficeConversionException manifestTooLarge() {
return new OfficeConversionException(
OfficeConversionFailureCode.POLICY_DENIED,
From 9b7baaa972c2ab5656b286e1f7aae35feda6917f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 20:25:51 +0900
Subject: [PATCH 168/219] test(conversion): keep ODF META-INF fixtures
schema-valid
---
.../viewer/conversion/OfficeOdfMetaInfPolicyTest.java | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
index 9bb0366e..9ff32563 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java
@@ -22,7 +22,9 @@ class OfficeOdfMetaInfPolicyTest {
""
+ ""
+ + "manifest:version=\"1.4\">"
+ + ""
+ + ""
).getBytes(StandardCharsets.UTF_8);
private static final byte[] SIGNATURE_XML = (
""
From 852c2e144a808bfecda3ec76809163ce16900526 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 10 Aug 2026 20:26:11 +0900
Subject: [PATCH 169/219] test(conversion): preserve ODF inventory failure
boundary
---
.../conversion/OfficeOdfManifestInventoryPolicyTest.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java
index 6c5b5d10..c5711bcc 100644
--- a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java
+++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java
@@ -78,7 +78,8 @@ private static byte[] odfPackage(boolean listContentXml) throws IOException {
String fileEntry = listContentXml
? ""
- : "";
+ : "";
byte[] manifest = (""
+ "
Date: Mon, 10 Aug 2026 20:42:39 +0900
Subject: [PATCH 170/219] fix(security): harden audit pseudonymization and
refresh Netty evidence (#270)
* security: rebuild audit pseudonymization on current main
* test(security): require strong policy override keys
* fix(security): reject weak policy override keys
* docs(security): document policy override key strength
* fix: trigger CI due to strix timeout
* chore: remove stray CI trigger script
* test: cover disabled policy signing startup path
* docs: consolidate unreleased changelog entries
* build: enforce zero missed production lines and branches
* ci: verify exact PR head with coverage gates
* ci: fuzz the exact pull request head
* docs: record exact-head and coverage gates
* test: add shared security provider fixture
* test: cover null policy secret normalization
* test: cover tenant delete fail-closed branches
* test: cover validation security edge cases
* test: cover conversion filename boundary
* test: cover artifact deletion failure
* test: cover repository delete edge cases
* test: cover exception log sanitization
* test: cover download filename and digest edges
* fix: redact rejected parameter values
* docs: record rejected-value redaction
* test: cover every override header separator
* refactor: trust normalized policy secret contract
* refactor: remove unreachable blank filename branch
* fix(ci): isolate exact-head evidence finalization runs
* fix(ci): use repository-native evidence verifier
* docs(evidence): ingest verified Netty 4.1.136 evidence
* fix(security): restore reviewed Netty 4.1.136 remediation
* test(evidence): verify complete Netty SBOM graph coherence
* docs(security): record deterministic Netty SBOM provenance
* docs(evidence): correct CycloneDX generation contract
* docs(changelog): record deterministic Netty buyer evidence
* test(security): require auditable policy overrides
* fix(security): fail closed without override audit key
* docs(security): require auditable override startup
* docs(changelog): record auditable override gate
* test(security): require standalone override auditability
* refactor(security): expose reusable override key validation
* fix(security): enforce override auditability in standalone service
* test(fuzz): use separated override audit keys
* test(web): use separated override audit keys
* test(service): use separated override audit keys
* test(audit): reject unauditable policy signing
* docs(security): cover standalone override validation
* docs(changelog): record standalone override guard
* test(build): require warning-free public Javadocs
* build(docs): gate warning-free public Javadocs
* docs(api): explain tenant context claims
* docs(api): explain conversion status payload
* docs(api): explain admin job list payload
* docs(api): explain conversion acceptance payload
* docs(api): explain viewer bootstrap payload
* docs(api): explain error response envelope
* docs(api): complete public Javadocs
* docs(api): finish warning-free Javadoc surface
* docs(changelog): record executable Javadoc gate
* docs(agents): make verify and Javadocs authoritative
* docs(acceptance): make verify evidence authoritative
* docs(governance): use current sibling repository names
* perf(io): remove filesystem TOCTOU prechecks
* test(ci): require non-skipped Maven report acceptance
* feat(ci): fail on skipped or empty Maven reports
* fix(ci): reject skipped or empty Maven test reports
* docs(ci): require zero-skipped Maven report evidence
* docs(changelog): record zero-skipped CI gate
* test(ci): require bounded entity-free XML reports
* fix(ci): bound and sanitize Maven XML evidence
* docs(ci): define bounded XML report parsing
* docs(changelog): record safe Maven report parsing
* fix(ci): eliminate report size-check race
* test(ci): reject encoded XML declaration bypasses
* fix(ci): enforce UTF-8 Maven report evidence
* docs(ci): require UTF-8 Maven XML evidence
* docs(changelog): record encoded XML rejection
* test(ci): reject failing Maven report evidence
* fix(ci): reject failing Maven report evidence
* docs(ci): reject contradictory Maven outcomes
* docs(changelog): record report outcome checks
* test(ci): assert singular Maven outcome diagnostics
* test: require complete Maven report counts
* fix: reject incomplete Maven report counts
* docs: record fail-closed report attributes
* docs: define complete Maven report evidence
* test(security): reproduce cross-tenant download IDOR
* fix(security): enforce tenant ownership on direct downloads
* test(security): authenticate direct download behavior tests
* docs(security): record tenant-scoped download contract
* docs(security): define direct download tenant boundary
* test(security): require dedicated artifact read permission
* feat(security): add dedicated artifact read permission
* fix(security): enforce artifact-specific download scope
* docs(security): record artifact-specific download scope
* docs(security): separate job metadata from artifact bytes
* test(security): authenticate direct download fixtures with artifact scope
* test(ci): require full verification on stacked pull requests
* fix(ci): verify stacked pull requests without weakening gates
* test(ci): support standard script discovery
* test(ci): bind stack evidence to individual jobs
* test(ci): bind Maven report gate to exact-head job
* test(security): require signed direct artifact delivery
* fix(security): share artifact range parsing
* refactor(security): share canonical artifact range parser
* refactor(security): make range parser stateless
* refactor(security): expose shared range rejection state
* fix(security): require signed direct artifact delivery
* test(security): cover rejected direct artifact range
* refactor(security): reuse shared artifact range rejection
* docs(test): ground acceptance evidence policy in research
* test(security): issue signed tokens for direct download regressions
* test(ci): require explicit synthetic merge checkout
* fix(ci): pin synthetic merge checkout revision
* docs(security): align direct download with signed artifact delivery
* test(security): reject suffix range for empty artifact
* fix(security): reject ranges on empty artifacts
* test(security): reject signed range positions
* fix(security): enforce ASCII range digits
* test(security): enforce RFC range unit and digit spacing
* fix(http): enforce RFC byte range grammar
* test(http): cover malformed byte range boundaries
* docs(security): specify direct download failure statuses
* test(http): reject every flagged range outcome
* fix(http): reject any invalid range outcome
* docs(ci): bind acceptance evidence to exact jobs
* test(conversion): require fail-closed unqualified formats
* perf(download): reuse verified artifact checksum
* fix(conversion): fail closed without qualified adapter
* test(download): remove obsolete checksum reflection
* test(conversion): keep placeholder out of production scan
* fix(conversion): isolate placeholder generator from production scan
* docs(conversion): state fail-closed fidelity boundary
* fix(docs): document fail-closed generator constructor
* refactor(security): centralize artifact response headers
* refactor(security): reuse canonical artifact response contract
* refactor(security): share artifact response semantics
* test(security): reject exception-controlled conversion status
* fix(security): redact conversion failure status details
* test: align worker failure assertions with privacy contract
* test: reject raw artifact-delete failure diagnostics
* fix: make artifact-delete failure logging privacy-safe
* test: reject raw unexpected-error diagnostics
* fix: make unexpected-error logging privacy-safe
---------
Co-authored-by: seonghobae <8172694+seonghobae@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 87 ++-
.github/workflows/fuzz.yml | 8 +
AGENTS.md | 36 +-
CHANGELOG.md | 45 +-
docs/diagrams/submit-flow.md | 4 +-
docs/diagrams/submit-policy-adapter-flow.md | 4 +-
docs/engineering/acceptance-criteria.md | 196 ++++-
.../2026-07-03-third-party-attribution.md | 44 +-
...lio-viewer-unified-document-preview-prd.md | 6 +-
.../2026-07-02-krw2b-sale-readiness/README.md | 230 +++---
.../sbom-cyclonedx.json | 698 +++++++++---------
docs/security/2026-07-02-auth-tenant-model.md | 123 ++-
.../2026-08-04-audit-pseudonymization.md | 117 +++
.../2026-08-05-netty-4.1.136-remediation.md | 131 ++++
pom.xml | 69 +-
scripts/test_ci_workflow_stack_coverage.py | 95 +++
.../test_render_third_party_attribution.py | 136 +++-
scripts/test_verify_maven_test_reports.py | 278 +++++++
scripts/verify_maven_test_reports.py | 182 +++++
.../viewer/ClearfolioViewerApplication.java | 7 +
.../viewer/analytics/KpiSnapshotLedger.java | 4 +-
.../viewer/api/AdminJobListResponse.java | 6 +-
.../viewer/api/ApiErrorResponse.java | 8 +-
.../api/ConversionJobStatusResponse.java | 22 +-
.../viewer/api/SubmitConversionResponse.java | 7 +-
.../viewer/api/ViewerBootstrapResponse.java | 19 +-
.../viewer/artifact/ArtifactLinkLedger.java | 4 +-
.../viewer/artifact/ArtifactLinkService.java | 1 +
.../artifact/ArtifactTokenException.java | 1 +
.../artifact/FileSystemArtifactStore.java | 6 +-
.../artifact/InMemoryArtifactStore.java | 7 +
.../artifact/PdfBoxArtifactGenerator.java | 19 +-
...edConversionRequiredArtifactGenerator.java | 44 ++
.../clearfolio/viewer/auth/TenantContext.java | 13 +-
.../viewer/auth/TenantPermissions.java | 5 +
.../viewer/config/ArtifactStoreConfig.java | 7 +
.../config/ArtifactStoreProperties.java | 7 +
.../config/ConversionExecutorConfig.java | 7 +
.../viewer/config/ConversionProperties.java | 48 ++
.../ViewerSecurityHeadersWebFilter.java | 12 +
.../controller/ApiExceptionHandler.java | 17 +-
.../viewer/controller/ArtifactController.java | 193 +----
.../viewer/controller/ArtifactHttpRange.java | 177 +++++
.../controller/ArtifactHttpResponse.java | 123 +++
.../controller/ConversionController.java | 108 ++-
.../viewer/controller/HealthController.java | 13 +-
.../viewer/controller/ViewerUiController.java | 6 +
.../UnsupportedDocumentFormatException.java | 1 +
.../viewer/model/ConversionJobStatus.java | 4 +
.../InMemoryConversionJobRepository.java | 7 +
.../security/AuditKeySeparationGuard.java | 111 +++
.../viewer/security/AuditPseudonymizer.java | 140 ++++
.../service/DefaultConversionWorker.java | 16 +-
.../DefaultDocumentConversionService.java | 21 +-
.../DefaultDocumentValidationService.java | 30 +-
.../viewer/service/PolicyOverrideRequest.java | 12 +-
src/main/resources/application.yml | 9 +-
.../FileSystemArtifactStoreCoverageTest.java | 36 +
...nversionRequiredArtifactGeneratorTest.java | 61 ++
.../ConversionPropertiesCoverageTest.java | 21 +
.../viewer/config/DependencyPolicyTest.java | 144 +++-
.../ApiExceptionHandlerCoverageTest.java | 58 ++
...ApiExceptionHandlerFailurePrivacyTest.java | 96 +++
.../controller/ArtifactHttpRangeTest.java | 111 +++
.../ConversionControllerCoverageTest.java | 41 +
...onversionControllerMultipartLimitTest.java | 29 +-
.../controller/ConversionControllerTest.java | 60 +-
.../ConversionDownloadAuthorizationTest.java | 284 +++++++
.../fuzz/DocumentValidationFuzzTest.java | 8 +-
...ryConversionJobRepositoryCoverageTest.java | 46 ++
.../security/AuditKeySeparationGuardTest.java | 94 +++
.../AuditPseudonymizerKeyStrengthTest.java | 41 +
.../security/AuditPseudonymizerTest.java | 182 +++++
...ultConversionWorkerFailurePrivacyTest.java | 62 ++
.../service/DefaultConversionWorkerTest.java | 6 +-
...DocumentConversionServiceCoverageTest.java | 52 ++
...ntConversionServiceFailurePrivacyTest.java | 100 +++
...DefaultDocumentValidationCoverageTest.java | 118 +++
...ultDocumentValidationServiceAuditTest.java | 163 ++++
...entValidationServiceConfigurationTest.java | 24 +
.../DefaultDocumentValidationServiceTest.java | 219 ++++--
...DocumentConversionServiceCoverageTest.java | 66 ++
.../service/PolicyOverrideRequestTest.java | 29 +-
.../SecurityProviderTestSupport.java | 52 ++
84 files changed, 5009 insertions(+), 925 deletions(-)
create mode 100644 docs/security/2026-08-04-audit-pseudonymization.md
create mode 100644 docs/security/2026-08-05-netty-4.1.136-remediation.md
create mode 100644 scripts/test_ci_workflow_stack_coverage.py
mode change 100644 => 100755 scripts/test_render_third_party_attribution.py
create mode 100644 scripts/test_verify_maven_test_reports.py
create mode 100644 scripts/verify_maven_test_reports.py
create mode 100644 src/main/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGenerator.java
create mode 100644 src/main/java/com/clearfolio/viewer/controller/ArtifactHttpRange.java
create mode 100644 src/main/java/com/clearfolio/viewer/controller/ArtifactHttpResponse.java
create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java
create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java
create mode 100644 src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGeneratorTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/config/ConversionPropertiesCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerFailurePrivacyTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/controller/ArtifactHttpRangeTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/controller/ConversionControllerCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/controller/ConversionDownloadAuthorizationTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerFailurePrivacyTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceFailurePrivacyTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java
create mode 100644 src/test/java/com/clearfolio/viewer/testsupport/SecurityProviderTestSupport.java
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 59391b3b..071076ab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,8 +3,9 @@ name: CI
on:
push:
branches: [main]
- pull_request:
- branches: [main]
+ # Every pull request target, including immutable stack branches, receives the
+ # same exact-head, synthetic-merge, and buyer-readiness acceptance evidence.
+ pull_request: {}
permissions:
contents: read
@@ -17,14 +18,87 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - name: Verify exact checked-out revision
+ env:
+ EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
- name: Use preinstalled Temurin JDK 21
# Uses the runner image's bundled JDK instead of actions/setup-java to
# keep every workflow dependency hash-pinned (Scorecard Pinned-Dependencies).
run: |
echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV"
echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH"
- - name: Run tests
- run: mvn -B --no-transfer-progress test
+ - name: Run tests and coverage acceptance gates
+ shell: bash
+ run: |
+ if ! mvn -B --no-transfer-progress verify; then
+ if [[ -f target/site/jacoco/jacoco.csv ]]; then
+ echo "::group::JaCoCo CSV diagnostics"
+ cat target/site/jacoco/jacoco.csv
+ echo "::endgroup::"
+ fi
+ if [[ -f target/site/jacoco/jacoco.xml ]]; then
+ echo "::group::JaCoCo uncovered line diagnostics"
+ python3 - <<'PY'
+ import xml.etree.ElementTree as ET
+ from pathlib import Path
+
+ report = Path("target/site/jacoco/jacoco.xml")
+ root = ET.parse(report).getroot()
+ gaps = []
+ for package in root.findall("package"):
+ package_name = package.get("name", "")
+ for source_file in package.findall("sourcefile"):
+ source_name = source_file.get("name", "")
+ source_path = f"{package_name}/{source_name}" if package_name else source_name
+ for line in source_file.findall("line"):
+ missed_instructions = int(line.get("mi", "0"))
+ missed_branches = int(line.get("mb", "0"))
+ if missed_instructions or missed_branches:
+ gaps.append(
+ (
+ source_path,
+ int(line.get("nr", "0")),
+ missed_instructions,
+ missed_branches,
+ )
+ )
+
+ for source_path, line_number, missed_instructions, missed_branches in gaps:
+ print(
+ f"{source_path}:{line_number}: "
+ f"missed_instructions={missed_instructions} "
+ f"missed_branches={missed_branches}"
+ )
+ PY
+ echo "::endgroup::"
+ fi
+ exit 1
+ fi
+ python3 scripts/verify_maven_test_reports.py
+
+ merge-compatibility:
+ name: Maven merge compatibility
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ ref: ${{ github.sha }}
+ - name: Verify merge revision
+ env:
+ EXPECTED_SHA: ${{ github.sha }}
+ run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
+ - name: Use preinstalled Temurin JDK 21
+ run: |
+ echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV"
+ echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH"
+ - name: Verify merged result
+ run: |
+ mvn -B --no-transfer-progress verify
+ python3 scripts/verify_maven_test_reports.py
script-checks:
name: Buyer-readiness script tests
@@ -33,6 +107,11 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - name: Verify exact checked-out revision
+ env:
+ EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
index c2178971..ea6ae6eb 100644
--- a/.github/workflows/fuzz.yml
+++ b/.github/workflows/fuzz.yml
@@ -45,6 +45,14 @@ jobs:
- TenantClaimsFuzzTest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
+ - name: Verify exact checked-out revision
+ env:
+ EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
- name: Set up JDK 21
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
diff --git a/AGENTS.md b/AGENTS.md
index ae0a7b55..2434ab8f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,10 +7,19 @@ including mandatory quality and security merge gates.
## Mandatory merge gates
-- `mvn -DskipTests compile` must pass with warning/deprecated budget = 0.
-- `mvn test` must pass.
-- JaCoCo coverage for production package must remain 100% line/branch.
-- JavaDoc gate must pass (`mvn -q -DskipTests javadoc:javadoc`) with no warnings/errors.
+- `mvn -B --no-transfer-progress verify` is the authoritative local and CI
+ acceptance command. Do not substitute `compile`, `test`, or a predecessor
+ head result for this exact-head lifecycle.
+- Java 21 compilation must pass with warning and deprecation budget = 0.
+- Every test must pass with zero failures, errors, and skips.
+- JaCoCo coverage for the `com.clearfolio.viewer.*` production package must
+ remain 100% statement/line and branch coverage, expressed as zero missed
+ production lines and branches.
+- The verify lifecycle must generate public Javadocs with Maven Javadoc Plugin
+ 3.12.0, `doclint=all`, `failOnError=true`, and `failOnWarnings=true`. Public
+ record components, constructors, methods, enum values, fields, parameters,
+ return values, and thrown failures must be understandable without reading the
+ implementation.
- Markdown lint for changed docs must pass.
- Security evidence must be attached on PR (SAST/code-scanning checks).
- CodeQL Java/Kotlin analysis must remain enabled through repository default
@@ -31,10 +40,15 @@ including mandatory quality and security merge gates.
`python3 scripts/summarize_buyer_readiness.py --manifest docs/diligence/2026-07-03-buyer-data-room-manifest.json --output docs/diligence/2026-07-03-buyer-readiness-scorecard.md --summary docs/qa/evidence/2026-07-02-krw2b-sale-readiness/buyer-readiness-scorecard-summary.json --check`.
- Figma Slides generation payload check must pass:
`python3 scripts/check_figma_deck_payload.py --payload docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json --summary docs/qa/evidence/2026-07-02-krw2b-sale-readiness/figma-deck-payload-check.json`.
-- `mvn test` includes `DependencyPolicyTest`, which prevents reintroducing the
- broad `tika-parsers-standard-package`, default Logback starter, or excluded
- Jakarta annotation dependency unless a future PR updates the license policy,
- SBOM evidence, attribution package, and buyer diligence docs together.
+- `mvn verify` includes `DependencyPolicyTest`, which prevents reintroducing the
+ broad `tika-parsers-standard-package`, default Logback starter, excluded
+ Jakarta annotation dependency, an unreviewed Netty version, or a weakened
+ public-Javadoc gate unless a future PR updates the corresponding security,
+ license, SBOM, attribution, acceptance, and buyer-diligence evidence together.
+- CI, Security Scan, SAST Semgrep, every fuzz target, required organization
+ reviews, and branch protection must all pass on the exact current PR head.
+ Queued, pending, cancelled, skipped-required, stale-head, or predecessor-head
+ evidence is not passing.
## Change management rule
@@ -58,7 +72,7 @@ Codex, Cursor, opencode, …) working in this repo.
then **remediate**:
- This is a Maven / Spring Boot app — findings are almost always vulnerable
Java dependencies. Fix by bumping the offending artifact (or its managed
- version) in `pom.xml`; re-run `mvn -DskipTests compile` and `mvn test`.
+ version) in `pom.xml`; re-run `mvn -B --no-transfer-progress verify`.
- There is currently no `Dockerfile` or k8s manifest here; if one is added,
trivy will also flag image/IaC misconfigs — fix those at the source.
- For a genuine false positive only, add a narrow, **documented**
@@ -108,11 +122,11 @@ Codex, Cursor, opencode, …) working in this repo.
DOM-decomposes emails and files into a persisted knowledge graph. Each
component is a standalone program that must ALSO work as a git submodule of
the hub, grown separately and together.
-- Sibling components: **waf-ids-ai-soc** (WAF / IDS / AI SOC / LB / APIM),
+- Sibling components: **wardnet** (WAF / IDS / AI SOC / LB / APIM),
**pg-erd-cloud** (ERD tool), **contextual-orchestrator** (LLM
cost/perf/upstream-LB gateway, beyond LiteLLM), **codec-carver** (STT /
omni-modal speech-video codec), **fast-mlsirm** (LLM-as-a-Judge calibration +
- evaluation-item quality, using aFIPC FIPC + kaefa item-fit), **feelanet-adfs**
+ evaluation-item quality, using aFIPC FIPC + kaefa item-fit), **keyverse**
(passwordless SSO — OIDC/SCIM/ADFS/LDAP/FIDO2/OAuth2.1, eliminate passwords),
**newsdom-api** (PDF→DOM sidecar), and **semantic-data-portal** (upper
ontology / catalog / governance plane with its own graph engine).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 373a661a..1187deb2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,27 +1,47 @@
-## [Unreleased]
-### Added
-- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다.
-
-### Changed
-- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다.
-
# Changelog
## [Unreleased]
-### 추가된 기능 (Added)
+### Added
+
+- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다.
- **관리자용 단건 작업 삭제 및 재시도 API 추가**
- 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다.
- 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다.
-
- **비동기 버튼 로딩 피드백 및 상태 복원 개선**
- - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다.
+ - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다.
- 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다.
+### Changed
+
+- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다.
+- CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다.
+- Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다.
+- Maven `verify` 이후 Surefire 보고서가 존재하고 실행 테스트 수가 1개 이상이며 skipped·failure·error 수가 모두 0인지 검증합니다. Failsafe 보고서가 생성된 경우 동일한 규칙을 적용하며, 보고서 누락·손상·음수 카운트·전체 skip·실패 결과는 exact-head CI와 merge-compatibility 모두에서 fail closed 처리합니다.
+- Maven `verify` 단계에서 Java 21 public Javadocs를 `doclint=all`로 생성하고 warning 또는 error가 하나라도 발생하면 실패하도록 했습니다. 공개 record 구성요소, 생성자, enum 값, 필드와 매개변수 문서를 초보자도 코드 분석 없이 이해할 수 있는 수준으로 보완했습니다.
+- Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다.
+- CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다.
+
+### Security
+
+- `GET /api/v1/convert/jobs/{jobId}/download`가 리소스 조회 전에 전용 `artifact:read` 권한을 검증하고, PDF 저장소 접근 전에 작업의 tenant 소유권을 확인하도록 강화했습니다. `job:read`만으로는 문서 바이트를 읽을 수 없으며, 인증 누락·권한 누락·교차 tenant UUID 접근은 각각 fail closed 처리되고 교차 tenant 요청은 리소스 존재를 숨기는 `404`를 반환합니다.
+- Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다.
+- Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다.
+- Maven XML 테스트 보고서 검증기는 파일당 16 MiB 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다.
+- Spring Boot 3.5.16이 관리하던 Netty `4.1.135.Final` 전이 의존성 전체를 Spring Boot의 공식 `netty.version` 속성을 통해 `4.1.136.Final`로 정렬했습니다. 실제 POM을 읽는 회귀 테스트와 보안 ADR을 추가해 개별 Netty 모듈의 혼합 버전 및 향후 무의식적 downgrade를 차단했습니다.
+- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 정책 재정의 서명이 비활성화된 경우에만 전용 키 부재를 비상관 `unavailable` 표식으로 표현하며, 원문이나 비키 해시로 폴백하지 않습니다.
+- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 Spring 시작과 `DefaultDocumentValidationService`의 독립·모듈식 직접 생성을 모두 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 모든 실행 모드에서 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다.
+- 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다.
+- 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다.
+
+### Fixed
+
+- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다.
## [0.1.0] - 2026-06-25
### 추가된 기능 (Added)
+
- **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)**
- 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다.
- 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다.
@@ -37,9 +57,11 @@
- 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다.
### 테스트 커버리지 (Tests)
+
- 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다.
### 보안 (Security)
+
- **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다.
- Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다.
- Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다.
@@ -48,6 +70,3 @@
- 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다.
- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다.
- 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다.
-
-### Fixed
-- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정
diff --git a/docs/diagrams/submit-flow.md b/docs/diagrams/submit-flow.md
index 7b51e3cd..bccde0f3 100644
--- a/docs/diagrams/submit-flow.md
+++ b/docs/diagrams/submit-flow.md
@@ -64,7 +64,7 @@ sequenceDiagram
V->>P: getBlockedExtensions()
alt Override headers valid
V-->>V: validate override=true + token + approver
- V-->>V: emit audit-safe log(extension, approver, tokenFingerprint)
+ V-->>V: emit audit-safe log(extension, approverFingerprint, tokenFingerprint)
V-->>Svc: validation ok
else Override missing/invalid
V-->>Svc: UnsupportedDocumentFormatException or IllegalArgumentException
@@ -94,6 +94,8 @@ sequenceDiagram
end
```
+`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym. The raw approver identifier is accepted only as validation input and is never emitted by the audit-safe log.
+
## Exception paths covered
- Missing or empty file
diff --git a/docs/diagrams/submit-policy-adapter-flow.md b/docs/diagrams/submit-policy-adapter-flow.md
index bc65dfe7..18cca561 100644
--- a/docs/diagrams/submit-policy-adapter-flow.md
+++ b/docs/diagrams/submit-policy-adapter-flow.md
@@ -29,7 +29,7 @@ sequenceDiagram
EH-->>C: 400 UNSUPPORTED_FORMAT
else extension blocked and override=true
alt token/approver valid
- Val-->>Val: audit-safe log(extension, approverId, tokenFingerprint)
+ Val-->>Val: audit-safe log(extension, approverFingerprint, tokenFingerprint)
Val-->>Svc: validation ok
Svc->>Repo: findOrStoreByContentHash(job)
Svc->>W: enqueue(jobId) when created
@@ -60,6 +60,8 @@ sequenceDiagram
end
```
+`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym; the raw approver identifier is never written to the audit-safe log.
+
## Deterministic adapter baseline
- `pdf -> PDF_JS`
diff --git a/docs/engineering/acceptance-criteria.md b/docs/engineering/acceptance-criteria.md
index b0befcee..4b1a3261 100644
--- a/docs/engineering/acceptance-criteria.md
+++ b/docs/engineering/acceptance-criteria.md
@@ -1,8 +1,11 @@
# Engineering Acceptance Criteria
-Last updated: 2026-02-21
+Last updated: 2026-08-09
-This document is the canonical acceptance policy for the current Clearfolio Viewer delivery baseline.
+This document is the canonical acceptance policy for the current Clearfolio
+Viewer delivery baseline. Historical evidence snapshots remain useful for
+provenance, but the required source of truth is the exact pull-request head and
+its protected GitHub Checks.
## Mandatory AC list (exact)
@@ -14,50 +17,185 @@ This document is the canonical acceptance policy for the current Clearfolio View
6. deprecated 0
7. 1-day schedule+security verification
+The labels above are stable governance identifiers. Their executable meanings
+are defined by the fail-closed gates below; changing a label requires an ADR and
+a coordinated update to `AGENTS.md`, `CLAUDE.md`, and both architecture maps.
+
## Runtime stance
-- Non-blocking web runtime is implemented with WebFlux (`spring-boot-starter-webflux`) in current code.
-- Servlet/MVC runtime is not the selected implementation for this repository baseline.
+- Non-blocking web runtime is implemented with WebFlux
+ (`spring-boot-starter-webflux`).
+- Servlet/MVC runtime is not the selected implementation for this repository
+ baseline.
+- Document conversion remains asynchronous; HTTP request handlers submit work
+ and expose status, retry, viewer, and artifact workflows rather than waiting
+ for conversion completion.
## Delivery context chain
- `Clearfolio Viewer <-> internal WAS -> Azure On-premise Gateway -> Power Platform -> mobile/tablet`
-- Current implementation in this repo covers the Clearfolio Viewer side of the contract and state gating.
+- This repository owns the Clearfolio Viewer side of the contract and its state,
+ authorization, document, artifact, and operational gates.
+
+## Required local acceptance commands
+
+```bash
+mvn -B --no-transfer-progress verify
+python3 scripts/verify_maven_test_reports.py
+```
+
+The commands are intentionally shared with CI. A contributor must not substitute
+`mvn test`, skip the documentation execution, disable JaCoCo, lower a threshold,
+suppress warnings, omit test-report verification, or present evidence containing
+skipped or zero executed tests.
+
+The report gate requires at least one Surefire `TEST-*.xml` report, a positive
+total test count, zero skipped tests, zero failures, and zero errors. Every
+`testsuite` element must explicitly provide non-negative integer `tests`,
+`skipped`, `failures`, and `errors` attributes; an omitted outcome count is
+incomplete evidence and fails closed rather than being inferred as zero. When
+Failsafe `TEST-*.xml` reports are present, the same rules apply. Missing,
+malformed, empty, negative-count, skipped, failing, or error-bearing report
+evidence fails closed even when a preceding Maven process returned success.
+Each XML report must be UTF-8, may include a UTF-8 byte-order mark, is limited to
+16 MiB, and is rejected before parsing when it contains a NUL byte, DTD, or
+entity declaration. This prevents alternate encodings from hiding
+external-entity or expansion payloads from the pre-parse checks, even when test
+code can write report files.
## Mandatory AC evidence mapping
-| AC | Gate check | Repro command | Evidence pointers |
-| --- | --- | --- | --- |
-| coverage | JaCoCo line/branch miss = 0 | `mvn -q -Djacoco.includes=com.clearfolio.viewer.* org.jacoco:jacoco-maven-plugin:0.8.13:prepare-agent test org.jacoco:jacoco-maven-plugin:0.8.13:report` | `docs/qa/evidence/2026-02-21-ac-gates/jacoco.csv` |
-| docstring | JavaDoc warnings/errors = none | `mvn -q -DskipTests javadoc:javadoc` | `docs/qa/evidence/2026-02-21-ac-gates/javadoc.log`, `docs/qa/evidence/2026-02-21-ac-gates/javadoc-status.txt` |
-| non-blocking web | Request path does not run conversion inline | N/A (code-path verification) | `src/main/java/com/clearfolio/viewer/controller/ConversionController.java`, `src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java` |
-| lightweight queue | Bounded queue + retry + dead-letter behavior | N/A (code-path verification) | `src/main/java/com/clearfolio/viewer/config/ConversionExecutorConfig.java`, `src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java` |
-| warning 0 | Compile path warning-free (`-Werror`) | `mvn -q -DskipTests compile` | `docs/qa/evidence/2026-02-21-ac-gates/compile.log` |
-| deprecated 0 | Deprecated usage blocked by warning gate | `mvn -q -DskipTests compile` | `docs/qa/evidence/2026-02-21-ac-gates/compile.log` |
-| 1-day schedule+security verification | Delivery plan and security checks completed | `semgrep --config auto --metrics=off --error --json --output docs/qa/evidence//semgrep.json src/main/java` and GitHub API checks in plan | `docs/plans/2026-02-20-24h-customer-delivery-plan.md`, `docs/qa/evidence/2026-02-21-ac-gates/semgrep.json`, `docs/qa/evidence/2026-02-21-ac-gates/gh-code-scanning-alerts-open.json` |
+| AC | Fail-closed gate | Reproduction and evidence |
+| --- | --- | --- |
+| coverage | JaCoCo 0.8.15 applies bundle-level `LINE` and `BRANCH` `MISSEDCOUNT` limits with a maximum of `0` | `mvn -B --no-transfer-progress verify`; inspect `target/site/jacoco/jacoco.csv` and the exact-head CI `Maven test` job |
+| docstring | Maven Javadoc Plugin 3.12.0 runs Java 21 doclint for public production APIs and fails on warnings or errors | `mvn -B --no-transfer-progress verify`; inspect `target/reports/apidocs` and the exact-head CI `Maven test` job |
+| non-blocking web | Request paths do not execute document conversion inline | `ConversionController`, `DefaultDocumentConversionService`, and their concurrency/integration tests |
+| lightweight queue | Capacity, rejection, retry, processing lease, and dead-letter behavior are executable contracts | `ConversionExecutorConfig`, `DefaultConversionWorker`, repository/state-store tests, and exact-head fuzzing |
+| warning 0 | Java compilation uses `-Xlint:all -Werror`; Maven report acceptance rejects skipped and zero-test evidence | `mvn -B --no-transfer-progress verify`, `python3 scripts/verify_maven_test_reports.py`, and the exact-head CI `Maven test` job |
+| deprecated 0 | Deprecated API warnings are build failures | `mvn -B --no-transfer-progress verify` in the exact-head CI `Maven test` job |
+| 1-day schedule+security verification | Required GitHub Checks must be successful for the exact current head; queued, pending, cancelled, stale-head, or skipped-required outcomes are not passing | Delivery-plan evidence plus the job-scoped exact-head evidence contract below |
+
+## Exact-head GitHub evidence authority
+
+The acceptance record is job-scoped. A green workflow name without the relevant
+job identity and revision proof is insufficient.
+
+- **CI / Maven test** — for pull requests, `actions/checkout` must use
+ `${{ github.event.pull_request.head.sha }}` (or the workflow's equivalent
+ exact-source expression), and `Verify exact checked-out revision` must prove
+ `git rev-parse HEAD` equals that source head. The same job executes
+ `mvn -B --no-transfer-progress verify` and then
+ `python3 scripts/verify_maven_test_reports.py`. This is the authoritative
+ source-head build, test, coverage, Javadoc, warning, deprecated-API and test-
+ report evidence.
+- **CI / Maven merge compatibility** — `actions/checkout` must use
+ `${{ github.sha }}` for the pull-request synthetic merge revision and the job
+ must prove `git rev-parse HEAD` equals that value before running Maven verify
+ and test-report validation. Synthetic-merge success demonstrates integration
+ compatibility only; it never substitutes for source-head evidence.
+- **CI / Buyer-readiness script tests** — checkout and explicit revision proof
+ must bind the script-policy tests to the exact source head before executing
+ the repository's script-test suite.
+- **Security Scan and SAST Semgrep** — the accepted workflow runs must be
+ associated with the same exact source-head SHA being considered for merge.
+ A successful run from a predecessor head, synthetic merge only, or another
+ ref is stale evidence. Job/check conclusions must be complete and successful.
+- **fuzz** — every configured matrix target is independent evidence. For the
+ current workflow this means `ArtifactTokenParserFuzzTest`,
+ `DocumentValidationFuzzTest`, and `TenantClaimsFuzzTest`; each target checks
+ out and explicitly verifies the same source-head SHA. One successful matrix
+ target cannot stand in for a missing, cancelled, skipped, or failed sibling.
+- **automated review** — CodeRabbit, OpenCode/Noema, GHAS and other review or
+ security evidence must identify or be demonstrably bound to the same source
+ head. Comment/status-only evidence is not a counted independent approval.
+- **independent approval** — the formal GitHub review submission must come from
+ an eligible non-author reviewer under the live repository/ruleset policy and
+ apply to the unchanged head. A predecessor-head approval, author review,
+ model verdict, check status, or advisory comment does not count.
+- **branch protection / ruleset** — evaluate the live required-check and review
+ policy against the unchanged expected head immediately before merge. A
+ historical PR `base.sha` is not the current protected base-ref tip.
+
+The merge record therefore keeps `source_head_sha`, the PR's historical base
+snapshot when useful for provenance, the independently resolved live base tip,
+workflow/run identity, job identity, and review identity as separate evidence.
+No single green badge collapses those authorities.
+
+## Methodological rationale for evidence gates
+
+The exact coverage threshold is a deliberate structural invariant, not a claim
+that code coverage alone establishes test effectiveness. Inozemtseva and Holmes
+(2014) found that, after controlling for test-suite size, coverage was not
+strongly correlated with test-suite effectiveness. Clearfolio therefore keeps
+100% owned production line/branch coverage as a fail-closed completeness floor
+**and separately requires domain-valid security, lifecycle, concurrency,
+fidelity, accessibility, crash/restart, migration/rollback, and release
+assertions**. A change must not satisfy the policy by adding execution without a
+meaningful behavioral oracle.
+
+Likewise, a test process returning exit code zero is not sufficient evidence
+when report generation, test discovery, skipping, or oracle quality can fail
+independently. Barr et al. (2015) describe the software-testing oracle problem:
+determining whether observed output is correct is itself a central testing
+problem. Clearfolio's report verifier therefore checks that tests actually ran,
+that outcome counters are explicit, and that no skipped/failing/error result is
+silently promoted to success. These literature references explain the evidence
+model; the executable Maven/JUnit/JaCoCo contracts remain the repository's
+normative merge gates.
+
+## Evidence boundaries
+
+- Local output is diagnostic evidence only. Merge evidence must identify the
+ exact commit SHA and protected GitHub workflow runs/jobs for that SHA.
+- A successful earlier head does not validate a later head.
+- Generated reports containing local paths or internal runtime details remain
+ local unless an explicit privacy and disclosure review approves publication.
+- Historical snapshots under `docs/qa/evidence/` must not be described as the
+ current gate after code, dependencies, tests, or workflows change.
## Optional tracks
-- client DB pooler
-- PostgreSQL 17
+- client DB pooler;
+- PostgreSQL 17.
-## DB and queue operating policy (future persistent DB phase)
+## Database and queue operating policy for a future persistent phase
-- Queue requests should not wait for completion in request path; use status polling/callback pattern.
-- Keep DB transactions short; avoid external network calls inside transactions.
-- Use timeout/retry and `SKIP LOCKED` for lock-contention-sensitive worker loops.
-- Read routing uses provided read-only endpoint/DSN; lock-sensitive or strongly consistent flows stay on primary.
-- Pooler detection is best-effort (`SHOW VERSION;` in `pgbouncer`/`pgcat` management DB), fallback state is `unknown`.
+- Queue requests must not wait for completion in the request path; use status
+ polling, callbacks, or an equivalent durable asynchronous contract.
+- Keep database transactions short and exclude external network calls from
+ transaction scope.
+- Use bounded timeouts, bounded retries, and `SKIP LOCKED` for
+ lock-contention-sensitive worker loops.
+- Read routing may use a provided read-only endpoint; lock-sensitive or strongly
+ consistent flows remain on the primary.
+- Pooler detection is best-effort (`SHOW VERSION;` in a `pgbouncer` or `pgcat`
+ management database); the fallback state is `unknown`.
+- New database objects must use at least two descriptive words and snake_case by
+ default.
## Architecture linkage
-- Root architecture map: `ARCHITECTURE.md` (updated 2026-02-21).
+- Root architecture map: `ARCHITECTURE.md`.
- Detailed architecture: `docs/architecture.md`.
-## File-level documentation evidence
+## References
+
+Apache Software Foundation. (2026). *Apache Maven Javadoc Plugin 3.12.0:
+`javadoc:javadoc`*. Retrieved August 5, 2026, from
+https://maven.apache.org/plugins/maven-javadoc-plugin/javadoc-mojo.html
+
+Apache Software Foundation. (2026). *Surefire reports*. Maven Surefire Plugin.
+Retrieved August 6, 2026, from
+https://maven.apache.org/surefire/maven-surefire-plugin/examples/reporting.html
+
+Barr, E. T., Harman, M., McMinn, P., Shahbaz, M., & Yoo, S. (2015). The oracle
+problem in software testing: A survey. *IEEE Transactions on Software
+Engineering, 41*(5), 507–525. https://doi.org/10.1109/TSE.2014.2372785
+
+Inozemtseva, L., & Holmes, R. (2014). Coverage is not strongly correlated with
+test suite effectiveness. In *Proceedings of the 36th International Conference
+on Software Engineering* (pp. 435–445). Association for Computing Machinery.
+https://doi.org/10.1145/2568225.2568271
-| File | Change(add/edit/delete/move) | Intent(의도) | Why(이유) | Risk/Notes |
-|---|---|---|---|---|
-| `docs/engineering/acceptance-criteria.md` | add | Canonicalize mandatory AC policy and evidence map | Prevent drift across PRD/TRD/plan docs | Keep run-id pointers current when evidence folder rotates |
-| `docs/qa/acceptance_evidence_checklist.md` | edit (existing baseline) | Reusable detailed checklist | Preserve command-level reproducibility | Must stay aligned with AGENTS.md gates |
-| `docs/qa/evidence/LATEST.md` | edit (existing baseline) | Latest evidence entrypoint | Fast operator lookup | Snapshot only, not historical trend |
+JaCoCo. (2026). *JaCoCo Maven plug-in: `jacoco:check`*. Retrieved August 5,
+2026, from https://www.jacoco.org/jacoco/trunk/doc/check-mojo.html
diff --git a/docs/legal/2026-07-03-third-party-attribution.md b/docs/legal/2026-07-03-third-party-attribution.md
index 4be3aeb5..7d691a02 100644
--- a/docs/legal/2026-07-03-third-party-attribution.md
+++ b/docs/legal/2026-07-03-third-party-attribution.md
@@ -20,26 +20,26 @@ CycloneDX SBOM. It is engineering evidence, not legal advice.
| com.fasterxml.jackson.datatype:jackson-datatype-jsr310 | 2.22.1 | Apache-2.0 | `pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.22.1?type=jar` |
| com.fasterxml.jackson.module:jackson-module-parameter-names | 2.22.1 | Apache-2.0 | `pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.22.1?type=jar` |
| com.fasterxml:classmate | 1.7.3 | Apache-2.0 | `pkg:maven/com.fasterxml/classmate@1.7.3?type=jar` |
-| commons-logging:commons-logging | 1.3.3 | Apache-2.0 | `pkg:maven/commons-logging/commons-logging@1.3.3?type=jar` |
+| commons-logging:commons-logging | 1.4.0 | Apache-2.0 | `pkg:maven/commons-logging/commons-logging@1.4.0?type=jar` |
| io.micrometer:micrometer-commons | 1.15.12 | Apache-2.0 | `pkg:maven/io.micrometer/micrometer-commons@1.15.12?type=jar` |
| io.micrometer:micrometer-observation | 1.15.12 | Apache-2.0 | `pkg:maven/io.micrometer/micrometer-observation@1.15.12?type=jar` |
-| io.netty:netty-buffer | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar` |
-| io.netty:netty-codec | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar` |
-| io.netty:netty-codec-dns | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar` |
-| io.netty:netty-codec-http | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar` |
-| io.netty:netty-codec-http2 | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar` |
-| io.netty:netty-codec-socks | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar` |
-| io.netty:netty-common | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar` |
-| io.netty:netty-handler | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar` |
-| io.netty:netty-handler-proxy | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar` |
-| io.netty:netty-resolver | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar` |
-| io.netty:netty-resolver-dns | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar` |
-| io.netty:netty-resolver-dns-classes-macos | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar` |
-| io.netty:netty-resolver-dns-native-macos | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar` |
-| io.netty:netty-transport | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar` |
-| io.netty:netty-transport-classes-epoll | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar` |
-| io.netty:netty-transport-native-epoll | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar` |
-| io.netty:netty-transport-native-unix-common | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar` |
+| io.netty:netty-buffer | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar` |
+| io.netty:netty-codec | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar` |
+| io.netty:netty-codec-dns | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar` |
+| io.netty:netty-codec-http | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar` |
+| io.netty:netty-codec-http2 | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar` |
+| io.netty:netty-codec-socks | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar` |
+| io.netty:netty-common | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar` |
+| io.netty:netty-handler | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar` |
+| io.netty:netty-handler-proxy | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar` |
+| io.netty:netty-resolver | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar` |
+| io.netty:netty-resolver-dns | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar` |
+| io.netty:netty-resolver-dns-classes-macos | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar` |
+| io.netty:netty-resolver-dns-native-macos | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar` |
+| io.netty:netty-transport | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar` |
+| io.netty:netty-transport-classes-epoll | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar` |
+| io.netty:netty-transport-native-epoll | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar` |
+| io.netty:netty-transport-native-unix-common | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar` |
| io.projectreactor.netty:reactor-netty-core | 1.2.18 | Apache-2.0 | `pkg:maven/io.projectreactor.netty/reactor-netty-core@1.2.18?type=jar` |
| io.projectreactor.netty:reactor-netty-http | 1.2.18 | Apache-2.0 | `pkg:maven/io.projectreactor.netty/reactor-netty-http@1.2.18?type=jar` |
| io.projectreactor:reactor-core | 3.7.19 | Apache-2.0 | `pkg:maven/io.projectreactor/reactor-core@3.7.19?type=jar` |
@@ -48,9 +48,9 @@ CycloneDX SBOM. It is engineering evidence, not legal advice.
| org.apache.logging.log4j:log4j-core | 2.25.4 | Apache-2.0 | `pkg:maven/org.apache.logging.log4j/log4j-core@2.25.4?type=jar` |
| org.apache.logging.log4j:log4j-jul | 2.25.4 | Apache-2.0 | `pkg:maven/org.apache.logging.log4j/log4j-jul@2.25.4?type=jar` |
| org.apache.logging.log4j:log4j-slf4j2-impl | 2.25.4 | Apache-2.0 | `pkg:maven/org.apache.logging.log4j/log4j-slf4j2-impl@2.25.4?type=jar` |
-| org.apache.pdfbox:fontbox | 3.0.3 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar` |
-| org.apache.pdfbox:pdfbox | 3.0.3 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar` |
-| org.apache.pdfbox:pdfbox-io | 3.0.3 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar` |
+| org.apache.pdfbox:fontbox | 3.0.8 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar` |
+| org.apache.pdfbox:pdfbox | 3.0.8 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar` |
+| org.apache.pdfbox:pdfbox-io | 3.0.8 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar` |
| org.apache.tomcat.embed:tomcat-embed-el | 10.1.55 | Apache-2.0 | `pkg:maven/org.apache.tomcat.embed/tomcat-embed-el@10.1.55?type=jar` |
| org.hibernate.validator:hibernate-validator | 8.0.3.Final | Apache-2.0 | `pkg:maven/org.hibernate.validator/hibernate-validator@8.0.3.Final?type=jar` |
| org.jboss.logging:jboss-logging | 3.6.3.Final | Apache-2.0 | `pkg:maven/org.jboss.logging/jboss-logging@3.6.3.Final?type=jar` |
@@ -72,7 +72,7 @@ CycloneDX SBOM. It is engineering evidence, not legal advice.
| org.springframework:spring-jcl | 6.2.19 | Apache-2.0 | `pkg:maven/org.springframework/spring-jcl@6.2.19?type=jar` |
| org.springframework:spring-web | 6.2.19 | Apache-2.0 | `pkg:maven/org.springframework/spring-web@6.2.19?type=jar` |
| org.springframework:spring-webflux | 6.2.19 | Apache-2.0 | `pkg:maven/org.springframework/spring-webflux@6.2.19?type=jar` |
-| org.webjars.npm:pdfjs-dist | 6.0.227 | Apache-2.0 | `pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar` |
+| org.webjars.npm:pdfjs-dist | 6.1.200 | Apache-2.0 | `pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar` |
| org.yaml:snakeyaml | 2.4 | Apache-2.0 | `pkg:maven/org.yaml/snakeyaml@2.4?type=jar` |
## Release Note
diff --git a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md
index d9fe8f4d..b4b8bc76 100644
--- a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md
+++ b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md
@@ -1,7 +1,7 @@
# PRD: Clearfolio Viewer Unified Document Preview (Internal)
Date: 2026-02-23
-Last updated: 2026-02-23
+Last updated: 2026-08-05
Owner: Product Manager
Sources: `docs/architecture.md`, `docs/trd-integrated-document-viewer-platform.md`, `docs/prd-integrated-document-viewer-platform.md`, `docs/engineering/acceptance-criteria.md`, `docs/workflow/one-day-delivery-plan.md`, `docs/diagrams/*`, `AGENTS.md`
@@ -196,7 +196,7 @@ Minimum claims/scopes (MVP intent):
- preview session creation
- viewer access (success/fail)
- blocked-format attempts
- - exception lane approvals (including approver id, token fingerprint, and rationale id if available)
+ - exception lane approvals (including `approverFingerprint`, token fingerprint, and rationale id if available); the raw approver identifier is never logged
- operator-triggered retries
### 10.4 Browser security headers / CSP
@@ -306,4 +306,4 @@ Minimum one-day deliverables:
- Risk: Office formats (`docx`/`pptx`/`xlsx`) preview quality depends on converter availability; failures could impact perceived “unified” promise if not clearly messaged.
- Risk: Gateway-induced header/proxy limitations can constrain token propagation; mitigation is short-lived viewer session tokens and minimized header set.
- Risk: Strict no-warnings/no-deprecations gates can slow dependency upgrades; mitigate with explicit upgrade windows and pre-merge checks.
-- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only fingerprint + approver id.
+- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only the token fingerprint and `approverFingerprint`; the raw approver identifier remains validation input only and is never logged.
diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md
index d8f2b78b..a071f400 100644
--- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md
+++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md
@@ -1,40 +1,49 @@
# KRW 2B Sale-Readiness Evidence
-Date: 2026-07-02
-Verification source head SHA before this evidence refresh:
-`7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd`
+Original evidence date: 2026-07-02
+Original verification source head: `7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd`
+Latest dependency-evidence refresh: 2026-08-05
+Netty SBOM generation source head: `3b6e43426790ab8590c9ef50656bfb5cbbb206ce`
+
+## Evidence Boundary
+
+This directory combines a historical sale-readiness snapshot with selected generated artifacts that remain under executable drift contracts. A `Pass` result describes the named artifact and its source revision; it is not automatically transferable to a later source head.
+
+The committed CycloneDX JSON and generated third-party attribution are shareable buyer data-room evidence. GitHub Actions logs and the one-day generation artifact are transient provenance. Any dependency change must regenerate the SBOM, attribution, hashes, and exact-head acceptance evidence before release.
## Gate Summary
| Gate | Result | Evidence |
| --- | --- | --- |
-| Java runtime | Pass, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` |
-| Compile warnings/deprecations | Pass | `compile.log` |
-| Tests + JaCoCo | Pass, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` |
-| JavaDoc | Pass, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` |
-| Markdown lint | Pass, 0 errors across changed docs | `markdownlint.log` |
-| JS syntax | Pass | `node-check.log` |
-| SAST | Pass, 0 findings | `semgrep.log`, `semgrep.json` |
-| SBOM | Pass, CycloneDX 1.6, 61 components, 0 components without license metadata | `sbom-cyclonedx.log`, `sbom-cyclonedx.json`, `sbom-status.txt` |
-| License review | Pass, buyer-release policy checker reports 61 allowed components, 0 review-required components, 0 unlisted violations, and passes `--require-no-review` | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` |
-| Third-party attribution | Pass, generated buyer data-room attribution contains all 61 current SBOM components and passes drift check | `docs/legal/2026-07-03-third-party-attribution.md`, `third-party-attribution-check.log` |
-| Buyer data-room manifest | Pass, manifest references required buyer evidence artifacts, all local paths exist, and ready gates reference only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` |
-| Buyer readiness scorecard | Pass, generated scorecard reports 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness, and ready-gate evidence integrity pass from the current data-room manifest | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` |
-| Figma Slides generation payload | Pass, payload check reports 11 slides, 4 objectives, and 0 errors; actual Slides generation still requires Figma team or organization plan selection | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` |
-| Auth/tenant, signed artifacts, and KPI snapshots | Partial, runtime tenant enforcement, optional gateway HMAC tenant-claim validation, production-profile fail-closed startup without signed tenant secret, signed artifact tokens, token revocation, artifact read audit API, optional file-backed artifact-link ledger replay, optional file-backed KPI snapshot ledger replay, and tenant-scoped KPI snapshot export API implemented; OIDC/JWT and centralized durable revocation/audit/analytics persistence pending | `docs/security/2026-07-02-auth-tenant-model.md`, `docs/security/2026-07-02-signed-artifact-link-design.md`, auth/artifact/analytics tests |
-| Buyer deployment integration | Pass for buyer sandbox scope; `buyer-demo` Spring profile, gateway-signed header contract, connector API table, OpenAPI connector seed, smoke path, and cutover gates are documented; buyer tenant import and production OIDC/JWT profile remain follow-up | `src/main/resources/application-buyer-demo.yml`, `docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md`, `docs/deployment/clearfolio-buyer-connector.openapi.yaml` |
-| Durable job repository design, state-store, lifecycle event, and recovery sweep slice | Partial, code boundary implemented; `ConversionJobStateStore` routes worker success/failure and operator retry transitions, `ConversionJobLifecycleEvent` records process-local append-only transition evidence, and `DefaultConversionWorker` now re-enqueues due submitted jobs plus stale processing leases from available repository state, while SQL persistence remains pending for true process-restart durability | `docs/persistence/2026-07-02-durable-conversion-job-repository-plan.md`, state-store, lifecycle event, and recovery sweep tests |
-| Seeded buyer-demo screenshots | Pass for local screenshot scope; seeded desktop and mobile viewports render after `Load demo story`, with no mobile horizontal overflow and uploaded FigJam screenshot nodes `25:1423` and `25:1422` | `seeded-demo-story-verification.md`, `screenshots/seeded-demo-desktop-viewport.png`, `screenshots/seeded-demo-mobile-viewport.png` |
-| Buyer diligence closure map | Pass for FigJam handoff scope; added `Clearfolio KRW 2B Buyer Diligence Closure Map`, `Clearfolio Buyer Readiness Scorecard Gate Map`, and `Clearfolio Buyer Diligence Slides Storyboard` on the existing evidence board, and captured Slides generation prerequisites plus deck outline | `docs/design/2026-07-03-buyer-diligence-slides-and-closure-map.md`, `docs/design/2026-07-02-buyer-demo-kpi-figjam-handoff.md` |
-| Local smoke | Pass, signed tenant claims plus file-backed artifact/KPI ledgers, KPI snapshot export API, buyer-demo KPI evidence panel, and operator recovery evidence panel | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` |
-| GitHub PR state | Seeded buyer-demo story branch is refreshed on current `main`; review and queued checks are not treated as blockers | PR body and GitHub UI |
+| Java runtime | Pass for original snapshot, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` |
+| Compile warnings/deprecations | Pass for original snapshot | `compile.log` |
+| Tests + JaCoCo | Pass for original snapshot, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` |
+| JavaDoc | Pass for original snapshot, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` |
+| Markdown lint | Pass for original snapshot, 0 errors across changed docs | `markdownlint.log` |
+| JS syntax | Pass for original snapshot | `node-check.log` |
+| SAST | Pass for original snapshot, 0 findings | `semgrep.log`, `semgrep.json` |
+| SBOM | Refreshed 2026-08-05, CycloneDX 1.6, 61 components, 17 Netty components at `4.1.136.Final`, 0 components without license metadata | `sbom-cyclonedx.json`, Netty ADR, permanent drift test |
+| License review | Pass for current 61-component generated SBOM; 0 review-required and 0 unlisted violations under buyer-release policy | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` |
+| Third-party attribution | Refreshed from the same generated SBOM and protected by byte-for-byte renderer drift validation | `docs/legal/2026-07-03-third-party-attribution.md`, `scripts/test_render_third_party_attribution.py` |
+| Buyer data-room manifest | Pass for original snapshot; required local paths existed and ready gates cited only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` |
+| Buyer readiness scorecard | Pass for original snapshot; 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` |
+| Figma Slides generation payload | Pass for payload scope; 11 slides, 4 objectives, 0 errors; actual Slides generation still requires an eligible Figma plan | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` |
+| Auth/tenant, signed artifacts, and KPI snapshots | Partial; runtime tenant enforcement, signed claims, signed artifact tokens, revocation, audit, and file-backed ledgers exist, while production OIDC/JWT and centralized durable persistence remain pending | Security model, artifact, analytics, and persistence tests |
+| Buyer deployment integration | Pass for buyer sandbox scope; connector seed, gateway-signed claims, smoke path, and cutover gates documented | Buyer deployment playbook, connector OpenAPI, buyer-demo profile |
+| Durable job repository and recovery slice | Partial; code boundary, state store, lifecycle events, and process-local recovery exist, while SQL process-restart durability remains pending | Persistence plan and repository/state-store tests |
+| Seeded buyer-demo screenshots | Pass for local screenshot scope; desktop/mobile seeded story, no mobile overflow | Seeded demo verification and screenshots |
+| Buyer diligence closure map | Pass for FigJam handoff scope | Design handoff documentation |
+| Local smoke | Pass for original signed-tenant, artifact-ledger, KPI-ledger, viewer, revocation, and recovery scope | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` |
+| GitHub PR state | Dynamic; queued or waiting review does not stop productive work but is never counted as merge acceptance | Current exact-head PR checks and reviews |
## SAST
-Command:
+Command used for the original evidence snapshot:
```bash
-uvx semgrep --config p/java --metrics=off --error --json --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json src/main/java src/test/java
+uvx semgrep --config p/java --metrics=off --error --json \
+ --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json \
+ src/main/java src/test/java
```
Result:
@@ -45,53 +54,77 @@ Result:
- Findings: 0.
- Errors: 0.
-Evidence:
-
-- `semgrep.json`
+Evidence: `semgrep.json`.
-## SBOM
+## SBOM Generation
-Command:
+### Canonical command
```bash
-mvn -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -Dcyclonedx.skipAttach=true -Dcyclonedx.outputFormat=json -Dcyclonedx.outputName=clearfolio-viewer-sbom
+mvn -B --no-transfer-progress -DskipTests \
+ org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \
+ -Dcyclonedx.skipAttach=true \
+ -DoutputFormat=json \
+ -DoutputName=bom
```
-Result:
+CycloneDX Maven Plugin 2.9.1 writes the canonical JSON output to `target/bom.json`. `outputFormat` and `outputName` are Maven user properties without a `cyclonedx.` prefix. The earlier evidence command incorrectly prefixed those two properties and is superseded by this contract.
+
+### Deterministic provenance
+
+Read-only workflow run `31004040777` generated the accepted dependency evidence at `2026-08-05T12:07:15Z`.
+
+| Field | Value |
+| --- | --- |
+| Source head | `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` |
+| Generator | `org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom` |
+| Artifact ID | `8929593015` |
+| Artifact archive SHA-256 | `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` |
+| SBOM SHA-256 | `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` |
+| Attribution SHA-256 | `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` |
+| CycloneDX specification | `1.6` |
+| Total components | `61` |
+| Netty components | `17` |
+| Netty version set | exactly `4.1.136.Final` |
+| Components without license metadata | `0` |
+
+```mermaid
+flowchart LR
+ H[Exact source head] --> R[Maven dependency resolution]
+ R --> G[CycloneDX 2.9.1]
+ G --> B[target/bom.json]
+ B --> V[Component and edge verifier]
+ V --> A[Attribution renderer]
+ B --> C[Committed SBOM]
+ A --> D[Committed attribution]
+ C --> T[Permanent drift test]
+ D --> T
+```
+
+The verifier requires every Netty component version, purl, bom-ref, and dependency edge to resolve to `4.1.136.Final`. It rejects the historical `4.1.135.Final` line, an empty component list, unmatched dependency references, or attribution that cannot be reproduced from the committed JSON.
+
+### Current generated result
- CycloneDX BOM format: 1.6.
- Components: 61.
- Components without license metadata: 0.
- Unique license metadata entries: 3.
-- Engineering license review is now documented in
- `docs/security/2026-07-02-license-allowlist-review.md`.
-- The unused `tika-parsers-standard-package` dependency was removed, which
- eliminated Tika transitive review-required components `jhighlight`, `junrar`,
- and `juniversalchardet` from the current SBOM.
-- Spring Boot's default Logback starter was replaced with
- `spring-boot-starter-log4j2`, and `jakarta.annotation-api` is excluded from
- the current starter paths.
-- The standard-library license policy checker passes buyer-release mode:
- 61 allowed components, 0 review-required components, and 0 unlisted
- violations with `--require-no-review`.
-- The standard-library attribution renderer generates
- `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM and
- the drift check confirms that the data-room attribution file is current.
-- The buyer data-room manifest checker confirms the sale-readiness package links
- to required local evidence and current Figma/GitHub handoff URLs, and prevents
- ready gates from citing partial or external artifacts as complete evidence.
-- The buyer readiness scorecard generator reports 23 current data-room
- artifacts, 8 readiness gates, 38 percent conservative gate readiness, and
- ready-gate evidence integrity pass while keeping partial gates as discount
- risks.
-- The Figma Slides payload checker confirms the buyer diligence deck payload has
- 11 slides, 4 objectives, explicit no-Code-Connect wording, readiness
- scorecard content, discount-risk content, and claim-boundary wording.
+- The unused `tika-parsers-standard-package` dependency remains absent, eliminating Tika transitive review-required components `jhighlight`, `junrar`, and `juniversalchardet` from the buyer-release graph.
+- Spring Boot's default Logback starter is replaced with `spring-boot-starter-log4j2`, and `jakarta.annotation-api` remains excluded from the current starter paths.
+- The standard-library attribution renderer generates `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM.
+- The buyer-release license policy records 61 allowed components, 0 review-required components, and 0 unlisted violations.
-Evidence:
+Primary generated evidence:
-- `sbom-cyclonedx.log`
- `sbom-cyclonedx.json`
+- `docs/legal/2026-07-03-third-party-attribution.md`
+- `docs/security/2026-08-05-netty-4.1.136-remediation.md`
+- `scripts/test_render_third_party_attribution.py`
+- `src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java`
+
+Related historical and buyer-handoff evidence:
+
+- `sbom-cyclonedx.log`
- `sbom-status.txt`
- `license-policy.log`
- `license-policy-summary.json`
@@ -101,7 +134,6 @@ Evidence:
- `buyer-readiness-scorecard-summary.json`
- `figma-deck-payload-check.json`
- `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`
-- `docs/legal/2026-07-03-third-party-attribution.md`
- `docs/security/2026-07-02-license-allowlist-review.md`
- `docs/security/2026-07-02-license-policy.json`
- `docs/security/2026-07-02-auth-tenant-model.md`
@@ -116,78 +148,36 @@ Evidence:
- `docs/superpowers/plans/2026-07-02-conversion-job-lifecycle-events.md`
- `docs/superpowers/plans/2026-07-03-conversion-recovery-sweep.md`
- `buyer-deployment-slice-verification.md`
-- FigJam diagrams:
- [Clearfolio Gateway Signed Tenant Claims Flow](https://www.figma.com/board/114nJPcTcQzXvAEIS9T4gM)
- and `Clearfolio KPI Snapshot Evidence Ledger Flow` plus
- `Clearfolio KPI Snapshot Export Evidence API Flow` and
- `Clearfolio Buyer Demo KPI Evidence Panel Flow` plus
- `Clearfolio Operator Recovery Evidence Flow` and
- `Clearfolio Conversion State Store Implementation Flow` plus
- `Clearfolio Conversion Lifecycle Event Trail Flow` plus
- `Clearfolio Buyer Readiness Scorecard Gate Map` plus
- `Clearfolio Buyer Diligence Slides Storyboard` plus
- `Clearfolio Ready Gate Evidence Integrity Check` plus
- `Clearfolio Conversion Recovery Sweep Flow`.
+
+FigJam handoff includes the gateway signed-tenant flow, KPI snapshot ledger/export flows, buyer-demo KPI panel, operator recovery flow, conversion state-store and lifecycle-event flows, buyer readiness gate map, diligence slides storyboard, ready-gate evidence integrity check, and conversion recovery sweep flow.
## Local Smoke
-Command path:
+Original command path:
-- Start app on a random local port with
- `clearfolio.tenant-claims.hmac-secret` and
- `clearfolio.artifact-link-ledger.path` plus
- `clearfolio.analytics-snapshot-ledger.path` configured.
-- Runtime Java: 21.0.11.
-- Verify `GET /`, buyer-demo KPI evidence panel markup,
- buyer-demo operator recovery evidence panel markup, `/assets/viewer/demo.js`,
- demo JS KPI export endpoint reference,
- missing-auth KPI denial, unsigned tenant-claim KPI denial, authenticated empty
- KPI snapshot with signed tenant claims, authenticated empty KPI export lookup,
- document upload with signed tenant headers, status polling to `SUCCEEDED`,
- `/viewer/{docId}`, authenticated viewer bootstrap, signed artifact URL
- creation, unsigned artifact denial, signed artifact range access, artifact
- read audit lookup, artifact token revocation, revoked-token denial,
- cross-tenant status denial, post-upload KPI snapshot, post-upload KPI export
- lookup, and file-backed KPI snapshot ledger append evidence.
+- Start the application on a random local port with `clearfolio.tenant-claims.hmac-secret`, `clearfolio.artifact-link-ledger.path`, and `clearfolio.analytics-snapshot-ledger.path` configured.
+- Verify the root shell, buyer-demo KPI and recovery panels, demo assets, signed claims, upload and status polling, viewer/bootstrap, signed and ranged artifact access, read audit, revocation, cross-tenant concealment, KPI snapshots and exports, and file-backed ledger append evidence.
-Result:
+Original result:
-- Root shell: 200.
-- Root shell evidence panel: present.
-- Root shell operator recovery panel: present.
-- Demo JS: 200.
-- Demo JS KPI export endpoint reference: present.
-- Missing-auth KPI: 401.
-- Unsigned tenant-claim KPI with secret configured: 401.
-- Authenticated empty KPI: 200.
-- Authenticated empty KPI exports: 200, 1 record, tenant id omitted.
-- Final conversion status: `SUCCEEDED`.
-- Status tenant: `buyer-demo`.
-- Viewer HTML: 200.
-- Viewer bootstrap: 200.
-- Artifact link creation: 200.
-- Unsigned artifact read: 401.
-- Signed artifact range read: 206.
-- Artifact read audit lookup: 200, 1 event, last status 206.
-- Artifact token revocation: 200, `revoked=true`.
-- Revoked artifact read: 403.
+- Runtime Java: 21.0.11.
+- Root shell: 200; evidence and recovery panels present.
+- Missing or unsigned tenant claims: 401.
+- Authenticated empty KPI and exports: 200.
+- Final conversion status: `SUCCEEDED` for tenant `buyer-demo`.
+- Viewer and bootstrap: 200.
+- Signed artifact range read: 206; unsigned read: 401.
+- Artifact read audit: 200; revocation succeeded; revoked read: 403.
- Cross-tenant status lookup: 404.
-- Post-upload KPI: `totalJobs=1`, `succeededJobs=1`,
- `conversionSuccessRate=1.0`, numeric `p95TimeToPreviewMs`.
-- Post-upload KPI exports: 200, 2 records, latest `totalJobs=1`, tenant id
- omitted.
-- Artifact ledger file: present, 2 `ISSUED` lines, 1 `REVOKED` line,
- and 1 `READ` line.
-- KPI snapshot ledger file: present, 2 `SNAPSHOT` lines.
+- Post-upload KPI: one successful job and numeric preview latency.
+- Artifact and KPI ledger append evidence present.
Evidence:
- `smoke-local.txt`
- `smoke-ui-root.txt`
+- `smoke-app.log`
-## GitHub Checks
+## GitHub Acceptance
-This evidence refresh was produced locally before publishing the recovery-sweep
-branch. The PR body should carry the local gate results from this file. Review
-and queued GitHub checks are not treated as blockers for continuing the
-sale-readiness work.
+The historical snapshot is not a substitute for current pull-request evidence. A release or merge requires the exact current head to pass repository CI, Maven `verify`, zero missed production lines and branches, warning-free public Javadocs, Security Scan, SAST, every fuzz target, dependency/security review, current automated review, zero unresolved threads, and a counted independent approval. Queued, pending, cancelled, skipped-required, stale-head, or local-only results are not passing.
diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json
index eb356a03..2f9e4c21 100644
--- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json
+++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json
@@ -4,7 +4,7 @@
"serialNumber" : "urn:uuid:b6017fa5-aa1e-3a06-ab1c-8fd43d993316",
"version" : 1,
"metadata" : {
- "timestamp" : "2026-07-10T19:18:50Z",
+ "timestamp" : "2026-08-05T12:07:15Z",
"lifecycles" : [
{
"phase" : "build"
@@ -1035,45 +1035,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-codec-http",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "17647f3dcda67916dac602de8c8f2ca5"
+ "content" : "0ea9a6efa8033fdaec83eba2070dedad"
},
{
"alg" : "SHA-1",
- "content" : "69d785784208bae296fa74d802772687c6947754"
+ "content" : "3f5101d264099848dc5fb708bad4a09c4601ac3f"
},
{
"alg" : "SHA-256",
- "content" : "4018529d3d6aecf4044b98c75d9a90c91839ddf49c7aa484c5ac81c90a15da02"
+ "content" : "ffd1e1b19a533bc6e47ef2cbc1290374ff4a7cb53a280defa3df392538214948"
},
{
"alg" : "SHA-512",
- "content" : "47d41c724aa9763ba2b36618d4905f02546e83ea0b498f0022a9a9483c158ce88794c657a679a6cf4495680f18d2a7d2f9c70038d5d0cb27d97f36cf4b36f423"
+ "content" : "ff2fa4c55a6954aa4dac52e66cad2a26e6fe551776bbf3a68da863610a0a8534496d2d66a80a941e8803994716ce3cc3635c21b666f6dd1ad91cae2a396ceb2d"
},
{
"alg" : "SHA-384",
- "content" : "a986969aaa528a168981b4b70031a3ec9adc482a78224a96ee7110840acce8d4c8c5ce0b39d330821c993f1a6483a94b"
+ "content" : "5bbfae77116c14020723d681d5d28d0073b58225b6332887df13c915d3705d14dcc1b153152294421308e975a0058862"
},
{
"alg" : "SHA3-384",
- "content" : "ec7423bd39906638bfd36a127f18f9fc9c8d51431aa7daa4981bc7361a1851e6afbd939f9d5c56e24ab94ada08c6ff52"
+ "content" : "2d41da28daea35313fc5c48c252e5aaaea2e53528ff989059e920861a02bce713b52b4ac9dec8e331f562cf714bd94d0"
},
{
"alg" : "SHA3-256",
- "content" : "c108c82107511a4d96a6dd7613f2f7a7887e6c79c2f2c0256abfbd7fa93bba54"
+ "content" : "355212f7d6dcd40ac9d1cae43311c00ca9831b01ae06e264225985b2cf94e169"
},
{
"alg" : "SHA3-512",
- "content" : "05d36acb34894cbfb3160bf2ec19950a0a1fdd15772c9e11a7006b8357b8a9c3b0111673b2fd1ac44f3d3a9cff7f37f56671baeec290428d0df008c086ebfb03"
+ "content" : "597974256e788cba3206ca7457b433dae2cf1eb34c9635e062be48df72fab1ff75ee40a915f96553b6d522529a1a1b6e08944ac52d4aae77036e6443fd78e37d"
}
],
"licenses" : [
@@ -1083,7 +1083,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1101,45 +1101,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-common",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "bfa2258e927ec224143136eda472297c"
+ "content" : "1fcc4e203d0b61b432bf10f4cfa94619"
},
{
"alg" : "SHA-1",
- "content" : "3f1fd5004102cd1146a53d29127804569b63c90f"
+ "content" : "c11b3dad4ad80a34b3860dbdb7ce63166913eb12"
},
{
"alg" : "SHA-256",
- "content" : "26775ca95820711403cf065fa2ec0134a0a04ff5417c688c0237aee68b55838d"
+ "content" : "e2f73be7ec359b46583ad875521137000eff520bafd320e730846ec9974f3be6"
},
{
"alg" : "SHA-512",
- "content" : "a4317485b0b1434b552c98bcb69a1e1fffdffcdee22c84e61c107dfa9752faff7f5799617c81ba80861f929fe7dcf4436d7bf2af95c4eddd550ebfb75df12cc6"
+ "content" : "8401400d2c786a62b513d394fd372facab2581a7964982b1c8ec5af53a235a881cdb3d1447415b22f791b3282113728376b50ac7384c1b13ed90ce779548fdcf"
},
{
"alg" : "SHA-384",
- "content" : "f6dbd09db24ddfbe0b0c7c4480eddbbfc29c9db89ecd38b56e4c9f0ca49907ea45a88479ec6d95ae83c98d490159ab58"
+ "content" : "94b02b8d7368c268828f85bf221907654dd6bfa334f6252fcaf26125a7b333d367b3a81d98c036d6d4a94716ded48ee2"
},
{
"alg" : "SHA3-384",
- "content" : "151dc11ed193e806580144e00260c6e98f82e7716fe81d7181b923fb1f2656a07acf6e324c90c35cf066e90c023462ba"
+ "content" : "2ce8d61f5f58d1dc8cc8fe11c4d4f078ac92cc3d40f8a61d316a83501e4491faba68929f499a46741c48dbb9c580578b"
},
{
"alg" : "SHA3-256",
- "content" : "5c0286ab9cd98756e203f38d8172577ed184c8236478532bbd78f85d4a583ea2"
+ "content" : "a9d06835f575037564b8ef926abfd5022bc09a5d57071082881691eec2fb889b"
},
{
"alg" : "SHA3-512",
- "content" : "d2f40345230b7f1d5b04b78fd82de5cb94134ca12510d0e53a22245f9c24dcb420c30e8a2cffb26b474782d09cf986c57f9f99df856a481c2fc7f8d066839c10"
+ "content" : "4815b59200870be324cd4a506b7f278840ce9f4b152249851f690cd310fba26de890d8c1cf984978a4e21748d505d32cb784f3ebdbf0d5bd6c2baa455197dfb8"
}
],
"licenses" : [
@@ -1149,7 +1149,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1167,45 +1167,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-buffer",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "fcd9f423d78592d1ca785b68ee9b16ee"
+ "content" : "974bb8b00957141a3c8aaa56dc845ecc"
},
{
"alg" : "SHA-1",
- "content" : "6e60e222c27d3534e63e519fb1a9d66c485f62bd"
+ "content" : "2675f7ca19b174466766127c59b52acdba01f6a2"
},
{
"alg" : "SHA-256",
- "content" : "2a194f99fc93d07c4d442d04ac71bd2dc56d3188cd0e4270cdc2a953d1956bf9"
+ "content" : "c88f13fc41156fde5df918c7b240038dfbe97ac57576163f208630391789b5d5"
},
{
"alg" : "SHA-512",
- "content" : "a839731d75317f515256c8340d73ea4344c9f488ce2cc31c99c2788d2a33a055482ebc438ebca63a27c0d49728fe778e7d0b06a031a0244728d0b0ccddfe6407"
+ "content" : "b335ae2f322969a4182fac5c8d056637fd4fadc3ef5a05a13f55fd2a25a22e13b4cade5d6af2aa8f77aed5e83269b3977966f7c57abda39b98420e72a749956a"
},
{
"alg" : "SHA-384",
- "content" : "ed107c69a75793161a6e3ade436cb564224b7bd58ea551b42c5b7d10dd6384fa25538a0064dba4521beb3ca69857c455"
+ "content" : "324583f3af2c2c3933add108fbdc5922fefe5e77595c608a6f7088b7aff168a0c8139a90b1f94dc8cb27052e1fa922b5"
},
{
"alg" : "SHA3-384",
- "content" : "6f1c0b2b2c428e9fdbda3aa37558fa91330be87a7fda93eb2ba66d3b224feef63120216a06e3de2388bdf8648c549f28"
+ "content" : "4b83e43ddef6aeeb0b55206b2abad41386812a60f33ff2303fc3e298dc0516b5798c12546f98fbea98881a6d140674eb"
},
{
"alg" : "SHA3-256",
- "content" : "bb6f4ce57de81fb991b78f4e6861088e46a3e8de176a84757d48d14a2ac1ce74"
+ "content" : "155689ada0fc252379de770957847fa38bcc4b27c6c44fbe62aa1d54dd2b3636"
},
{
"alg" : "SHA3-512",
- "content" : "13f9bafd0b5534316b77170719aab58a46daf9d7303b5541887b9171522501fff7b1fbbe6ab887a2a94b2d8455aa42fa059fce9dc21db98309d02e6539e32835"
+ "content" : "557b47c06465ce198de44efd3999af3f540b6682ac2888a42114721e7b657713d5f36936fd4c287b064e0d3ca536910ab8279aeca4842efb74e5783c25adc80f"
}
],
"licenses" : [
@@ -1215,7 +1215,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1233,45 +1233,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-transport",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "06b22eaac4fad8e22753969b96b2c36a"
+ "content" : "bb5b1ee9a2d1792931fcb008cf3557e3"
},
{
"alg" : "SHA-1",
- "content" : "cbc238f1f9707f3652252fdc48568cce0e9a01d9"
+ "content" : "0c2e6961562760a9128766cb84a50d2ac51402ff"
},
{
"alg" : "SHA-256",
- "content" : "6bde734d1ec073142eed31b1e68cd5d68fbf241e060b37f07a164e5ecb15631c"
+ "content" : "b92881ea925721ed42fee5122a42b8bce4b84da737dbc3ca2d84dfaca52c28b6"
},
{
"alg" : "SHA-512",
- "content" : "ff06cf5971ed28f46f73ecc6f5cbcb2b7dc7f54b9c046678737616fde57ec8e7ca215c63cecd5d2f09c174c1483ec7538a63781009955539be782183fba6558f"
+ "content" : "a76ab760519837e9d0a28fa6699ad4e279be1bb5e63a5c4173e9e5566e0e1bcb143a18a66b54fb1261aa07de5beb383cd93d9f043ae1ee869a7bef32896ba650"
},
{
"alg" : "SHA-384",
- "content" : "043d7346fd771eba584d272ab0160aa9c10c011c3f3e7006d02191af6fdd33f73eac4d6fc5e727cba4544dea670cf4a6"
+ "content" : "84d8a4776e8ec72e625381aca19ac105b4053ed7da431991ca8091bb2df7a25b2b9505f650219d1a4650bdec2224ab92"
},
{
"alg" : "SHA3-384",
- "content" : "599fe6ed7bfc5f22614b8bb82d57467e18c45328d6714d5055d6b2d8cd5286fb014f90672f6b51c52759d1b64222717a"
+ "content" : "411ef1e0b07cdee90cfd5ad00563aed6df3f5c13b41e8156458fb668744711aae29c4817156ad6e2a2c7867f976a90ab"
},
{
"alg" : "SHA3-256",
- "content" : "bb0022cdfa7c304bda6aeb620815ca795973cec8ee1f4d5a6e10225bac004f67"
+ "content" : "3024e3a5448062c69490c4f5496514cd902b3a3269ff024b4d344bdaf09849ad"
},
{
"alg" : "SHA3-512",
- "content" : "92a7fee52ff206f36d150aeacab70169b3e89bb5c8c7878c2932cb8cf98738c7122584336e183cfeb132721f8f22a55b44bca1a724336cadece6ccc4d4621946"
+ "content" : "2aeddb9fa673e3698c1b2189bb33ee20ae8d369956b7f3fa9e4554ab8bb58507be0fd11a824c20305aa55ae2f0052f8506dd35371d1cb0c80cc136f9042eefcb"
}
],
"licenses" : [
@@ -1281,7 +1281,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1299,45 +1299,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-codec",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "d4c7467d989f3fce353fde66eb1b4bdf"
+ "content" : "b61698c2f484ca179da5699da54688f9"
},
{
"alg" : "SHA-1",
- "content" : "cf36e54dc81aa160a93780478727bdb3c3fa4600"
+ "content" : "f91a3bb222a8da5e08a8a2feae60e5fcda74e4dc"
},
{
"alg" : "SHA-256",
- "content" : "7252171264dbb5bb8ed38e77f89643b31e3cabc96144ec27b6882435d718a61e"
+ "content" : "2de4fc13005c7740b46427a47ee04265a19779c147d53e583f441889b1148159"
},
{
"alg" : "SHA-512",
- "content" : "663db82a0b3a83a4417b647b0b08620c915c9f476033d241bd57790fb3e024972a2ac421a66bba49451439c101e04915828d109a76316b8533c87848b7e95453"
+ "content" : "b9fba7ceea4acc923745d097b30b9978b3ab058aad3e880ee3061bab22cf5460a170ff6f8b94932b491d22740b027b74fb65e4245acbdd754be1837479782dbb"
},
{
"alg" : "SHA-384",
- "content" : "4d66221bfb9846a4abaf037c91acc3b50831210933f4d352559b075a118704eba58024e5bbaea33f1b96e38ff459030e"
+ "content" : "16893a5504150305e44df81704d862565d8089a149edaff9dff9ad8057f7b26a72aa1910dc943680c816bf88b1821e3b"
},
{
"alg" : "SHA3-384",
- "content" : "db590cd2eaa49fe7f49473af1dc4d55bea45c68c56caa6794455182d5fd6a0792b9aa4a614912fd57859803d437138fa"
+ "content" : "3c8addeb374411ef72d87ed55d023521cd2997843ac1e76cc947d86bdae3357de431135d3d9b6a3b0076b45c3ded93b1"
},
{
"alg" : "SHA3-256",
- "content" : "2e87051eec597bbdea9b1c6b621cc1a08c3737ad5d80abbb9dcfa7f5b6412932"
+ "content" : "60e83534593483ea4066da60d968c21891cc46ca51df3190e8d2bb64517d04da"
},
{
"alg" : "SHA3-512",
- "content" : "6a7f5b8b01c8cd272ac0f4936867a896489854856f3b65ee7ba6dd3ecfda5bfad9e1e9150cd76d1421a6e6ef04f90847d1180c1a81b29496cf1424c0f2d39d75"
+ "content" : "7a9ba3da73796fdd34e4d6882809e8ada882e398a2625d83a67f1704028c49fdf05d70f4b757b1947a891f0005fa1133d99516e2751f706481c7c5025c6e74ff"
}
],
"licenses" : [
@@ -1347,7 +1347,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1365,45 +1365,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-handler",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "3ac32114d15dc2b284571f5d35c6b790"
+ "content" : "d7b41fac3cf644831b0a5bb70ec0ebcc"
},
{
"alg" : "SHA-1",
- "content" : "567f8742a3d7a0a8be0401dada8fbdbaa7bf02b6"
+ "content" : "9114e02d69825eed3a901d762026261af15a56ea"
},
{
"alg" : "SHA-256",
- "content" : "245e74e04b6f4e8ef98853152412e3bf1499ce6fcf15329b798c8ce36c3537e2"
+ "content" : "54bb1a59f46a3aefd117942e6acee09e555b756776bad731fc06159d89c2da28"
},
{
"alg" : "SHA-512",
- "content" : "33542ba61fa2ae931d06d6e4c1df904331e0c02dee912a06793959982481df15cc284ebb105cd7de9f31b5beb140a9cd31a609f4fd71182c317a82ead35a4299"
+ "content" : "13a4d19f934e2327764b19ff12816baa6bd3cdb5991e06104520241276184e052f895a5e5ab1c0ac6492d002e6e412efa65c47c7f14c7fa8eecb5655b1371b27"
},
{
"alg" : "SHA-384",
- "content" : "9d80cf4a3d8d402b453e7ce34a4d885a2924b6a56c9ffd9fb26100e123b5ca5397deb70c46085e8714c76db792d658ed"
+ "content" : "225271b726dbc059393cdc898688fb2733802451a2b0fd9e8e9743ec6578cf9d11e482e5c71e8f29d21e9248d8a54703"
},
{
"alg" : "SHA3-384",
- "content" : "0bcbf90f65c5333a60b15e46ac69f0b669f5a8932c3042dc3bbd4dcd0f6186a21155911148ae2f19a58394d9dddb6cb7"
+ "content" : "5654da7bacdbeba834b698ebb680fbefdd36dcd9f1879df2a76020213ab9c80fcf17937b16dbc98786cad40baf3d0d97"
},
{
"alg" : "SHA3-256",
- "content" : "2844a349d6c7446874174272167217bb70287ae50244fe9f6c9159e23d804dad"
+ "content" : "529cc2ac8c6bb306b64a02f9004e5b3533962a5d9ac3db5217f11de4752704b9"
},
{
"alg" : "SHA3-512",
- "content" : "c668303216efc23b45f9efd6c02d8b7814941bcdceb63facb165334f1119a7cb76bfc042a6ca982a852f38a753da71515a02839ab325e230d1a362ce38e88760"
+ "content" : "42d68ceb93c6d3855f5c522716f48a8d090567a5d3cad589f2bb10820f196e30ea37341b43081711f8d6326838be206b691aef691bfe2ed88bf2109f89330dbd"
}
],
"licenses" : [
@@ -1413,7 +1413,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1431,45 +1431,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-codec-http2",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "6449e3a486f2ed3a3c3d2e55eee93cd9"
+ "content" : "a738556000f1858e63207524bbaa03a2"
},
{
"alg" : "SHA-1",
- "content" : "56375e341f0ca44079b1bfb62030285b9de5d713"
+ "content" : "4ce8dfcec2376f945f7c29e5d423105161230117"
},
{
"alg" : "SHA-256",
- "content" : "aa4e81ab5fa3b7b243eb3e814aa582ab26c073d31b0abffdbb58ee150fa49c16"
+ "content" : "14f67ae095c056b062aa0ee2c7b01f6b78004cf3620a6e9061bcb637f079387a"
},
{
"alg" : "SHA-512",
- "content" : "aac3e7c20a1af95b8adf69b08cc09c0045f2ca4ec25516d57f8a4d5f05973369005b8b117e0c18e3741a77ffb0f50d9624bb64c81f9ddf33fe572e4607d79f29"
+ "content" : "920b1c00f297e56fc3b2dbb2d1a983d014f5727179d147c17bba56e4d9d85c1a2b1bc918bb4a63b59852e572a83c430646ad4a8d52644a083e66433ab4e8da6e"
},
{
"alg" : "SHA-384",
- "content" : "a38dd44f5cb48587f0fb42c41a64d3d4d677ecfad681c4c4bee404d79294d9b5f099458648ab7047f3846eb9e15f85cf"
+ "content" : "805957ca9f9884b6c1bec3c92452233bd77a575f2b2851ed3a86c0dbd3ccc344e8092a3f66082341f1421ec7d9359474"
},
{
"alg" : "SHA3-384",
- "content" : "3d3bf5ee22ddf455393ee7ab15d888640e86618ddce6f4649fbfd06323105113376c1f7c665c76e6d2d5d9f3e34a57cd"
+ "content" : "20d9c091e9401b70ff2564fb93fdca0488b00128751eeaac3e3d022d67ab9d0243bca9023860db9309b68876cc7dcbdd"
},
{
"alg" : "SHA3-256",
- "content" : "612686c5b416a81f2bc9c8a4b553d59e6d218c83c4204b58df56f9109d2f82bb"
+ "content" : "229c79f4b60b7c5ed6b6200af10a15bd3918175a36162094fbf7a16eb4f7079e"
},
{
"alg" : "SHA3-512",
- "content" : "85d4272bf01de6b1f64725dc168fb2ac00d83b2f2dd0c63f341967554f15b5dd0b902028448e7a24c9d5948f1d25b7163810c3a612b5056e212203635e5fc797"
+ "content" : "93e122e0c47079b60142cb8a4132c2ebfe354e1362803cf0e299070357609c54e653246e85956be346cfaff556cc241e0668bf428a91930ba3a15ee422895808"
}
],
"licenses" : [
@@ -1479,7 +1479,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1497,45 +1497,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-resolver-dns",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "2099aaf4771a939f4720897331acacaf"
+ "content" : "83bc3a4af7a32017330cbb698d9393e7"
},
{
"alg" : "SHA-1",
- "content" : "0d092a2851ecef2a07722da7da80a843f7e936ae"
+ "content" : "de43e9f34094a67a3495659fc49ee1a303f4d78c"
},
{
"alg" : "SHA-256",
- "content" : "ca25581e4cebd55797ef3b4d0953b75df32c1af77fe771b96bfaa9e701cdb7c3"
+ "content" : "9c05a9b18b5d54fcc1569c48b93958019cf3dde5ae7011f8a46451ee05e7daff"
},
{
"alg" : "SHA-512",
- "content" : "6fc7130da77cfef4510fe9ae2bacd985980cb357d993242605e956c8c4f51bbbdfa9caf1950adaf524793ae0caa520277c0c036be9b5ae2a03447f836fe30256"
+ "content" : "4bc7209071556bdaebabeadcd17da258bf7859c7c4524351c1d292bb84211b14496158352c03421d043fcdd21be9cfac3bea2ce1a754dea0baa6a5557e00571a"
},
{
"alg" : "SHA-384",
- "content" : "d858b05204f75696016ad813e8fdfd18a87d8f403bb32154fabcf1c5a08df2f97f8821142c07e08871575fb61093963a"
+ "content" : "5d3cedd0d3610b892ea03d7a2c8c7aae4fd6a6c7437c33234cc6d2a1575c70af75b335037dca562ac04bf039dd1807b2"
},
{
"alg" : "SHA3-384",
- "content" : "7d098aad0661a71b9575c9cd6752ce08f4abb3a9fe786c949aa06cf3895f6afaad4debc800565fa710f6ad26bdd97330"
+ "content" : "3eca0a8d27cdcff557dcea06ab91213840d4cbf06e2f10c8f275dd35e83dbd0768c46d9ef0bfec6ead05bc2cd370c5f6"
},
{
"alg" : "SHA3-256",
- "content" : "197fbd0361d6136445d478ebf488a050f3ce3ac6f81a95656d13a2c67490b5c4"
+ "content" : "7bfb9c4cb0d9ed788e6be029802fc5331a2f38e671bdb812fd2ca97111cf6705"
},
{
"alg" : "SHA3-512",
- "content" : "d561f016fc0be5fddf0b45d3b84fdfd482f5fa3b8a9373778e2183a4ab3e7ca3a9e0ac66626e75763d374911358ee3e74d249037c6d2cbd1b9de416b466b5779"
+ "content" : "9e54e1c2759b87d07a5fbfdb5a698967d17a882e15a492833136a7a7f24a67a04c4029817f063b9fa5f4493c425202ac38d930b7b40a2aea25eeb742a243533a"
}
],
"licenses" : [
@@ -1545,7 +1545,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1563,45 +1563,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-resolver",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "129c1aa96462490d58a358aceaba0553"
+ "content" : "020fa71ad6ac3fd061d8a4427e118758"
},
{
"alg" : "SHA-1",
- "content" : "d43b00856050cfd4445a063d5816a4782ae422f9"
+ "content" : "54e0ce6acf52451fa951a4632f40b516b00a87b6"
},
{
"alg" : "SHA-256",
- "content" : "77dd03865965b6c12b9e521bddec82f035caeb33156e09c158289c5094318481"
+ "content" : "e64972fec474f5b9bd086b738154d190a923e82c4923ac550aff4f5ea9e98600"
},
{
"alg" : "SHA-512",
- "content" : "f4f6d22bd7805e631780d660dfec268960780f9c73da4aa5c2835c89719cb738f457d6a833473c621ccf578cbeacfbf83764360bc50ae3d95b72fe7b5cf3a6c5"
+ "content" : "fb73dc56ac6ac0b4233c2f6eaa2918b58519ff1073ac70520a17f714d21cdef12b38c72f87895c680ab040d8b46cb8ce1db0ad1839b4f2712cfc422b36ad27c4"
},
{
"alg" : "SHA-384",
- "content" : "efa50893602f0bbd54e7cf6e0066192882fb77b0f7271ccb8ba2f5efad3365fe0c5257dab21873300019113376ae9bcf"
+ "content" : "04bb48b4c9755f14d480fca64ac6b658acb6ccd112a34eb0e209eb9a50573232d98f2c9b1aa15aadff1661893f235320"
},
{
"alg" : "SHA3-384",
- "content" : "edd91ab9326b41343c7030bef8d897e0ba861ee9e40898ed4b7a38512c31c76b34a8e085f6f96886686b4732e7d3d7a2"
+ "content" : "1a4006f6c3592c05d8160ed6b8670d2a51c7bc8c8948898a43e494fff092b3b7948bbaf58dfac2b91c824bf34a4e04ef"
},
{
"alg" : "SHA3-256",
- "content" : "316ea500ee5b62175051f725693669a4909c046e4b009164a23584a0169d6b35"
+ "content" : "9a4bfc5f2afffe2725efecbeeb7602de72223fe00ec68bddb54c57d66055f9fc"
},
{
"alg" : "SHA3-512",
- "content" : "3b44e554de6c1aee42d2087eb04e8b9096a5a1ed85ebdd5f8d8e2254dc4b1b3d0a3cf3daad4dfc3d9486de88c70b0b03d75f3e055e6880343d821f147f36e0bd"
+ "content" : "b9c0eaef1c4b98e7896d265eb6191c44c15488096972e1fb62e31c1c60c1ccb00c9d21d08d07ac64a12d4576076951924229989284d075f08f5bb133fc605f88"
}
],
"licenses" : [
@@ -1611,7 +1611,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1629,45 +1629,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-codec-dns",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "a424ef4d2ebfea699f9d1218c41f8662"
+ "content" : "6344e1103219485b1c46503c3f741859"
},
{
"alg" : "SHA-1",
- "content" : "0814e353f6c5f9383f2b93d941961f89565f4161"
+ "content" : "143b952318f5624dff1707dfd1c4cf8565998385"
},
{
"alg" : "SHA-256",
- "content" : "5e996d7ac7597f368ab114fbb91d16788918c7e5bf166345c51e56db54d50fd1"
+ "content" : "89bddc4ec42756c41a0195a50a1323a324836162b6712a843c3de5c29096c93b"
},
{
"alg" : "SHA-512",
- "content" : "6776da183ffbdef92da487c1ff071cb77f1f020c2f9730183a1959226d3bb4f8a592fa8118ea78f3f8938d35129c1bcfd60176d0802803b8897ead66a6e92846"
+ "content" : "9aab4727b460c95e7b9a56e76cf0d0f18ac2f55f01c65cad7cc1559a6d7ede9236e73740350f890e6813394ab75491b60cc567ddb4a24e4c5874770a97f2d094"
},
{
"alg" : "SHA-384",
- "content" : "8cf961cacb0ca2ad8edc654105d746078c76a8922780ca209babf65037cdbe1f9cdbbaa9cf6a4d602e88877032867c38"
+ "content" : "acfd49075478a33a0ed2393dfa64ee6400eabb904724f338697be544aa7cf99f1808a4a7cf41236987759ff37f7295c7"
},
{
"alg" : "SHA3-384",
- "content" : "cb6d88cdc3ccf23c20d89322ffb35c6172746bb8b9a785599245fc72c645aee44cad19c69a5305e0559158a7fc62b562"
+ "content" : "eed5eb30fb135fb7d5e5cf37eb9f242b397990696c8a77709030a0277a9adbb5ee3b735a36cc3143dbda8ac3a9575c0f"
},
{
"alg" : "SHA3-256",
- "content" : "d396287ecffdf697be77ea8bdb43da2f9ce91e13e5abc07ee931a889c5406e99"
+ "content" : "75161e25f223eb91cb7a574be46b058eb4f8060323144b94caa56f60bb134d07"
},
{
"alg" : "SHA3-512",
- "content" : "d9cb1b743e95eed44fd9fe40db6638e6f0f870b536c240ac23364acc3be06b86e49421bc38d138c6bc30f272a90ccc9ba7cf9d0017acf03d3664cc69a40aa6e0"
+ "content" : "9fe9df9a48ab7e89d202a45b24eb96afb74dc0cffb87ad5ba5cf4ed416c3d6a3445eabf26cb869403bf84803687bd3eaddbd96bc58ae97cd650b11981890ce89"
}
],
"licenses" : [
@@ -1677,7 +1677,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1695,45 +1695,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-resolver-dns-native-macos",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "9aff4b62bef6c3e4cccb8fc5e72db35c"
+ "content" : "ef6154c32fff9fcf90ded6acabf6acd7"
},
{
"alg" : "SHA-1",
- "content" : "160036ecf20002bf9b7b563ea2101f26c3ee64d8"
+ "content" : "fd7a2c2da74df9a3aaa6ae31902365ef594f4d48"
},
{
"alg" : "SHA-256",
- "content" : "0c86fa27317c4172fff03a0c20286e2c62ef9d60ad78f389a83ede48a5bb54cd"
+ "content" : "0d4ee2dbe280d70099618c7efcd029b46b85359ba633a0eef05e5bab3667bdd5"
},
{
"alg" : "SHA-512",
- "content" : "74ce3f47ea26a890d51e8fcbc2e25fba034b12a067b4df61734d5c7ac7d44971e8f55c3360e031f7e867e30b43ba4b8ca8d22e57ae78ed6a0cd769869feec128"
+ "content" : "05c0bbc8864f48b439e2af6403e9f51bded8239b759662f1cd696656fe6d89150cee3a0478645fd70fe9971d761f5870ff5e7eca863069628dd915d8dd432cc3"
},
{
"alg" : "SHA-384",
- "content" : "849b343d9e7ef7ad09756ac9e347c0c08315ee60eab116f236eacec23af1ecf4d7a8329f2a0f8699faaf5e8888e135ac"
+ "content" : "ae9941f406a273bcadad741dc66a210b3aea3b9adf6bfeb07116dd2250356a8424a4a6773af6b996e862ba7cd3552a2a"
},
{
"alg" : "SHA3-384",
- "content" : "9f64f63e61b7c8b7d57d196bb6067e8842643321e32afc7ad62362c8915fdc0f92f3efa639c80173715024cf5916c41d"
+ "content" : "4072ade6dec8794f3c9e999f3dac56671f8c419db690bd2ededc2307342c864a107af3732e1d81af5dfd29872d3e9f99"
},
{
"alg" : "SHA3-256",
- "content" : "907a413fb830590379df951cfe91051df322ac640e5d8a4f4c4306d6217b0737"
+ "content" : "3d51e5edadf72fb575a32cd7b5d8f7627c22e83ce445f10712140271c2742d14"
},
{
"alg" : "SHA3-512",
- "content" : "932585e4251601a5d6a6a482ae45a924fae7452e19609bc446d74c584977b6264e60bcc58761cf74b89912dad834520ec1dae16600978528a34a99488cec0b4f"
+ "content" : "9f37dfa1cc4f1d24b72dc8751d18065e94997c3c04fe84164b14fb40ae5224661fda5662164250e5342ae9e47b3206004efa5c491681e77818852524bdacf53c"
}
],
"licenses" : [
@@ -1743,7 +1743,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar",
+ "purl" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1761,45 +1761,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-resolver-dns-classes-macos",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "1cc229f2acf3ac7ea3331021f38a684f"
+ "content" : "77e4bc0cf256cbfcaf5a2bf73e378381"
},
{
"alg" : "SHA-1",
- "content" : "508fc43de462e60c814e76d7e23dea59b0cc4fe8"
+ "content" : "474af89eaee42a9b9ceef222a7fcecc80cddbbed"
},
{
"alg" : "SHA-256",
- "content" : "4aab49a507dbbe446ad2c6a7587fe69c511defa6c273ce1a559e3458a3378a5b"
+ "content" : "c8c3ad3b364d302cf2a3ed8702bfeae455536963382d0d9260b9fc47ea6f234a"
},
{
"alg" : "SHA-512",
- "content" : "47d2792a6c257b22d4f61a3dbbc3150d8cada4f89343869c67af5d04282af9f53d3ae1f55b20b173d425725a8e41bb98747a37f8652428a39f0ed02cd801a326"
+ "content" : "ec1cd7fe56a1df1853934bc511e7a802f2e18a29edba881e54b443351473ea71780095c61d194ae87df89801f0a43e506fe05f8c4b7f2678f023d76535749865"
},
{
"alg" : "SHA-384",
- "content" : "6cce60f35b59287ca061318411681bba557e046580065d326baaa9097593935b28904d10303cf6cad7b75e71648301cd"
+ "content" : "63e02bbf5166a9564af91c560235ac6bfe5098c8195edab93adc685e956b450a6807bb196b72c6a20e7bad87d1e87be6"
},
{
"alg" : "SHA3-384",
- "content" : "ea645153e34d957274f4c98d1796976abe8bbf11655f77f6534143a24c003f1c26a6b70ac1449a5c64058c9be157e682"
+ "content" : "72daaa90d671802dba4462fc9cc23300969109e7818269af81ddddfe21daeeffa9b96ec38adc530a21a35f6ef1b0b61e"
},
{
"alg" : "SHA3-256",
- "content" : "de57d13e362c759ec02c183ff526b130deff23b4c376084ed8dd36f760311159"
+ "content" : "d04a1022e2342063817caf392c4ecb585558e023a8a462eb8ba4f24ead3fdf66"
},
{
"alg" : "SHA3-512",
- "content" : "b9dd08e2ea0d488f3943027d8a92837697f4687ab3e8fbe54af9ce64c04423740e8a392042e28e81902bc4c1efc5afe7731eaf4553b546ff1900868a70ed1c20"
+ "content" : "42fd7915ec51bfaf936465fe13eb003cb9bc405a914167f944662739295be38d5c03faf648de8fa36b4181325b31700616d924641a11c75ba7cade64326d2fd1"
}
],
"licenses" : [
@@ -1809,7 +1809,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1827,45 +1827,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-transport-native-epoll",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "97756f389188954811a6dacbfe0005ab"
+ "content" : "d01515edc2dc9280fcb605d0d1638c1a"
},
{
"alg" : "SHA-1",
- "content" : "9321b17feb084585c9286208ad2c438cc9abed30"
+ "content" : "4d2ce2c0069e2b5bc5b7e546a5a7bf1e0742f75c"
},
{
"alg" : "SHA-256",
- "content" : "18a40063da3364cffff81c6c2097fb6ebcb45c62264dabcce45aade4fdac3125"
+ "content" : "c3956f90241582bbbad5612c3159cffeb7c3533324759f3c3bd778ca9b176cae"
},
{
"alg" : "SHA-512",
- "content" : "89e0517d5aded7a91dab4560ff65b7dd8ec30d8a0acadff8fd708dda843df7648cbed22225724376b699c221758a02c674b443a7d18dd707bae7c1985380cc0a"
+ "content" : "1b3077f7c921ddc37122ff95d69ceac288858ab59b8636548f8f7ecb1df872c33105c0c48fb574c1520bcd4d660d79710b2ed6efe7186f0b2fc65b55d8f0d940"
},
{
"alg" : "SHA-384",
- "content" : "a87d40e90002dac61eff262131570623bac1d6a40ceb576601c8e68f5aa791539ac1c5983b6cfc783804fcbe7b2fa51f"
+ "content" : "f3b8f22160152e149da61dbb588c200f808a7f93136ae49af10bff0eb96adc7d1ab77fe2db0e212f03d6c1d02ff57937"
},
{
"alg" : "SHA3-384",
- "content" : "015a7d8c67bd0354c13ceabc183ed717b4977d54e2b2c19602d75f439fef81e5e6618ea3222781d6bd7eecb865d5118b"
+ "content" : "2b3eb64886c36a4c4f5234233c6752fdf576538568e50f8257ff2c35493c6b34cc15f497111bd7a405b9932a0a5a41e4"
},
{
"alg" : "SHA3-256",
- "content" : "48ba69067259e8ca33aecf4fa5849089ad078a309338e2f2a89a3fcb3aabc2a4"
+ "content" : "f6fd525ed45de1e68925283112b6902356943464a65df6dbec0110608129ad8d"
},
{
"alg" : "SHA3-512",
- "content" : "102fdaf685a08dcf3e57101b3470b967a1aa84373b1a874c3e253a66b611daade82faf6954958f018d6e4ed82c612a250bec806d0c7a60966703054fa1ac328b"
+ "content" : "3ebcae1ce865793d18304e56b96dc14efaa724a087d0051b606ecc67c8425f24b4cf89a5d3f3509d876ff37a8f9bb0a2f3d43113a7ca658f0404525c390f3789"
}
],
"licenses" : [
@@ -1875,7 +1875,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar",
+ "purl" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1893,45 +1893,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-transport-native-unix-common",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Static library which contains common unix utilities.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "1e2845ca46311605e0a5e1623bc22e47"
+ "content" : "4de177266bbf4335c7168ddeef5de796"
},
{
"alg" : "SHA-1",
- "content" : "9ceeb22325232e9998e3f0ae396e5e4cb462672f"
+ "content" : "550e2091240ce07f9c1f114aecd89294df92fcb7"
},
{
"alg" : "SHA-256",
- "content" : "a7895075f112611d1640a596c2678a28aab92d5681c1c14755b109b8998f995e"
+ "content" : "7e014c9b13defd9d254d4e5a5edd8ab6dde17533e1152f99699a6b28b2967a8a"
},
{
"alg" : "SHA-512",
- "content" : "e6be72757a08d1389ec5f78c2cb4ab099ed1dc4f1d1003ca892641a07b2fbe248bf2fb5f0b23a67999b98f2feb0e6fa9d897dae4fb6413c912a8b8945986bc11"
+ "content" : "b96384fc0757b7b4da71a791addf9a6bd7b22794726de49148e7bb0c463b8481058eaeb02c99365fd990a9328c7d3801f4b607c7effda55b6993d1c87c9aef07"
},
{
"alg" : "SHA-384",
- "content" : "fcb44d8789ce69fa7e3245ee13d01754a3a85c6fe9a6a9ecb0ef6c904ea8833525001cfb817db8afa6e412f670f93fcd"
+ "content" : "61e5ce1f86efef4422952ef6b34e157b3c0e022398af27fabf1f3e29cfc3ed9bf06412798d056befbbbbac7f4fa9814b"
},
{
"alg" : "SHA3-384",
- "content" : "c8ae6de42edbfe2cff295a4b808beae783eacdfa9b431b090a59340548e03f138bb50dfca3e83ea6365b96fb4a86ee7d"
+ "content" : "ba535dfccf8eb23188059efa87d239b3ea1515d02ae2d66b6519e301dbb5a5d58775003490e7c899c02239523aaf0441"
},
{
"alg" : "SHA3-256",
- "content" : "111d714be16afedf991598213fd8cab5f0b8ccaa6596ac6cb635b8bda62c61a0"
+ "content" : "b24eb906574fc11aaeb74026fc2e5554a24197a45c6010cf984123daa268bc3b"
},
{
"alg" : "SHA3-512",
- "content" : "b6bc5cbb82b3cbcb250bbc1d1548084ca6d92bf3ced1bced111f4aa3bbfe6f77f87208d781165f49045262e4fc502d5a2349257046b08a6b8432ec2e6afc3819"
+ "content" : "82b09870019e5229a428519b5fdaca932abaa742512b01cbc7cae2c1891969a8e9f43face683b2f68f483587bad48168883a30baf91d52ef62c216d6f8f33e64"
}
],
"licenses" : [
@@ -1941,7 +1941,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -1959,45 +1959,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-transport-classes-epoll",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "5494b16595a173bc4353eb1fbc4b8033"
+ "content" : "544fc46a9f54b83567ffdff8700f0405"
},
{
"alg" : "SHA-1",
- "content" : "4f2bd9bcd7c91768c1f9aca8e62870ece18527bd"
+ "content" : "bf7e09fc712f4c3fef6563fe121d4b424fede6f3"
},
{
"alg" : "SHA-256",
- "content" : "9d9537ab9e15164c9f0dc0748884c148814a18d78ac6dfa65cf4b3d06068ce01"
+ "content" : "f6a0b631b98667f131daf4ca07a9cae1072d58e259dcbc0f8c6a053d843449c6"
},
{
"alg" : "SHA-512",
- "content" : "1fb2bd89e154d771580ad2a14f954f3b0325bdf949c1cfe4102a62ff9eb53fd8b1c45373206262a64b644016302cf13f8ae8dfc8a757350402212f82b1dc4e8a"
+ "content" : "1198533994e14eb5f4afbbe0db83f6cafcfd2d1a58138a911386cfb8f35d554e733277bb1d079e0b63bf34c5f15eb2fa913e29ddb647f71a2b3d78fc9b4a9715"
},
{
"alg" : "SHA-384",
- "content" : "d676506b94103de1b1adc82083eb84e4132c4419ee838a0cdbadef251a835d61bb427e3b4a258fd5d02493615ef67107"
+ "content" : "765cc327cadbe46d5230aa24d806d48a210f860e4d8575f700fe8b49788a0241efbf4f08bbe2aa9bd3a2e86485e47a7e"
},
{
"alg" : "SHA3-384",
- "content" : "f71e38b5f318a186c0f405fa27a88f35a6029a64226730094db93f8714331d023775f84317d5bf4c6c9e40727f520e87"
+ "content" : "ae6158851bebb9fae34fbda6a2fd2466b167129c8588e260e17149a68bd29d4bd4b7391a2846c7e3ecb711fd88065d57"
},
{
"alg" : "SHA3-256",
- "content" : "bc4e704f8a354fe07524d7eebe2852b8bcc7b61ef5dc285176f8c1d0867d9d08"
+ "content" : "fc5a54f07589f8e3a58e9bac8617b22f70a691f6741ee42ce4969e1a601669e6"
},
{
"alg" : "SHA3-512",
- "content" : "1e2f5e1c527812bcda9830eac830242614842b2e0d1292f53bc0e7bddd9b6ab5816dfd0350c29cb2699b736f4e5319cce743bf3890ea117073cb350cda622e81"
+ "content" : "1f49a2f1c57a99b0edee3c40851b51cdf2d6187c6ac168ec0156f8a013e113b0d8145b15aae8ffc9009a5c4bea3a2ee000b10d8f8174357b5caecb2a2c698130"
}
],
"licenses" : [
@@ -2007,7 +2007,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -2091,45 +2091,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-handler-proxy",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "e981f38ac2e481c77e0685e5bcb0e284"
+ "content" : "b16d047bc4f63673b642984e973f08db"
},
{
"alg" : "SHA-1",
- "content" : "df2a683563e8ca8442a49ddc9c2da9110dcc3cef"
+ "content" : "3172bd2f12e169a103140c8f82915376687aa22a"
},
{
"alg" : "SHA-256",
- "content" : "75661010630a44468f0e85d7ed8be7779c0cb1369fe85d30799cedc52e9ed3b7"
+ "content" : "47400551f0444dea33629ee0c52d4e30856b2ddf00b12adf1881902957e74cf2"
},
{
"alg" : "SHA-512",
- "content" : "367aff1b592e168775d8de14f5256a714a2bbbf85deb19a2b1c5d21f85d5bd067356dc028466080b6086301bf0a9df16be98286f0b4ddae6e1a679e3f68a58a5"
+ "content" : "8c8d99f25f8ae9a045a40e37b44b0b346126c4ddce8d149796c98d48c99a4b757e2f59df0f25c16e009f6bad63c0ce62b5117d3d74674df12135e0f94f01d8da"
},
{
"alg" : "SHA-384",
- "content" : "fcd074ad72eea319a8c479f91a2ccf352199d32ea608dad8ec8f243e19e4cdbaeca3c3cdf9fdf568e73c77804b050f34"
+ "content" : "ac6b39a8f9794a778b0ee109237d9759d0f95a3763b9eeba0a541d179f42ea8647b65e4ef1ddb6791f47d1d761b6206d"
},
{
"alg" : "SHA3-384",
- "content" : "bfd4c4c8f7a4f57239a824b442cf1ad5d11e70d7abf26794342b296bc8c1e59f510f6f7c082fca349e2cc1aa9c0d5fb8"
+ "content" : "e70f09b674ea3ec9092df0dfafdf3dd4eab14df925418e0dc23a2e3c22404af9b37d0bfd6b89cefd85cbab6ec86bb762"
},
{
"alg" : "SHA3-256",
- "content" : "2bbd040bd29192dca98f50c9f28a711613d2bc72e1b38e20aaf91d7c8e6e0a7c"
+ "content" : "a60487c209137050a18453773bfa6a6ca51f7b08f1b95a9911b57148ba704a7b"
},
{
"alg" : "SHA3-512",
- "content" : "b815dc49bfc7bccae35905aae97546c9156673f7c524ec1a0f505c8e7fc0e462710e611bc212c8b5ae803c2a77d458701ae02c54d127d18a0aeaa64ead762232"
+ "content" : "95cecf430d76f1e52219bd8a99f7f3d50160e6e2467056710476bb44eaed486c030e84b925b67da8820bbd91e23d20d0049deac7cca370a42df1a12d06d07533"
}
],
"licenses" : [
@@ -2139,7 +2139,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -2157,45 +2157,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar",
+ "bom-ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar",
"publisher" : "The Netty Project",
"group" : "io.netty",
"name" : "netty-codec-socks",
- "version" : "4.1.135.Final",
+ "version" : "4.1.136.Final",
"description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "f022acaa2b77f55e286168e22a868932"
+ "content" : "fc4f904e8b36adddf7e6da7b433fe1aa"
},
{
"alg" : "SHA-1",
- "content" : "65675a46d40f11b27dce2593d05be327d083f5f2"
+ "content" : "9513dc426df3365f5422e5d83b0d40d2726f0460"
},
{
"alg" : "SHA-256",
- "content" : "ec7a39e8d7d7e223014115a021273f011c3cb1e8fb187cbfb90a74e76d68c25c"
+ "content" : "341f47dbaac667a6e7e6cad4114218025f9755ced641bb0740fb69e34d29b421"
},
{
"alg" : "SHA-512",
- "content" : "ab55a58bb253d2ad63adaef77e642f009902efa8749746798ad598e219ed487445ccc5f5c4dfaeb8a0616122b5b12257ef709c3ca834786152d7b887a674ec51"
+ "content" : "b004b9a30f536d53c7b15ef48757a0b26d9550459fede7b68d22a4821cf876a3268c059f49f89a71010cb160750f0ed2d519ed1715d262cb9ef56de314050b9a"
},
{
"alg" : "SHA-384",
- "content" : "68d1053454ae05519e6092ec23c1b11986dac155e128dc6f3f249653bcf8c90e71e31c8db6c88b5691efe5cc26530dd0"
+ "content" : "42742f0902ef695eb82b7fd4118a11c37b5f01e971edfab27449d3e7fcd50c3fd98bb52508fad455dbb11c943108e910"
},
{
"alg" : "SHA3-384",
- "content" : "bd2903d5efe990b81cfc9844cbfbfc2cf36d9f73a7d137bd89eda2590d507a622d0e89ff7d5f08d52be5d2944e71d360"
+ "content" : "0674aae29a22360a92e4278f81a187482a194b014fd170e99d1d70f4ef0b6ff4bc6bee661139b0d6f7e123f538ddd501"
},
{
"alg" : "SHA3-256",
- "content" : "5d6ebec22d5ae3695aea50fff14777bc1b1452477b593ad3d44127149bf9b883"
+ "content" : "07c8f65fe764c1135132f00c34c15494ce9b6bcce39c8baef849efcbcc6fd113"
},
{
"alg" : "SHA3-512",
- "content" : "06686081615d84611543363cdb0398e9411fd7d7c47765b9cf4ee6a2599095c8d55aed91c21759b50e9507eb535b825623a4b5be39caa60444bec00b5cb22805"
+ "content" : "64ee3f092ee297b9a7e1e719952d777a4d38de56f6467a1923453b2f551545d9e26b24582d7332d72d39be2d23d2ccf7d073b4acc68fdcd6994e20d660b8ad3b"
}
],
"licenses" : [
@@ -2205,7 +2205,7 @@
}
}
],
- "purl" : "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar",
+ "purl" : "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -3493,45 +3493,45 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar",
+ "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar",
"publisher" : "The Apache Software Foundation",
"group" : "org.apache.pdfbox",
"name" : "pdfbox",
- "version" : "3.0.3",
+ "version" : "3.0.8",
"description" : "The Apache PDFBox library is an open source Java tool for working with PDF documents.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "15cd4480a886e22cd081537be5a11f14"
+ "content" : "92ed0441337f2ed1f43eeaa901bd7814"
},
{
"alg" : "SHA-1",
- "content" : "a739bfc1b72d2f98d973cd1419f5ae2decd36068"
+ "content" : "c5d8c0b56aa156d64283f07729215a9938be2e69"
},
{
"alg" : "SHA-256",
- "content" : "5be38d2ec81691b05d535eb720de4dc566c5d07e5a04731fa00668d153a8b4a6"
+ "content" : "97647cfbde61ebcfc06b4cf8c9b0ffcaaee073396eceb4a7f6836a9b9128903c"
},
{
"alg" : "SHA-512",
- "content" : "84c51bce7ff6ebc9f7159a824a0de4ad575c6c546920cc776de494b1af7d28ba9f30c90a59ccd8ea25390c43567389e168535cc1da4b1d5aaad4cb8810b6c743"
+ "content" : "bd59e4918285dbed5d5a5e922368b139b2d3eed3e7b1102e726bbc69041328c82175826b92f341885f17f22c7af8438f4c5e676f25b90a8c062d42b1d7e5a57c"
},
{
"alg" : "SHA-384",
- "content" : "6fd95bb4a5a20d9905ab1925e6721c4d57602f5a6aee4740f3fd152a50d042c63c945d6a48a67717a9529672f3945161"
+ "content" : "bf70e79d86149d82d3a475bf8be8bb19dedd88ebb975af4cb4e07b53c1ee70e6d53e18a83db2ce2b48918ea733014956"
},
{
"alg" : "SHA3-384",
- "content" : "e6beb355db668b0cc477cb81163e4e70a9bb4cbce12021bc109c5f8f40270e06511d9e0989ac3744fdf163496631c5f5"
+ "content" : "420f994bff085278cc7f2061ec10373e5c0b39809cc6dff82986b0f4ed65bdca88cb3f65fb124066c6905c02f565430a"
},
{
"alg" : "SHA3-256",
- "content" : "a47d4c4f31d25621ee4e77d534573d36a4502631a62ed2db9f1e6882695c927d"
+ "content" : "f54d3f1432e99a116d703f9d17dfca9adaf4cfe9e5dc2e57d1e6e22d7b9958b1"
},
{
"alg" : "SHA3-512",
- "content" : "dd416a53eff57fbb8a5d41a6cb8d074a31352604ebd3749c2d0097a0d253d20dbb92682a291614c86ba7ed86901a4d40ce0ddf9850c8ac1cd32ee01037341ab3"
+ "content" : "e344572c8bf9a207113cb4a4f9ffc15e211d23413b791a4a8c009b4976eec9b4da39b3ef80d37b32f44a1e239c3b8c9148161f918d5452584472cf9d9a721364"
}
],
"licenses" : [
@@ -3542,7 +3542,7 @@
}
}
],
- "purl" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar",
+ "purl" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -3562,51 +3562,51 @@
},
{
"type" : "vcs",
- "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.3/pdfbox"
+ "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.8/pdfbox"
}
]
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar",
+ "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar",
"publisher" : "The Apache Software Foundation",
"group" : "org.apache.pdfbox",
"name" : "pdfbox-io",
- "version" : "3.0.3",
+ "version" : "3.0.8",
"description" : "The Apache PDFBox library is an open source Java tool for working with PDF documents. This artefact contains IO related classes.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "a348b5a7bdb7784fcf3d7ad7cc31d2b4"
+ "content" : "f45a2386cf4178b5955dda2807139745"
},
{
"alg" : "SHA-1",
- "content" : "c40dcf555b72b1d8c0cd19391d63e5b58382b9cb"
+ "content" : "0953f459ebc08048b75afc3e823aea2e0466a01b"
},
{
"alg" : "SHA-256",
- "content" : "123ea3187b497c54e661d50c3c867479bf77668ff450e50710c658f2bb4687ba"
+ "content" : "36a0e04001010b4c764857817412b96339930b19755e728959805cc0352061b2"
},
{
"alg" : "SHA-512",
- "content" : "35a778196ccfb75959812cb17de5225413a99deb6b00e35533a32df9d5ba93fe6a9648765de8c03f242af5ab017580c1b5df4849370019867cd030fd2ea6b9fd"
+ "content" : "659c72ecfa9743df09321ef594cb0e948cabff17d7aed01583f78ec344e2126e6c75506abc43bc36369c0692bf09ea9e81b956ea9a3e28af29dd5c86bae9ff7b"
},
{
"alg" : "SHA-384",
- "content" : "ca41c4bba6371ba6edf7d00f9bcacb83466a857b391b3bcf08b1e59e370241dac7607ebb164b97dc5c0e697f2f876f23"
+ "content" : "6df807534a26d19789036801d0d36e7efc10e53e854160732cf5827219c58f1a9fd8c1f84e9d8a8d08afec05545b81bc"
},
{
"alg" : "SHA3-384",
- "content" : "41eca88d9e1daf1ea73761d6c3e5d47a699debcc546fc2216dc72231e449a1312220d17f46b0f7b8db745c82411eea84"
+ "content" : "83692991b95310b6d0a20dd5f591153740286615856ff72939df44628f0221d9f9f02275a8707bf59af7d12ed941bb05"
},
{
"alg" : "SHA3-256",
- "content" : "118a036da75c311d6812d984f8f5788fcd2f3c4e6260dd744920cae920ece2ee"
+ "content" : "8a5620ef92655e317e1c8a9301d2a57ccd672354e6dd37fe24d5d9224867699e"
},
{
"alg" : "SHA3-512",
- "content" : "710b5b1c4ec3a6f19201f0c9695b6c5601cfa9cf06776e431c642860e316e936971db1a58f35a8b6c8325041258e8c6f9ac8f081f9d820a181e1742692bccc4c"
+ "content" : "3b56fe5e9b77d3206ba4fb342c4a7b9329f357baf625bc5686cc89cc3df6057bcad526c90707ce2168733b3ca6113733abb9a96f1ccffbf04bfb1865b8ce4cdf"
}
],
"licenses" : [
@@ -3617,7 +3617,7 @@
}
}
],
- "purl" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar",
+ "purl" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -3637,51 +3637,51 @@
},
{
"type" : "vcs",
- "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.3/io"
+ "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.8/io"
}
]
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar",
+ "bom-ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar",
"publisher" : "The Apache Software Foundation",
"group" : "org.apache.pdfbox",
"name" : "fontbox",
- "version" : "3.0.3",
+ "version" : "3.0.8",
"description" : "The Apache FontBox library is an open source Java tool to obtain low level information from font files. FontBox is a subproject of Apache PDFBox.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "05adabd366e6f8a22ac04c293237dd89"
+ "content" : "379e6faf6ea614ad3318eda879e4f423"
},
{
"alg" : "SHA-1",
- "content" : "9eebd1ee868a79fcf7390283b2baf4179dadb8ed"
+ "content" : "e9f4225dc564b212ecdc31d1574f2d0c1dc3ad30"
},
{
"alg" : "SHA-256",
- "content" : "65690c3f39b04a14d12c17f4998c15186ce877d3e2ec222c708577e3cc028030"
+ "content" : "a1915c24e3edbe0ecec93896dfbf6d41427810b663ade97bd4e8bae86ec3fdab"
},
{
"alg" : "SHA-512",
- "content" : "4e88662f50d0ecafe2bfccdbe3164a1ec01ab8ed451e8727f7c344794e53ee735fa1a4b584b8483795c905d13949eede22de417135e72652cd341d7317ef57a5"
+ "content" : "fdc1a5bcb016280c561e20684db77cba126660872f6ad03440596b8817ed97452e80237e0c3e41f8914711dac96abb8da8d1f37014eef7a7433fff85aff953b7"
},
{
"alg" : "SHA-384",
- "content" : "41dfedf5735484e433cc8aae7e81fa388b5091ba6057940242960dfc78c873a48ddfe6c9245cfa01bd3e28ead30f7c32"
+ "content" : "a003c4f5a10df5885ae07dd485a4e2932bb9dfc1d858f44d582b6254e72171954e341f15b07f96f9fc745e47b2dfc6a5"
},
{
"alg" : "SHA3-384",
- "content" : "79c39834996bc7fb66138f7e82dc1a64add1442a914f52710a5be006398bdcae50a43b2d98abd35d469bdb8a45a79d14"
+ "content" : "019569e5e5043e27220becc49b14d781c9e96ad3a20156b1e24249e8c36b4a2d4a6373b64b8640dea9b2c05dca9bda8f"
},
{
"alg" : "SHA3-256",
- "content" : "98db31e368cb097e3e5928f1db6220f16ddd144b276471b2a8aa560cf8fdc89a"
+ "content" : "1d7995e3c97eab0405539ce436aac2b4f832a3ca7cb8628346e05955fb85c42c"
},
{
"alg" : "SHA3-512",
- "content" : "8cc1fcf1d602d81b4680e5d34ae8bea76ffbf4e678be30fbe1fffa0d773b15d40e20ad9bb9f61e5af10dde46fb7e8df15e85ce83a40d37ddee55ee0d13c4cbc2"
+ "content" : "b00c2d71f06b9a754c5dbd667efad8edf2ac10fca14f2d222dcd09d8d1f3379a6675f7eb2a003ca7ea234ca8f67dcc7dd3d6872ce0b1401d4a13f4dd3bbd7dc0"
}
],
"licenses" : [
@@ -3692,7 +3692,7 @@
}
}
],
- "purl" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar",
+ "purl" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -3712,51 +3712,51 @@
},
{
"type" : "vcs",
- "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.3/fontbox"
+ "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.8/fontbox"
}
]
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar",
+ "bom-ref" : "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar",
"publisher" : "The Apache Software Foundation",
"group" : "commons-logging",
"name" : "commons-logging",
- "version" : "1.3.3",
+ "version" : "1.4.0",
"description" : "Apache Commons Logging is a thin adapter allowing configurable bridging to other, well-known logging systems.",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "62de1aea096b3ac52e46b908dac4ac97"
+ "content" : "954e27d33e55e587a4694d5952a0c5c6"
},
{
"alg" : "SHA-1",
- "content" : "580ad1a4f34876c4f964c083361de31b3d60be68"
+ "content" : "e8f6313365dfa0580e49c58837afc8caa9b4ce05"
},
{
"alg" : "SHA-256",
- "content" : "5828f96c09d886f9b1a0993c7804b27cf4fcec8534517164f5137ac8b67ea9b9"
+ "content" : "d175dbd751dd782a63bde28c7a039520e971f25e84b79c19b8435edc3603e0dc"
},
{
"alg" : "SHA-512",
- "content" : "86adf089e9d5723bce8d4e0dcd0a7590c3aa27eed3982c8fde49f865dccb262043df8b47b2e721258fa8a880d0e32defa3406e9dd48ccc54c5d34793c919f36b"
+ "content" : "5c333d925f81fdd09a80176ff15cd38381eb12e9ec2dac75f113d66006002175b33cdd9a0668100c5c6a49ae81f9f576909b89219a345a0075ed4683a997570a"
},
{
"alg" : "SHA-384",
- "content" : "b25673d250c7043c55ba65ba8160e4b8814ab99691f3a3c51e6069bbec330503a74e42a37bc00969beff19fcce4c6a35"
+ "content" : "31ed518d623d408894a188878693fd953a3aefebebdddc4a304769bf2862ae70623340cdea387b911a62ef7cb9043a37"
},
{
"alg" : "SHA3-384",
- "content" : "f4c459c29b7f4cd9665f4110acb41370449db898512f95a424f2a06d2119dc22bf052a21d7896a3aa967603a010e0b59"
+ "content" : "1c91fab5c64bfa679da2186cdef39a0360c9d18122bf37b4dd932eee4502527e8458b5c849dbcc11e46ef5fb87d1a777"
},
{
"alg" : "SHA3-256",
- "content" : "27483413bbea96155f333884b9c1ba0d776e4ce338674fa4ba7cc33af1073196"
+ "content" : "2189847ad7dd3d50bd17aab9dab8b31c8e7e25e72ca0d25ad83a4215bbfb5e2d"
},
{
"alg" : "SHA3-512",
- "content" : "6af0c80bb419e4604f525e4fb5bd4e2cd373bc07daa106bef3d87af11c93759b2216e1ff6b1061017082145fc877c65b9cb079ef4a6acd4d16806de2fb47019b"
+ "content" : "f57be786553cc10966ce4af25ce401125ae76f6ee569812eafd67a6eaff06618ae835d637e5e198bd121463afa4993db61975f4ed0380fe1dcbfe3108b3ada51"
}
],
"licenses" : [
@@ -3767,7 +3767,7 @@
}
}
],
- "purl" : "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar",
+ "purl" : "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -3775,7 +3775,7 @@
},
{
"type" : "build-system",
- "url" : "https://github.com/apache/commons-parent/actions"
+ "url" : "https://github.com/apache/commons-logging/actions"
},
{
"type" : "distribution-intake",
@@ -3797,44 +3797,44 @@
},
{
"type" : "library",
- "bom-ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar",
+ "bom-ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar",
"group" : "org.webjars.npm",
"name" : "pdfjs-dist",
- "version" : "6.0.227",
+ "version" : "6.1.200",
"description" : "WebJar for pdfjs-dist",
"scope" : "required",
"hashes" : [
{
"alg" : "MD5",
- "content" : "72411f297134490511fcaafd566bf4e4"
+ "content" : "f1f7565a3639df404408f1770ea112c7"
},
{
"alg" : "SHA-1",
- "content" : "308f852590def6b814c707312ccbb814f4d42fb5"
+ "content" : "92fb124655143e47cb0b3dcb902add53aa9cede3"
},
{
"alg" : "SHA-256",
- "content" : "3943ada724d106abbc8f1f231087f2c09b7bed0b34e0a94a0f575f7dea6d3b99"
+ "content" : "22639fd7614aed05df9a06861e07540e11a2790bb820e008c17269425b68890e"
},
{
"alg" : "SHA-512",
- "content" : "eb7fdf934e9d9b1dda84fb68519ab065dfc4e50958efe1f565dbaf8c03d1aaf06bf9629a57d02392da978cace39f6e125b597cbc9523fc667982faf43fe87c77"
+ "content" : "cf0b7394a2fd8db0cff1624f7141a810a1e7c34e51226c03c0d4582711e37edb82a48119c388788dcb26f5c5171528566431460ae30840e1e0b4e6c42494e202"
},
{
"alg" : "SHA-384",
- "content" : "a178dc7a1bf0b98f569456a6c8b03ad3b781eb5b0a9786910e383bdcb75b5de86f6eb93b4313f204b1547f0e29d99fd6"
+ "content" : "c31dd289b74a7154cb86c5bf8a7ca204dfe0659f28cbcb981c2639a867af62427f77a346a24f86667bbef42d6ed05e89"
},
{
"alg" : "SHA3-384",
- "content" : "59c16ea9820319e1b7af774d861788c8ba0f653a18cccab5e3e908abf3759017402844ecfa8ff29d6df50ebe480614ec"
+ "content" : "82260e56e7177a87633ec72b486d4f151d5af3aa3a646e8a59b5cfd3235f4f302145436ad54daeeb75e5e2594e42cc87"
},
{
"alg" : "SHA3-256",
- "content" : "b54d499a0186e9ecdcec53a2ffda8c895419e1c43915578aa3991d52bc06b42d"
+ "content" : "e44c08ce546326f4ab445d94348cdb281ac94eafd866b2d3b6645b4f625aa650"
},
{
"alg" : "SHA3-512",
- "content" : "50c2486ac3b73637b8d55a624fc6703bec5a7fa7646f310ff3109490a5b446288701c58263219a6f3476821e2e089bcbce7f767fe6746e5a67dd4d013177eb28"
+ "content" : "71eabf677a2fc65cdff96eb79c3e72773a5506f1c3315274d7955f0621c96cfe53d400ff312fdb59105d2753e744e76b0dd166b99e3342ea2c517f6a2c700450"
}
],
"licenses" : [
@@ -3845,7 +3845,7 @@
}
}
],
- "purl" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar",
+ "purl" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar",
"externalReferences" : [
{
"type" : "website",
@@ -4211,8 +4211,8 @@
"pkg:maven/org.springframework.boot/spring-boot-starter-webflux@3.5.16?type=jar",
"pkg:maven/org.springframework.boot/spring-boot-starter-validation@3.5.16?type=jar",
"pkg:maven/org.springframework.boot/spring-boot-starter-log4j2@3.5.16?type=jar",
- "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar",
- "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar",
+ "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar",
+ "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar",
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.22.1?type=jar"
]
},
@@ -4366,170 +4366,170 @@
{
"ref" : "pkg:maven/io.projectreactor.netty/reactor-netty-http@1.2.18?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar",
- "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar",
+ "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar",
+ "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar",
"pkg:maven/io.projectreactor.netty/reactor-netty-core@1.2.18?type=jar",
"pkg:maven/io.projectreactor/reactor-core@3.7.19?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
"dependsOn" : [ ]
},
{
- "ref" : "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar",
+ "ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar",
+ "ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar"
]
},
{
"ref" : "pkg:maven/io.projectreactor.netty/reactor-netty-core@1.2.18?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar",
- "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar",
+ "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar",
+ "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar",
"pkg:maven/io.projectreactor/reactor-core@3.7.19?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar"
]
},
{
- "ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar",
+ "ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar",
"dependsOn" : [
- "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar",
- "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar"
+ "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar",
+ "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar"
]
},
{
@@ -4620,32 +4620,32 @@
]
},
{
- "ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar",
+ "ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar",
"dependsOn" : [
- "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar",
- "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar",
- "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar"
+ "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar",
+ "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar",
+ "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar"
]
},
{
- "ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar",
+ "ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar",
"dependsOn" : [
- "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar"
+ "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar"
]
},
{
- "ref" : "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar",
+ "ref" : "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar",
"dependsOn" : [ ]
},
{
- "ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar",
+ "ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar",
"dependsOn" : [
- "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar",
- "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar"
+ "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar",
+ "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar"
]
},
{
- "ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar",
+ "ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar",
"dependsOn" : [ ]
}
]
diff --git a/docs/security/2026-07-02-auth-tenant-model.md b/docs/security/2026-07-02-auth-tenant-model.md
index d81a2a12..d6babd80 100644
--- a/docs/security/2026-07-02-auth-tenant-model.md
+++ b/docs/security/2026-07-02-auth-tenant-model.md
@@ -1,6 +1,7 @@
# Auth, RBAC, and Tenant Model
Date: 2026-07-02
+Last updated: 2026-08-09
This document defines the production authorization contract needed before
Clearfolio Viewer can claim tenant-safe preview access. It now includes the
@@ -44,7 +45,7 @@ Current buyer-demo runtime headers:
- `X-Clearfolio-Tenant-Id: buyer-demo`
- `X-Clearfolio-Subject-Id: buyer-demo-operator`
-- `X-Clearfolio-Permissions: job:create,job:read,job:retry,viewer:read,artifact-link:create,analytics:read`
+- `X-Clearfolio-Permissions: job:create,job:read,job:retry,viewer:read,artifact:read,artifact-link:create,analytics:read`
These headers are a runtime enforcement scaffold. In unsigned demo mode they
are not a cryptographic identity proof. When
@@ -108,13 +109,24 @@ to the identity provider or gateway, not the viewer service.
Server-side authorization must check both permission and tenant ownership. A
matching permission without matching `tenantId` is insufficient.
+Artifact-byte authorization deliberately has two independent layers when the
+endpoint contract requires them:
+
+1. tenant authorization (`artifact:read` plus same-tenant ownership), and
+2. signed artifact-delivery authority (signature, expiry, `artifact:read` scope,
+ document/tenant/checksum binding, issued-token ledger, revocation, canonical
+ single-Range handling, and controlled read-audit evidence).
+
+Possessing the tenant permission does not bypass the signed token boundary, and
+possessing a signed token does not bypass an endpoint's tenant authorization.
+
## Resource Ownership Rules
| Resource | Tenant binding | Access rule |
| --- | --- | --- |
-| Conversion job | `job.tenantId` | Caller `tenantId` must match before status, viewer bootstrap, retry, or analytics drill-down. |
+| Conversion job | `job.tenantId` | Caller `tenantId` must match before status, direct download, viewer bootstrap, retry, or analytics drill-down. |
| Source document metadata | `document.tenantId` | Exposed only through job/viewer APIs after permission check. |
-| Preview artifact | `artifact.tenantId` and `artifactChecksum` | Read only through short-lived signed artifact token. |
+| Preview artifact | `artifact.tenantId` and `artifactChecksum` | Read through the short-lived signed artifact-delivery contract. The direct job-download route additionally requires dedicated `artifact:read` and matching job tenant before artifact-store access, then validates signature, expiry, scope, document/tenant/checksum binding, issuance and revocation before returning bytes. |
| Artifact link | `artifactLink.tenantId` and `tokenId` | Revocable by operator or tenant admin in the same tenant. |
| Metrics event | `event.tenantId` | Aggregate views must filter tenant unless explicitly buyer-demo scoped. |
| Audit event | `audit.tenantId` | Read by operator, tenant admin, or buyer reviewer for scoped evidence. |
@@ -125,60 +137,88 @@ unauthorized action, depending on route semantics.
## API Enforcement Matrix
-| API | Required permission | Tenant check |
+| API | Required permission / signed authority | Tenant and artifact checks |
| --- | --- | --- |
| `POST /api/v1/convert/jobs` | `job:create` | Assign job to caller `tenantId`. |
| `GET /api/v1/convert/jobs/{jobId}` | `job:read` | `job.tenantId == token.tenantId`. |
+| `GET /api/v1/convert/jobs/{jobId}/download` | tenant `artifact:read` **and** valid signed artifact token | `401` when tenant claims or the signed token are missing/structurally invalid/signature-invalid/expired; `403` when tenant permission is missing or signed scope/document/ledger/revocation/checksum authority fails; `404` for missing or cross-tenant job and missing artifact; `409` until the owned job is `SUCCEEDED`; `416` for invalid, multi-range, or unsatisfiable Range; `200` for a verified full read; `206` for a verified single-range read. Verified full, partial, and rejected-range reads are audited. |
| `POST /api/v1/convert/jobs/{jobId}/retry` | `job:retry` | Same tenant plus operator role. |
-| `GET /api/v1/viewer/{docId}` | `viewer:read` | `job.tenantId == token.tenantId`; artifact tokens are enforced in the signed-link slice. |
+| `GET /api/v1/viewer/{docId}` | `viewer:read` | `job.tenantId == token.tenantId`; bootstrap issues signed artifact link for ready jobs. |
| `GET /viewer/{docId}` | none for HTML shell | Shell does not inspect job existence; protected JSON APIs decide state. |
| `POST /api/v1/viewer/{docId}/artifact-links` | `artifact-link:create` | Same tenant and succeeded job. |
-| `GET /artifacts/{docId}.pdf` | `artifact:read` | Signed artifact token tenant and checksum must match. |
+| `GET /artifacts/{docId}.pdf` | valid signed artifact token | Signed token scope/document/tenant/current checksum/issuance/revocation must match; zero or one Range; record read audit. |
| `GET /api/v1/analytics/kpi-snapshot` | `analytics:read` | Tenant-scoped aggregate by default. |
-Current implementation status:
-
-- Implemented: `job:create`, `job:read`, `job:retry`, `viewer:read`, and
- `analytics:read` permission checks on JSON APIs.
+## Current Branch Implementation Status
+
+The bullets in this section describe the current branch under review. They do
+not become protected-main release evidence until the unchanged exact head passes
+all repository gates and integrates.
+
+- Implemented: `job:create`, `job:read`, `job:retry`, `viewer:read`,
+ `artifact:read`, and `analytics:read` permission checks on JSON APIs.
+- Implemented: direct conversion-job downloads validate dedicated
+ `artifact:read` before resource lookup, enforce same-tenant ownership before
+ artifact-store access, and conceal cross-tenant UUID access as `404`.
+- Implemented on the current branch: direct downloads now reuse
+ `ArtifactLinkService` signed-delivery verification rather than returning bytes
+ on tenant permission alone. Missing/invalid tokens fail closed, revoked tokens
+ are rejected, token scope/document/tenant/current-checksum/issuance state is
+ validated, zero-or-one Range semantics are shared with canonical artifact
+ delivery, and verified full/partial/rejected-range reads emit controlled audit
+ evidence.
- Implemented: `ConversionJob.tenantId` and `ConversionJob.subjectId`.
- Implemented: tenant-aware content-hash dedupe so two tenants do not collapse
onto one canonical job for the same upload bytes.
-- Implemented: cross-tenant status, retry, and viewer-bootstrap lookup returns
- `404` without revealing the other tenant's job.
+- Implemented: cross-tenant status, direct download, retry, and viewer-bootstrap
+ lookup returns `404` without revealing the other tenant's job.
- Implemented: KPI snapshots filter to the request tenant.
- Implemented: optional HMAC validation for gateway-signed tenant headers when
`clearfolio.tenant-claims.hmac-secret` is configured.
- Implemented: `production` Spring profile startup fails when signed tenant
claim secret is missing.
-- Not implemented: OIDC/JWT signature, issuer, audience, expiry, revocation, and
- role mapping.
+- Not implemented: production OIDC/JWT signature, issuer, audience, expiry,
+ revocation, and role mapping.
- Implemented: signed artifact link creation and artifact token verification
- for current in-memory PDF artifacts.
+ for current PDF artifacts.
- Implemented: runtime artifact token ledger, tenant-scoped token revocation,
and artifact read audit-event API.
-- Not implemented: durable artifact metadata, externally persisted revocation
- state, persisted artifact audit events, and production key management.
+- Not implemented: durable distributed artifact metadata/revocation/audit state
+ and production external key-management integration.
+
+## Artifact Delivery Failure Semantics
-## Error Semantics
+The direct-download rows below are executable contracts and are covered by
+`ConversionDownloadAuthorizationTest`; the canonical `/artifacts/{docId}.pdf`
+route uses the same signed-token and single-range authority.
-| Condition | Status | Error code |
+| Condition | Status | Contract |
| --- | ---: | --- |
-| Missing token | 401 | `AUTH_TOKEN_REQUIRED` |
-| Invalid token or signature | 401 | `AUTH_TOKEN_INVALID` |
-| Expired token | 401 | `AUTH_TOKEN_EXPIRED` |
-| Missing permission | 403 | `AUTH_FORBIDDEN` |
-| Wrong tenant | 403 or 404 | `TENANT_RESOURCE_FORBIDDEN` |
-| Revoked token | 401 | `AUTH_TOKEN_REVOKED` |
-| Unknown issuer or audience | 401 | `AUTH_TOKEN_INVALID` |
+| Missing tenant claims | 401 | Fail before job or artifact lookup. |
+| Missing tenant `artifact:read` permission | 403 | Fail before job or artifact lookup. |
+| Missing job or cross-tenant job | 404 | Conceal cross-tenant existence and do not read artifact bytes. |
+| Owned job not yet `SUCCEEDED` | 409 | Do not expose bytes from submitted, processing, or failed work. |
+| Missing stored artifact for an owned succeeded job | 404 | Do not convert missing bytes into a successful response. |
+| Missing signed artifact token | 401 | Fail before document bytes are returned. |
+| Malformed token, invalid signature, or expired token | 401 | `ArtifactLinkService.verifyReadToken()` treats these as authentication failures without exposing token internals. |
+| Signed token scope is not `artifact:read` | 403 | Signed authority does not include artifact-byte access. |
+| Signed token document id does not match the route job id | 403 | Prevent token reuse across documents. |
+| Signed token is absent from the issued-token ledger | 403 | A structurally valid token is not sufficient without issuance evidence. |
+| Revoked issued artifact token | 403 | Preserve revocation without falling back to tenant permission. |
+| Signed token ledger tenant/document/checksum binding mismatch | 403 | Issuance evidence must match the verified claim. |
+| Signed token job tenant mismatch | 403 | Signed authority must remain bound to the owned job tenant. |
+| Current artifact checksum differs from signed checksum | 403 | Prevent reuse after artifact replacement. |
+| Valid request without `Range` | 200 | Return the verified full artifact with `Accept-Ranges`, `no-store`, `nosniff`, attachment disposition, checksum, and read audit. |
+| Valid single `bytes` Range | 206 | Return one bounded slice with `Content-Range`, `Accept-Ranges`, attachment disposition, checksum, and read audit. |
+| Invalid syntax, unsupported unit, multi-range, or unsatisfiable Range | 416 | Do not silently serve a whole artifact; record the rejected verified read. |
+| Unknown OIDC issuer/audience (future IdP path) | 401 | Production identity integration remains planned. |
Error payloads must keep the existing shared API shape and must not include raw
tokens or cross-tenant identifiers.
Current scaffold note: the shared `ApiExceptionHandler` emits HTTP status names
-as `errorCode` values, so missing tenant headers currently return
-`errorCode=UNAUTHORIZED` with message `auth token required`, and missing
-permissions return `errorCode=FORBIDDEN`. Auth-specific error codes can replace
-those once the OIDC/JWT validator is introduced.
+as `errorCode` values for tenant-claim failures. Artifact-token delivery returns
+controlled low-information HTTP failures and does not echo token content.
## Audit Events
@@ -192,7 +232,7 @@ those once the OIDC/JWT validator is introduced.
| `artifact.link.revoked` | `tenantId`, `operatorId`, `tokenId`, `reason`, `traceId` |
| `artifact.read` | `tenantId`, `subjectId`, `docId`, `tokenId`, `rangeRequested`, `statusCode`, `traceId` |
-Store token fingerprints, not raw tokens.
+Store token fingerprints or controlled token identifiers, not raw tokens.
## Buyer Acceptance Criteria
@@ -200,6 +240,11 @@ Store token fingerprints, not raw tokens.
tenant boundary.
- Every write or sensitive read has a server-side permission check.
- Artifact reads use signed artifact tokens, not bare `docId` capability URLs.
+- Direct conversion-job downloads require authenticated `artifact:read`,
+ same-tenant ownership, a valid non-revoked signed artifact token bound to the
+ current artifact checksum, the canonical zero-or-one Range profile, and
+ controlled read-audit evidence; `job:read` or `artifact:read` alone never
+ authorizes document bytes.
- Operator retry requires an operator permission and is auditable.
- KPI snapshots can be shown for one tenant without leaking another tenant's
volume, latency, or failure rate.
@@ -212,17 +257,23 @@ Store token fingerprints, not raw tokens.
buyer-demo runtime.
2. Done: add `tenantId`, `subjectId`, and permission checks to conversion job
metadata and JSON API paths.
-3. Done: enforce `job:create`, `job:read`, `job:retry`, `viewer:read`, and
- `analytics:read` on existing JSON routes.
+3. Done: enforce `job:create`, `job:read`, `job:retry`, `viewer:read`,
+ `artifact:read`, and `analytics:read` on existing JSON routes.
4. Done: add tenant-scoped KPI projection from current in-memory jobs.
5. Done: add optional gateway-signed tenant headers with HMAC and timestamp
skew controls.
6. Done: fail closed for `production` profile when the tenant-claim signing
secret is absent.
7. Next: replace demo headers with validated gateway/OIDC JWT claims.
-8. Done: add signed artifact link creation and token verification.
-9. Next: add durable revocation, persisted audit events, and CI/contract tests
- for production token rejection paths.
+8. Done: add signed artifact link creation, issued-token ledger, revocation,
+ current-artifact checksum binding, Range handling, and read auditing.
+9. Done on the current branch: route direct conversion-job downloads through the
+ same signed artifact-delivery authority while preserving dedicated tenant
+ `artifact:read` and cross-tenant `404` concealment.
+10. Next: move token issuance/revocation/read-audit and job lifecycle evidence
+ from process/local-ledger boundaries to the reviewed durable distributed
+ persistence design; add production key-management integration and end-to-end
+ IdP rejection contracts.
No library split is justified until a second Clearfolio service or external SDK
needs to reuse this authorization contract.
diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md
new file mode 100644
index 00000000..497679fa
--- /dev/null
+++ b/docs/security/2026-08-04-audit-pseudonymization.md
@@ -0,0 +1,117 @@
+# Audit identifier pseudonymization
+
+## Decision
+
+Clearfolio must not write raw approver identifiers or approval tokens to application logs. Policy-override audit events use a domain-separated keyed HMAC for the approver identifier and a non-reversible token fingerprint for the already high-entropy approval signature. Authentication-token handling is outside this policy-override logging contract and remains governed by the repository-wide logging and authorization controls.
+
+The approver field is named `approverFingerprint`, not `approverId`, so downstream log consumers cannot mistake pseudonymous data for the source identifier. Pseudonymized values remain personal data when they can be related back to a person using separately held information; they are not treated as anonymized data.
+
+## Cryptographic contract
+
+### Policy override key
+
+A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup and direct construction of the public validation service before any conversion endpoint or standalone module can accept traffic. Configuring a valid policy-override key without a dedicated audit pseudonym key fails through the same shared validation contract, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The gates measure encoded bytes rather than Java character count and never log supplied key material.
+
+Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024).
+
+### Approver identifier
+
+The approver fingerprint is calculated as follows:
+
+```text
+HMAC-SHA-256(
+ dedicated_audit_key,
+ UTF-8("clearfolio:audit-approver:v1\n" + exact_approver_identifier)
+)
+```
+
+The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non-sensitive key version:
+
+```text
+:<32 lowercase hexadecimal characters>
+```
+
+The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents both application startup and direct construction of an override-capable validation service.
+
+A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing key, fails the shared configuration validation before traffic is accepted. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024).
+
+Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected.
+
+### Approval token
+
+The approval token is a policy-override HMAC signature and is therefore already a high-entropy authentication value. The audit-only token fingerprint is calculated independently as follows:
+
+```text
+SHA-256(UTF-8(exact_approval_token))
+```
+
+The first eight digest bytes are encoded as 16 lowercase hexadecimal characters and written as `tokenFingerprint`. The fingerprint is unkeyed and has no domain prefix because it is used only as a short diagnostic correlation value for an already high-entropy signature; it must never be accepted as an authentication credential or used to validate a policy override. Null, empty, and blank approval tokens are rejected by request validation before fingerprinting, so the audit fingerprint function has no absent or empty sentinel contract.
+
+## Runtime secret loading
+
+Runtime key material is supplied through Spring Boot's config-tree property source rather than direct secret-bearing environment variables. The default mount is `/run/secrets/clearfolio/`; `CLEARFOLIO_SECRET_CONFIG_DIR` may select another bootstrap directory but must not contain a secret value.
+
+The secret store or orchestrator mounts files with these exact names:
+
+```text
+conversion.policy-override-secret
+conversion.audit-pseudonym-secret
+conversion.audit-pseudonym-key-version
+```
+
+Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise Spring startup fails before traffic is accepted. Standalone and MSA consumers that instantiate `DefaultDocumentValidationService` directly receive the identical fail-closed validation and therefore cannot bypass the key-strength, mandatory-audit-key, or key-separation rules by omitting the Spring container.
+
+## Key ownership and rotation
+
+- `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager.
+- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. It is mandatory whenever `conversion.policy-override-secret` is configured.
+- The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier.
+- The shared configuration guard used by Spring startup and direct validation-service construction rejects an enabled policy-signing key without a configured audit key and rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's guard.
+- `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic.
+- Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists.
+- Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited.
+
+## Retention and access
+
+Audit log retention must be limited to the shortest period required by the documented security, contractual, and regulatory purpose. Read access is restricted by least privilege. Export, search, re-identification, and deletion workflows must be auditable. Logs and pseudonym keys must never be stored in the same access domain.
+
+## Incident response
+
+If the audit pseudonym key is suspected to be exposed:
+
+1. Rotate the key and version immediately.
+2. Preserve affected log ranges under incident hold without broadening access.
+3. Determine whether dictionary attacks against likely identifiers were feasible.
+4. Treat exposed pseudonymized records as potentially exposed personal data.
+5. Follow the applicable breach-assessment and notification process.
+6. Verify that no raw identifiers, approval tokens, or key material were written to logs.
+
+## Verification requirements
+
+Automated tests must prove:
+
+- determinism within one key version and domain;
+- separation across keys, versions, and domains;
+- Spring-startup and direct-construction rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes;
+- Spring-startup and direct-construction rejection when policy-override signing is enabled without a configured audit pseudonym key;
+- acceptance of multibyte policy keys based on encoded byte length rather than character count;
+- rejection of invalid explicit key versions;
+- Spring-startup and direct-construction rejection when policy and audit purposes reuse the same nonblank key;
+- distinct absent, empty, and unavailable approver behavior while policy signing is disabled;
+- rejection of null, empty, or blank approval tokens before token fingerprinting;
+- safe handling of Unicode and control characters;
+- no raw approver identifier or approval token in captured policy-override audit output;
+- stable failure behavior if the HMAC provider is unavailable;
+- 100% JaCoCo line and branch coverage for the `com.clearfolio.viewer.*` production package.
+
+## References
+
+European Parliament and Council of the European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. *Official Journal of the European Union, L 119*, 1–88.
+
+National Institute of Standards and Technology. (2008). *The keyed-hash message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.198-1
+
+National Institute of Standards and Technology. (2025, June 23). *Proposed withdrawal of FIPS 198-1, HMAC*. Computer Security Resource Center. https://csrc.nist.gov/News/2025/proposed-withdrawal-of-fips-198-1-hmac
+
+OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
+
+Turan, M. S., & Brandão, L. T. A. N. (2024). *Keyed-hash message authentication code (HMAC): Specification of HMAC and recommendations for message authentication* (NIST SP 800-224 Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-224.ipd
diff --git a/docs/security/2026-08-05-netty-4.1.136-remediation.md b/docs/security/2026-08-05-netty-4.1.136-remediation.md
new file mode 100644
index 00000000..e9a8dd99
--- /dev/null
+++ b/docs/security/2026-08-05-netty-4.1.136-remediation.md
@@ -0,0 +1,131 @@
+# ADR: Align the Reactive HTTP Stack on Netty 4.1.136.Final
+
+- **Status:** Accepted
+- **Decision date:** 2026-08-05
+- **Decision owners:** Clearfolio maintainers and security reviewers
+- **Applies to:** `clearfolio-viewer` reactive HTTP runtime and every transitive `io.netty` module managed through Spring Boot
+
+## Context
+
+Clearfolio uses Spring Boot WebFlux, which resolves Reactor Netty and the Netty transport, codec, resolver, and handler modules transitively. Spring Boot 3.5.16 manages the Netty 4.1 line at `4.1.135.Final`. Exact-head Strix run `30997430437`, job `92277868841`, reported a HIGH dependency finding against that line and identified `4.1.136.Final` as the fixed 4.1 release.
+
+The Netty project released `4.1.136.Final` at 20:18 UTC on July 8, 2026. Its primary release record includes HTTP/1.1 and HTTP/2 boundary validation, MQTT decoder and UTF-8 validation, compression safety, parser-boundary, flow-control, and traffic-shaping fixes relative to `4.1.135.Final`.
+
+A partial dependency override would be unsafe because Netty is a coordinated family of modules. Mixing codec, transport, resolver, and handler patch levels can create an unreviewed runtime graph even when Maven resolves successfully.
+
+## Decision
+
+Set the Spring Boot-supported Maven property below in the root `pom.xml`:
+
+```xml
+4.1.136.Final
+```
+
+This property changes the managed version for the complete Netty module family while retaining Spring Boot's dependency-management structure. Do not pin individual Netty artifacts independently unless an accepted follow-up ADR proves that a mixed module graph is required and compatible.
+
+Two executable contracts guard the decision:
+
+- `DependencyPolicyTest.pomPinsPatchedNettyLineForReactiveHttpServing` reads the real root POM through an XML parser configured to reject document types, external entities, parameter entities, XInclude, and entity expansion.
+- `scripts/test_render_third_party_attribution.py` independently reads the single nonblank `netty.version` property, then proves that every committed Netty component version, purl, bom-ref, dependency edge, and attribution row resolves to that version.
+
+## Deterministic buyer-evidence flow
+
+The committed SBOM is generated evidence, not a hand-edited dependency inventory. The accepted generation path is:
+
+```mermaid
+flowchart LR
+ H[Exact source head
3b6e434] --> M[Maven resolves
Spring Boot + netty.version]
+ M --> C[CycloneDX Maven Plugin 2.9.1
makeAggregateBom]
+ C --> B[target/bom.json
CycloneDX 1.6]
+ B --> V[Graph verifier
61 components / 17 Netty]
+ V --> A[Deterministic attribution renderer]
+ B --> I[Immutable Actions artifact
ID 8929593015]
+ A --> I
+ I --> D[Committed buyer data-room evidence]
+ D --> T[Permanent drift and dependency tests]
+```
+
+The exact generation command is:
+
+```bash
+mvn -B --no-transfer-progress -DskipTests \
+ org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \
+ -Dcyclonedx.skipAttach=true \
+ -DoutputFormat=json \
+ -DoutputName=bom
+```
+
+The plugin writes the canonical JSON document to `target/bom.json`. The `outputFormat` and `outputName` parameters are Maven user properties without a `cyclonedx.` prefix; only `cyclonedx.skipAttach` uses that prefix in this invocation.
+
+## Evidence record
+
+Read-only workflow run `31004040777` generated the accepted evidence from source head `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` at `2026-08-05T12:07:15Z`.
+
+- Artifact ID: `8929593015`
+- Artifact archive SHA-256: `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f`
+- CycloneDX version: `1.6`
+- Total components: `61`
+- Netty components: `17`
+- Netty version set: exactly `4.1.136.Final`
+- SBOM SHA-256: `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c`
+- Attribution SHA-256: `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9`
+
+The graph verifier proved that every Netty dependency reference has a corresponding current Netty component ref and that `4.1.135.Final` is absent from both generated files. The attribution renderer was rerun from the generated JSON and matched the committed Markdown byte contract.
+
+The committed SBOM and attribution are shareable buyer evidence. Workflow logs and the one-day artifact are transient generation provenance and must not be presented as durable data-room evidence after expiry. Reproduction therefore depends on the documented command, exact source revision, immutable plugin version, committed hashes, permanent drift tests, and fresh exact-head CI.
+
+## Security and compatibility boundaries
+
+- The override changes only the Netty patch line. It does not change Spring Boot, Spring Framework, Reactor, or the public Clearfolio API.
+- Maven must resolve all applicable `io.netty` modules to `4.1.136.Final`; stale modules at `4.1.135.Final` or an older version are a release blocker.
+- Existing zero-missed-line and zero-missed-branch JaCoCo gates, compiler warnings-as-errors, fuzzing, SAST, dependency review, OSV, Trivy, Scorecard, Strix, and independent review remain mandatory.
+- A successful unit-test run does not replace dependency-tree and security-scanner evidence.
+- The override must not be copied into downstream modules as separate ad hoc pins. Standalone builds inherit the root property; modular consumers should use a versioned BOM or equivalent explicit contract.
+- The evidence record describes the dependency graph of its exact generation head. Any dependency change requires regeneration and a new evidence hash record.
+
+## Verification
+
+For the exact pull-request head:
+
+1. Run `mvn -B --no-transfer-progress verify`.
+2. Run `mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty` and confirm one coherent `4.1.136.Final` line for every applicable Netty module.
+3. Run `python3 scripts/test_render_third_party_attribution.py` and require the generated graph and attribution drift contract to pass.
+4. Require successful CI, Security Scan, SAST Semgrep, every fuzz target, CodeRabbit, Strix, OpenCode, and Noema evidence for the same head.
+5. Reject queued, cancelled, skipped-required, stale-head, local-only, or manually inferred results.
+6. Preserve an independent approving review that GitHub counts under protected-branch rules.
+
+## Removal and upgrade rule
+
+Keep this override until one of the following occurs:
+
+- the Spring Boot parent used by Clearfolio manages Netty `4.1.136.Final` or a later reviewed compatible release; or
+- Clearfolio moves to a different supported reactive HTTP stack through an accepted architecture decision.
+
+Removing or increasing the override requires the same exact-head dependency-tree, compatibility, security, coverage, SBOM regeneration, and review evidence. A newer version number alone is not proof of compatibility.
+
+## Consequences
+
+### Positive
+
+- The complete Netty family moves to one reviewed fixed 4.1 patch line.
+- The remediation uses Spring Boot's documented version-property mechanism rather than fragile per-artifact pins.
+- Real-project and generated-evidence contracts prevent silent dependency or data-room drift.
+- Exact generation provenance, hashes, and local-versus-shareable evidence boundaries remain auditable for acquisition diligence.
+
+### Trade-offs
+
+- Clearfolio temporarily diverges from the Netty patch version selected by Spring Boot 3.5.16.
+- The project must retain explicit exact-head compatibility and scanner evidence until the parent line catches up.
+- A future parent upgrade must reconcile this property deliberately and regenerate the buyer evidence.
+
+## References
+
+CycloneDX Project. (n.d.). *CycloneDX Maven plugin* [Source code]. GitHub. Retrieved August 5, 2026, from https://github.com/CycloneDX/cyclonedx-maven-plugin
+
+Netty Project. (2026, July 8). *Netty 4.1.136.Final* [Software release]. GitHub. https://github.com/netty/netty/releases/tag/netty-4.1.136.Final
+
+OWASP Foundation. (2024, April 9). *CycloneDX 1.6* [Software bill of materials specification]. https://github.com/CycloneDX/specification/releases/tag/1.6
+
+Spring. (n.d.). *Managed dependency coordinates: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/coordinates.html
+
+Spring. (n.d.). *Version properties: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/properties.html
diff --git a/pom.xml b/pom.xml
index b5bf49d4..ebdd52f7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,8 +29,15 @@
${java.version}
UTF-8
+ 0.8.15
+ 3.12.0
3.0.8
6.1.200
+
+ 4.1.136.Final