Skip to content

feat(sdk): accept io.Reader in CreateTDF and drop the 64 GB payload cap - #3945

Open
dmihalcik-virtru wants to merge 2 commits into
mainfrom
dspx-2604-16-createtdf-reader
Open

feat(sdk): accept io.Reader in CreateTDF and drop the 64 GB payload cap#3945
dmihalcik-virtru wants to merge 2 commits into
mainfrom
dspx-2604-16-createtdf-reader

Conversation

@dmihalcik-virtru

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

Copy link
Copy Markdown
Member

Part 16 of 20 in the DSPX-2604 re-cut. Base branch: dspx-2604-07-readfull.

This stack replaces #3782 / #3865 / #3921, which stay open and untouched
until it lands. Nothing here is a rebase of those branches — the work was
re-cut from the ticket so each PR stands on its own.

Proposed Changes

CreateTDF and CreateTDFContext took an io.ReadSeeker, so a caller with a pipe,
a socket, or any other one-pass source had to spool the whole payload to disk
or memory first. That is the block DSPX-2604 exists to remove: the Everfox
re-wrap pipeline hands us a stream it cannot rewind. Both now take an
io.Reader and consume it from its current position through EOF.

Seekability was only ever used to measure the input. The length still matters,
but it is now resolved rather than required:

  • WithInputSize(n) declares it outright, for a reader that cannot report it;
  • failing that, a reader that happens to implement io.Seeker is probed, and
    the cursor restored, so every existing caller keeps today's behavior byte
    for byte;
  • failing both, the payload is unmeasurable and is read until it ends.

The one thing an unmeasurable payload gives up is the compact ZIP32 layout.
The ZIP64 decision is baked into the payload's local file header, which is
emitted ahead of the first segment, so it cannot be revisited once the archive
has started; a payload that might exceed a 32-bit offset has to be written as
ZIP64 from the outset. WithInputSize exists to buy that back — declaring the
length of a piped payload keeps it in ZIP32 when it fits.

The read loop no longer computes a segment count up front. It reads a buffer
at a time until EOF, which is what makes an unknown length workable, and
happens to be the same code path for a short final segment. An empty payload
still produces one empty segment. The segment count is still passed to the
archive writer when it is known, because that is what keeps a large declared
count from being clamped to a one-segment capacity hint.

Two behavior changes worth calling out:

  • The 64 GB cap (maxFileSizeSupported/errFileTooLarge) is gone. It could
    only ever be enforced on a measurable payload, so keeping it would have
    meant encrypt bigfile failing where encrypt < bigfile succeeded. Both
    were unexported; nothing outside the package referenced them.

  • A declared size is exact, not an upper bound. A reader that reaches EOF
    early now fails the call with errInputShorterThanDeclared instead of
    returning a TDF that is silently short of the payload the caller asked to
    encrypt. Reading still stops at the declared size if the reader has more.

Testing: Test_CreateTDF_StreamingInput covers the three measurement modes
across empty, sub-segment, exact-multiple, and partial-final-segment payloads,
asserting the ZIP64 choice, the segment count, and a full round trip through
LoadTDF. Test_CreateTDF_InputSizeBounds covers the negative, over-long, short,
and mid-stream-start cases. Both guards were mutation-checked: removing the
io.LimitReader fails "declared size bounds the read", and dropping the
unknown-size ZIP64 rule fails every unmeasurable case.

Checklist

  • I have added or updated unit tests
  • I have added or updated integration tests (if appropriate)
  • I have added or updated documentation

Testing Instructions

cd sdk && go test ./... -race

Test_CreateTDF_StreamingInput and Test_CreateTDF_InputSizeBounds are the
new coverage. Both guards were mutation-checked: removing the io.LimitReader
fails "declared size bounds the read", and dropping the unknown-size ZIP64 rule
fails every unmeasurable case.

The full DSPX-2604 stack — 20 PRs
# PR Based on
01 #3930 chore: bump go.work toolchain to go1.25.12 and simplify an rt_test condition main
02 #3931 feat(sdk): make the zipstream clock injectable for deterministic ZIP output main
03 #3932 fix(sdk): reject a zipstream write set that omits segment 0 #3931
04 #3933 fix(sdk): map ReadAt plaintext offsets from cumulative segment sizes main
05 #3934 chore(sdk): extract integrityAlgorithmString, createPolicyBinding, signAssertions main
06 #3935 chore(sdk): add direct tests for createKeyAccess, encryptMetadata and tdfSalt main
07 #3936 fix(sdk): fill each segment with io.ReadFull and size the buffer to the input main
08 #3937 chore(cli): move streaming IO helpers into pkg main
09 #3938 fix(cli): stream encrypt instead of buffering the whole payload #3937
10 #3939 fix(cli): stream decrypt and inspect instead of buffering #3938
11 #3940 feat(sdk): add a chunked segment writer (experimental) dspx-2604-base-11 = #3932 + #3934 + #3935
12 #3941 fix(sdk): stop GetManifest from splitting the key under the lock #3940
13 #3942 fix(sdk): reject a chunked split naming a KAS with no resolved public key #3941
14 #3943 chore(sdk): alias experimental/tdf manifest and assertion types #3942
15 #3944 fix(sdk): emit spec-compliant key access in experimental/tdf and delegate Writer #3943
16 #3945 feat(sdk): accept io.Reader in CreateTDF and drop the 64 GB payload cap #3936
17 #3946 chore(sdk): rewrite CreateTDF on top of the chunked writer dspx-2604-base-17 = #3944 + #3945
18 #3947 chore(sdk): drop dead TDFConfig fields and deprecate the TDFFormat enum #3946
19 #3948 fix(cli): drop the encrypt-side stdin spool dspx-2604-base-19 = #3947 + #3939
20 #3949 feat(sdk): graduate the chunked writer to stable API #3948

Reviewable in parallel right now, since they sit directly on main and depend on
nothing else: 01, 02, 04, 05, 06, 07, 08.

Why three PRs have a dspx-2604-base-* base. A GitHub PR takes one base branch,
but 11, 17 and 19 each build on more than one parent. The base-* branches are empty
merge commits that exist only to join those parents so the PR diff shows exactly its
own change and nothing else. They contain no code, have no PR of their own, and go
away once their parents land — retarget the child onto main at that point.

Wants a cross-SDK xtest run before merge: 15, 17 (and therefore 20). They touch
the KAS wire format.

Red checks you may see are network flakes, not this stack. Four distinct ones hit
this batch and all clear on re-run: golangci-lint config verify timing out on
https://golangci-lint.run/.../golangci.v2.8.jsonschema.json (fails the whole go (<module>) job and fail-fast cancels its siblings), the bats installer getting a 403,
Docker Hub timing out on keycloak/keycloak:26.4, and buf reporting "the server
hosted at that remote is unavailable" while the Java SDK generates sources. The
govulncheck step also emits ##[error] annotations against the go1.25.11 stdlib, but
it is continue-on-error: true and never fails a job — 01 bumps the toolchain and
clears those annotations.

@dmihalcik-virtru
dmihalcik-virtru requested review from a team as code owners September 1, 2026 02:57
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 8d1f2d5e-0ab3-4df8-8dfc-1d4b8edad3e5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 added comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 263.72817ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 130.545787ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 441.28035ms
Throughput 226.61 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 45.705312997s
Average Latency 456.209228ms
Throughput 109.40 requests/second

…he input

Two problems in CreateTDFContext's encrypt loop.

io.Reader.Read is permitted to return fewer bytes than the caller asked
for without erroring, and the loop treated that as fatal:
"io.ReadSeeker.Read size mismatch". A *bytes.Reader or *os.File on a
local disk rarely returns short, which is why this has held up, but any
wrapping ReadSeeker -- a decompressor, a network-backed store, an
instrumented reader -- can trigger it and there is nothing wrong with
the input when it does. io.ReadFull retries until the segment is full,
so the manual size check goes away with it. The new
Test_TDFCreateShortReads fails on main with exactly that error message.

The read buffer was also sized on defaultSegmentSize alone, which is
2 MiB, so encrypting a twelve-byte payload allocated 2 MiB to hold it.
Size it to min(segmentSize, inputSize) instead; the max(inputSize, 1)
keeps the empty-payload case, which still emits one empty segment, from
asking for a zero-length buffer.

Peeled out of the DSPX-2604 stack.

Signed-off-by: David Mihalcik <dmihalcik@virtru.com>
CreateTDF and CreateTDFContext took an io.ReadSeeker, so a caller with a pipe,
a socket, or any other one-pass source had to spool the whole payload to disk
or memory first. That is the block DSPX-2604 exists to remove: the Everfox
re-wrap pipeline hands us a stream it cannot rewind. Both now take an
io.Reader and consume it from its current position through EOF.

Seekability was only ever used to measure the input. The length still matters,
but it is now resolved rather than required:

  - WithInputSize(n) declares it outright, for a reader that cannot report it;
  - failing that, a reader that happens to implement io.Seeker is probed, and
    the cursor restored, so every existing caller keeps today's behavior byte
    for byte;
  - failing both, the payload is unmeasurable and is read until it ends.

The one thing an unmeasurable payload gives up is the compact ZIP32 layout.
The ZIP64 decision is baked into the payload's local file header, which is
emitted ahead of the first segment, so it cannot be revisited once the archive
has started; a payload that might exceed a 32-bit offset has to be written as
ZIP64 from the outset. WithInputSize exists to buy that back — declaring the
length of a piped payload keeps it in ZIP32 when it fits.

The read loop no longer computes a segment count up front. It reads a buffer
at a time until EOF, which is what makes an unknown length workable, and
happens to be the same code path for a short final segment. An empty payload
still produces one empty segment. The segment count is still passed to the
archive writer when it is known, because that is what keeps a large declared
count from being clamped to a one-segment capacity hint.

Two behavior changes worth calling out:

  - The 64 GB cap (maxFileSizeSupported/errFileTooLarge) is gone. It could
    only ever be enforced on a measurable payload, so keeping it would have
    meant `encrypt bigfile` failing where `encrypt < bigfile` succeeded. Both
    were unexported; nothing outside the package referenced them.

  - A declared size is exact, not an upper bound. A reader that reaches EOF
    early now fails the call with errInputShorterThanDeclared instead of
    returning a TDF that is silently short of the payload the caller asked to
    encrypt. Reading still stops at the declared size if the reader has more.

Testing: Test_CreateTDF_StreamingInput covers the three measurement modes
across empty, sub-segment, exact-multiple, and partial-final-segment payloads,
asserting the ZIP64 choice, the segment count, and a full round trip through
LoadTDF. Test_CreateTDF_InputSizeBounds covers the negative, over-long, short,
and mid-stream-start cases. Both guards were mutation-checked: removing the
io.LimitReader fails "declared size bounds the read", and dropping the
unknown-size ZIP64 rule fails every unmeasurable case.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru force-pushed the dspx-2604-16-createtdf-reader branch from dd43c7e to 002849a Compare September 3, 2026 14:13
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 231.875186ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 130.619994ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 424.153805ms
Throughput 235.76 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 57.511583468s
Average Latency 573.894339ms
Throughput 86.94 requests/second

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • otdfctl
  • service
  • tests-bdd

See the workflow run for details.

Base automatically changed from dspx-2604-07-readfull to main September 8, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant