From 392129c253727adc98e6b8795399ab30f81b66ff Mon Sep 17 00:00:00 2001 From: cabbage Date: Mon, 31 Aug 2026 08:54:31 +0000 Subject: [PATCH] feat: add stable cursors and effective config rendering --- internal/config/effective.go | 23 ++++++ internal/config/effective_test.go | 44 ++++++++++++ internal/domain/cursor.go | 112 ++++++++++++++++++++++++++++++ internal/domain/cursor_test.go | 74 ++++++++++++++++++++ test/coverage.toml | 21 ++++++ 5 files changed, 274 insertions(+) create mode 100644 internal/config/effective.go create mode 100644 internal/config/effective_test.go create mode 100644 internal/domain/cursor.go create mode 100644 internal/domain/cursor_test.go diff --git a/internal/config/effective.go b/internal/config/effective.go new file mode 100644 index 0000000..465da97 --- /dev/null +++ b/internal/config/effective.go @@ -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 +} diff --git a/internal/config/effective_test.go b/internal/config/effective_test.go new file mode 100644 index 0000000..b3a78db --- /dev/null +++ b/internal/config/effective_test.go @@ -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) + } +} diff --git a/internal/domain/cursor.go b/internal/domain/cursor.go new file mode 100644 index 0000000..26b3b45 --- /dev/null +++ b/internal/domain/cursor.go @@ -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 +} diff --git a/internal/domain/cursor_test.go b/internal/domain/cursor_test.go new file mode 100644 index 0000000..ccea608 --- /dev/null +++ b/internal/domain/cursor_test.go @@ -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) + } + } +} diff --git a/test/coverage.toml b/test/coverage.toml index 1612b8f..af09a5c 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -11,6 +11,27 @@ tests = [ "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]] id = "HP-IDEM-01" layer = "unit"