Skip to content
Open
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
14 changes: 10 additions & 4 deletions cli/context/store/io_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"io"
)

var errLimitExceeded = errors.New("read exceeds the defined limit")

// limitedReader is a fork of [io.LimitedReader] to override Read.
type limitedReader struct {
R io.Reader
Expand All @@ -14,16 +16,20 @@ type limitedReader struct {
// Read is a fork of [io.LimitedReader.Read] that returns an error when limit exceeded.
func (l *limitedReader) Read(p []byte) (n int, err error) {
if l.N < 0 {
return 0, errors.New("read exceeds the defined limit")
}
if l.N == 0 {
return 0, io.EOF
return 0, errLimitExceeded
}
// have to cap N + 1 otherwise we won't hit limit err
if int64(len(p)) > l.N+1 {
Comment on lines 18 to 22

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this was a real hole, fixed in the latest push.

limitedReader now checks the budget after decrementing and returns the limit error in preference to the underlying error:

n, err = l.R.Read(p)
l.N -= int64(n)
if l.N < 0 {
    return n, errLimitExceeded
}
return n, err

Added a regression test using iotest.DataErrReader, which returns data together with io.EOF on the read that crosses the limit. Against the previous version it fails with expected an error, got nil — i.e. io.ReadAll reported success on truncated content, exactly as described.

Also hoisted the error to a package-level errLimitExceeded sentinel rather than allocating a new one on each Read.

p = p[0 : l.N+1]
}
n, err = l.R.Read(p)
l.N -= int64(n)
if l.N < 0 {
// The limit must take precedence over the underlying error: a reader
// is allowed to return data together with [io.EOF], and returning that
// EOF here would make [io.ReadAll] report success for content that was
// silently truncated.
return n, errLimitExceeded
}
return n, err
}
14 changes: 14 additions & 0 deletions cli/context/store/io_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"io"
"strings"
"testing"
"testing/iotest"

"gotest.tools/v3/assert"
)
Expand All @@ -21,4 +22,17 @@ func TestLimitReaderReadAll(t *testing.T) {
r = strings.NewReader("Test")
_, err = io.ReadAll(&limitedReader{R: r, N: 2})
assert.Error(t, err, "read exceeds the defined limit")

// exceeding the limit must error even when a read lands exactly on
// the limit before the remaining data is reached
r = strings.NewReader("Test")
_, err = io.ReadAll(&limitedReader{R: iotest.OneByteReader(r), N: 2})
assert.Error(t, err, "read exceeds the defined limit")

// a reader is allowed to return data together with io.EOF; the limit
// must still be reported, otherwise io.ReadAll treats the truncated
// content as a successful read
r = strings.NewReader("Tes")
_, err = io.ReadAll(&limitedReader{R: iotest.DataErrReader(r), N: 2})
assert.Error(t, err, "read exceeds the defined limit")
}
2 changes: 1 addition & 1 deletion cli/context/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ func importZip(name string, s Writer, reader io.Reader) error {
if err != nil {
return err
}
data, err := io.ReadAll(f)
data, err := io.ReadAll(&limitedReader{R: f, N: maxAllowedFileSizeToImport})
defer f.Close()
if err != nil {
return err
Expand Down
40 changes: 40 additions & 0 deletions cli/context/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,46 @@ func TestImportZipInvalid(t *testing.T) {
assert.ErrorContains(t, err, "unexpected context file")
}

func TestImportZipTLSDataTooLarge(t *testing.T) {
testDir := t.TempDir()
zf := path.Join(testDir, "test.zip")

f, err := os.Create(zf)
assert.NilError(t, err)
defer f.Close()
w := zip.NewWriter(f)

meta, err := json.Marshal(Metadata{
Endpoints: map[string]any{
"ep1": endpoint{Foo: "bar"},
},
Metadata: context{Bar: "baz"},
Name: "source",
})
assert.NilError(t, err)
mf, err := w.Create("meta.json")
assert.NilError(t, err)
_, err = mf.Write(meta)
assert.NilError(t, err)

// a highly compressible TLS entry that inflates beyond the allowed
// import size, even though the zip file itself stays well under it
tf, err := w.Create(path.Join("tls", "docker", "ca.pem"))
assert.NilError(t, err)
_, err = tf.Write(make([]byte, 2*maxAllowedFileSizeToImport))
assert.NilError(t, err)
err = w.Close()
assert.NilError(t, err)

source, err := os.Open(zf)
assert.NilError(t, err)
defer source.Close()
var r io.Reader = source
s := New(testDir, testCfg)
err = Import("zipTLSTooLarge", s, r)
assert.ErrorContains(t, err, "exceeds the defined limit")
}

func TestCorruptMetadata(t *testing.T) {
tempDir := t.TempDir()
s := New(tempDir, testCfg)
Expand Down