check / check (push) Successful in 15m34s
blobgen.NewReader returns ErrNotAgeStream when age cannot open the data, in place of age's error, which quotes the data's first bytes. A failed read of the header is still returned as the read error, and a wrong key as age.NoIdentityMatchError. Under --skip-errors, restore reports a blob that could not be downloaded once, with the number of files that need it. Those files are counted in the final warning, not listed; each is logged at debug level. Model: opus-5-5
224 lines
6.1 KiB
Go
224 lines
6.1 KiB
Go
package blobgen_test
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"testing/iotest"
|
|
|
|
"filippo.io/age"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
)
|
|
|
|
// TestNewReaderWrongIdentity covers issue case 4: opening a blob with an
|
|
// identity other than the recipient reports no matching identity.
|
|
func TestNewReaderWrongIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, recipient := makeIdentity(t)
|
|
other, _ := makeIdentity(t)
|
|
|
|
blob := encryptBlob(t, []byte("secret payload"), recipient)
|
|
|
|
_, err := blobgen.NewReader(bytes.NewReader(blob), other)
|
|
require.Error(t, err)
|
|
|
|
var noMatch *age.NoIdentityMatchError
|
|
assert.ErrorAs(t, err, &noMatch)
|
|
}
|
|
|
|
// TestNewReaderTruncated covers issue case 6: a multi-segment blob cut at
|
|
// several points must never read back as valid data. The point immediately
|
|
// after the header and nonce is intentionally excluded: it reads as a valid
|
|
// empty blob today and is the regression case for
|
|
// https://git.eeqj.de/sneak/vaultik/issues/152.
|
|
func TestNewReaderTruncated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
id, recipient := makeIdentity(t)
|
|
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
|
h := ageHeaderLen(t, blob)
|
|
|
|
require.Greater(t, len(blob), h+ageNonceSize+ageSegmentSize,
|
|
"test needs a blob of at least two age segments")
|
|
|
|
cases := []struct {
|
|
name string
|
|
size int
|
|
}{
|
|
{"inside header", h / 2},
|
|
{"inside nonce", h + 8},
|
|
{"inside first segment", h + ageNonceSize + 100},
|
|
{"end of first full segment", h + ageNonceSize + ageSegmentSize},
|
|
{"last byte removed", len(blob) - 1},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
requireBlobUnreadable(t, blob[:tc.size], id)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewReaderCorrupted covers issue case 7: one flipped byte in each region
|
|
// of a multi-segment blob makes it unreadable.
|
|
func TestNewReaderCorrupted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
id, recipient := makeIdentity(t)
|
|
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
|
h := ageHeaderLen(t, blob)
|
|
|
|
firstNL := bytes.IndexByte(blob, '\n')
|
|
require.Positive(t, firstNL, "header must have a version line")
|
|
|
|
cases := []struct {
|
|
name string
|
|
pos int
|
|
}{
|
|
{"header stanza", firstNL + 5},
|
|
{"header MAC line", h - 2},
|
|
{"nonce", h + 4},
|
|
{"body segment", h + ageNonceSize + 50},
|
|
{"final tag", len(blob) - 1},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
corrupt := append([]byte(nil), blob...)
|
|
corrupt[tc.pos] ^= 0xff
|
|
requireBlobUnreadable(t, corrupt, id)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewReaderTrailingAndGarbage covers issue case 8: bytes appended after a
|
|
// valid blob, empty input, and random garbage each fail to read.
|
|
func TestNewReaderTrailingAndGarbage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
id, recipient := makeIdentity(t)
|
|
|
|
valid := encryptBlob(t, []byte("small payload"), recipient)
|
|
appended := append(append([]byte(nil), valid...), []byte("trailing junk")...)
|
|
|
|
t.Run("appended bytes", func(t *testing.T) {
|
|
t.Parallel()
|
|
requireBlobUnreadable(t, appended, id)
|
|
})
|
|
t.Run("empty input", func(t *testing.T) {
|
|
t.Parallel()
|
|
requireBlobUnreadable(t, []byte{}, id)
|
|
})
|
|
t.Run("random garbage", func(t *testing.T) {
|
|
t.Parallel()
|
|
requireBlobUnreadable(t, randomBytes(t, 512), id)
|
|
})
|
|
}
|
|
|
|
// TestNewReaderGarbageErrorQuotesNoBytes checks that random bytes in
|
|
// place of a blob fail with ErrNotAgeStream, and that the error carries
|
|
// none of those bytes.
|
|
func TestNewReaderGarbageErrorQuotesNoBytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
id, _ := makeIdentity(t)
|
|
garbage := bytes.Repeat([]byte("garbage-bytes-"), 100)
|
|
|
|
_, err := blobgen.NewReader(bytes.NewReader(garbage), id)
|
|
|
|
require.ErrorIs(t, err, blobgen.ErrNotAgeStream)
|
|
assert.NotContains(t, err.Error(), "garbage-bytes-")
|
|
}
|
|
|
|
// errConnectionReset stands for a storage read that fails.
|
|
var errConnectionReset = errors.New("connection reset")
|
|
|
|
// TestNewReaderReadFailure checks that a failed read of the age header is
|
|
// returned as that read error, not as ErrNotAgeStream.
|
|
func TestNewReaderReadFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
id, _ := makeIdentity(t)
|
|
|
|
_, err := blobgen.NewReader(iotest.ErrReader(errConnectionReset), id)
|
|
|
|
require.ErrorIs(t, err, errConnectionReset)
|
|
assert.NotErrorIs(t, err, blobgen.ErrNotAgeStream)
|
|
}
|
|
|
|
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
|
|
// out-of-range compression level errors and writes nothing to the destination.
|
|
func TestNewWriterInvalidLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, recipient := makeIdentity(t)
|
|
|
|
for _, level := range []int{0, -1, 20} {
|
|
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
|
require.ErrorIs(t, err, blobgen.ErrInvalidCompressionLevel)
|
|
assert.Nil(t, w)
|
|
assert.Zero(t, buf.Len(), "nothing written on an invalid level")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewWriterInvalidRecipients covers issue case 10: nil and empty recipient
|
|
// lists and an unparsable recipient string each error.
|
|
func TestNewWriterInvalidRecipients(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
recipients []string
|
|
}{
|
|
{"nil list", nil},
|
|
{"empty list", []string{}},
|
|
{"invalid recipient string", []string{"not-a-recipient"}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
w, err := blobgen.NewWriter(&buf, 1, tc.recipients)
|
|
require.Error(t, err)
|
|
assert.Nil(t, w)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewWriterFailingDestination covers issue case 11: a destination that
|
|
// fails mid-blob surfaces its error from Write or Close.
|
|
func TestNewWriterFailingDestination(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, recipient := makeIdentity(t)
|
|
|
|
// The limit clears the age header and nonce so NewWriter succeeds, then
|
|
// trips once the compressed body starts flowing.
|
|
dst := &failAfterWriter{limit: 512}
|
|
|
|
w, err := blobgen.NewWriter(dst, 1, []string{recipient})
|
|
require.NoError(t, err)
|
|
|
|
_, writeErr := w.Write(randomBytes(t, 256*1024))
|
|
closeErr := w.Close()
|
|
|
|
assert.True(t, writeErr != nil || closeErr != nil,
|
|
"destination failure must surface from Write or Close")
|
|
}
|