Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented binary marshalling/unmarshalling #228

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions benchmark_test.go
Expand Up @@ -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)
}
}
74 changes: 74 additions & 0 deletions version.go
Expand Up @@ -3,6 +3,7 @@ package semver
import (
"bytes"
"database/sql/driver"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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 -
Copy link
Author

Choose a reason for hiding this comment

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

Or we can just copy the obvious 5-line long function from golang (if the license permits that, of course 😇)

// 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)
Copy link
Author

Choose a reason for hiding this comment

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

Is the major-minor-patch-pre-metadata combo completely set in stone? If not, then perhaps we should throw in a "binary format version" number.

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)))
Copy link
Author

Choose a reason for hiding this comment

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

Technically we can omit the last string length if we assume that the data slice is always correctly sized, but that seems to be a very niche optimization

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
Expand Down
75 changes: 75 additions & 0 deletions version_test.go
Expand Up @@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"math"
"testing"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -705,3 +737,46 @@ 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.Error("Failed to unmarshal marshaled value")
}
})
}

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)
})
}