ident: implement system identifier
Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
23
ident/ident.go
Normal file
23
ident/ident.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Package ident is the reference implementation of system and member
|
||||||
|
// identifiers.
|
||||||
|
package ident
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNewline is returned for identifiers found to contain newline characters.
|
||||||
|
var ErrNewline = errors.New("identifier contains newline characters")
|
||||||
|
|
||||||
|
// UnexpectedSizeError describes a malformed string representation of an
|
||||||
|
// identifier, with unexpected length.
|
||||||
|
type UnexpectedSizeError struct {
|
||||||
|
Data []byte
|
||||||
|
Want int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UnexpectedSizeError) Error() string {
|
||||||
|
return "got " + strconv.Itoa(len(e.Data)) + " bytes for " +
|
||||||
|
"a " + strconv.Itoa(e.Want) + "-byte identifier"
|
||||||
|
}
|
||||||
101
ident/ident_test.go
Normal file
101
ident/ident_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package ident_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gensokyo.uk/cofront/cof-spec/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rTestCases describes multiple representation test cases.
|
||||||
|
type rTestCases[V any, S interface {
|
||||||
|
encoding.TextMarshaler
|
||||||
|
encoding.TextUnmarshaler
|
||||||
|
fmt.Stringer
|
||||||
|
|
||||||
|
*V
|
||||||
|
}] []struct {
|
||||||
|
name string
|
||||||
|
ident V
|
||||||
|
want []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRepresentation runs tests described by testCases.
|
||||||
|
func (testCases rTestCases[V, S]) run(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("decode", func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var got V
|
||||||
|
if err := S(&got).UnmarshalText(tc.want); !reflect.DeepEqual(err, tc.err) {
|
||||||
|
t.Fatalf("UnmarshalText: error = %v, want %v", err, tc.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, tc.ident) {
|
||||||
|
t.Errorf("UnmarshalText: %#v, want %#v", got, tc.ident)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if tc.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("encode", func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got, err := S(&tc.ident).MarshalText(); err != nil {
|
||||||
|
t.Fatalf("MarshalText: error = %v", err)
|
||||||
|
} else if string(got) != string(tc.want) {
|
||||||
|
raw, decodeErr := base64.URLEncoding.AppendDecode(nil, got)
|
||||||
|
t.Logf("AppendDecode: %#v, error = %v", raw, decodeErr)
|
||||||
|
|
||||||
|
t.Errorf("MarshalText: %#v, want %#v", got, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := S(&tc.ident).String(); got != string(tc.want) {
|
||||||
|
t.Errorf("String: %#v, want %#v", []byte(got), tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"UnexpectedSizeError", &ident.UnexpectedSizeError{
|
||||||
|
Data: make([]byte, 1<<10),
|
||||||
|
Want: ident.EncodedSizeSystem,
|
||||||
|
}, "got 1024 bytes for a 32-byte identifier"},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := tc.err.Error(); got != tc.want {
|
||||||
|
t.Errorf("Error: %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ident/system.go
Normal file
93
ident/system.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package ident
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TrivialSite is conventionally reserved for the [S.Site] field on a
|
||||||
|
// trivial, single-host server implementation.
|
||||||
|
TrivialSite = 0xfeedcafe
|
||||||
|
// TrivialHost is conventionally reserved for the [S.Host] field on a
|
||||||
|
// trivial, single-host server implementation.
|
||||||
|
TrivialHost = 0xcafebabe
|
||||||
|
)
|
||||||
|
|
||||||
|
// S represents a unique system identifier.
|
||||||
|
type S struct {
|
||||||
|
// Deployment site, typically denoting a datacenter servicing a region.
|
||||||
|
Site uint32
|
||||||
|
// Servicing host behind load balancer, unique within its Site.
|
||||||
|
Host uint32
|
||||||
|
|
||||||
|
// An instant in time, some time after the corresponding system metadata
|
||||||
|
// first appeared to the backend, represented in nanoseconds since
|
||||||
|
// 1970-01-01.
|
||||||
|
Time uint64
|
||||||
|
// Randomly generated value. The implementation must guarantee that the same
|
||||||
|
// value cannot be emitted for a Time value on a servicing Host.
|
||||||
|
ID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SizeSystem is the size of the binary representation of [S].
|
||||||
|
SizeSystem = 24
|
||||||
|
// EncodedSizeSystem is the size of the string representation of [S].
|
||||||
|
EncodedSizeSystem = SizeSystem / 3 * 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encode appends the canonical string representation of sid to dst and returns
|
||||||
|
// the extended buffer.
|
||||||
|
func (sid *S) Encode(dst []byte) []byte {
|
||||||
|
var buf [SizeSystem]byte
|
||||||
|
|
||||||
|
p := buf[:]
|
||||||
|
binary.LittleEndian.PutUint32(p, sid.Site)
|
||||||
|
p = p[4:]
|
||||||
|
binary.LittleEndian.PutUint32(p, sid.Host)
|
||||||
|
p = p[4:]
|
||||||
|
binary.LittleEndian.PutUint64(p, sid.Time)
|
||||||
|
p = p[8:]
|
||||||
|
binary.LittleEndian.PutUint64(p, sid.ID)
|
||||||
|
return base64.URLEncoding.AppendEncode(dst, buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the canonical string representation of sid.
|
||||||
|
func (sid *S) String() string {
|
||||||
|
s := sid.Encode(nil)
|
||||||
|
return unsafe.String(unsafe.SliceData(s), len(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText returns the result of Encode.
|
||||||
|
func (sid *S) MarshalText() (data []byte, err error) {
|
||||||
|
return sid.Encode(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText strictly decodes data into sid.
|
||||||
|
func (sid *S) UnmarshalText(data []byte) error {
|
||||||
|
if len(data) != EncodedSizeSystem {
|
||||||
|
return &UnexpectedSizeError{data, EncodedSizeSystem}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf [SizeSystem]byte
|
||||||
|
if n, err := base64.URLEncoding.Decode(
|
||||||
|
buf[:],
|
||||||
|
data,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
} else if n != SizeSystem {
|
||||||
|
return ErrNewline
|
||||||
|
}
|
||||||
|
|
||||||
|
p := buf[:]
|
||||||
|
sid.Site = binary.LittleEndian.Uint32(p)
|
||||||
|
p = p[4:]
|
||||||
|
sid.Host = binary.LittleEndian.Uint32(p)
|
||||||
|
p = p[4:]
|
||||||
|
sid.Time = binary.LittleEndian.Uint64(p)
|
||||||
|
p = p[8:]
|
||||||
|
sid.ID = binary.LittleEndian.Uint64(p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
55
ident/system_test.go
Normal file
55
ident/system_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package ident_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gensokyo.uk/cofront/cof-spec/ident"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rTestCases[ident.S, *ident.S]{
|
||||||
|
{"short", ident.S{}, nil, &ident.UnexpectedSizeError{
|
||||||
|
Data: nil,
|
||||||
|
Want: ident.EncodedSizeSystem,
|
||||||
|
}},
|
||||||
|
|
||||||
|
{"malformed", ident.S{}, []byte{
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
0xfe, 0xe1, 0xde, 0xad,
|
||||||
|
}, base64.CorruptInputError(0)},
|
||||||
|
|
||||||
|
{"newline", ident.S{}, []byte(
|
||||||
|
"AAAA" + strings.Repeat("\n", ident.EncodedSizeSystem-4),
|
||||||
|
), ident.ErrNewline},
|
||||||
|
|
||||||
|
{"valid", ident.S{
|
||||||
|
Site: ident.TrivialSite,
|
||||||
|
Host: ident.TrivialHost,
|
||||||
|
|
||||||
|
Time: uint64(time.Date(
|
||||||
|
0xfd, 7, 15,
|
||||||
|
23, 59, 59, 0xcafe,
|
||||||
|
time.UTC,
|
||||||
|
).UnixNano()),
|
||||||
|
|
||||||
|
ID: 0xfee1dead0badf00d,
|
||||||
|
}, base64.URLEncoding.AppendEncode(nil, []byte{
|
||||||
|
/* site: */ 0xfe, 0xca, 0xed, 0xfe,
|
||||||
|
/* host: */ 0xbe, 0xba, 0xfe, 0xca,
|
||||||
|
|
||||||
|
/* time: */ 0xfe, 0, 0x43, 0xce, 0xf1, 0x92, 0x4a, 0x10,
|
||||||
|
/* id: */ 0xd, 0xf0, 0xad, 0xb, 0xad, 0xde, 0xe1, 0xfe,
|
||||||
|
}), nil},
|
||||||
|
}.run(t)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user