Skip to content

Commit 76f94e8

Browse files
committed
fix AR mime-type reader error wrap to preserve errors.Is identity
The wrap at pkg/handlers/ar.go:101 used %v on the inner error returned from newMimeTypeReader, dropping its identity from the errors.Is chain. The reachability path is bufReader.Peek -> io.SectionReader over the deb library reader -> BufferedReadSeeker.ReadAt -> the original io.Reader passed to HandleFile. For HTTP/GCS-backed source readers, parent-context cancellation surfaces as a Read error whose chain satisfies errors.Is(err, context.DeadlineExceeded). The %v strips that inner identity, so isFatal at handlers.go:482 misclassifies the wrapped error as a non-fatal warning. Switch to %w to match the same-shape fix already applied at pkg/handlers/default.go:137 and pkg/handlers/ar.go:109. Blast radius is lower than ar.go:109 because processARFiles checks ctx.Done() at the top of each loop iteration, so the next iteration backstops cancellation. The fix is a symmetry/correctness change that lets isFatal classify the wrapped error against its actual cause. Add a regression test that drives processARFiles directly with a crafted *deb.Ar whose first entry's data section returns the target error from ReadAt. The table covers context.DeadlineExceeded, context.Canceled, and io.ErrUnexpectedEOF (io.EOF is filtered inside newMimeTypeReader and never reaches the wrap site). Each case asserts the outer ErrProcessingWarning wrap is preserved, the inner cause is inspectable via errors.Is, and isFatal classifies based on the inner cause.
1 parent 99dc7bd commit 76f94e8

2 files changed

Lines changed: 131 additions & 1 deletion

File tree

pkg/handlers/ar.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func (h *arHandler) processARFiles(ctx logContext.Context, reader *deb.Ar, dataO
9898
rdr, err := newMimeTypeReader(arEntry.Data)
9999
if err != nil {
100100
dataOrErrChan <- DataOrErr{
101-
Err: fmt.Errorf("%w: error creating AR mime-type reader: %v", ErrProcessingWarning, err),
101+
Err: fmt.Errorf("%w: error creating AR mime-type reader: %w", ErrProcessingWarning, err),
102102
}
103103
h.metrics.incErrors()
104104
continue

pkg/handlers/ar_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package handlers
22

33
import (
4+
stdctx "context"
5+
"errors"
6+
"fmt"
7+
"io"
48
"os"
59
"testing"
610
"time"
711

812
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"pault.ag/go/debian/deb"
915

1016
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
1117
)
@@ -34,3 +40,127 @@ func TestHandleARFile(t *testing.T) {
3440

3541
assert.Equal(t, wantChunkCount, count)
3642
}
43+
44+
// stagedReaderAt is an io.ReaderAt that serves a fixed AR header region from
45+
// head and returns bodyErr for any read inside the first entry's data section.
46+
// Reads past the first entry return io.EOF so deb.Ar.Next reports a clean end
47+
// of archive on the iteration after the failure, letting processARFiles return
48+
// without an extra wrapped error masking the one we want to assert on.
49+
type stagedReaderAt struct {
50+
head []byte
51+
bodySize int64
52+
bodyErr error
53+
}
54+
55+
func (s *stagedReaderAt) ReadAt(p []byte, off int64) (int, error) {
56+
headLen := int64(len(s.head))
57+
switch {
58+
case off < headLen:
59+
n := copy(p, s.head[off:])
60+
if n < len(p) {
61+
// Caller wants bytes that span into the body; surface the body error.
62+
return n, s.bodyErr
63+
}
64+
return n, nil
65+
case off < headLen+s.bodySize:
66+
return 0, s.bodyErr
67+
default:
68+
return 0, io.EOF
69+
}
70+
}
71+
72+
// arHeaderForEntry builds the AR magic plus a single 60-byte entry header
73+
// declaring the given size. The deb library accepts space-padded fields and
74+
// requires the trailing 0x60 0x0A magic, which is what this fixture provides.
75+
func arHeaderForEntry(size int64) []byte {
76+
const magic = "!<arch>\n"
77+
header := make([]byte, 60)
78+
for i := range header {
79+
header[i] = ' '
80+
}
81+
copy(header[0:16], "evil.dat/")
82+
copy(header[16:28], "0")
83+
copy(header[28:34], "0")
84+
copy(header[34:40], "0")
85+
copy(header[40:48], "100644")
86+
copy(header[48:58], fmt.Sprintf("%d", size))
87+
header[58] = 0x60
88+
header[59] = 0x0A
89+
return append([]byte(magic), header...)
90+
}
91+
92+
// TestARHandler_MimeTypeReaderErrPreservesIdentity is a regression test for
93+
// the %v wrap at ar.go:101. Before the fix, wrapping the inner error returned
94+
// from newMimeTypeReader with %v dropped its identity from the errors.Is
95+
// chain, which let isFatal misclassify a context-cancelled or
96+
// deadline-exceeded read on the underlying source io.Reader as a non-fatal
97+
// warning. The reachability path is bufReader.Peek -> io.SectionReader over
98+
// the deb library's reader -> BufferedReadSeeker.ReadAt -> the original
99+
// source io.Reader passed to HandleFile, where HTTP/GCS-backed source readers
100+
// surface parent-context cancellation as a Read error whose chain satisfies
101+
// errors.Is(err, context.DeadlineExceeded).
102+
//
103+
// io.EOF is filtered inside newMimeTypeReader (handlers.go:95), so it never
104+
// reaches the wrap site at ar.go:101. The non-fatal column uses
105+
// io.ErrUnexpectedEOF instead, which is the closest analogue that the wrap
106+
// site can actually observe.
107+
func TestARHandler_MimeTypeReaderErrPreservesIdentity(t *testing.T) {
108+
cases := []struct {
109+
name string
110+
innerErr error
111+
wantFatal bool
112+
}{
113+
{
114+
name: "context.DeadlineExceeded is fatal",
115+
innerErr: stdctx.DeadlineExceeded,
116+
wantFatal: true,
117+
},
118+
{
119+
name: "context.Canceled is fatal",
120+
innerErr: stdctx.Canceled,
121+
wantFatal: true,
122+
},
123+
{
124+
name: "io.ErrUnexpectedEOF is non-fatal",
125+
innerErr: io.ErrUnexpectedEOF,
126+
wantFatal: false,
127+
},
128+
}
129+
130+
for _, tc := range cases {
131+
t.Run(tc.name, func(t *testing.T) {
132+
const bodySize int64 = 4096
133+
raa := &stagedReaderAt{
134+
head: arHeaderForEntry(bodySize),
135+
bodySize: bodySize,
136+
bodyErr: tc.innerErr,
137+
}
138+
139+
arReader, err := deb.LoadAr(raa)
140+
require.NoError(t, err)
141+
142+
handler := newARHandler()
143+
dataOrErrChan := make(chan DataOrErr, defaultBufferSize)
144+
go func() {
145+
defer close(dataOrErrChan)
146+
_ = handler.processARFiles(context.Background(), arReader, dataOrErrChan)
147+
}()
148+
149+
var warnErr error
150+
for d := range dataOrErrChan {
151+
if d.Err != nil && errors.Is(d.Err, ErrProcessingWarning) {
152+
warnErr = d.Err
153+
break
154+
}
155+
}
156+
require.Error(t, warnErr, "expected wrapped warning from ar.go mime-type reader path")
157+
158+
assert.True(t, errors.Is(warnErr, ErrProcessingWarning),
159+
"outer ErrProcessingWarning wrap should be preserved")
160+
assert.True(t, errors.Is(warnErr, tc.innerErr),
161+
"inner cause should be inspectable via errors.Is")
162+
assert.Equal(t, tc.wantFatal, isFatal(warnErr),
163+
"isFatal should classify based on the inner cause")
164+
})
165+
}
166+
}

0 commit comments

Comments
 (0)