Add negative and boundary tests for blobgen and types (closes #170) #192
@@ -0,0 +1,119 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// ageChunkSize is age's STREAM plaintext chunk size (64 KiB); each encrypted
|
||||
// chunk adds a 16-byte ChaCha20-Poly1305 tag.
|
||||
const (
|
||||
ageChunkSize = 64 * 1024
|
||||
ageChunkTagSize = 16
|
||||
ageSegmentSize = ageChunkSize + ageChunkTagSize
|
||||
ageNonceSize = 16
|
||||
)
|
||||
|
||||
// makeIdentity returns a fresh X25519 identity and its recipient string.
|
||||
func makeIdentity(t *testing.T) (*age.X25519Identity, string) {
|
||||
t.Helper()
|
||||
|
||||
id, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
return id, id.Recipient().String()
|
||||
}
|
||||
|
||||
// randomBytes returns n cryptographically random bytes, which do not compress
|
||||
// so the encrypted payload spans multiple age segments.
|
||||
func randomBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
require.NoError(t, err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// compressibleBytes returns n bytes of a repeating pattern, which zstd packs
|
||||
// down to a small payload.
|
||||
func compressibleBytes(n int) []byte {
|
||||
pattern := bytes.Repeat([]byte("compressible-"), n/13+1)
|
||||
|
||||
return pattern[:n]
|
||||
}
|
||||
|
||||
// encryptBlob compresses, encrypts and returns a blob for plaintext at
|
||||
// compression level 1.
|
||||
func encryptBlob(t *testing.T, plaintext []byte, recipients ...string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, recipients)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = w.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// ageHeaderLen returns the byte length of blob's age header, i.e. the offset
|
||||
// of the 16-byte payload nonce that follows it. The header ends with a MAC
|
||||
// line "--- <mac>\n"; the nonce begins right after that newline.
|
||||
func ageHeaderLen(t *testing.T, blob []byte) int {
|
||||
t.Helper()
|
||||
|
||||
i := bytes.Index(blob, []byte("\n--- "))
|
||||
require.GreaterOrEqual(t, i, 0, "age MAC footer line not found")
|
||||
|
||||
nl := bytes.IndexByte(blob[i+1:], '\n')
|
||||
require.GreaterOrEqual(t, nl, 0, "newline ending MAC line not found")
|
||||
|
||||
return i + 1 + nl + 1
|
||||
}
|
||||
|
||||
// requireBlobUnreadable asserts that data never decrypts to a plaintext with a
|
||||
// nil error: either NewReader fails, or reading it does.
|
||||
func requireBlobUnreadable(t *testing.T, data []byte, id age.Identity) {
|
||||
t.Helper()
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(data), id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.ReadAll(r)
|
||||
_ = r.Close()
|
||||
|
||||
require.Error(t, err, "reading a damaged blob must fail")
|
||||
}
|
||||
|
||||
// errFailWriter is returned by failAfterWriter once its byte limit is passed.
|
||||
var errFailWriter = errors.New("destination write failed")
|
||||
|
||||
// failAfterWriter accepts writes until more than limit bytes have been sent,
|
||||
// then fails every write. It models a destination that dies mid-blob.
|
||||
type failAfterWriter struct {
|
||||
limit int
|
||||
written int
|
||||
}
|
||||
|
||||
func (f *failAfterWriter) Write(p []byte) (int, error) {
|
||||
f.written += len(p)
|
||||
if f.written > f.limit {
|
||||
return 0, errFailWriter
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
})
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// checkRoundTrip writes input through a Writer, reads it back through a Reader,
|
||||
// and verifies the plaintext, the byte counts, and the content hashes.
|
||||
func checkRoundTrip(
|
||||
t *testing.T, id *age.X25519Identity, recipient string,
|
||||
level int, input []byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := w.Write(input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(input), n)
|
||||
require.NoError(t, w.Close())
|
||||
require.Equal(t, int64(len(input)), w.BytesWritten())
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Equal(t, input, got, "decrypted output must equal input")
|
||||
require.Equal(t, int64(len(input)), r.BytesRead())
|
||||
|
||||
// The hash values are checked by decrypting: the reader's single SHA-256
|
||||
// is the hash of the plaintext, and hashing it once more (DoubleSHA256)
|
||||
// gives the writer's ContentID.
|
||||
single := sha256.Sum256(got)
|
||||
assert.Equal(t, single[:], r.Sum256())
|
||||
assert.Equal(t, blobgen.DoubleSHA256(r.Sum256()), w.ContentID())
|
||||
}
|
||||
|
||||
// TestWriterReaderRoundTrip covers issue cases 1 and 2: every size round trips
|
||||
// for both random and compressible data, and the reader hash, its double hash
|
||||
// and the byte counts all agree.
|
||||
func TestWriterReaderRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
// Sizes exercise the age segment boundary (64 KiB) from just below to a
|
||||
// few segments above it, plus the empty and single-byte edges.
|
||||
sizes := []int{0, 1, 65535, 65536, 65537, 4*65536 + 123}
|
||||
|
||||
kinds := []struct {
|
||||
name string
|
||||
fill func(*testing.T, int) []byte
|
||||
}{
|
||||
{"random", randomBytes},
|
||||
{"compressible", func(_ *testing.T, n int) []byte {
|
||||
return compressibleBytes(n)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, k := range kinds {
|
||||
for _, size := range sizes {
|
||||
name := fmt.Sprintf("%s/%d", k.name, size)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
checkRoundTrip(t, id, recipient, 1, k.fill(t, size))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroLengthNoWrite covers issue case 3: a Writer closed with no Write at
|
||||
// all produces the double hash of the empty input, and the blob reads back as
|
||||
// empty with no error.
|
||||
func TestZeroLengthNoWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
assert.Equal(t, int64(0), w.BytesWritten())
|
||||
|
||||
empty := sha256.Sum256(nil)
|
||||
doubled := sha256.Sum256(empty[:])
|
||||
assert.Equal(t, doubled[:], w.ContentID(),
|
||||
"ContentID of empty input is SHA256(SHA256(\"\"))")
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Empty(t, got, "empty blob decrypts to empty output")
|
||||
assert.Equal(t, int64(0), r.BytesRead())
|
||||
assert.Equal(t, empty[:], r.Sum256())
|
||||
}
|
||||
|
||||
// TestNewWriterValidLevelsRoundTrip covers the accepted end of issue case 9:
|
||||
// the boundary compression levels 1 and 19 both round trip.
|
||||
func TestNewWriterValidLevelsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
input := randomBytes(t, 4096)
|
||||
|
||||
for _, level := range []int{1, 19} {
|
||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
checkRoundTrip(t, id, recipient, level, input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
// TestWriterHashIsDoubleHash verifies that Writer.ContentID() returns
|
||||
// the double hash SHA256(SHA256(plaintext)) for security.
|
||||
// Double hashing prevents attackers from confirming existence of known content.
|
||||
// SHA256(SHA256(plaintext)). Stored objects are named by this second hash so a
|
||||
// name is not the plaintext's own SHA-256; this does not stop someone who
|
||||
// already holds the plaintext from confirming it.
|
||||
func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -60,11 +61,11 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
|
||||
// The writer hash should match the double hash
|
||||
assert.Equal(t, expectedDoubleHash, writerHash,
|
||||
"Writer.ContentID() should return SHA256(SHA256(plaintext)) for security")
|
||||
"Writer.ContentID() must be SHA256(SHA256(plaintext))")
|
||||
|
||||
// Verify it's NOT the single hash (would leak information)
|
||||
// It must be the second hash, not the plaintext's own SHA-256.
|
||||
assert.NotEqual(t, singleHashStr, writerHash,
|
||||
"Writer hash should not be single hash (would allow content confirmation attacks)")
|
||||
"Writer hash must be the double hash, not the single SHA-256")
|
||||
}
|
||||
|
||||
// TestWriterDeterministicHash verifies that the same input always produces
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// scannableID is the shared behaviour of the UUID-backed id types. A pointer
|
||||
// to FileID or BlobID satisfies it, so both are tested through one set of
|
||||
// cases.
|
||||
type scannableID interface {
|
||||
driver.Valuer
|
||||
sql.Scanner
|
||||
fmt.Stringer
|
||||
IsZero() bool
|
||||
}
|
||||
|
||||
// idKind adapts one id type to the generic tests below.
|
||||
type idKind struct {
|
||||
name string
|
||||
newZero func() scannableID
|
||||
newRandom func() scannableID
|
||||
parse func(string) (scannableID, error)
|
||||
}
|
||||
|
||||
func idKinds() []idKind {
|
||||
return []idKind{
|
||||
{
|
||||
name: "FileID",
|
||||
newZero: func() scannableID { return &types.FileID{} },
|
||||
newRandom: func() scannableID {
|
||||
id := types.NewFileID()
|
||||
|
||||
return &id
|
||||
},
|
||||
parse: func(s string) (scannableID, error) {
|
||||
id, err := types.ParseFileID(s)
|
||||
|
||||
return &id, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BlobID",
|
||||
newZero: func() scannableID { return &types.BlobID{} },
|
||||
newRandom: func() scannableID {
|
||||
id := types.NewBlobID()
|
||||
|
||||
return &id
|
||||
},
|
||||
parse: func(s string) (scannableID, error) {
|
||||
id, err := types.ParseBlobID(s)
|
||||
|
||||
return &id, err
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDValueScan checks that Value then Scan round trips from both a string
|
||||
// and a []byte, that a NULL scans to the zero id, and that a non-string type
|
||||
// and malformed text are rejected.
|
||||
func TestIDValueScan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orig := k.newRandom()
|
||||
v, err := orig.Value()
|
||||
require.NoError(t, err)
|
||||
|
||||
s, ok := v.(string)
|
||||
require.True(t, ok, "Value must yield a string")
|
||||
|
||||
fromString := k.newZero()
|
||||
require.NoError(t, fromString.Scan(s))
|
||||
assert.Equal(t, orig.String(), fromString.String())
|
||||
assert.False(t, fromString.IsZero())
|
||||
|
||||
fromBytes := k.newZero()
|
||||
require.NoError(t, fromBytes.Scan([]byte(s)))
|
||||
assert.Equal(t, orig.String(), fromBytes.String())
|
||||
|
||||
nulled := k.newRandom()
|
||||
require.NoError(t, nulled.Scan(nil))
|
||||
assert.True(t, nulled.IsZero(), "NULL scans to the zero id")
|
||||
|
||||
require.Error(t, k.newZero().Scan(42),
|
||||
"a non-string type must be rejected")
|
||||
assert.Error(t, k.newZero().Scan("not-a-uuid"),
|
||||
"malformed text must be rejected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDParse checks that the Parse function accepts a canonical id and
|
||||
// rejects malformed text.
|
||||
func TestIDParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canonical := k.newRandom().String()
|
||||
|
||||
parsed, err := k.parse(canonical)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonical, parsed.String())
|
||||
|
||||
_, err = k.parse("not-a-uuid")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDIsZero checks IsZero on the zero and on a freshly generated id.
|
||||
func TestIDIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, k.newZero().IsZero())
|
||||
assert.False(t, k.newRandom().IsZero())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user