fix(forward+node): canonical HTTP-over-ZAP security hardening (v0.8.8)

V4 CRITICAL: forward.handle no longer panics on hostile method/path
  (validForwardLine→400 + http.NewRequestWithContext); node.safeHandle adds a
  recover() boundary across all 5 dispatch sites (TCP dispatchLoop x2,
  getOrConnect, QUIC handleCallStream + invokeHandlerOneWay) — a handler panic
  drops one conn, never crashes the node. Proven on TCP+QUIC.
V2 HIGH: forward/identity.go single-source strip set (superset of base's
  StripIdentityHeaders + Cookie drop + X-Gateway-* sweep), applied on BOTH legs
  (serve.go backend + client.go gateway); Authorization preserved.
V6: SSE responses → 501 (buffered-stream guard).
+ consolidation: zap_crosswire_test.go proves byte-identical wire vs zap-proto/go;
  MIGRATION.md roadmap.
Red-reviewed SHIP (0 crit/high/med). Zero exported-API change — source-compatible.
This commit is contained in:
zeekay
2026-06-18 13:39:56 -07:00
parent 6dd965f755
commit 2ba428c21b
10 changed files with 1098 additions and 36 deletions
+38
View File
@@ -5,6 +5,44 @@ The canonical KV + transport for Lux is `luxfi/zap` (this repo) backed by
**and** cold ancient stores. PebbleDB / leveldb remain only as legacy
in-place upgrade paths.
## ZAP runtime consolidation (serialization core)
`github.com/zap-proto/go` is the canonical pure-stdlib runtime for the ZAP
wire format (`Parse`/`Message`/`Object`/`List` + `Builder`/`ObjectBuilder`/
`ListBuilder` + `Schema`). This repo (`luxfi/zap`) historically duplicated
that core in its own `zap.go`/`builder.go`/`schema.go` while adding the
network layer (Node mesh, QUIC/TCP, mDNS, PQ-TLS handshake, EVM types, the
`forward` HTTP-over-ZAP contract). The duplication is being collapsed so
there is ONE serialization runtime; `zap-proto/go` is the source of truth and
this repo will ride it.
**Status (first safe step landed):** the two cores are now proven to emit a
byte-identical data segment, and `zap-proto/go` has absorbed the two deltas
that previously diverged it from this repo's hardened core, so the readers now
agree on accept/reject for every input.
- **Wire proof.** `zap_crosswire_test.go` (here) and the file of the same
name in `zap-proto/go` pin the SAME `goldenV1Hex` constant. This repo's
`NewBuilderV1` emits it byte-for-byte; `zap-proto/go`'s default `NewBuilder`
emits it too. The ONLY header difference between this repo's default
(Version2) output and the v1 golden vector is byte 4 (the version). A live
cross-encode in a throwaway `go.work` joining both modules confirmed every
field round-trips both directions.
- **Deltas reconciled in `zap-proto/go`** (additive — no honest wire changed):
(1) it now accepts `Version1` AND `Version2` on read (was v1-only → it would
reject this repo's default v2 buffers); (2) its reader now applies the same
hardening this repo already had — unsigned-forward `Bytes` with
`absPos < HeaderSize` rejection, `Object`/`List` `absOffset < HeaderSize`
rejection, and `List` `length ≤ msgsize` clamp.
**Remaining steps** (see `zap-proto/go`'s LLM.md for the canonical list):
`schema.go` is already byte-identical and is the first alias target; the
reader/builder alias follows once `cmd/zapgen` lands (the read-buffer pool —
`Message.refs`/`Release`/`Retain` — is network-local and stays here); the
`StartObject` pre-reserve discipline is a documented, test-covered delta to
unify when the builder is aliased (adopt `zap-proto/go`'s pre-reserve — it is
strictly more reserving and byte-identical for every honest layout).
This doc enumerates every storage surface in luxd and the migration path
per backend.
+10 -4
View File
@@ -73,6 +73,11 @@ func requestToForward(req *http.Request) (Forward, error) {
if h == nil {
h = http.Header{}
}
// Lift the edge-validated identity out of the X-* headers into the typed
// fields. These four are only honoured here because by the time a request
// reaches the gateway-side Forwarder the gateway has ALREADY validated the
// JWT and re-set them; a request that bypassed the gateway carries no
// trustworthy values and the full strip below scrubs everything regardless.
isAdmin, _ := strconv.ParseBool(h.Get(HeaderUserIsAdmin))
perms, _ := strconv.ParseInt(h.Get(HeaderUserPerms), 10, 64)
fwd := Forward{
@@ -84,10 +89,11 @@ func requestToForward(req *http.Request) (Forward, error) {
Path: path,
Body: body,
}
h.Del(HeaderOrgID)
h.Del(HeaderUserID)
h.Del(HeaderUserIsAdmin)
h.Del(HeaderUserPerms)
// Strip the FULL canonical identity set (not just the 4 promoted ones) so
// no forged identity header — X-Roles, X-User-Email, X-IAM-*, X-Hanzo-*,
// X-Gateway-*, identity cookies — is ever serialized into the envelope.
// Authorization (the Bearer JWT base validates) is preserved.
stripClientIdentity(h)
if len(h) > 0 {
hdrJSON, err := json.Marshal(map[string][]string(h))
+85
View File
@@ -0,0 +1,85 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package forward
import (
"net/http"
"strings"
)
// identity.go is the single source of truth for which inbound headers may
// carry an authenticated principal across the HTTP-over-ZAP boundary.
//
// Only the EDGE may assert identity. The gateway validates the IAM JWT and
// promotes the result into the typed Forward identity fields (TenantID,
// UserID, IsAdmin, Permissions); the backend re-injects those as the
// canonical X-* headers AFTER stripping every client-supplied copy. A
// client that sets any identity header directly must never have it survive
// into a backend handler.
//
// This list mirrors the authoritative set in
// github.com/hanzoai/base/tools/claims.StripIdentityHeaders. The two MUST
// stay in lock-step: base strips on its own ingress as defence-in-depth,
// and this contract strips on BOTH legs (gateway-side in client.go before
// the envelope is built, backend-side in serve.go before identity is
// injected) so a forged identity header never enters the envelope and never
// reaches a handler even if the envelope is crafted directly.
var clientIdentityHeaders = []string{
// Canonical edge-injected identity (this contract's own 4).
HeaderOrgID, // X-Org-Id
HeaderUserID, // X-User-Id
HeaderUserIsAdmin, // X-User-IsAdmin
HeaderUserPerms, // X-User-Permissions
// Base's canonical roles header + gateway auxiliaries (JWT derivatives).
"X-Roles",
"X-User-Email",
"X-Phone-Number",
// Non-canonical legacy identity headers shipped across the ecosystem.
"X-User-Role", // singular
"X-User-Roles", // plural — renamed to X-Roles
"X-User-Name",
"X-Tenant-Id",
"X-Org",
"X-Is-Admin",
// Pre-validation hints from older gateway flows. The X-Gateway-* prefix
// is also caught by the prefix sweep below; listed here for parity with
// base's authoritative set.
"X-Gateway-Validated",
"X-Gateway-User-Id",
"X-Gateway-Org-Id",
"X-Gateway-User-Email",
}
// stripClientIdentity removes every client-supplied identity-bearing header
// from h. It deletes the explicit list above (case-insensitive via
// http.Header's canonicalization) and, unconditionally, every header whose
// name starts with "X-IAM-", "X-Hanzo-", or "X-Gateway-" — closing the
// "clever-new-prefix" smuggling vector. It also drops the Cookie header
// entirely: cookies can carry session/identity material the edge has
// already lifted into the typed fields, and the backend's auth path is the
// forwarded Authorization Bearer JWT, not cookies, so a forwarded Cookie is
// only a smuggling surface here.
//
// Authorization is deliberately PRESERVED: base validates the forwarded
// Bearer JWT — that is the real authentication path and must survive.
func stripClientIdentity(h http.Header) {
for _, name := range clientIdentityHeaders {
h.Del(name)
}
for key := range h {
upper := strings.ToUpper(key)
if strings.HasPrefix(upper, "X-IAM-") ||
strings.HasPrefix(upper, "X-HANZO-") ||
strings.HasPrefix(upper, "X-GATEWAY-") {
h.Del(key)
}
}
// Cookies are an identity-bearing surface the edge does not promote;
// drop them so a forwarded Cookie cannot smuggle a session. Authorization
// (the Bearer JWT base validates) is intentionally left intact.
h.Del("Cookie")
}
+186 -22
View File
@@ -9,7 +9,9 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
zaplib "github.com/luxfi/zap"
)
@@ -30,44 +32,130 @@ func Serve(node *zaplib.Node, h http.Handler) {
})
}
// handle turns one Forward into one Response by invoking h. Exported logic
// lives here (not in the closure) so it is unit-testable without two live
// nodes — feed a Forward, assert the Response.
func handle(ctx context.Context, h http.Handler, f Forward) Response {
method := f.Method
// errorResponse renders a fail-closed JSON error Response. The body is a
// fixed JSON shape so callers (and the bridged *http.Response) get a
// well-formed payload, never leaked internal detail. status is the HTTP
// code to surface to the original client.
func errorResponse(status int, reason string) Response {
body, _ := json.Marshal(map[string]string{"error": reason})
hdr, _ := json.Marshal(map[string][]string{"Content-Type": {"application/json"}})
return Response{Status: status, Headers: hdr, Body: body}
}
// validForwardLine reports whether method+path can be safely turned into an
// *http.Request. It rejects:
// - methods that are not a single HTTP token (RFC 7230 §3.2.6) — this
// excludes spaces, CR, LF, NUL, and all separators/control bytes;
// - paths carrying any control byte (C0 incl. CR/LF/NUL, or DEL), and
// paths that url.ParseRequestURI cannot parse (e.g. a non-absolute
// target).
//
// A space in the path is NOT rejected: url.ParseRequestURI percent-encodes
// it to %20 — a space cannot split a header/request line the way the
// control bytes can, so encoding it is the correct, non-lossy outcome.
//
// On success it returns the cleaned method and the parsed *url.URL so the
// caller builds the request from already-validated values. Empty method
// defaults to GET and empty path to "/" (the legacy lenient behaviour),
// but a NON-empty malformed value is rejected, never defaulted.
func validForwardLine(method, path string) (cleanMethod string, u *url.URL, reason string, ok bool) {
if method == "" {
method = http.MethodGet
} else if !isHTTPToken(method) {
// An HTTP method is a single token (RFC 7230 §3.2.6: 1*tchar). A
// method with a space ("GE T"), CR, LF, NUL, or any separator/
// control byte is not a token and is rejected here — this is the
// exact grammar net/http's own (unexported) validMethod enforces.
return "", nil, "invalid method", false
}
path := f.Path
if path == "" {
path = "/"
}
// Reject control bytes (C0 + DEL) outright before parsing. These are the
// bytes that, left in a path, can ride into a request line / log line and
// split it; url.Parse would silently encode some and tolerate others, so
// we refuse them up front. The 3-header identity contract in base
// (claims.hasControlByte) rejects the same byte class for symmetry.
if hasControlByte(path) {
return "", nil, "invalid path", false
}
parsed, err := url.ParseRequestURI(path)
if err != nil {
return "", nil, "invalid path", false
}
return method, parsed, "", true
}
// httptest.NewRequest parses Path (incl. raw query) into URL.Path +
// URL.RawQuery. The body is the verbatim client bytes.
req := httptest.NewRequest(method, path, bytes.NewReader(f.Body))
req = req.WithContext(ctx)
// hasControlByte reports whether s contains any C0 control byte (< 0x20,
// includes NUL/TAB/CR/LF) or DEL (0x7f). These are the universal
// primitives for request-line / header / log injection and never appear in
// a legitimate method or request-target. Mirrors base's claims parser.
func hasControlByte(s string) bool {
for i := 0; i < len(s); i++ {
if b := s[i]; b < 0x20 || b == 0x7f {
return true
}
}
return false
}
// handle turns one Forward into one Response by invoking h. Exported logic
// lives here (not in the closure) so it is unit-testable without two live
// nodes — feed a Forward, assert the Response.
//
// Hostile input is rejected BEFORE any *http.Request is constructed: a
// malformed method or path yields a 400 Response, never a panic. (The prior
// implementation called httptest.NewRequest directly, which panics on a
// method containing a space or a path containing a control byte — an
// unrecovered panic in the dispatch goroutine. node.go's dispatch now also
// recovers as defence in depth, but the contract refuses to generate the
// panic in the first place.)
func handle(ctx context.Context, h http.Handler, f Forward) Response {
method, u, reason, ok := validForwardLine(f.Method, f.Path)
if !ok {
return errorResponse(http.StatusBadRequest, reason)
}
// Build the request from already-validated values via the non-panicking
// constructor. http.NewRequestWithContext re-checks the method and URL
// and returns an error (never panics) on anything we missed.
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(f.Body))
if err != nil {
return errorResponse(http.StatusBadRequest, "invalid request")
}
req.RequestURI = "" // a server-side request must not carry RequestURI
// Decode the client headers JSON onto the request.
// Decode the client headers JSON onto the request. Reject header NAMES
// that are not valid HTTP tokens and VALUES that carry CR/LF/NUL so a
// crafted Headers blob cannot smuggle a header or split a downstream
// log/response. (req.Header.Add would also reject these at write time in
// recent Go, but we filter explicitly so behaviour is deterministic and
// independent of stdlib internals.)
if len(f.Headers) > 0 {
var hdrs map[string][]string
if err := json.Unmarshal(f.Headers, &hdrs); err == nil {
for k, vs := range hdrs {
if !isHTTPToken(k) {
continue
}
for _, v := range vs {
if !validHeaderValue(v) {
continue
}
req.Header.Add(k, v)
}
}
}
}
// Inject the pre-validated identity. Strip any client-supplied copies
// first — only the edge may assert identity (gateway already strips
// these inbound, this is defence-in-depth at the trust boundary).
req.Header.Del(HeaderOrgID)
req.Header.Del(HeaderUserID)
req.Header.Del(HeaderUserIsAdmin)
req.Header.Del(HeaderUserPerms)
// Strip the FULL canonical identity set the client may have smuggled in
// the Headers blob, THEN inject the edge-validated identity. Only the
// edge may assert identity; the gateway strips inbound, this is
// defence-in-depth at the ZAP trust boundary. Mirrors base's
// claims.StripIdentityHeaders so the same forged-identity vectors are
// closed on both legs of the contract.
stripClientIdentity(req.Header)
if f.TenantID != "" {
req.Header.Set(HeaderOrgID, f.TenantID)
}
@@ -77,13 +165,22 @@ func handle(ctx context.Context, h http.Handler, f Forward) Response {
req.Header.Set(HeaderUserIsAdmin, strconv.FormatBool(f.IsAdmin))
req.Header.Set(HeaderUserPerms, strconv.FormatInt(f.Permissions, 10))
// TODO(stream): when h's handler flushes (http.Flusher), a flush-aware
// ResponseWriter should emit Push frames (BuildPush, Encoding=EncSSE/
// EncWS*) back to node for ConnID=f.ConnID instead of buffering. For now
// we buffer the full response correct, just not incremental.
// Streaming guard: a handler that declares an SSE/streaming response
// would otherwise be buffered until idle-timeout (no Push path yet —
// see TODO(stream) below). Detect it post-hoc via the recorder's
// committed headers and surface a clear 501 instead of hanging.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if isStreamingResponse(rec.Header()) {
// TODO(stream): when h flushes (http.Flusher) a flush-aware
// ResponseWriter should emit Push frames (BuildPush, Encoding=
// EncSSE/EncWS*) back to node for ConnID=f.ConnID instead of
// buffering. Until then a streaming content-type cannot be served
// over a single buffered Response — fail loud, don't hang.
return errorResponse(http.StatusNotImplemented, "streaming not supported over Forward yet")
}
hdrJSON, _ := json.Marshal(map[string][]string(rec.Header()))
return Response{
Status: rec.Code,
@@ -91,3 +188,70 @@ func handle(ctx context.Context, h http.Handler, f Forward) Response {
Body: rec.Body.Bytes(),
}
}
// isStreamingResponse reports whether the handler committed a streaming
// content type that the buffered Response path cannot carry. SSE is the
// canonical case; chunked text/event-stream is detected by the media type
// regardless of any trailing "; charset=" parameter.
func isStreamingResponse(h http.Header) bool {
ct := h.Get("Content-Type")
if ct == "" {
return false
}
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
return strings.EqualFold(strings.TrimSpace(ct), "text/event-stream")
}
// tokenTable marks the bytes that are valid in an HTTP token (RFC 7230
// §3.2.6: tchar). A token excludes all CTLs (incl. NUL/CR/LF), SP, and the
// separator set ()<>@,;:\"/[]?={}. This is the byte-for-byte table net/http
// uses internally (its private isTokenTable). Kept local so this core
// module needs no golang.org/x/net (which would drag golang.org/x/text via
// idna) for two table lookups.
var tokenTable = [256]bool{
'!': true, '#': true, '$': true, '%': true, '&': true, '\'': true,
'*': true, '+': true, '-': true, '.': true, '^': true, '_': true,
'`': true, '|': true, '~': true,
'0': true, '1': true, '2': true, '3': true, '4': true, '5': true,
'6': true, '7': true, '8': true, '9': true,
'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true,
'G': true, 'H': true, 'I': true, 'J': true, 'K': true, 'L': true,
'M': true, 'N': true, 'O': true, 'P': true, 'Q': true, 'R': true,
'S': true, 'T': true, 'U': true, 'V': true, 'W': true, 'X': true,
'Y': true, 'Z': true,
'a': true, 'b': true, 'c': true, 'd': true, 'e': true, 'f': true,
'g': true, 'h': true, 'i': true, 'j': true, 'k': true, 'l': true,
'm': true, 'n': true, 'o': true, 'p': true, 'q': true, 'r': true,
's': true, 't': true, 'u': true, 'v': true, 'w': true, 'x': true,
'y': true, 'z': true,
}
// isHTTPToken reports whether s is a non-empty HTTP token (1*tchar). Used
// to validate the request method and every client-supplied header NAME.
func isHTTPToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if !tokenTable[s[i]] {
return false
}
}
return true
}
// validHeaderValue reports whether s is a safe HTTP header field-value: no
// CR, LF, or NUL (the bytes that split a header / smuggle a response).
// Matches httpguts.ValidHeaderFieldValue's rejection set; TAB and other
// VCHAR/obs-text bytes are permitted as the RFC allows.
func validHeaderValue(s string) bool {
for i := 0; i < len(s); i++ {
switch s[i] {
case '\r', '\n', 0x00:
return false
}
}
return true
}
+368
View File
@@ -0,0 +1,368 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package forward
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
)
// neverCalled is a handler that fails the test if it is ever invoked. Used
// to prove hostile input is rejected BEFORE reaching the application.
func neverCalled(t *testing.T) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("handler must not run for hostile input; ran for %q %q", r.Method, r.URL)
})
}
// --- V4: hostile method / path → 400, NEVER a panic ---------------------
func TestHandleHostileMethodPathReturns400NotPanic(t *testing.T) {
// Every one of these tripped httptest.NewRequest's panic in the old
// implementation (method/path injected verbatim into a raw HTTP request
// line fed to http.ReadRequest — a space split the line, CR/LF smuggled
// a second line, NUL broke the parser). They must now yield a clean 400
// with the handler NEVER invoked: these are the injection bytes that can
// split a request/header/log, so the only safe action is to refuse.
cases := []struct {
name string
method, path string
}{
{"method with space", "GE T", "/v1/x"},
{"method with CRLF", "GET\r\nX-Evil: 1", "/v1/x"},
{"method with NUL", "GET\x00", "/v1/x"},
{"method with tab", "GET\t", "/v1/x"},
{"method with separator comma", "GE,T", "/v1/x"},
{"method with separator paren", "GET()", "/v1/x"},
{"path with NUL", http.MethodGet, "/v1/x\x00"},
{"path with CR", http.MethodGet, "/v1/x\r/y"},
{"path with LF", http.MethodGet, "/v1/x\n/y"},
{"path with CRLF header injection", http.MethodGet, "/v1/x\r\nX-Evil: 1"},
{"path with control byte 0x1f", http.MethodGet, "/v1/\x1f"},
{"path with DEL 0x7f", http.MethodGet, "/v1/\x7f"},
{"path not absolute", http.MethodGet, "v1/x"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// If handle panicked, the test binary would crash; reaching the
// assertion at all is half the proof. recover() here would only
// fire if handle itself re-introduced a panic — it must not.
defer func() {
if r := recover(); r != nil {
t.Fatalf("handle panicked on hostile input (%q %q): %v", tc.method, tc.path, r)
}
}()
resp := handle(context.Background(), neverCalled(t), Forward{
Method: tc.method,
Path: tc.path,
})
if resp.Status < 400 || resp.Status >= 500 {
t.Fatalf("hostile input got status %d, want 4xx", resp.Status)
}
// Error body is well-formed JSON with an "error" key, never a leak.
var e map[string]string
if err := json.Unmarshal(resp.Body, &e); err != nil {
t.Fatalf("error body not JSON: %q (%v)", resp.Body, err)
}
if e["error"] == "" {
t.Errorf("error body missing reason: %q", resp.Body)
}
})
}
}
// TestHandleSpaceInPathIsSafelyEncodedNotPanic documents that a SPACE in
// the path — which crashed the old httptest.NewRequest path — is now safely
// percent-encoded by url.ParseRequestURI rather than rejected. The space is
// not an injection byte (it cannot split a header/log the way CR/LF/NUL
// can); encoding it to %20 is the correct, non-lossy, non-panicking
// outcome. The contract is "no panic, no smuggling", which encoding
// satisfies.
func TestHandleSpaceInPathIsSafelyEncodedNotPanic(t *testing.T) {
var gotPath string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path // decoded form
w.WriteHeader(http.StatusOK)
})
defer func() {
if r := recover(); r != nil {
t.Fatalf("space in path panicked: %v", r)
}
}()
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/x y"})
if resp.Status != http.StatusOK {
t.Fatalf("space-in-path got %d, want 200 (safely encoded)", resp.Status)
}
// The decoded path is intact; no second request line was smuggled.
if gotPath != "/v1/x y" {
t.Errorf("decoded path = %q, want %q", gotPath, "/v1/x y")
}
}
func TestHandleEmptyMethodPathStillDefaults(t *testing.T) {
// Backwards-compatible leniency: EMPTY method/path default to GET / "/".
// (Only NON-empty malformed values are rejected.)
var gotMethod, gotPath string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
w.WriteHeader(http.StatusNoContent)
})
resp := handle(context.Background(), h, Forward{}) // all zero
if resp.Status != http.StatusNoContent {
t.Fatalf("status: got %d want 204", resp.Status)
}
if gotMethod != http.MethodGet || gotPath != "/" {
t.Errorf("defaults wrong: method=%q path=%q want GET /", gotMethod, gotPath)
}
}
func TestHandleValidMethodsPass(t *testing.T) {
for _, m := range []string{
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodDelete, http.MethodHead, http.MethodOptions, "PROPFIND", "MKCOL",
} {
ran := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ran = true
w.WriteHeader(http.StatusOK)
})
resp := handle(context.Background(), h, Forward{Method: m, Path: "/v1/x"})
if !ran {
t.Errorf("method %q was rejected but is a valid token", m)
}
if resp.Status != http.StatusOK {
t.Errorf("method %q got status %d want 200", m, resp.Status)
}
}
}
// --- V2: FULL identity strip — smuggled identity must not survive --------
func TestHandleStripsFullIdentitySet(t *testing.T) {
// A hostile client packs every identity-bearing header into the Headers
// blob. None may reach the handler. Edge identity (here: only TenantID)
// is the sole survivor for the canonical 4, injected AFTER the strip.
smuggled := map[string][]string{
HeaderOrgID: {"evil-org"},
HeaderUserID: {"evil-user"},
HeaderUserIsAdmin: {"true"},
HeaderUserPerms: {"9223372036854775807"},
"X-Roles": {"admin,root"},
"X-User-Email": {"attacker@evil.test"},
"X-User-Role": {"superuser"},
"X-User-Roles": {"admin"},
"X-User-Name": {"mallory"},
"X-Tenant-Id": {"evil-tenant"},
"X-Tenant-ID": {"evil-tenant-2"},
"X-Org": {"evil"},
"X-Is-Admin": {"1"},
"X-Phone-Number": {"+10000000000"},
"X-Gateway-Validated": {"true"},
"X-Gateway-User-Id": {"evil"},
"X-Gateway-Org-Id": {"evil"},
"X-Gateway-User-Email": {"evil@evil.test"},
"X-IAM-User": {"evil"},
"X-IAM-Roles": {"admin"},
"X-Hanzo-Org": {"evil"},
"X-Hanzo-User-Id": {"evil"},
"Cookie": {"session=hijacked; iam_token=forged"},
// A benign header that MUST survive the strip.
"Content-Type": {"application/json"},
// Authorization MUST survive (the real auth path).
"Authorization": {"Bearer real.jwt.token"},
}
blob, _ := json.Marshal(smuggled)
var seen http.Header
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
handle(context.Background(), h, Forward{
TenantID: "real-org", // the only edge-asserted identity
Method: http.MethodGet,
Path: "/v1/whoami",
Headers: blob,
})
// 1. Every smuggled identity header is gone or overwritten by the edge.
if got := seen.Get(HeaderOrgID); got != "real-org" {
t.Errorf("X-Org-Id: got %q, smuggled value must be replaced by edge 'real-org'", got)
}
if got := seen.Get(HeaderUserID); got != "" {
t.Errorf("X-User-Id: smuggled %q survived (edge asserted none)", got)
}
// IsAdmin/Perms are re-injected from the (zero) Forward, overwriting the
// smuggled "true"/max-int.
if got := seen.Get(HeaderUserIsAdmin); got != "false" {
t.Errorf("X-User-IsAdmin: got %q want false (smuggled true must be overridden)", got)
}
if got := seen.Get(HeaderUserPerms); got != "0" {
t.Errorf("X-User-Permissions: got %q want 0 (smuggled max-int must be overridden)", got)
}
// 2. Every NON-injected identity header is fully absent.
for _, h := range []string{
"X-Roles", "X-User-Email", "X-User-Role", "X-User-Roles", "X-User-Name",
"X-Tenant-Id", "X-Tenant-ID", "X-Org", "X-Is-Admin", "X-Phone-Number",
"X-Gateway-Validated", "X-Gateway-User-Id", "X-Gateway-Org-Id", "X-Gateway-User-Email",
"X-IAM-User", "X-IAM-Roles", "X-Hanzo-Org", "X-Hanzo-User-Id",
"Cookie",
} {
if v := seen.Get(h); v != "" {
t.Errorf("identity header %q leaked through with value %q", h, v)
}
}
// 3. Authorization is PRESERVED — base validates the forwarded Bearer JWT.
if got := seen.Get("Authorization"); got != "Bearer real.jwt.token" {
t.Errorf("Authorization was stripped (%q); base needs it to validate the JWT", got)
}
// 4. Benign non-identity header survives.
if got := seen.Get("Content-Type"); got != "application/json" {
t.Errorf("benign Content-Type was dropped: %q", got)
}
}
func TestRequestToForwardStripsFullIdentityFromEnvelope(t *testing.T) {
// V2 gateway/ingress leg: a forged identity header must never even enter
// the Forward envelope. requestToForward promotes the edge-validated 4
// into typed fields and strips the FULL set from the carried Headers.
req, _ := http.NewRequest(http.MethodPost, "http://backend/v1/x", strings.NewReader("body"))
req.Header.Set(HeaderOrgID, "real-org")
req.Header.Set(HeaderUserID, "real-user")
req.Header.Set("X-Roles", "admin")
req.Header.Set("X-User-Email", "x@y.z")
req.Header.Set("X-IAM-Token", "forged")
req.Header.Set("X-Hanzo-Org", "evil")
req.Header.Set("X-Gateway-Validated", "true")
req.Header.Set("Cookie", "session=hijacked")
req.Header.Set("Authorization", "Bearer real.jwt")
req.Header.Set("Content-Type", "text/plain")
fwd, err := requestToForward(req)
if err != nil {
t.Fatalf("requestToForward: %v", err)
}
// The 4 are promoted to typed fields.
if fwd.TenantID != "real-org" || fwd.UserID != "real-user" {
t.Errorf("identity not promoted: org=%q user=%q", fwd.TenantID, fwd.UserID)
}
// The carried Headers blob contains NO identity header, but keeps
// Authorization + Content-Type.
var carried map[string][]string
if len(fwd.Headers) > 0 {
_ = json.Unmarshal(fwd.Headers, &carried)
}
get := func(k string) string {
// http.Header canonicalization is applied by Clone()+Del; the JSON
// keys are canonical. Look up case-insensitively to be safe.
for kk, vv := range carried {
if strings.EqualFold(kk, k) && len(vv) > 0 {
return vv[0]
}
}
return ""
}
for _, leak := range []string{
HeaderOrgID, HeaderUserID, "X-Roles", "X-User-Email",
"X-IAM-Token", "X-Hanzo-Org", "X-Gateway-Validated", "Cookie",
} {
if v := get(leak); v != "" {
t.Errorf("envelope carried forged identity header %q=%q", leak, v)
}
}
if get("Authorization") != "Bearer real.jwt" {
t.Errorf("Authorization missing from envelope; got %q", get("Authorization"))
}
if get("Content-Type") != "text/plain" {
t.Errorf("benign Content-Type missing from envelope; got %q", get("Content-Type"))
}
}
// --- V4: hostile header name/value in the Headers blob is filtered -------
func TestHandleFiltersHostileHeaderNamesAndValues(t *testing.T) {
// A crafted Headers blob with an invalid header name and a CRLF-bearing
// value must not inject headers into the served request.
blob, _ := json.Marshal(map[string][]string{
"X-Good": {"fine"},
"X-Bad Name": {"has space in name"}, // invalid token name
"X-Inject": {"ok\r\nX-Smuggled: evil"}, // CRLF in value
"X-NulVal": {"ok\x00evil"}, // NUL in value
"X-Mixed": {"first-ok", "bad\r\nsecond"}, // one good, one bad value
})
var seen http.Header
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/x", Headers: blob})
if resp.Status != http.StatusOK {
t.Fatalf("status: got %d want 200", resp.Status)
}
if seen.Get("X-Good") != "fine" {
t.Errorf("valid header dropped: X-Good=%q", seen.Get("X-Good"))
}
if got := seen.Get("X-Bad Name"); got != "" {
t.Errorf("invalid header name was added: %q", got)
}
if got := seen.Get("X-Smuggled"); got != "" {
t.Errorf("CRLF value smuggled a second header X-Smuggled=%q", got)
}
if got := seen.Get("X-Inject"); got != "" {
t.Errorf("CRLF-bearing value was added: %q", got)
}
if got := seen.Get("X-NulVal"); got != "" {
t.Errorf("NUL-bearing value was added: %q", got)
}
// X-Mixed: only the good value survives.
if vals := seen.Values("X-Mixed"); len(vals) != 1 || vals[0] != "first-ok" {
t.Errorf("X-Mixed values: got %v want [first-ok]", vals)
}
}
// --- V6: streaming response over Forward → 501, not a hang ---------------
func TestHandleStreamingResponseReturns501(t *testing.T) {
for _, ct := range []string{
"text/event-stream",
"text/event-stream; charset=utf-8",
"Text/Event-Stream",
} {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", ct)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: hi\n\n"))
})
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/stream"})
if resp.Status != http.StatusNotImplemented {
t.Errorf("ct=%q: got status %d want 501", ct, resp.Status)
}
if !strings.Contains(string(resp.Body), "streaming not supported") {
t.Errorf("ct=%q: body %q lacks streaming-not-supported reason", ct, resp.Body)
}
}
}
func TestHandleNonStreamingResponsePassesThrough(t *testing.T) {
// A normal JSON response is NOT mistaken for streaming.
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/x"})
if resp.Status != http.StatusOK {
t.Fatalf("non-streaming response got %d want 200", resp.Status)
}
if string(resp.Body) != `{"ok":true}` {
t.Errorf("body: %q", resp.Body)
}
}
+33 -3
View File
@@ -12,6 +12,7 @@ import (
"io"
"log/slog"
"net"
"runtime/debug"
"sync"
"time"
@@ -494,7 +495,7 @@ func (n *Node) dispatchLoop(netConn net.Conn, conn *Conn, peerID string) {
ref.release()
continue
}
resp, herr := handler(n.ctx, peerID, msg)
resp, herr := n.safeHandle(handler, peerID, msgType, msg)
if herr != nil {
n.logger.Error("Handler error", "peerID", peerID, "msgType", msgType, "error", herr)
ref.release()
@@ -531,7 +532,7 @@ func (n *Node) dispatchLoop(netConn net.Conn, conn *Conn, peerID string) {
ref.release()
continue
}
resp, herr := handler(n.ctx, peerID, msg)
resp, herr := n.safeHandle(handler, peerID, msgType, msg)
if herr != nil {
n.logger.Error("Handler error", "peerID", peerID, "msgType", msgType, "error", herr)
ref.release()
@@ -728,7 +729,11 @@ func (n *Node) getOrConnect(peerID string) (*Conn, error) {
n.handlersMu.RUnlock()
if ok {
handler(n.ctx, peerID, msg)
// Guarded: a handler panic here must not kill this receive
// goroutine / the node. The error return is intentionally
// dropped — this is the fire-and-forget receive path for an
// outbound-dialed peer; safeHandle has already logged.
_, _ = n.safeHandle(handler, peerID, msgType, msg)
}
ref.release()
}
@@ -958,3 +963,28 @@ func attachToMessage(m *Message, ref *bufRef) {
m.refs = ref
}
}
// safeHandle invokes a registered handler with a recover() guard. A handler
// is application code (e.g. forward.Serve's HTTP bridge) reachable directly
// from attacker-controlled bytes on the wire; a panic inside it — out-of-
// range index on a malformed envelope, a nil deref, a third-party library
// fault — must NEVER unwind into the per-connection dispatch goroutine and
// crash the whole node/process. On panic we log with the peer and message
// type and return an error so the dispatch loop drops that one connection;
// every other connection and the node itself survive.
//
// This is the single recover boundary for ALL handler dispatch in this
// node (correlated Call requests and uncorrelated messages alike). It is
// the one-and-only place a handler panic is contained.
func (n *Node) safeHandle(handler Handler, peerID string, msgType uint16, msg *Message) (resp *Message, err error) {
defer func() {
if r := recover(); r != nil {
n.logger.Error("ZAP handler panic recovered",
"peerID", peerID, "msgType", msgType, "panic", r,
"stack", string(debug.Stack()))
resp = nil
err = fmt.Errorf("handler panic (msgType=%d): %v", msgType, r)
}
}()
return handler(n.ctx, peerID, msg)
}
+11 -7
View File
@@ -204,7 +204,10 @@ func (n *Node) handleCallStream(peerID string, stream TransportStream) {
return
}
resp, herr := handler(n.ctx, peerID, msg)
// Guarded: a handler panic on the QUIC per-Call stream must not crash
// the node any more than on the TCP path. safeHandle recovers, logs,
// and converts the panic to an error; the stream is then closed.
resp, herr := n.safeHandle(handler, peerID, msgType, msg)
if herr != nil {
n.logger.Error("Handler error (stream)", "peerID", peerID, "msgType", msgType, "error", herr)
return
@@ -227,7 +230,9 @@ func (n *Node) invokeHandlerOneWay(peerID string, msg *Message) {
if !ok {
return
}
_, _ = handler(n.ctx, peerID, msg)
// Guarded: same recover boundary as every other dispatch path so a
// panicking one-way handler cannot kill the node.
_, _ = n.safeHandle(handler, peerID, msgType, msg)
}
// quicCall is the QUIC path for Node.Call.
@@ -255,10 +260,10 @@ func (n *Node) quicCall(ctx context.Context, peerID string, msg *Message) (*Mess
// quicCallStream runs one Call on a fresh per-Call QUIC stream.
//
// The stream lifecycle is:
// 1. OpenCallStream — server peer's AcceptCallStream wakes.
// 2. WriteFrame(req) — single length-prefixed ZAP frame.
// 3. ReadFrame() — single length-prefixed ZAP frame (response).
// 4. Close — stream ID returns to the QUIC pool.
// 1. OpenCallStream — server peer's AcceptCallStream wakes.
// 2. WriteFrame(req) — single length-prefixed ZAP frame.
// 3. ReadFrame() — single length-prefixed ZAP frame (response).
// 4. Close — stream ID returns to the QUIC pool.
//
// No correlation header is needed on the wire: each stream carries
// exactly one request + one response. The response payload is the raw
@@ -379,4 +384,3 @@ func (n *Node) getOrConnectQUIC(ctx context.Context, peerID string) (TransportCo
go n.serveTransportConn(peerID, newTC)
return newTC, nil
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap_test
import (
"context"
"testing"
"time"
zap "github.com/luxfi/zap"
)
// buildTypedMsg builds a one-field message tagged msgType so the dispatcher
// routes it to that handler. Mirrors buildEchoMessage but with a caller-
// chosen type.
func buildTypedMsg(t *testing.T, msgType uint16, v uint64) *zap.Message {
t.Helper()
b := zap.NewBuilder(64)
o := b.StartObject(16)
o.SetUint64(0, v)
o.FinishAsRoot()
msg, err := zap.Parse(b.FinishWithFlags(msgType << 8))
if err != nil {
t.Fatalf("build typed msg: %v", err)
}
return msg
}
// TestQUICHandlerPanicDoesNotCrashNode proves the recover() guard also
// covers the QUIC transport's per-Call stream dispatch (handleCallStream).
// A handler that panics over QUIC must not unwind into the stream goroutine
// and crash the node: safeHandle recovers, the panicking Call fails/closes,
// and a subsequent valid Call to a different handler on the SAME server node
// still succeeds.
func TestQUICHandlerPanicDoesNotCrashNode(t *testing.T) {
const (
qmtPanic uint16 = 0x93
qmtEcho uint16 = 0x94
)
srvAddr, srv, cli := newQUICTestPair(t, "qsrv-panic", "qcli-panic")
defer srv.Stop()
defer cli.Stop()
srv.Handle(qmtPanic, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
panic("quic handler blew up")
})
srv.Handle(qmtEcho, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
return buildTypedMsg(t, qmtEcho, msg.Root().Uint64(0)+1), nil
})
if err := cli.ConnectDirect(srvAddr); err != nil {
t.Fatalf("ConnectDirect: %v", err)
}
waitForPeer(t, srv, "qcli-panic", 2*time.Second)
waitForPeer(t, cli, "qsrv-panic", 2*time.Second)
// 1. Fire the panic-inducing Call over QUIC. The stream is torn down on
// recover; the Call errors or times out. Process MUST stay up.
ctx1, cancel1 := context.WithTimeout(context.Background(), 2*time.Second)
_, err := cli.Call(ctx1, "qsrv-panic", buildTypedMsg(t, qmtPanic, 1))
cancel1()
if err == nil {
t.Log("QUIC panic Call returned nil error; node-survival is the real assertion")
} else {
t.Logf("QUIC panic Call errored as expected: %v", err)
}
// 2. THE PROOF: the server node survived. A valid echo Call on the same
// node over a fresh QUIC stream still works.
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel2()
resp, err := cli.Call(ctx2, "qsrv-panic", buildTypedMsg(t, qmtEcho, 41))
if err != nil {
t.Fatalf("echo Call after QUIC handler panic failed — node did not survive: %v", err)
}
if got := resp.Root().Uint64(0); got != 42 {
t.Errorf("echo returned %d want 42 (node alive but handler misbehaved)", got)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap
import (
"context"
"net"
"testing"
"time"
)
// pickFreePortNode returns an OS-assigned free TCP port.
func pickFreePortNode(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
func waitPeersNode(t *testing.T, n *Node) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(n.Peers()) > 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("no peers after 2s")
}
const (
mtPanic uint16 = 0x90 // handler that panics
mtEcho uint16 = 0x91 // handler that echoes — proves the node survived
)
// buildTyped makes a minimal typed message carrying a single uint32 so the
// dispatcher routes it by msgType (Flags()>>8) and the echo handler has
// something to read back.
func buildTyped(t *testing.T, msgType uint16, payload uint32) *Message {
t.Helper()
b := NewBuilder(64)
ob := b.StartObject(8)
ob.SetUint32(0, payload)
ob.FinishAsRoot()
msg, err := Parse(b.FinishWithFlags(msgType << 8))
if err != nil {
t.Fatalf("build typed msg: %v", err)
}
return msg
}
// TestHandlerPanicDoesNotCrashNode proves the V4 dispatch-layer fix: a
// handler that panics on a Call request must NOT unwind into the node's
// dispatch goroutine and crash the process. The recover() in safeHandle
// must fire, the offending Call returns an error to the caller (the
// connection is dropped), and the SERVER NODE keeps running — a fresh
// connection + a valid Call to a different handler still succeeds.
func TestHandlerPanicDoesNotCrashNode(t *testing.T) {
srvPort := pickFreePortNode(t)
cliPort := pickFreePortNode(t)
srv := NewNode(NodeConfig{NodeID: "server", Port: srvPort, NoDiscovery: true})
cli := NewNode(NodeConfig{NodeID: "client", Port: cliPort, NoDiscovery: true})
// A handler that always panics — simulates an out-of-range index on a
// malformed envelope, a nil deref, or a third-party fault.
srv.Handle(mtPanic, func(ctx context.Context, from string, msg *Message) (*Message, error) {
panic("hostile input blew up the handler")
})
// A well-behaved echo handler on a different type.
srv.Handle(mtEcho, func(ctx context.Context, from string, msg *Message) (*Message, error) {
v := msg.Root().Uint32(0)
return buildTyped(t, mtEcho, v+1), nil
})
if err := srv.Start(); err != nil {
t.Fatalf("srv.Start: %v", err)
}
defer srv.Stop()
if err := cli.Start(); err != nil {
t.Fatalf("cli.Start: %v", err)
}
defer cli.Stop()
if err := cli.ConnectDirect("127.0.0.1:" + itoa(srvPort)); err != nil {
t.Fatalf("ConnectDirect: %v", err)
}
waitPeersNode(t, cli)
// 1. Fire the panic-inducing Call. safeHandle recovers; the handler
// returns an error, so the dispatch loop logs + drops that connection.
// From the caller's view the Call either errors or times out — either
// way it does NOT bring the process down.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, err := cli.Call(ctx, "server", buildTyped(t, mtPanic, 1))
cancel()
// We don't assert the exact error: the contract is "node survives", not
// "specific error shape". A nil error would be wrong (no valid response
// exists), but the load-bearing assertion is step 2 below.
if err == nil {
t.Log("panic Call returned nil error (response channel closed); node-survival is the real assertion")
} else {
t.Logf("panic Call errored as expected: %v", err)
}
// 2. THE PROOF: the server node is still alive. Reconnect (the prior
// conn may have been dropped) and issue a valid Call to the echo
// handler. If the node had crashed, this fails.
cli2 := NewNode(NodeConfig{NodeID: "client2", Port: pickFreePortNode(t), NoDiscovery: true})
if err := cli2.Start(); err != nil {
t.Fatalf("cli2.Start: %v", err)
}
defer cli2.Stop()
if err := cli2.ConnectDirect("127.0.0.1:" + itoa(srvPort)); err != nil {
t.Fatalf("server node died after handler panic — reconnect failed: %v", err)
}
waitPeersNode(t, cli2)
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
resp, err := cli2.Call(ctx2, "server", buildTyped(t, mtEcho, 41))
if err != nil {
t.Fatalf("echo Call after panic failed — node did not survive: %v", err)
}
if got := resp.Root().Uint32(0); got != 42 {
t.Errorf("echo returned %d want 42 (node alive but handler misbehaved)", got)
}
}
// itoa is a tiny strconv.Itoa to keep this test file's imports minimal.
func itoa(i int) string {
if i == 0 {
return "0"
}
var b [20]byte
pos := len(b)
for i > 0 {
pos--
b[pos] = byte('0' + i%10)
i /= 10
}
return string(b[pos:])
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap
import (
"bytes"
"encoding/hex"
"testing"
)
// Cross-wire conformance: this runtime (github.com/luxfi/zap) and the
// pure-stdlib baseline runtime (github.com/zap-proto/go) implement the SAME
// wire format. zap-proto/go is the canonical runtime this package is
// converging onto (see MIGRATION.md "ZAP runtime consolidation"). This test
// pins the contract from THIS side WITHOUT importing zap-proto/go, so neither
// repo takes a build dependency on the other yet.
//
// The proof meets the zap-proto/go side at a shared golden vector: both repos
// pin the byte-for-byte identical goldenV1Hex constant and assert their own
// Builder emits it / their own reader decodes it. Changing the wire on one
// side without the other is a wire break and MUST fail CI in both repos.
//
// field @0 uint32 = 0xDEADBEEF
// field @8 text = "zap"
// field @16 bytes = 01 02 03 04
// dataSize = 24
//
// luxfi/zap's default NewBuilder tags Version2 (the v3 platformvm TxKind
// discriminator schema). The shared golden vector is a Version1 header, so the
// luxfi encode side builds via NewBuilderV1 to meet zap-proto's default
// (Version1) byte-for-byte. The v2 default is asserted to differ ONLY at the
// version byte — the single documented header delta between the runtimes.
const (
xwU32 = 0
xwText = 8
xwBytes = 16
xwSize = 24
// goldenV1Hex — SHARED VERBATIM with zap-proto/go's zap_crosswire_test.go.
// 47-byte canonical ZAP buffer (Version1) for the layout above.
goldenV1Hex = "5a41500001000000100000002f000000efbeadde0000000010000000030000000b000000040000007a617001020304"
)
func buildCanonicalV1(tb testing.TB) []byte {
tb.Helper()
b := NewBuilderV1(64)
ob := b.StartObject(xwSize)
ob.SetUint32(xwU32, 0xDEADBEEF)
ob.SetText(xwText, "zap")
ob.SetBytes(xwBytes, []byte{0x01, 0x02, 0x03, 0x04})
ob.FinishAsRoot()
return b.Finish()
}
func buildCanonicalV2(tb testing.TB) []byte {
tb.Helper()
b := NewBuilder(64) // default = Version2
ob := b.StartObject(xwSize)
ob.SetUint32(xwU32, 0xDEADBEEF)
ob.SetText(xwText, "zap")
ob.SetBytes(xwBytes, []byte{0x01, 0x02, 0x03, 0x04})
ob.FinishAsRoot()
return b.Finish()
}
// TestCrossWireGoldenEncodeV1 proves luxfi's Builder (v1 mode) emits exactly
// the shared golden vector — byte-for-byte identical to zap-proto/go's
// default NewBuilder output. This is the load-bearing "the two runtimes
// produce the same wire" assertion.
func TestCrossWireGoldenEncodeV1(t *testing.T) {
got := buildCanonicalV1(t)
want, err := hex.DecodeString(goldenV1Hex)
if err != nil {
t.Fatalf("decode goldenV1Hex: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("luxfi v1 encode != shared golden:\n got %s\n want %s", hex.EncodeToString(got), goldenV1Hex)
}
}
// TestCrossWireGoldenDecode proves luxfi's reader decodes the shared golden
// vector field-by-field — the "decode with the other" leg (the bytes are the
// canonical wire, identical to zap-proto's NewBuilder output).
func TestCrossWireGoldenDecode(t *testing.T) {
buf, err := hex.DecodeString(goldenV1Hex)
if err != nil {
t.Fatalf("decode goldenV1Hex: %v", err)
}
msg, err := Parse(buf)
if err != nil {
t.Fatalf("Parse(golden) failed: %v", err)
}
if msg.Version() != Version1 {
t.Fatalf("golden Version = %d, want %d", msg.Version(), Version1)
}
r := msg.Root()
if got := r.Uint32(xwU32); got != 0xDEADBEEF {
t.Errorf("Uint32 = %#x, want 0xDEADBEEF", got)
}
if got := r.Text(xwText); got != "zap" {
t.Errorf("Text = %q, want %q", got, "zap")
}
if got := r.Bytes(xwBytes); !bytes.Equal(got, []byte{0x01, 0x02, 0x03, 0x04}) {
t.Errorf("Bytes = %v, want [1 2 3 4]", got)
}
}
// TestCrossWireV2DiffersOnlyAtVersionByte proves the single documented header
// delta: luxfi's default NewBuilder (Version2) output equals the golden v1
// vector with ONLY byte 4 changed from 1 to 2. The data segment past the
// magic+version prefix is byte-identical. This is exactly the buffer zap-proto
// must (now) accept on read — verified on the zap-proto side by
// TestCrossWireAcceptsV2.
func TestCrossWireV2DiffersOnlyAtVersionByte(t *testing.T) {
v1, err := hex.DecodeString(goldenV1Hex)
if err != nil {
t.Fatalf("decode goldenV1Hex: %v", err)
}
v2 := buildCanonicalV2(t)
if len(v2) != len(v1) {
t.Fatalf("v2 len %d != v1 len %d", len(v2), len(v1))
}
if v2[4] != byte(Version2) {
t.Fatalf("v2 version byte = %d, want %d", v2[4], Version2)
}
// Reconstruct the expected v2 buffer: v1 with byte 4 := 2.
wantV2 := append([]byte(nil), v1...)
wantV2[4] = byte(Version2)
if !bytes.Equal(v2, wantV2) {
t.Fatalf("luxfi v2 encode differs from v1 in more than the version byte:\n v2 %s\n v1 %s", hex.EncodeToString(v2), goldenV1Hex)
}
// Data segment identity (past magic+version).
if !bytes.Equal(v1[6:], v2[6:]) {
t.Fatal("v1 and v2 data segments diverge past the version byte")
}
}