Skip to content

fix(sdk): DSPX-4589 zip64 + manifest conformance (combined view of #397 + #398) - #396

Closed
dmihalcik-virtru wants to merge 2 commits into
mainfrom
DSPX-4589-zip64-conformance
Closed

dmihalcik-virtru wants to merge 2 commits into
mainfrom
DSPX-4589-zip64-conformance

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Sep 3, 2026

Copy link
Copy Markdown
Member

Jira: https://virtru.atlassian.net/browse/DSPX-4589

Top of a 2-PR stack. Do not review or merge this one — review #397 and #398.

This PR keeps its base at main, so its diff is the combined work: it exists to show what the stack adds up to and to hold the shared context. Its head branch is the same commit as #398's.

PR base contents
1 #397 main per-segment size defaults — the finding with field impact
2 #398 #397 zip64 EOCD sentinels, truncated archive detection, UTF-8 entry names
this PR main the combined diff of 1 + 2

Merge order: #397, then #398. This PR closes itself out once both have landed.


Four zip64 / manifest conformance findings from the container audit. Findings 1-3 were written against pre-#393 main; each was re-verified against current main (98c839e2) before being changed, and the honest status of each is below. Only finding 4 has field impact, which is why it was pulled to the bottom of the stack as #397.

Finding 1 — end of central directory sentinel thresholds (fixed) — #398

ZipWriter.finish() decided whether the archive needed a zip64 end of central directory record with:

(numEntries & ~0xFF) != 0 || (startOfCentralDirectory & ~0xFFFF) != 0 || (sizeOfCentralDirectory & ~0xFFFF) != 0

None of those masks match the real field widths. The entry count field is 2 bytes, not 1, and the central directory offset and size fields are 4 bytes each, not 2. The practical effect was that any archive with 256 or more entries was needlessly promoted to zip64, and the offset/size checks fired three orders of magnitude too early.

Now:

var isZip64 = hasZip64Entry
        || numEntries > MAX_NON_ZIP64_ENTRY_COUNT          // 0xFFFE; 0xFFFF is the sentinel itself
        || needsZip64(startOfCentralDirectory, sizeOfCentralDirectory);

The offset and size go through needsZip64, so they honor the same deliberate 2 GiB (Integer.MAX_VALUE) ceiling as the per-entry fields — released readers widen these unsigned wire fields with signed reads, so we never write a value that would come back negative. Using needsZip64 also keeps the ZipWriter(out, maxNonZip64Value) test seam that #393 added usable for these two fields.

Not touched by #393 — verified with jj diff -r 98c839e2.

Finding 2 — truncated archive detection (fixed, but the branch was latent) — #398

ZipReader.readEndOfCentralDirectory() scanned backwards for the EOCD signature and treated a null from readInteger() (a short read) as if it were a signature match, so a truncated archive could fall out of the loop and parse whatever bytes followed as an EOCD record. It now only breaks on a genuine match and throws InvalidZipException if the scan runs off the front, and separately rejects an archive too small to hold the zip64 locator its EOCD claims to have.

Stated plainly: with the SeekableInMemoryByteChannel the SDK actually uses, the scan position is always <= size - 22, so readInteger() always had four bytes available and the null branch was unreachable. This is a correctness/robustness fix (a FileChannel could in principle short-read), not a bug anyone was hitting. The new tests still pass on unpatched code for the plain-truncation cases; they are there to lock the behavior in.

Finding 3 — UTF-8 filename length (already correct on the wire; dead code removed) — #398

The ticket says the central directory filename length was computed from String.length(). That assignment did exist:

cdFileHeader.filenameLength = (short) fileInfo.filename.length();

but CDFileHeader.write() never read the field — it wrote (short) filename.length from the already-encoded byte array. The bytes on the wire were already correct. No archive was ever mis-written.

What changed: the misleading dead filenameLength field is removed from both LocalFileHeader and CDFileHeader, the name is encoded to UTF-8 once instead of twice, and a new encodeFilename helper rejects a name whose encoded length exceeds 0xFFFF with an SDKException rather than silently truncating it into the 2-byte field.

Finding 4 — per-segment sizes not defaulted from the manifest defaults (fixed; this is the one with field impact) — #397

integrityInformation.segments[].segmentSize and .encryptedSegmentSize are optional in the TDF spec; when absent, the reader is supposed to fall back to segmentSizeDefault / encryptedSegmentSizeDefault. Gson left the absent primitives at 0, and java-sdk read the payload with a zero-length segment. Every web SDK TDF larger than one default segment (1 MiB) failed to decrypt in java-sdk, surfacing as a confusing integrity error rather than as a manifest problem.

A primitive long cannot distinguish an absent JSON key from a literal 0, so the fix consults the parse tree: a Gson TypeAdapterFactory registered for IntegrityInformation walks the parsed segments array alongside the deserialized list and fills in the defaults only where the key is absent or JSON null. Boxing Segment.segmentSize to Long would have been the other option, but it breaks the public API (== in Segment.equals, an int -> Long assignment in TDF, existing assertEquals(Long, int) in tests) for no added behavior, so the post-deserialization fixup was chosen instead. Explicit 0 in the JSON is preserved as 0.

TDF.Reader.readPayload additionally rejects a segment with a non-positive encryptedSegmentSize up front — an encrypted segment always carries at least an IV and a tag — so a manifest that supplies neither a per-segment size nor a usable default now says so instead of failing downstream with an unrelated complaint about the payload being too small to GMAC.

Tests

11 new tests, all in sdk/src/test/java/io/opentdf/platform/sdk/:

The sentinel and default-fallback tests were confirmed to be genuine regression tests by reverting the EOCD mask fix and the registerTypeAdapterFactory line and watching them fail.

CI-equivalent commands, on JDK 21:

mvn --batch-mode verify -Dmaven.antrun.skip -P 'coverage,non-fips,!fips'
  -> 242 tests, 0 failures, 0 errors, 8 skipped (231 before this change)
     #397 alone: 235 tests, 0 failures, 0 errors, 8 skipped

mvn --batch-mode install -pl sdk-fips-bc -am -Dmaven.antrun.skip -Dmaven.test.skip
mvn --batch-mode test enforcer:enforce -P 'fips,!non-fips' -Dmaven.antrun.skip
  -> BUILD SUCCESS

End-to-end validation of finding 4

Run on the opentdf/tests DSPX-4592-02-chunky branch, which adds test_tdfs.py::test_chunky_roundtrip — a 5 MiB round trip, versus the 128 bytes the suite has used for four years, which is what it takes for a writer to emit a segment whose size equals the manifest default. All runs pass force-supports=chunky, which makes tdfs.skip_chunky_skew return early so the cell reports a real pass or fail instead of skipping on the unreleased version gate.

java-ref run js -> java chunky cell
fix (#397) DSPX-4589-01-segment-size-defaults 34353420418 PASSED
control main (the stack's base) 34355312405 FAILED
full stack (#398 / this branch) DSPX-4589-02-zip-format 34357326964 PASSED

Exactly one cell flips between the fix run and the control. Every chunky pair, side by side:

encrypt -> decrypt control (java@main) fix (java@DSPX-4589-01-...)
js -> java FAILED PASSED
go -> java PASSED PASSED
java -> java PASSED PASSED
java -> go PASSED PASSED
java -> js PASSED PASSED

(The four non-java pairs report SKIPPED in both runs — focus-sdk=java deselects them, not the feature gate.)

js -> java is precisely finding 4: a web-SDK writer omits the per-segment sizes, and the java reader cannot default them back. The control fails with the confusing downstream symptom described above:

java.lang.IllegalArgumentException: tried to calculate GMAC on too small a payload. payload is 0bytes while GMAC is 16 bytes
	at io.opentdf.platform.sdk.TDF.calculateSignature(TDF.java:481)
	at io.opentdf.platform.sdk.TDF$Reader.readPayload(TDF.java:447)

Job totals: control js job 1 failed, 23 passed, 50 skipped; fix java job 82 passed, 22 skipped, no failures and no chunky skips.

All confirmed by grepping the run logs for the cell's own PASSED/FAILED/SKIPPED line rather than trusting the job's colour — a green job with a skipped cell is the vacuous pass the test exists to prevent.

Findings 1-3 (#398) have no xtest cell of their own; they are covered by unit tests plus the full-stack run above showing no regression.

⚠️ Follow-up required in opentdf/tests — merging will NOT flip it

force-supports is a pre-release override for the runs above only. xtest/sdk/java/cli.sh still answers chunky unsupported: see DSPX-4589 and hard-codes exit 1, so by default test_chunky_roundtrip keeps skipping for java. That shim lives in the opentdf/tests repo, not in this one, so nothing in this stack changes it.

When this fix releases, someone must version-gate the chunky) case in xtest/sdk/java/cli.sh so java reports support at or above the releasing version. Deliberately not done here: this stack touches only opentdf/java-sdk, and DSPX-4592 owns the tests repo.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4589-zip64-conformance branch from 9053c2c to 49689fe Compare September 3, 2026 20:12
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

…defaults

integrityInformation.segments[].segmentSize and .encryptedSegmentSize are
optional in the TDF spec: manifest.schema.json marks segmentSizeDefault and
encryptedSegmentSizeDefault required on integrityInformation but puts no
required list on segments/items, so an absent per-segment value means "the
default", not zero. Gson leaves an absent primitive at 0, so java-sdk read
those segments with a zero length buffer and failed inside the integrity
check. web-sdk omits a per-segment size whenever it equals the default,
which is every full segment, so every web-sdk TDF larger than one default
segment (1 MiB) failed to decrypt, surfacing as a confusing integrity error
rather than as a manifest problem.

A primitive long cannot distinguish an absent JSON key from a literal 0, so
a Gson TypeAdapterFactory registered for IntegrityInformation walks the
parsed segments array alongside the deserialized list and fills in the
defaults only where the key is absent or JSON null. Boxing
Segment.segmentSize to Long was the other option, but it breaks the public
API for no added behavior. An explicit 0 in the JSON is preserved as 0.

TDF.Reader.readPayload additionally rejects a segment with a non-positive
encryptedSegmentSize up front -- an encrypted segment always carries at
least an IV and a tag -- so a manifest that supplies neither a per-segment
size nor a usable default now says so instead of failing downstream with an
unrelated complaint about the payload being too small to GMAC.
…, UTF-8 entry names

Three zip container conformance fixes found while auditing the TDF zip
container against PKWARE APPNOTE.TXT.

1. ZipWriter only set the zip64 flag on the end of central directory record
   when the entry count exceeded 0xFF or the central directory offset/size
   exceeded 0xFFFF. Those masks do not match the field widths: the entry
   count is 2 bytes and the offset and size are 4 bytes each. Archives with
   between 256 and 65534 entries were needlessly promoted to zip64, and the
   offset/size checks now go through needsZip64 so they honor the same 2 GiB
   ceiling as the per-entry fields.

2. ZipReader treated a short read while scanning backwards for the end of
   central directory signature as a signature match, so a truncated archive
   could fall out of the scan loop and parse whatever followed as an end of
   central directory record. It now only breaks on a real match and throws
   InvalidZipException otherwise, and rejects an archive too small to hold
   the zip64 locator it claims to have.

3. ZipWriter computed the central directory filename length from
   String.length() rather than from the UTF-8 encoded byte count. The value
   was assigned to a field that write() never read, so the bytes on the wire
   were already correct, but the dead field is removed, the name is encoded
   once instead of twice, and a name too long for the 2 byte length field is
   now rejected instead of silently truncated.
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4589-zip64-conformance branch from 49689fe to 5196409 Compare September 9, 2026 12:51
@dmihalcik-virtru dmihalcik-virtru changed the title fix(sdk): DSPX-4589 zip64 EOCD sentinels, truncated archive detection, and defaulted segment sizes fix(sdk): DSPX-4589 zip64 + manifest conformance (combined view of #397 + #398) Sep 9, 2026
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

dmihalcik-virtru added a commit to opentdf/tests that referenced this pull request Sep 10, 2026
Closes [DSPX-4592](https://virtru.atlassian.net/browse/DSPX-4592).

## Why

ZIP central-directory offsets and sizes are 32-bit **unsigned** on the
wire. A reader that widens one with a signed read sees anything `>=
2**31` as negative; at or above `2**32` the format mandates the ZIP64
sentinel, so the 32-bit field never holds a real value. That leaves
exactly one broken window, `[2**31, 2**32)`, and nothing in this suite
reached it — `--large` is 5 GiB, which steps straight over.

## What lands

- `xtest/sizes.py` — adds `medium` (2 254 857 830 B, ~102 MiB inside the
low edge of the window — shrinking it doesn't make the test cheaper, it
makes it vacuous) and the window predicates (`in_zip64_window`,
`exercises_zip64_window`).
- `xtest/zipinspect.py` — a raw ZIP central-directory reader that keeps
the 32-bit fields alongside the resolved values. `zipfile` normalises
ZIP64 away, which is exactly the encoding under test. Lets a failure
name the SDK at fault instead of reporting "decrypt failed" after an
hour and 6 GiB of IO.
- `xtest/test_zip64.py` — the roundtrip cell, marked `zip64` and
**deselected** (not skipped) unless the session's sizes reach `2**31`.
Asserts an offset actually landed in the window, so a mis-sized payload
fails rather than passes vacuously. Writer conformance is checked
*before* the reader xfail is applied, so a writer regression can't hide
behind a known reader bug. Reuses `tdfs.skip_chunky_skew` (from
[#590](#590)) to keep the
independent segment-defaulting defect out of the ZIP64 result.
- `tdfs.zip64_reader_xfail` — `xfail(strict=True)` keyed on semver for
java decryptors predating java-sdk#393. Strict, so the cell must flip to
a hard failure when the fix ships and somebody deletes the predicate.
- A nightly-only `zip64` job in `xtest.yml`: own 90 m timeout, matrixed
over the encrypting SDK, no `--skip-released-pairs` (a released java
decryptor is the point). Parses its own junit XML and fails if no cell
executed. Also pins the `bench` job's platform ref through the same
resolved main SHA the zip64 job uses, so both share one commit instead
of resolving "main" independently.
- `xtest/test_zip64_units.py` (20 tests) on the offline PR gate, since
the nightly's verdict is only as good as this parser.
- `spec/DSPX-4592.md` — spec and live-run findings.

## Sibling PRs

| Repo | PR | Covers |
|---|---|---|
| java-sdk | opentdf/java-sdk#396 | DSPX-4589 — `readUnsignedInt`,
`needsZip64`, segment-size defaulting |
| platform (go) | opentdf/platform#3979 | DSPX-4590 —
`resolveSegmentSizes`, `LoadTDF` payload size |
| web-sdk | opentdf/web-sdk#1017 | DSPX-4591 — ZIP64 writer conformance
|

Stacked on [#590](#590) (chunky
segment-defaulting), which stacks on
[#589](#589) (configurable payload
sizes), which stacks on
[#588](#588) (XT_FORCE_SUPPORTS).
This PR is scoped to ZIP64 conformance only — chunky segment-defaulting
coverage split out to #590 since it's an orthogonal,
independently-mergeable concern found along the way.

## Follow-ups (not in this PR)

- Once the go and java fixes release, replace the `exit 1` in the
`chunky)` case of `xtest/sdk/{go,java}/cli.sh` with real version gates.
- Consider widening `zip64_reader_xfail` once the first nightly reports
which cells actually fail.

## Verification

`ruff check` / `ruff format` / `pyright` clean from `xtest/`. Full
offline harness suite (177 tests) passes. `actionlint` on
`xtest.yml`/`check.yml` reports the same 15 pre-existing shellcheck info
findings as `main`, no new ones.

Draft: the `zip64` job has not had a live `workflow_dispatch` run yet.
Doing that against this branch is the last gate before marking ready.

[DSPX-4592]:
https://virtru.atlassian.net/browse/DSPX-4592?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added configurable payload-size testing for small, medium, chunky, and
large scenarios.
- Added optional controls for forcing feature support and running ZIP64
validation workflows.
- Added cross-SDK ZIP64 boundary coverage for large files and
multi-segment containers.

- **Bug Fixes**
- Improved detection and reporting of malformed ZIP64 structures and
unexpected test-support errors.

- **Documentation**
- Documented test-size options, environment settings, and ZIP64
validation coverage.
  - Documented the deprecated `--large` option alias.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
dmihalcik-virtru added a commit that referenced this pull request Sep 21, 2026
…defaults (#397)

Jira: https://virtru.atlassian.net/browse/DSPX-4589

**Stack — this is 1 of 2.** Split out of #396 so the fix with actual
field impact can be reviewed and land on its own.

| | PR | contents |
|---|---|---|
| 1 | **this PR** (base `main`) | per-segment size defaults |
| 2 | #398 (base this) | zip64 EOCD sentinels, truncated archive
detection, UTF-8 entry names |
| — | #396 (base `main`) | the combined diff of 1 + 2, as originally
opened |

---

`integrityInformation.segments[].segmentSize` and
`.encryptedSegmentSize` are optional in the TDF spec; when absent, the
reader is supposed to fall back to `segmentSizeDefault` /
`encryptedSegmentSizeDefault`. `manifest.schema.json` marks the two
defaults required on `integrityInformation` but puts no `required` list
on `segments/items`, so the per-segment values are optional overrides
and an absent one means "the default", not zero.

Gson left the absent primitives at `0`, and java-sdk read the payload
with a zero-length segment. **Every web SDK TDF larger than one default
segment (1 MiB) failed to decrypt in java-sdk**, surfacing as a
confusing integrity error rather than as a manifest problem.

A primitive `long` cannot distinguish an absent JSON key from a literal
`0`, so the fix consults the parse tree: a Gson `TypeAdapterFactory`
registered for `IntegrityInformation` walks the parsed `segments` array
alongside the deserialized list and fills in the defaults only where the
key is absent or JSON `null`. Boxing `Segment.segmentSize` to `Long`
would have been the other option, but it breaks the public API (`==` in
`Segment.equals`, an `int` -> `Long` assignment in `TDF`, existing
`assertEquals(Long, int)` in tests) for no added behavior, so the
post-deserialization fixup was chosen instead. Explicit `0` in the JSON
is preserved as `0`.

`TDF.Reader.readPayload` additionally rejects a segment with a
non-positive `encryptedSegmentSize` up front — an encrypted segment
always carries at least an IV and a tag — so a manifest that supplies
neither a per-segment size nor a usable default now says so instead of
failing downstream with an unrelated complaint about the payload being
too small to GMAC.

## Tests

4 new tests:

- `ManifestTest.testAbsentSegmentSizesFallBackToTheManifestDefaults` —
absent / partially overridden / fully overridden, plus a `toJson` round
trip.
- `ManifestTest.testExplicitZeroSegmentSizeIsNotTreatedAsAbsent`.
- `TDFTest.testReadingATDFThatOmitsDefaultedSegmentSizes` — encrypts ~2
MiB + 4242 bytes at a 1 MiB segment size, strips every per-segment size
equal to the default from the manifest, and asserts a byte-exact
decrypt.
- `TDFTest.testZeroLengthSegmentIsRejectedWithAClearError`.

Confirmed to be genuine regression tests by reverting the
`registerTypeAdapterFactory` line and watching them fail.

```
mvn --batch-mode verify -Dmaven.antrun.skip -P 'coverage,non-fips,!fips'
  -> 235 tests, 0 failures, 0 errors, 8 skipped (231 before this change)  [JDK 21]
```

## End-to-end validation

Run on the `opentdf/tests` `DSPX-4592-02-chunky` branch, which adds
`test_tdfs.py::test_chunky_roundtrip` — a 5 MiB round trip, versus the
128 bytes the suite has used for four years, which is what it takes for
a writer to emit a segment whose size equals the manifest default. Both
runs pass `force-supports=chunky`, which makes `tdfs.skip_chunky_skew`
return early so the cell reports a real pass or fail instead of skipping
on the unreleased version gate.

| | `java-ref` | run | `js -> java` chunky cell |
|---|---|---|---|
| fix | `DSPX-4589-01-segment-size-defaults` |
[34353420418](https://github.com/opentdf/tests/actions/runs/34353420418)
✅ | **PASSED** |
| control | `main` (this PR's base) |
[34355312405](https://github.com/opentdf/tests/actions/runs/34355312405)
❌ | **FAILED** |

Exactly one cell flips between the two runs. Every chunky pair, side by
side:

| encrypt -> decrypt | control (`java@main`) | fix (`java@this-branch`)
|
|---|---|---|
| **js -> java** | **FAILED** | **PASSED** |
| go -> java | PASSED | PASSED |
| java -> java | PASSED | PASSED |
| java -> go | PASSED | PASSED |
| java -> js | PASSED | PASSED |

(The four non-java pairs report `SKIPPED` in both runs —
`focus-sdk=java` deselects them, not the feature gate.)

`js -> java` is precisely the reported bug: a web-SDK writer omits the
per-segment sizes, and the java reader cannot default them back. The
control fails with the confusing downstream symptom this PR describes,
on the `main` that this branch is based on:

```
java.lang.IllegalArgumentException: tried to calculate GMAC on too small a payload. payload is 0bytes while GMAC is 16 bytes
	at io.opentdf.platform.sdk.TDF.calculateSignature(TDF.java:481)
	at io.opentdf.platform.sdk.TDF$Reader.readPayload(TDF.java:447)
```

Job totals: control js job `1 failed, 23 passed, 50 skipped`; fix java
job `82 passed, 22 skipped`, no failures and no chunky skips.

Both were confirmed by grepping the run logs for the cell's own
`PASSED`/`FAILED`/`SKIPPED` line rather than trusting the job's colour —
a green job with a skipped cell is the vacuous pass the test exists to
prevent.

## Follow-up in opentdf/tests

`force-supports` is a pre-release override for these runs only.
`xtest/sdk/java/cli.sh` still answers `chunky unsupported: see
DSPX-4589` and hard-codes exit 1; when this fix releases, that case has
to become a version gate or the cell goes back to skipping. Tracked on
DSPX-4592, which owns the tests repo.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved compatibility when reading manifests that omit segment-size
fields by applying documented defaults.
* Added validation to reject invalid, undersized, or excessively large
segments before payload processing.
* Prevented plaintext output when encrypted payload segments fail size
validation.
  * Improved handling of missing, zero, and null segment-size values.
* TDF files with unsupported integrity algorithms are now rejected
instead of being processed with an incorrect fallback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@dmihalcik-virtru

Copy link
Copy Markdown
Member Author

Closing as a duplicate of #398.

This PR existed to show the combined diff of the 2-PR stack (#397 + #398). #397 has merged, so what is left of the stack is exactly #398 — and with both PRs now based on main and pointing at the same head commit, the two are literally the same pull request. No reason to keep both open.

Review continues in #398. Not deleting DSPX-4589-zip64-conformance for now; it is the same commit as DSPX-4589-02-zip-format, so nothing is lost with it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant