Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions otdfctl/cmd/tdf/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package tdf

import (
"errors"
"log/slog"

"github.com/opentdf/platform/otdfctl/cmd/common"
"github.com/opentdf/platform/otdfctl/pkg/cli"
"github.com/opentdf/platform/otdfctl/pkg/handlers"
"github.com/opentdf/platform/otdfctl/pkg/man"
"github.com/opentdf/platform/otdfctl/pkg/streamio"
"github.com/opentdf/platform/sdk"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -42,17 +44,36 @@ func inspectRun(cmd *cobra.Command, args []string) {
h := common.NewHandler(c)
defer h.Close()

data := cli.ReadFromArgsOrPipe(args, nil)
if len(data) == 0 {
c.ExitWithError("must provide ONE of the following: [file argument, stdin input]", errors.New("no input provided"))
var path string
Comment thread
dmihalcik-virtru marked this conversation as resolved.
if len(args) > 0 {
path = args[0]
}
in, cleanup, err := streamio.OpenSeekable(path)
if err != nil {
if errors.Is(err, streamio.ErrNoInput) {
c.ExitWithError("must provide ONE of the following: [file argument, stdin input]", err)
}
c.ExitWithError("failed to read input", err)
}
// cli.ExitWithError calls os.Exit, which does not run deferred functions, so
// cleanup is also invoked explicitly before every exit below — including the
// successful one, since piped input is spooled to a temporary file.
defer cleanup()

result, errs := h.InspectTDF(data)
result, errs := h.InspectTDF(in)
Comment thread
dmihalcik-virtru marked this conversation as resolved.
for _, err := range errs {
if errors.Is(err, handlers.ErrTDFInspectFailNotValidTDF) {
switch {
case errors.Is(err, handlers.ErrTDFInspectFailNotValidTDF):
cleanup()
c.ExitWithError("not a valid TDF", err)
} else if errors.Is(err, handlers.ErrTDFInspectFailNotInspectable) {
case errors.Is(err, handlers.ErrTDFInspectFailNotInspectable):
cleanup()
c.ExitWithError("failed to inspect TDF", err)
default:
// Attribute/metadata reads (e.g. a denied or unreachable KAS rewrap)
// are non-fatal by design so the manifest still prints, but must not
// vanish silently.
slog.Warn("inspect: partial result", slog.Any("error", err))
}
}

Expand All @@ -76,8 +97,10 @@ func inspectRun(cmd *cobra.Command, args []string) {
Attributes: result.Attributes,
}

cleanup()
c.ExitWithJSON(m, cli.ExitCodeSuccess)
}
cleanup()
c.ExitWithError("failed to inspect TDF", nil)
}

Expand Down
26 changes: 17 additions & 9 deletions otdfctl/cmd/tdf/tdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"github.com/opentdf/platform/otdfctl/pkg/cli"
"github.com/opentdf/platform/otdfctl/pkg/streamio"
)

const (
Expand All @@ -15,17 +16,24 @@ const (
GroupID = TDF
)

// readPipedStdin returns the whole of piped stdin, or nil when stdin is a
// terminal or an empty redirect.
//
// Detection is delegated to streamio.PipeReader so there is a single answer to
// "is there piped input?" across the CLI. The read itself is still unbounded;
// callers that must not hold the payload in memory should use
// streamio.OpenSeekable instead.
func readPipedStdin() []byte {
stat, err := os.Stdin.Stat()
r, ok, err := streamio.PipeReader(os.Stdin)
if err != nil {
cli.ExitWithError("Failed to read stat from stdin", err)
cli.ExitWithError("failed to scan bytes from stdin", err)
}
if (stat.Mode() & os.ModeCharDevice) == 0 {
buf, err := io.ReadAll(os.Stdin)
if err != nil {
cli.ExitWithError("failed to scan bytes from stdin", err)
}
return buf
if !ok {
return nil
}
return nil
buf, err := io.ReadAll(r)
if err != nil {
cli.ExitWithError("failed to scan bytes from stdin", err)
}
return buf
}
47 changes: 34 additions & 13 deletions otdfctl/pkg/cli/pipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ package cli
import (
"io"
"os"

"github.com/opentdf/platform/otdfctl/pkg/streamio"
)

// These wrappers read their whole input into memory and call ExitWithError —
// which calls os.Exit — from inside the read, so they cannot be used anywhere
// that wants to handle the failure itself. They delegate to pkg/streamio so
// there is one implementation of "find the file argument or piped stdin" to
// maintain.
//
// New code should call pkg/streamio directly, which streams and returns
// errors instead of buffering and exiting.

// Deprecated: reads the entire input into memory and terminates the process on
// failure. Use streamio.OpenSeekable, which resolves the same "file argument or
// piped stdin" choice without buffering and returns an error.
func ReadFromArgsOrPipe(args []string, pipe *os.File) []byte {
if len(args) > 0 {
return ReadFromFile(args[0])
Expand All @@ -15,31 +29,38 @@ func ReadFromArgsOrPipe(args []string, pipe *os.File) []byte {
return ReadFromPipe(pipe)
}

// Deprecated: reads the entire pipe into memory and terminates the process on
// failure. Use streamio.PipeReader, which reports whether input is present
// without consuming it, paired with io.ReadAll for the equivalent []byte.
func ReadFromPipe(in *os.File) []byte {
stat, err := in.Stat()
r, ok, err := streamio.PipeReader(in)
if err != nil {
ExitWithError("failed to read stat from stdin", err)
}
if (stat.Mode() & os.ModeCharDevice) == 0 {
buf, err := io.ReadAll(in)
if err != nil {
ExitWithError("failed to scan bytes from stdin", err)
}
return buf
if !ok {
return nil
}
buf, err := io.ReadAll(r)
if err != nil {
ExitWithError("failed to scan bytes from stdin", err)
}
return nil
return buf
}

// Deprecated: reads the entire file into memory with no size cap at all — not
// even the 10 GB one the tdf commands apply — and terminates the process on
// failure. Open the file and stream from it, or use utils.ReadBytesFromFile if
// a bounded in-memory read is genuinely wanted.
func ReadFromFile(filePath string) []byte {
fileToEncrypt, err := os.Open(filePath)
f, err := os.Open(filePath)
if err != nil {
ExitWithError("Failed to git open file at path: "+filePath, err)
ExitWithError("Failed to open file at path: "+filePath, err)
}
defer fileToEncrypt.Close()
defer f.Close()

bytes, err := io.ReadAll(fileToEncrypt)
buf, err := io.ReadAll(f)
if err != nil {
ExitWithError("Failed to read bytes from file at path: "+filePath, err)
}
return bytes
return buf
}
13 changes: 9 additions & 4 deletions otdfctl/pkg/handlers/tdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,19 @@ func (h Handler) DecryptBytes(
return out, nil
}

func (h Handler) InspectTDF(toInspect []byte) (TDFInspect, []error) {
b := bytes.NewReader(toInspect)
switch sdk.GetTdfType(b) {
// InspectTDF reads the manifest and attributes of a TDF.
//
// It takes an io.ReadSeeker rather than a byte slice because only the manifest
// at the end of the archive is needed; buffering the whole payload to reach it
// costs memory proportional to the file. GetTdfType rewinds to the start, so
// the reader is positioned for LoadTDF.
func (h Handler) InspectTDF(toInspect io.ReadSeeker) (TDFInspect, []error) {
switch sdk.GetTdfType(toInspect) {
case sdk.Standard:
// grouping errors so we don't impact the piping of the data
errs := []error{}

tdfreader, err := h.sdk.LoadTDF(bytes.NewReader(toInspect))
tdfreader, err := h.sdk.LoadTDF(toInspect)
if err != nil {
if strings.Contains(err.Error(), "zip: not a valid zip file") {
return TDFInspect{}, []error{ErrTDFInspectFailNotInspectable}
Expand Down
114 changes: 114 additions & 0 deletions otdfctl/pkg/streamio/input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Package streamio provides the input and output plumbing shared by commands
// that move payloads too large to hold in memory.
//
// Everything here returns errors rather than terminating the process, so the
// decision to exit stays with the command layer. That is the difference from
// the older helpers in pkg/cli, which call cli.ExitWithError from inside the
// read and are therefore unusable from anywhere that wants to recover.
package streamio

import (
"bufio"
"errors"
"io"
"os"
)

// PipeBufferSize is the window PipeReader buffers over a pipe — generous
// enough that a typical CLI payload is served from a single read.
const PipeBufferSize = 1024 * 1024

// ErrNoInput reports that a command was given neither a file argument nor a
// non-empty pipe. It is distinct from a failure to open a named file, which
// callers report differently.
var ErrNoInput = errors.New("no input provided")

// PipeReader reports whether in is a pipe or redirect carrying at least one
// byte, and returns a reader over it.
//
// Presence is established with a one-byte Peek rather than a read, so the
// payload still reaches the caller intact and nothing is buffered beyond the
// reader's window. A terminal, or an empty redirect such as
// `otdfctl encrypt < /dev/null`, reports false — matching the behavior of a
// buffered implementation, which decides the same question by checking whether
// a full read came back empty.
func PipeReader(in *os.File) (*bufio.Reader, bool, error) {
stat, err := in.Stat()
if err != nil {
return nil, false, err
}
if (stat.Mode() & os.ModeCharDevice) != 0 {
return nil, false, nil
}

r := bufio.NewReaderSize(in, PipeBufferSize)
if _, err := r.Peek(1); err != nil {
if errors.Is(err, io.EOF) {
return nil, false, nil
}
return nil, false, err
}
return r, true, nil
}

// Spool copies r into a temporary file and rewinds it, giving a seekable view
// of a stream that has none.
//
// A TDF's manifest sits at the end of the archive, so any command that needs
// to seek it cannot consume a pipe directly. Spooling trades disk for the
// memory a whole-payload read would use, and needs a TMPDIR with room for the
// full TDF — it fails loudly if there isn't one.
//
// The returned cleanup must run on every path. cli.ExitWithError calls os.Exit
// and skips deferred functions, so deferring it alone is not enough.
func Spool(r io.Reader) (*os.File, func(), error) {
f, err := os.CreateTemp("", "otdfctl-spool-*.tdf")
if err != nil {
return nil, func() {}, err
}
cleanup := func() {
f.Close()
os.Remove(f.Name())
}
if _, err := io.Copy(f, r); err != nil {
cleanup()
return nil, func() {}, err
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
cleanup()
return nil, func() {}, err
}
return f, cleanup, nil
}

// OpenSeekable resolves a command's input to something seekable: the named file
// when one is given, otherwise piped stdin spooled to disk. It returns
// ErrNoInput for the same "nothing to read" condition whether the file argument
// was absent or the pipe was empty.
//
// The returned cleanup must run on every path, per Spool.
func OpenSeekable(path string) (*os.File, func(), error) {
if path != "" {
f, err := os.Open(path)
if err != nil {
return nil, func() {}, err
}
if _, err := f.Seek(0, io.SeekCurrent); err == nil {
return f, func() { f.Close() }, nil
}
// Not seekable (a FIFO or /dev/fd/N): spool it like piped stdin so
// callers still get something they can seek.
spooled, cleanup, err := Spool(f)
f.Close()
return spooled, cleanup, err
}

piped, ok, err := PipeReader(os.Stdin)
if err != nil {
return nil, func() {}, err
}
if !ok {
return nil, func() {}, ErrNoInput
}
return Spool(piped)
}
Loading
Loading