Skip to content

refactor(cli): give streamio one input resolver - #4100

Draft
dmihalcik-virtru wants to merge 2 commits into
mainfrom
dspx-2604-19a-streamio-open
Draft

dmihalcik-virtru wants to merge 2 commits into
mainfrom
dspx-2604-19a-streamio-open

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

Follow-up to #3948 (stacked on dspx-2604-19-drop-encrypt-spool; review that one first). Pure refactor of otdfctl/pkg/streamio — no behavior change.

Proposed Changes

#3948 left encrypt open-coding the "file argument or stdin" choice that decrypt and inspect already got from streamio, and left PipeReader named for the case it handles most easily rather than the one that is hard.

  • streamio.Open(path) — resolves either source without copying, so a measurable payload stays measurable. Prefers the file argument and does not look at stdin at all when one is given: a command inside while read f; do otdfctl … "$f"; done < list must not peek the loop's own input out from under it.
  • streamio.OpenExclusive(path) — Open for a command that refuses to guess. Being handed both a file argument and a payload on stdin is the new ErrTwoInputs rather than a silent preference. This is what encrypt has always done; it is now said once, in the package that owns it.
  • OpenSeekable is Open + Spool — its duplicate stdin branch goes away (30 lines → 12).
  • OpenFile → unexported openFile, reached through Open/OpenExclusive. It keeps the zero-stat rule from fix(cli): drop the encrypt-side stdin spool #3948 and its tests.
  • PipeReader → Piped — it takes redirects as much as pipes, and the old name collided with io.PipeReader.
  • encryptRun's input block — a PipeReader call, an inputCount tally, a cliExit closure and an open/branch (35 lines) become one call and a switch, matching the shape decryptRun already uses.

Behavior

Unchanged, deliberately:

  • encrypt still rejects two inputs, with the same two messages.
  • decrypt and inspect still prefer the file argument and still never touch a stdin they were not given. Making the ambiguity check universal was the obvious way to fold this into one entry point, and it is why OpenExclusive exists separately instead — detecting a non-empty pipe means peeking it, and peeking eats bytes that belong to whatever shell construct supplied them.

Checklist

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

Testing Instructions

cd otdfctl && go test ./... -race. Seven new tests cover Open/OpenExclusive: file-beats-stdin (asserting stdin comes back unread), stdin-only, ErrNoInput for neither, ErrTwoInputs for both, and that a piped payload reaches the SDK unmeasurable — i.e. unspooled, which is the guarantee #3948 adds.

Also run on Linux, where the procfs-dependent cases actually execute rather than skip:

docker run --rm -v "$PWD":/src -w /src/otdfctl golang:1.25 go test ./cmd/tdf/... ./pkg/streamio/...

DSPX-4499 fixed encrypt's OOM by streaming, but CreateTDF still required an
io.ReadSeeker, so piped stdin had to be spooled to a temporary file first. That
traded the whole-payload allocation for a disk write and a writable TMPDIR --
better, but not the point. Now that CreateTDF takes an io.Reader, the pipe goes
straight to the SDK.

What is spooled is decided by what the fd actually is, not by which argument it
arrived through. A file argument and a shell redirect from a regular file both
seek, so both reach the SDK unwrapped; only a genuine stream is buffered. The
SDK measures whatever it can seek, and under ~2 GiB that keeps the archive in
the compact ZIP32 layout; an unmeasurable payload has to be ZIP64, because the
choice is baked into the payload's local file header before the first segment
goes out. So `encrypt file.txt` and `encrypt < file.txt` are unchanged byte for
byte, and `... | encrypt` produces a slightly larger TDF than it did when it was
spooled. That is the trade, and it is the right way round: nobody should need a
writable temp directory to encrypt a stream.

Measurability is worth a second thing the archive layout does not show: the SDK
fails a measured payload whose reader runs dry early, rather than returning a
TDF that is silently short. A pipe has no declared length to check against.
handlers.Handler's Encrypt is now the one place that states both, including that
sdk.WithInputSize could restore the check for a caller who knows the length and
that otdfctl does not expose it.

The same distinction reaches decrypt and inspect, which spool because a TDF's
manifest lives at the end of the archive. A redirect from a regular file is
seekable already, so they read it in place rather than copying the whole TDF
into TMPDIR to get a seekable view of it.

MIME sniffing is what made this more than a deletion. It reads the first
megabyte and previously seeked back to zero, which a pipe cannot do. Wrapping
the input in a bufio.Reader is not an option either -- that hides the Seeker and
would silently flip every file encrypt to ZIP64. detectMimeType now returns a
reader alongside the type: the input itself, restored to the offset it was
handed over at, when it can be; the sniffed prefix pushed back with
io.MultiReader when it cannot. A megabyte in memory at most, and only when
--mime-type was not given. Restoring the saved offset rather than seeking to
zero matters once a redirect stays seekable: stdin arrives part-consumed
whenever a wrapper reads a header before exec'ing us, and the payload is what
remains, not the whole file.

Rewindability is decided by attempting the seek, not by asserting io.Seeker. An
*os.File on a FIFO, a process substitution, or /dev/stdin on the end of a pipe
satisfies the interface and then returns ESPIPE, and treating that as fatal made
`encrypt <(echo hi)` fail while `encrypt --mime-type text/plain <(echo hi)`
succeeded -- a manifest-shaping flag deciding whether the command ran at all. A
failed lseek leaves the offset untouched, so replaying the prefix is always
correct. resolveInputSize in sdk/tdf.go already drew the same line.

Input resolution moves into resolveEncryptInput so the ZIP32 invariant can be
asserted without a running platform. That invariant lives in encryptRun, not in
detectMimeType: anything wrapping the file between there and CreateTDF would
flip every file encrypt to ZIP64 with the unit suite still green. encryptRun
probes it the same way the SDK does, so the "this will be ZIP64" breadcrumb is
logged once and is right whether or not detection ran -- --mime-type skips
detectMimeType entirely, and that was the path on which an input that satisfies
io.Seeker and then refuses reached the SDK unremarked.

Testing: TestDetectMimeTypePreservesThePayload runs each case over all three
reader shapes -- rewindable, Seek hidden, Seek refused with ESPIPE -- asserting
both that the payload arrives whole and that only the first comes back
measurable. Cases added for a payload of exactly the sniff window, the shortest
input that fills the buffer and the boundary at which replaying the prefix could
duplicate or drop a byte; for a reader handed over mid-payload, which must be
restored to where it was rather than to zero; and for a read that fails partway,
which must abort rather than encrypt a truncated payload.
TestDetectMimeTypeKeepsSeekability guards the ZIP32 layout directly, since
nothing else would fail if a file came back wrapped. streamio gains regular-file
stdin coverage it never had: the file comes back unwrapped with its offset
untouched, an empty or fully-consumed one still reports absent, and OpenSeekable
hands it over without creating a spool.

e2e gains "encrypt measures a file and streams a pipe", which reads the local
file header's extra field length to tell the two layouts apart -- both forms
round-trip, so a quiet return to spooling would show up nowhere else -- now
covering the redirect alongside the file and the pipe. A process-substitution
case covers the ESPIPE path end to end, with and without --mime-type, which unit
tests can only reach through a hand-written fake. The multi-segment case uses a
real pipe rather than a redirect, since a redirect is now measured; 3 MiB clears
the SDK's 2 MiB segment size, so the archive spans several segments whose hashes
have to be combined on the way out and verified on the way back in. Decrypt from
stdin is split into a piped case, which still spools and must remove the spool,
and a redirect case, which no longer spools at all. extra_field_len fails rather
than returning a number when it cannot read a full header: empty od output makes
bash evaluate the arithmetic to 0, which is exactly the value the ZIP32
assertion passes on, so a missing archive would have looked like a pass.

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

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

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.

encrypt open-coded the file-argument-or-stdin choice that decrypt and
inspect already got from streamio, and PipeReader was named for the case
it handles most easily rather than the one that is hard.

streamio.Open resolves either source without copying, preferring the file
argument; OpenExclusive is the same for a command that refuses to guess,
reporting ErrTwoInputs instead of silently dropping one of two payloads.
OpenSeekable is now Open plus a spool, which drops its duplicate stdin
branch, and encrypt's input block becomes one call and a switch. OpenFile
is unexported behind them, and PipeReader is Piped.

Behavior is unchanged: encrypt still rejects two inputs, decrypt and
inspect still prefer the file argument and never touch a stdin they were
not given -- Open leaves it unread, so a command inside a while-read loop
cannot eat the loop's own input.
@dmihalcik-virtru
dmihalcik-virtru force-pushed the dspx-2604-19a-streamio-open branch from e9c2ded to 4c81900 Compare September 25, 2026 16:00
@github-actions

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 127.228665ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

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

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 252.954626ms
Throughput 395.33 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 34.71775282s
Average Latency 346.552677ms
Throughput 144.02 requests/second

@github-actions

Copy link
Copy Markdown
Contributor

@dmihalcik-virtru
dmihalcik-virtru marked this pull request as draft September 25, 2026 16:04
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • otdfctl
  • service
  • tests-bdd

See the workflow run for details.

@github-actions

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 236.021973ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

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

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 427.936556ms
Throughput 233.68 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 59.821592865s
Average Latency 597.154856ms
Throughput 83.58 requests/second

Base automatically changed from dspx-2604-19-drop-encrypt-spool to main September 25, 2026 19:16

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant