feat: add stable cursors and effective config rendering
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// RenderEffectiveServer returns deterministic, payload-free JSON suitable for
|
||||||
|
// the one-time startup configuration log.
|
||||||
|
func RenderEffectiveServer(config *Server) ([]byte, error) {
|
||||||
|
return renderEffective(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderEffectiveClient returns deterministic, payload-free JSON. TLS file
|
||||||
|
// paths are included, but file contents are never read or rendered.
|
||||||
|
func RenderEffectiveClient(config *Client) ([]byte, error) {
|
||||||
|
return renderEffective(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderEffective(value any) ([]byte, error) {
|
||||||
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(encoded, '\n'), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEffectiveConfigurationGolden_HP_CFG_09(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server, err := DecodeServer(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := RenderEffectiveServer(server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, _ := RenderEffectiveServer(server)
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Fatal("effective server configuration is nondeterministic")
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(first)
|
||||||
|
const goldenSHA256 = "55c5fec2c10bc27372353a60f317c5a2959f8087685cf7c11202bb12dc59aa28"
|
||||||
|
if got := hex.EncodeToString(digest[:]); got != goldenSHA256 {
|
||||||
|
t.Fatalf("effective server configuration golden changed: got %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := DecodeClient(nil, ClientOptions{Platform: PlatformUnix})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rendered, err := RenderEffectiveClient(client)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
digest = sha256.Sum256(rendered)
|
||||||
|
const clientGoldenSHA256 = "4c54c212f18f65cad0a2ea65ecbf1d1a6a0f45da5192857ff61f0cdf85ebb68b"
|
||||||
|
if got := hex.EncodeToString(digest[:]); got != clientGoldenSHA256 {
|
||||||
|
t.Fatalf("effective client configuration golden changed: got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCursor = errors.New("invalid pagination cursor")
|
||||||
|
ErrCursorFilterMismatch = errors.New("pagination cursor does not match query filters")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cursorVersion = 1
|
||||||
|
maxCursorFieldBytes = 1024
|
||||||
|
cursorMACBytes = sha256.Size
|
||||||
|
)
|
||||||
|
|
||||||
|
type CursorKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
CursorKindCommands CursorKind = iota + 1
|
||||||
|
CursorKindOutput
|
||||||
|
CursorKindIncidents
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cursor struct {
|
||||||
|
Kind CursorKind
|
||||||
|
FilterHash [sha256.Size]byte
|
||||||
|
Position []byte
|
||||||
|
SnapshotBoundary []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type CursorCodec struct{ key []byte }
|
||||||
|
|
||||||
|
func NewCursorCodec(key []byte) (*CursorCodec, error) {
|
||||||
|
if len(key) < sha256.Size {
|
||||||
|
return nil, fmt.Errorf("%w: signing key must be at least %d bytes", ErrInvalidCursor, sha256.Size)
|
||||||
|
}
|
||||||
|
return &CursorCodec{key: append([]byte(nil), key...)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashCursorFilters(canonical []byte) [sha256.Size]byte { return sha256.Sum256(canonical) }
|
||||||
|
|
||||||
|
func (codec *CursorCodec) Encode(cursor Cursor) (string, error) {
|
||||||
|
if !validCursorKind(cursor.Kind) || len(cursor.Position) == 0 || len(cursor.Position) > maxCursorFieldBytes || len(cursor.SnapshotBoundary) == 0 || len(cursor.SnapshotBoundary) > maxCursorFieldBytes {
|
||||||
|
return "", ErrInvalidCursor
|
||||||
|
}
|
||||||
|
payloadLength := 1 + 1 + sha256.Size + 2 + len(cursor.Position) + 2 + len(cursor.SnapshotBoundary)
|
||||||
|
payload := make([]byte, payloadLength, payloadLength+cursorMACBytes)
|
||||||
|
payload[0] = cursorVersion
|
||||||
|
payload[1] = byte(cursor.Kind)
|
||||||
|
copy(payload[2:], cursor.FilterHash[:])
|
||||||
|
offset := 2 + sha256.Size
|
||||||
|
binary.BigEndian.PutUint16(payload[offset:], uint16(len(cursor.Position)))
|
||||||
|
offset += 2
|
||||||
|
copy(payload[offset:], cursor.Position)
|
||||||
|
offset += len(cursor.Position)
|
||||||
|
binary.BigEndian.PutUint16(payload[offset:], uint16(len(cursor.SnapshotBoundary)))
|
||||||
|
offset += 2
|
||||||
|
copy(payload[offset:], cursor.SnapshotBoundary)
|
||||||
|
mac := hmac.New(sha256.New, codec.key)
|
||||||
|
_, _ = mac.Write(payload)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(append(payload, mac.Sum(nil)...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (codec *CursorCodec) Decode(token string, expectedKind CursorKind, expectedFilterHash [sha256.Size]byte) (Cursor, error) {
|
||||||
|
if token == "" || len(token) > base64.RawURLEncoding.EncodedLen(2*maxCursorFieldBytes+128) {
|
||||||
|
return Cursor{}, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil || len(decoded) < 1+1+sha256.Size+2+1+2+1+cursorMACBytes {
|
||||||
|
return Cursor{}, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
payload, suppliedMAC := decoded[:len(decoded)-cursorMACBytes], decoded[len(decoded)-cursorMACBytes:]
|
||||||
|
mac := hmac.New(sha256.New, codec.key)
|
||||||
|
_, _ = mac.Write(payload)
|
||||||
|
if !hmac.Equal(suppliedMAC, mac.Sum(nil)) {
|
||||||
|
return Cursor{}, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
if payload[0] != cursorVersion || !validCursorKind(CursorKind(payload[1])) || CursorKind(payload[1]) != expectedKind {
|
||||||
|
return Cursor{}, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
var filterHash [sha256.Size]byte
|
||||||
|
copy(filterHash[:], payload[2:2+sha256.Size])
|
||||||
|
if !hmac.Equal(filterHash[:], expectedFilterHash[:]) {
|
||||||
|
return Cursor{}, ErrCursorFilterMismatch
|
||||||
|
}
|
||||||
|
offset := 2 + sha256.Size
|
||||||
|
positionLength := int(binary.BigEndian.Uint16(payload[offset:]))
|
||||||
|
offset += 2
|
||||||
|
if positionLength == 0 || positionLength > maxCursorFieldBytes || offset+positionLength+2 > len(payload) {
|
||||||
|
return Cursor{}, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
position := append([]byte(nil), payload[offset:offset+positionLength]...)
|
||||||
|
offset += positionLength
|
||||||
|
boundaryLength := int(binary.BigEndian.Uint16(payload[offset:]))
|
||||||
|
offset += 2
|
||||||
|
if boundaryLength == 0 || boundaryLength > maxCursorFieldBytes || offset+boundaryLength != len(payload) {
|
||||||
|
return Cursor{}, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
boundary := append([]byte(nil), payload[offset:]...)
|
||||||
|
return Cursor{Kind: expectedKind, FilterHash: filterHash, Position: position, SnapshotBoundary: boundary}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCursorKind(kind CursorKind) bool {
|
||||||
|
return kind >= CursorKindCommands && kind <= CursorKindIncidents
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthenticatedCursorRoundTrip_HP_CTL_06(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
codec, err := NewCursorCodec(bytes.Repeat([]byte{0x42}, 32))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
filters := HashCursorFilters([]byte("client=host-1\x00streams=stdout"))
|
||||||
|
want := Cursor{Kind: CursorKindOutput, FilterHash: filters, Position: []byte("uuid/event/offset"), SnapshotBoundary: []byte("upper-event")}
|
||||||
|
token, err := codec.Encode(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := codec.Decode(token, CursorKindOutput, filters)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Kind != want.Kind || got.FilterHash != want.FilterHash || !bytes.Equal(got.Position, want.Position) || !bytes.Equal(got.SnapshotBoundary, want.SnapshotBoundary) {
|
||||||
|
t.Fatalf("decoded cursor = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorForgeryAndFilterBinding_BH_CTL_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
codec, _ := NewCursorCodec(bytes.Repeat([]byte{0x42}, 32))
|
||||||
|
filters := HashCursorFilters([]byte("client=one"))
|
||||||
|
token, _ := codec.Encode(Cursor{Kind: CursorKindCommands, FilterHash: filters, Position: []byte("position"), SnapshotBoundary: []byte("boundary")})
|
||||||
|
forged := []byte(token)
|
||||||
|
forged[len(forged)/2] ^= 1
|
||||||
|
if _, err := codec.Decode(string(forged), CursorKindCommands, filters); !errors.Is(err, ErrInvalidCursor) {
|
||||||
|
t.Fatalf("forged cursor error = %v", err)
|
||||||
|
}
|
||||||
|
otherFilters := HashCursorFilters([]byte("client=two"))
|
||||||
|
if _, err := codec.Decode(token, CursorKindCommands, otherFilters); !errors.Is(err, ErrCursorFilterMismatch) {
|
||||||
|
t.Fatalf("filter mismatch error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := codec.Decode(token, CursorKindOutput, filters); !errors.Is(err, ErrInvalidCursor) {
|
||||||
|
t.Fatalf("kind mismatch error = %v", err)
|
||||||
|
}
|
||||||
|
for _, malformed := range []string{"", "***", strings.Repeat("a", 4096)} {
|
||||||
|
if _, err := codec.Decode(malformed, CursorKindCommands, filters); !errors.Is(err, ErrInvalidCursor) {
|
||||||
|
t.Errorf("malformed cursor %q error = %v", malformed[:min(len(malformed), 16)], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorInputBounds_BH_CTL_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if _, err := NewCursorCodec([]byte("short")); !errors.Is(err, ErrInvalidCursor) {
|
||||||
|
t.Fatalf("short key error = %v", err)
|
||||||
|
}
|
||||||
|
codec, _ := NewCursorCodec(bytes.Repeat([]byte{1}, 32))
|
||||||
|
filter := HashCursorFilters(nil)
|
||||||
|
for _, cursor := range []Cursor{
|
||||||
|
{Kind: 0, FilterHash: filter, Position: []byte("x"), SnapshotBoundary: []byte("y")},
|
||||||
|
{Kind: CursorKindCommands, FilterHash: filter, SnapshotBoundary: []byte("y")},
|
||||||
|
{Kind: CursorKindCommands, FilterHash: filter, Position: bytes.Repeat([]byte("x"), maxCursorFieldBytes+1), SnapshotBoundary: []byte("y")},
|
||||||
|
} {
|
||||||
|
if _, err := codec.Encode(cursor); !errors.Is(err, ErrInvalidCursor) {
|
||||||
|
t.Errorf("invalid cursor error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,27 @@ tests = [
|
|||||||
"test/harness/harness_test.go:TestSampleRunLifecycle_HP_CFG_01",
|
"test/harness/harness_test.go:TestSampleRunLifecycle_HP_CFG_01",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-CFG-09"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-CTL-06"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/domain/cursor_test.go:TestAuthenticatedCursorRoundTrip_HP_CTL_06"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-CTL-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/domain/cursor_test.go:TestCursorForgeryAndFilterBinding_BH_CTL_01",
|
||||||
|
"internal/domain/cursor_test.go:TestCursorInputBounds_BH_CTL_01",
|
||||||
|
]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-IDEM-01"
|
id = "HP-IDEM-01"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
|
|||||||
Reference in New Issue
Block a user