From f609d5bb629811abca30cdb6f2d18517f58808d4 Mon Sep 17 00:00:00 2001 From: Yonah Date: Sat, 21 Mar 2026 17:05:55 +0900 Subject: [PATCH] ident: implement system identifier Signed-off-by: Yonah --- ident/ident.go | 23 ++++++++++ ident/ident_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++ ident/system.go | 93 +++++++++++++++++++++++++++++++++++++++ ident/system_test.go | 55 +++++++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 ident/ident.go create mode 100644 ident/ident_test.go create mode 100644 ident/system.go create mode 100644 ident/system_test.go diff --git a/ident/ident.go b/ident/ident.go new file mode 100644 index 0000000..bfcb948 --- /dev/null +++ b/ident/ident.go @@ -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" +} diff --git a/ident/ident_test.go b/ident/ident_test.go new file mode 100644 index 0000000..241d031 --- /dev/null +++ b/ident/ident_test.go @@ -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) + } + }) + } +} diff --git a/ident/system.go b/ident/system.go new file mode 100644 index 0000000..5d98c53 --- /dev/null +++ b/ident/system.go @@ -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 +} diff --git a/ident/system_test.go b/ident/system_test.go new file mode 100644 index 0000000..2e8f68b --- /dev/null +++ b/ident/system_test.go @@ -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) +}