Skip to content

feat(sdk): size the manifest for 50 TiB payloads (DSPX-4651) - #1021

Open
dmihalcik-virtru wants to merge 1 commit into
DSPX-4650-scale-limitsfrom
DSPX-4651-manifest-scale
Open

dmihalcik-virtru wants to merge 1 commit into
DSPX-4650-scale-limitsfrom
DSPX-4651-manifest-scale

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

Task DSPX-4651, under DSPX-4648 (epic DSPX-4502). Item 1 of
spec/DSPX-4648-web-sdk-large-files.md.

Stacked on #1020. Review only the last commit; the base diff belongs to the PR below it.

What and why

The manifest, not the payload, is what caps TDF size today. At the 1 MiB default segment size a
50 TiB source produces 52,428,800 segments and a ~2.9 GB manifest — against a 10 MB read-side
ceiling that, until #1019, was only enforced at decrypt time. Neither a bigger cap nor a bigger
segment fixes that alone; the landing point argued for in the spec is 16 MiB segments under a
256 MiB manifest cap
, and this PR implements it.

50 TiB, HS256 segments manifest
1 MiB 52,428,800 ~2.94 GB
4 MiB 13,107,200 ~734 MB
16 MiB 3,276,800 ~184 MB
64 MiB 819,200 ~45.9 MB

Sizes are computed for HS256 (56 B/entry) even though the default is GMAC (36 B/entry), so the
numbers hold whichever is in force — see the ADR in #1020.

Changes

1. MANIFEST_MAX_SIZE → 256 MiB, and configurable. The value moves to
DEFAULT_MANIFEST_MAX_SIZE in scale-limits.ts, next to the arithmetic that derives what fits
under it. ZipReader takes { manifestMaxSize }; EncryptConfiguration takes manifestMaxSize.
Both default to the same constant, and a test asserts the read and write ceilings cannot drift
apart — otherwise the SDK could write a container it then refuses to open. 256 MiB was chosen so
50 TiB at 16 MiB/HS256 fits with ~28% headroom while a one-shot JSON.parse stays viable.

2. Auto-selected segment size. chooseSegmentSize() walks a 1/4/16/64/256 MiB ladder and
returns the smallest rung whose segments array fits 80% of the manifest budget (the rest is
headroom for policy, key access, and assertions, whose size isn't known at that point). 50 TiB
lands on 16 MiB under both algorithms; anything up to ~3.6 TiB keeps the historical 1 MiB.

The tradeoff is documented on SEGMENT_SIZE_LADDER: larger segments cost seek granularity, a
larger integrity-failure blast radius (GCM verification is all-or-nothing per segment), and more
per-segment memory — which is what will constrain item 4's prefetch window.

Scope note: only the OpenTDF path auto-selects. EncryptParamsBuilder still sets
windowSize: DEFAULT_SEGMENT_SIZE, so the legacy tdf3 builder's behaviour is byte-for-byte
unchanged.

3. Fail fast instead of fail late. #1019 made the write side enforce the cap, but at
end-of-stream — after the whole payload is encrypted and already downstream. This adds the
up-front check for sources whose length is knowable:

  • sourceSize() (lib/src/seekable.ts) reports a length for 'buffer' and 'file-browser'
    directly, and probes 'remote' with HEAD falling back to a one-byte Content-Range request.
    'stream' and 'chunker' report undefined.
  • Any probe failure yields undefined, never an exception: a size probe must not be able to
    fail an otherwise valid encrypt.
  • OpenTDF.createZTDF passes the result through as knownSourceSize; writeStream rejects via
    estimateManifestBytes() before getReader() is ever called. A test counts source pulls and
    asserts zero.

The end-of-stream assert stays authoritative. The up-front number is an estimatemanifest
doesn't yet carry the assertions or the root signature, neither of which exists until the payload
is done — and the error message says "an estimated N KiB" to match.

4. ByteAccumulator replaces the Blob concatenation. The spec asked for incremental root
hashing; that isn't available. WebCrypto exposes no incremental HMAC, and aggregateHash is also
the message assertions bind to, so the concatenation genuinely has to exist as one buffer. What
was avoidable is its cost: the old code held one Uint8Array per segment and joined them through
a Blob. At 3.3M segments that's 3.3M live typed arrays — several hundred MB of object overhead on
top of ~105 MB of actual digest — plus a full extra copy. ByteAccumulator writes straight into one
exactly-sized buffer, on both the write and read paths. Output is byte-identical.

5. Mock server. /file now answers HEAD and emits Content-Range on 206, with
Access-Control-Expose-Headers. Without this the remote size probe was untestable anywhere but
Node.

Not done — deliberately deferred

Recorded in the spec under item 1 rather than quietly dropped:

  • segmentInfos and the eager chunks array (tdf.ts:~1482). segmentHashList is gone, but
    these two remain and are why item 4 alone will not deliver bounded memory — the scheduler paces
    fetching, not this allocation. At 3.28M segments (16 MiB) they're manageable, which is what the
    256 MiB cap buys; at 52.4M (1 MiB) each is independently multi-GB. Fixing them is invasive enough
    to want its own PR.
  • Measuring the JSON.parse transient heap. The 300–400 MB figure behind the 256 MiB choice is
    reasoned, not measured. If a future cap raise pushes past ~512 MiB, a streaming parser has to be
    revisited.
  • Cross-SDK confirmation that a 16 MiB segmentSize needs no manifest-schema coordination with
    go-sdk/java-sdk. fix(sdk): zip64/APPNOTE conformance in the TDF3 zip reader and writer (DSPX-4591) #1017's notes suggest segmentSize is already optional there, but no real
    round trip has been run.

Testing

cd lib && npm test — 449 mocha passing / 6 pending / 0 failing, 449 karma SUCCESS, 253
web-test-runner passing, coverage gate green (scale-limits.ts and byte-accumulator.ts both at
100% lines). npm run lint clean.

New coverage:

  • unit/manifest-budget.spec.ts — read and write ceilings are one constant; the up-front rejection
    fires with zero source pulls; the end-of-stream backstop still catches the unknown-size case
    and does not claim to be an estimate; estimateManifestBytes is exactly 1 byte over a real
    manifest (the separating comma counted for the last entry, which has none).
  • unit/byte-accumulator.spec.ts — equivalence against a plain concatenation across sizes and
    hints, no reallocation given an exact hint, growth past an under-estimate, a run larger than
    capacity, empty pushes, copy-not-alias, and no exposure of an over-allocated tail.
  • unit/scale-limits.spec.tschooseSegmentSize keeps 1 MiB for ordinary files and reaches
    16 MiB at 50 TiB under both algorithms; ladder membership; monotonicity in source size; the 80%
    budget invariant; staying above the AES-GCM invocation floor; largest-rung-not-throw when nothing
    fits.
  • unit/seekable.spec.tssourceSize across all five source types, the HEAD→range fallback, and
    both "report unknown rather than throw" paths.
  • unit/zip.spec.ts — the existing oversized-manifest test used 128 MiB, which is under the new
    cap; it was silently falling through to JSON.parse and passing on the wrong error. Fixed, and
    given a sibling that proves a configured ceiling is enforced.

What isn't testable here: the 16 MiB auto-selection can't be exercised end-to-end without a
multi-TiB source. It's covered by unit tests on chooseSegmentSize plus the existing encrypt suite
confirming small files still land on 1 MiB.

Risk

Touches crypto-adjacent code. ByteAccumulator produces byte-identical input to getSignature, so
the root signature and assertion bindings are unchanged — the existing round-trip suite is the check
on that. The cap raise is backward compatible in one direction only: a reader on this version opens
anything an older writer produced, but a container written under the new 256 MiB cap is not readable
by an older SDK. At the sizes anyone is writing today that is unreachable, since the segment size
only climbs past ~3.6 TiB.

Closes out item 1 of spec/DSPX-4648-web-sdk-large-files.md: the manifest,
not the payload, is what actually caps TDF size today. At the old 1 MiB
default a 50 TiB source needs 52.4M segments and a ~2.9 GB manifest,
against a 10 MB read-side ceiling.

Four changes, all sized off the pure helpers landed in DSPX-4650:

- Raise MANIFEST_MAX_SIZE to 256 MiB (DEFAULT_MANIFEST_MAX_SIZE) and make
  it configurable on both sides -- ZipReader takes { manifestMaxSize },
  EncryptConfiguration takes manifestMaxSize. Both default to the same
  constant, and a test asserts they cannot drift apart.
- Auto-select the segment size by source length. chooseSegmentSize walks a
  1/4/16/64/256 MiB ladder and takes the smallest rung whose segments array
  fits 80% of the budget. 50 TiB lands on 16 MiB under both GMAC and HS256;
  anything under ~3.6 TiB keeps the historical 1 MiB, so ordinary files are
  untouched. Only the OpenTDF path auto-selects; EncryptParamsBuilder still
  pins windowSize, so its behaviour is unchanged.
- Reject an over-budget manifest before encrypting rather than after.
  sourceSize() reports a length for 'buffer' and 'file-browser' directly and
  probes 'remote' with HEAD, falling back to a one-byte Content-Range
  request; failures yield undefined rather than throwing, since a size probe
  must not be able to fail an otherwise valid encrypt. writeStream then
  rejects before getReader() is ever called. The end-of-stream assert stays
  authoritative -- the up-front number cannot see the assertions or the root
  signature, neither of which exists until the payload is done.
- Replace the Blob-concatenation root signature with ByteAccumulator on both
  the write and read paths. WebCrypto has no incremental HMAC and the
  concatenation is also the message assertions bind to, so a streaming digest
  is not available; this instead writes the digests straight into one
  exactly-sized buffer. Byte-identical output, without 3.3M live typed arrays
  or the extra Blob copy.

Also teaches the mock server to answer HEAD and emit Content-Range, so the
remote size probe is testable in the browser tiers and not just in Node.

Still open, and called out as deferred in the spec: the segmentInfos and
eager chunks per-segment arrays, measuring the manifest JSON.parse transient
heap, and confirming with go-sdk/java-sdk that a 16 MiB segmentSize needs no
schema coordination.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru requested a review from a team as a code owner September 9, 2026 13:49
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 59 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: da0233f6-48c3-4ee2-b355-92afdc30b4e7

📥 Commits

Reviewing files that changed from the base of the PR and between 1d6a578 and 4f2bfc7.

📒 Files selected for processing (16)
  • lib/src/opentdf.ts
  • lib/src/seekable.ts
  • lib/tdf3/src/client/builders.ts
  • lib/tdf3/src/client/index.ts
  • lib/tdf3/src/tdf.ts
  • lib/tdf3/src/utils/byte-accumulator.ts
  • lib/tdf3/src/utils/scale-limits.ts
  • lib/tdf3/src/utils/zip-reader.ts
  • lib/tests/mocha/helpers/write-tdf.ts
  • lib/tests/mocha/unit/byte-accumulator.spec.ts
  • lib/tests/mocha/unit/manifest-budget.spec.ts
  • lib/tests/mocha/unit/scale-limits.spec.ts
  • lib/tests/mocha/unit/seekable.spec.ts
  • lib/tests/mocha/unit/zip.spec.ts
  • lib/tests/server.ts
  • spec/DSPX-4648-web-sdk-large-files.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 9, 2026

Copy link
Copy Markdown

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