Skip to content

fix(sdk): zip64/APPNOTE conformance in the TDF3 zip reader and writer (DSPX-4591) - #1017

Open
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4591-zip64-conformance
Open

fix(sdk): zip64/APPNOTE conformance in the TDF3 zip reader and writer (DSPX-4591)#1017
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4591-zip64-conformance

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

https://virtru.atlassian.net/browse/DSPX-4591

Audit of java-sdk #393 against the Go
and Web zip implementations turned up four ZIP64/APPNOTE conformance divergences in
web-sdk. All four are addressed here.

Note on the writer's ZIP64 threshold: there isn't one, and that's deliberate.
ZipWriter sets this.zip64 = true in its constructor and never clears it, so
web-sdk unconditionally emits ZIP64 structures. That is already the safest posture
and is unchanged by this PR. The siblings (DSPX-4589 java-sdk, DSPX-4590 go-sdk) are
the ones moving thresholds.

Finding 1 — the reader never parsed the end of central directory record

getCDBuffers() scanned the last 1000 bytes of the file backwards for anything
matching the central directory signature 0x02014b50 and treated every hit as an
entry, inferring record boundaries from the position of the next hit.

Replaced with real end-of-central-directory parsing:

  • ZipReader.getEndOfCentralDirectory() locates the EOCD record by scanning the tail
    for 0x06054b50 and accepting a candidate only when its declared comment length
    puts the end of the comment exactly at the end of the archive. That rules out
    payload bytes that happen to spell the signature, and it handles trailing archive
    comments. The tail read starts at 1 KiB (enough for the comment-less containers we
    write) and widens once to 22 + 0xffff + 20 bytes if the record isn't in there.
  • When any of the three EOCD fields carries its sentinel (0xffff entry count,
    0xffffffff CD size, 0xffffffff CD offset) the reader follows the ZIP64 end of
    central directory locator that sits immediately before the EOCD and reads the ZIP64
    EOCD record it points at, taking the entry count, CD size and CD offset from there.
  • getCDBuffers() now takes the central directory chunk plus the declared entry
    count, and walks exactly that many records from the declared offset, advancing by
    46 + fileNameLength + extraFieldLength + fileCommentLength and validating the
    signature and the remaining length at each step.
  • Cross-checks added: the CD chunk must be the declared length, the declared CD size
    must be at least 46 * entryCount, and it is bounded at 16 MiB so a hostile or
    corrupt EOCD can't ask us to buffer the sentinel 4 GiB.

getCDBuffers changed signature. It's a public method on ZipReader but ZipReader
is not re-exported from any package entry point (lib/tdf3/src/utils/index.ts only),
so this isn't a published API break.

Finding 2 — ZIP64 extra field ignored unless versionNeededToExtract >= 45

Dropped the version gate in parseCDBuffer. APPNOTE 4.5.3 does not condition the
validity of a ZIP64 extended information extra field on the version-needed-to-extract
byte; the sentinel values in the fixed-size fields are what select it. The gate meant
a conformant producer writing a ZIP64 extra with a lower declared version had it
silently dropped, leaving 0xffffffff to flow into byteStart/byteEnd arithmetic
as if it were a real number.

sliceExtraFields already did the right thing (iterates the whole extra-field area,
rejects conflicting duplicate header IDs, bounds-checks dataSize, reads the ZIP64
fields in APPNOTE order) and is unchanged. Also added a length guard to parseCDBuffer
so a record shorter than the 46-byte fixed prefix is rejected rather than parsed out
of a short buffer.

Finding 3 — non-ZIP64 data descriptor wrote uncompressedSize twice

Kept the branch and fixed it, rather than deleting it. Justification: ZipWriter.zip64
is a public mutable field and the existing unit tests exercise zip64 = false for all
four writer methods (getLocalFileHeader, writeDataDescriptor,
writeCentralDirectoryRecord, writeEndOfCentralDirectoryRecord). Deleting one of
those four branches would leave the writer unable to emit a coherent non-ZIP64 archive
while the other three remain, which is a worse state than the bug.

writeDataDescriptor now takes an explicit optional compressedSize (defaulting to
uncompressedSize, since we only ever STORE) and writes it into the compressed size
slot in both the ZIP64 and non-ZIP64 branches. Output for every existing call site is
byte-identical; the existing characteristic tests are unchanged. Two new tests pass
distinct compressed and uncompressed sizes and assert each lands in its own slot.

Finding 4 — 32-bit shift on a potentially large size in an error path

(cdObj.uncompressedSize >> 10) became Math.floor(cdObj.uncompressedSize / 1024).
>> coerces to signed 32 bits, so a 2 GiB manifest used to be reported as
-2,097,152 KiB. Error-message only.

Tests

lib/tests/mocha/unit/zip.spec.ts gains a buildZip helper that assembles complete
in-memory archives with ZipWriter, so the reader is exercised end to end rather than
against hand-rolled record fragments. New coverage:

  • zip64 and non-zip64 round trips (central directory, manifest, payload segment)
  • an archive with a trailing comment
  • an archive whose comment contains the EOCD signature bytes
  • an archive with a maximum-length (64 KiB) comment, which forces the widened tail read
  • a payload containing the bytes 0x02014b50 — the false-positive case
  • an entry whose ZIP64 extra field is not the first extra field (both as a bare
    central directory record and inside a full archive)
  • a ZIP64 extra field honoured with versionNeededToExtract forced to 20
  • rejection cases: missing EOCD, missing ZIP64 locator, overstated entry count
  • the oversized-manifest error message reports a positive KiB figure

The 0x02014b50 test was verified against the pre-change reader: with the old backward
scanner restored, getCentralDirectory() returns four entries
(['', '', '0.manifest.json', '0.payload']) instead of two, the two empty-named ones
being payload bytes misread as central directory records. It passes with the new
reader.

Deliberately not changed: segment-size emission

lib/tdf3/src/tdf.ts omits segmentSize / encryptedSegmentSize from a segment
object when they equal the manifest-level defaults, and that stays as it is. The
emission is legal: manifest.schema.json gives segments/items no required list,
and web-sdk's own reader defaults them back. go-sdk and java-sdk fail to apply that
fallback, which is why web-sdk TDFs over 1 MiB are currently unreadable by them —
those are the bugs, tracked as DSPX-4590 finding 7 and DSPX-4589 finding 4. Making
web-sdk write the redundant fields would paper over two real reader bugs and silently
un-cover them.

Cross-SDK interop

The shared interop harness is DSPX-4592 and is already built in opentdf/tests on
branch DSPX-4592-java-underflow. It needs nothing from this PR.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 52 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 57b87d5f-5110-4bda-bd0c-6107f5022ebb

📥 Commits

Reviewing files that changed from the base of the PR and between 2003ed6 and 72faecd.

📒 Files selected for processing (4)
  • lib/tdf3/src/utils/zip-reader.ts
  • lib/tdf3/src/utils/zip-writer.ts
  • lib/tests/mocha/unit/zip.spec.ts
  • spec/DSPX-4591.md

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.

@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

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
dmihalcik-virtru added this pull request to stack #1033 September 10, 2026 20:52
@dmihalcik-virtru
dmihalcik-virtru marked this pull request as ready for review September 10, 2026 20:59
@dmihalcik-virtru
dmihalcik-virtru requested a review from a team as a code owner September 10, 2026 20:59
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