diff --git a/Makefile b/Makefile index 0e7b5c7..938b125 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,8 @@ fuzz: go test -fuzz=FuzzNewVersion -fuzztime=15s . go test -fuzz=FuzzStrictNewVersion -fuzztime=15s . go test -fuzz=FuzzNewConstraint -fuzztime=15s . + go test -fuzz=FuzzMarshalBinary -fuzztime=15s . + go test -fuzz=FuzzUnmarshalBinary -fuzztime=15s . $(GOLANGCI_LINT): # Install golangci-lint. The configuration for it is in the .golangci.yml diff --git a/benchmark_test.go b/benchmark_test.go index 2804563..c19f690 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -245,3 +245,49 @@ func BenchmarkStrictNewVersionMetaDash(b *testing.B) { b.ResetTimer() benchStrictNewVersion("1.0.0-alpha.1+meta.data", b) } + +/* Marshalling benchmarks */ + +func BenchmarkTextMarshal(b *testing.B) { + v := MustParse("1.0.0-alpha.1+meta.data") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = v.MarshalText() + } +} + +func BenchmarkBinaryMarshal(b *testing.B) { + v := MustParse("1.0.0-alpha.1+meta.data") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = v.MarshalBinary() + } +} + +func BenchmarkTextUnmarshal(b *testing.B) { + var v Version + text, err := MustParse("1.0.0-alpha.1+meta.data").MarshalText() + if err != nil { + panic(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = v.UnmarshalText(text) + } +} + +func BenchmarkBinaryUnmarshal(b *testing.B) { + var v Version + data, err := MustParse("1.0.0-alpha.1+meta.data").MarshalBinary() + if err != nil { + panic(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = v.UnmarshalText(data) + } +} diff --git a/version.go b/version.go index 7c4bed3..abd764a 100644 --- a/version.go +++ b/version.go @@ -3,6 +3,7 @@ package semver import ( "bytes" "database/sql/driver" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -471,6 +472,79 @@ func (v Version) MarshalText() ([]byte, error) { return []byte(v.String()), nil } +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (v *Version) UnmarshalBinary(data []byte) (err error) { + var nums [3]uint64 + r := bytes.NewReader(data) + for i := range nums { + nums[i], err = binary.ReadUvarint(r) + if err != nil { + return ErrInvalidSemVer + } + } + + var strings [2][]byte + var i int + // range doesn't advance i to 2 + // (used to determine whether `break` happened) + for i = 0; i < len(strings); i++ { + length, err := binary.ReadUvarint(r) + if err != nil { + break + } + if length == 0 { + continue + } + if length > uint64(r.Len()) { + // Parsed length can't be larger than the remaining + // length of the data; without this check a maliciously crafted + // `data` can make us attempt to allocate a huge buffer + break + } + // We allocate here instead of returning a slice of data: + // data might be a slice of some enormous binary blob. It would be bad + // if our reference to the tiny prerelease string would prevent GC + // from collecting the whole large slice. + strings[i] = make([]byte, length) + _, err = r.Read(strings[i]) + if err != nil { + break + } + } + switch i { + case 0: // `break` during prerelease string parsing + return ErrInvalidPrerelease + case 1: // `break` during metadata string parsing + return ErrInvalidMetadata + } + + *v = Version{ + major: nums[0], + minor: nums[1], + patch: nums[2], + pre: string(strings[0]), + metadata: string(strings[1]), + } + return nil +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (v Version) MarshalBinary() ([]byte, error) { + // Once semver has 1.19 as a minimal supported go version - + // this can be rewritten with binary.AppendUvarint and + // we can allocate a smaller buffer, assuming 5 Uvarints are (usually) <128 + buf := make([]byte, 5*binary.MaxVarintLen64+len(v.pre)+len(v.metadata)) + n := 0 + n += binary.PutUvarint(buf[n:], v.major) + n += binary.PutUvarint(buf[n:], v.minor) + n += binary.PutUvarint(buf[n:], v.patch) + n += binary.PutUvarint(buf[n:], uint64(len(v.pre))) + n += copy(buf[n:], v.pre) + n += binary.PutUvarint(buf[n:], uint64(len(v.metadata))) + n += copy(buf[n:], v.metadata) + return buf[:n], nil +} + // Scan implements the SQL.Scanner interface. func (v *Version) Scan(value interface{}) error { var s string diff --git a/version_test.go b/version_test.go index 16156dd..c2db2c1 100644 --- a/version_test.go +++ b/version_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "math" "testing" ) @@ -608,6 +609,37 @@ func TestTextUnmarshal(t *testing.T) { } } +func TestBinaryMarshallingRoundtrip(t *testing.T) { + maxUint64 := uint64(math.MaxUint64) + tests := []struct { + version string + }{ + {"1.2.3"}, + {fmt.Sprintf("%d.%d.%d-beta.1+build.123", maxUint64, maxUint64, maxUint64)}, + {"1.2.3-loooooooooooongString+loooooooooooooooooooongerString"}, + } + + for _, tc := range tests { + v, err := NewVersion(tc.version) + if err != nil { + t.Errorf("Error creating version: %s", err) + } + data, err := v.MarshalBinary() + if err != nil { + t.Errorf("Error marshaling version: %s", err) + } + var v2 Version + err = v2.UnmarshalBinary(data) + if err != nil { + t.Errorf("Error unmarshaling version: %s", err) + } + + if tc.version != v2.String() { + t.Errorf("Expected version=%q, but got %q", tc.version, v2.String()) + } + } +} + func TestSQLScanner(t *testing.T) { sVer := "1.1.1" x, err := StrictNewVersion(sVer) @@ -705,3 +737,54 @@ func FuzzStrictNewVersion(f *testing.F) { _, _ = StrictNewVersion(a) }) } + +func FuzzMarshalBinary(f *testing.F) { + testcases := [][]any{ + {uint64(1), uint64(2), uint64(3), "alpha.1", "bar"}, + {uint64(0), uint64(math.MaxUint64), uint64(0), "", ""}, + } + + for _, tc := range testcases { + f.Add(tc...) + } + + f.Fuzz(func(t *testing.T, major uint64, minor uint64, patch uint64, pre string, metadata string) { + v := New(major, minor, patch, pre, metadata) + data, err := v.MarshalBinary() + if err != nil { + t.Error("MarshalBinary is unfallable, but error is not nil!") + } + var v2 Version + err = v2.UnmarshalBinary(data) + if err != nil { + t.Fatal("Failed to unmarshal marshaled value") + } + completelyEqual := (v.major == v2.major && + v.minor == v2.minor && + v.patch == v2.patch && + v.pre == v2.pre && + v.metadata == v2.metadata) + if !completelyEqual { + t.Errorf("Marshaling changed the data! Expected %s, got %s", v.String(), v2.String()) + } + }) +} + +func FuzzUnmarshalBinary(f *testing.F) { + testcases := [][]byte{ + []byte(""), + []byte("\x01\x02\x03\aalpha.1\x03bar"), + []byte("\xff\xff\x03\xff\xff\xff\xff\x0f" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01\aalpha.1\x03bar"), + []byte("\x00\x00\x94\xc5@\xee\xd1\xd1\xd1\xff\x7f0"), + } + + for _, tc := range testcases { + f.Add(tc) + } + + f.Fuzz(func(t *testing.T, data []byte) { + var v Version + _ = v.UnmarshalBinary(data) + }) +}