Skip to content
Draft
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
4 changes: 3 additions & 1 deletion otdfctl/cmd/tdf/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ func decryptRun(cmd *cobra.Command, args []string) {
defer closeIn()

// cli.ExitWithError calls os.Exit, which skips deferred functions, so both
// the spooled input and the partial output have to be discarded first.
// the input and the partial output have to be discarded first. closeIn is
// what removes the spool, when there is one -- a piped TDF is spooled to get
// a seekable view of it, while a file argument or a redirect is not.
// Declared before the destination exists so every exit below can use it.
var outFile *streamio.OutputFile
fail := func(msg string, err error) {
Expand Down
135 changes: 76 additions & 59 deletions otdfctl/cmd/tdf/encrypt.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tdf

import (
"bytes"
"errors"
"io"
"log/slog"
Expand Down Expand Up @@ -29,26 +30,34 @@ var (
EncryptCmd = &encryptDoc.Command
)

// detectMimeType sniffs the payload's type from its head and rewinds, so the
// whole payload still reaches the encoder.
// detectMimeType sniffs the payload's type from its head and returns a reader
// that still yields the whole payload.
//
// Detection needs only the first megabyte, which is what mimetype is limited to
// anyway, so this reads a bounded prefix rather than the whole payload.
func detectMimeType(in io.ReadSeeker, fileExt string) (string, error) {
// anyway, so this reads a bounded prefix rather than the whole payload. An input
// that can be rewound is handed back unchanged, keeping it measurable; anything
// else gets the sniffed prefix pushed back in front of it, a megabyte in memory
// at most.
//
// On error the returned reader is nil, and the payload has been partly consumed.
func detectMimeType(in io.Reader, fileExt string) (string, io.Reader, error) {
mimetype.SetLimit(Size1MB) // limit to 1MB

// The rewind below restores where the payload started rather than seeking to
// zero: stdin can arrive part-consumed — `{ read -r header; otdfctl encrypt; }
// < payload.txt` leaves it mid-file — and the payload is what remains.
seeker, start, _ := streamio.Seekable(in)

head := make([]byte, Size1MB)
// A payload shorter than the sniff window is the common case, not an error.
n, err := io.ReadFull(in, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return "", err
}
if _, err := in.Seek(0, io.SeekStart); err != nil {
return "", err
return "", nil, err
}
head = head[:n]

// defaults to application/octet-stream if nothing is recognized
detected := mimetype.Detect(head[:n]).String()
detected := mimetype.Detect(head).String()
if detected == "application/octet-stream" && fileExt != "" {
// mime.TypeByExtension is the extension lookup. mimetype.Lookup takes a
// MIME type string, so passing it a bare extension always returned nil
Expand All @@ -59,7 +68,18 @@ func detectMimeType(in io.ReadSeeker, fileExt string) (string, error) {
detected = byExt
}
}
return detected, nil

// Rewinding keeps the payload measurable; replaying the prefix is always
// correct, since a failed lseek(2) leaves the offset untouched. So a refused
// seek is a fallback, not an error — the SDK draws the same line when it
// sizes the payload.
if seeker != nil {
if _, err := seeker.Seek(start, io.SeekStart); err == nil {
return detected, in, nil
}
slog.Debug("payload rewind failed after its position probe succeeded; replaying the sniffed prefix instead")
}
return detected, io.MultiReader(bytes.NewReader(head), in), nil
}

func encryptRun(cmd *cobra.Command, args []string) {
Expand Down Expand Up @@ -95,87 +115,79 @@ func encryptRun(cmd *cobra.Command, args []string) {
wrappingKeyAlgorithm = ocrypto.RSA2048Key
}

piped, hasPiped, err := streamio.PipeReader(os.Stdin)
if err != nil {
cli.ExitWithError("failed to scan bytes from stdin", err)
}

inputCount := 0
inputName := "stdin"
if filePath != "" {
inputCount++
}
if hasPiped {
inputCount++
inputName = filePath
}

cliExit := func(s string) {
cli.ExitWithError("Must provide "+s+" of the following to encrypt: [file argument, stdin input]", nil)
}
if inputCount == 0 {
cliExit("ONE")
} else if inputCount > 1 {
cliExit("ONLY ONE")
// Whichever source it is, it goes to the SDK as-is rather than through a
// temporary file, since CreateTDF takes a plain io.Reader.
in, cleanup, err := streamio.OpenExclusive(filePath)
switch {
case errors.Is(err, streamio.ErrNoInput):
cli.ExitWithError("Must provide ONE of the following to encrypt: [file argument, stdin input]", err)
case errors.Is(err, streamio.ErrTwoInputs):
cli.ExitWithError("Must provide ONLY ONE of the following to encrypt: [file argument, stdin input]", err)
case err != nil:
cli.ExitWithError("Failed to read "+inputName+":", err)
}
// cli.ExitWithError calls os.Exit, which skips deferred functions, so every
// exit below goes through fail() instead of relying on this.
defer cleanup()

// The SDK seeks to the end of the payload to size it, so the input has to be
// seekable. A file already is; a pipe is spooled to disk, which trades the
// temporary file for the memory a whole-payload read used to cost.
var in io.ReadSeeker
var cleanup func()
if filePath != "" {
f, err := os.Open(filePath)
if err != nil {
cli.ExitWithError("Failed to read file:", err)
}
in, cleanup = f, func() { f.Close() }
} else {
f, spoolCleanup, err := streamio.Spool(piped)
if err != nil {
cli.ExitWithError("Failed to read stdin:", err)
// fail does not return -- cli.ExitWithError ends in os.Exit -- but call sites
// return immediately afterwards so encryptRun reads top to bottom. With -o it
// discards the partial TDF, the no-partial-output guarantee streamio.OutputFile
// exists to provide; a stdout destination cannot be given one, since the bytes
// are already gone. Declared before tdfFile exists, which is why the nil guard.
var tdfFile *streamio.OutputFile
fail := func(msg string, err error) {
if tdfFile != nil {
tdfFile.Cleanup()
}
in, cleanup = f, spoolCleanup
cleanup()
cli.ExitWithError(msg, err)
}
// cli.ExitWithError calls os.Exit, which skips deferred functions, so every
// exit below goes through fail() to discard the spool and any partial output.
defer cleanup()

// Resolve the destination before encrypting, so the payload streams straight
// to it rather than accumulating in memory first.
var dest io.Writer
var tdfFile *streamio.OutputFile
if out != "" {
// make sure output ends in .tdf extension
if !strings.HasSuffix(out, ".tdf") {
out += ".tdf"
}
tdfFile, err = streamio.NewOutputFile(out, encryptedOutputFileMode)
if err != nil {
cleanup()
cli.ExitWithError("Failed to write encrypted file "+out, err)
fail("Failed to write encrypted file "+out, err)
return
}
defer tdfFile.Cleanup()
dest = tdfFile
} else {
dest = os.Stdout
}

fail := func(msg string, err error) {
if tdfFile != nil {
tdfFile.Cleanup()
}
cleanup()
cli.ExitWithError(msg, err)
}

// auto-detect mime type if not provided
if fileMimeType == "" {
slog.Debug("detecting mime type of file")
fileMimeType, err = detectMimeType(in, fileExt)
// Assigned through temporaries rather than straight into fileMimeType and
// in: on error detectMimeType returns a nil reader, and writing that into
// in would arm a nil dereference for anyone who later drops the return.
mimeType, rest, err := detectMimeType(in, fileExt)
if err != nil {
fail("Failed to read file:", err)
fail("Failed to read "+inputName+" to detect its type (pass --mime-type to skip detection):", err)
return
}
fileMimeType, in = mimeType, rest
}
slog.Debug("encrypting file", slog.String("mime_type", fileMimeType))
// Worth a breadcrumb: the same bytes encrypt to a slightly larger TDF this
// way, and nothing else tells the user why. Asked here rather than inside
// detectMimeType so --mime-type, which skips detection entirely, still logs.
if !streamio.Measurable(in) {
slog.Debug("payload length is not knowable up front; writing a ZIP64 archive")
}

// Do the encryption
err = h.Encrypt(c.Context(), dest, in, handlers.EncryptOptions{
Expand All @@ -188,12 +200,17 @@ func encryptRun(cmd *cobra.Command, args []string) {
TargetMode: targetMode,
})
if err != nil {
// The return matters: the Commit below would otherwise rename a
// partially-written TDF into place, which is the exact outcome
// streamio.OutputFile exists to prevent.
fail("Failed to encrypt", err)
return
}

if tdfFile != nil {
if err := tdfFile.Commit(); err != nil {
fail("Failed to write encrypted file "+out, err)
return
}
}
}
Expand Down
Loading
Loading