From 2ba428c21b3c8c6f3a2e5cc1c546c00b65db216d Mon Sep 17 00:00:00 2001 From: zeekay Date: Thu, 18 Jun 2026 13:39:56 -0700 Subject: [PATCH] fix(forward+node): canonical HTTP-over-ZAP security hardening (v0.8.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- MIGRATION.md | 38 ++++ forward/client.go | 14 +- forward/identity.go | 85 +++++++++ forward/serve.go | 208 ++++++++++++++++++--- forward/serve_test.go | 368 ++++++++++++++++++++++++++++++++++++++ node.go | 36 +++- node_quic.go | 18 +- node_quic_recover_test.go | 80 +++++++++ node_recover_test.go | 149 +++++++++++++++ zap_crosswire_test.go | 138 ++++++++++++++ 10 files changed, 1098 insertions(+), 36 deletions(-) create mode 100644 forward/identity.go create mode 100644 forward/serve_test.go create mode 100644 node_quic_recover_test.go create mode 100644 node_recover_test.go create mode 100644 zap_crosswire_test.go diff --git a/MIGRATION.md b/MIGRATION.md index ef25d1d..8f2b6fb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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. diff --git a/forward/client.go b/forward/client.go index 6ee9ccf..4320d98 100644 --- a/forward/client.go +++ b/forward/client.go @@ -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)) diff --git a/forward/identity.go b/forward/identity.go new file mode 100644 index 0000000..d4c05d1 --- /dev/null +++ b/forward/identity.go @@ -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") +} diff --git a/forward/serve.go b/forward/serve.go index 268b843..058b10a 100644 --- a/forward/serve.go +++ b/forward/serve.go @@ -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 +} diff --git a/forward/serve_test.go b/forward/serve_test.go new file mode 100644 index 0000000..8a05e7b --- /dev/null +++ b/forward/serve_test.go @@ -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) + } +} diff --git a/node.go b/node.go index 979436a..56a7726 100644 --- a/node.go +++ b/node.go @@ -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) +} diff --git a/node_quic.go b/node_quic.go index 5709a99..36d346f 100644 --- a/node_quic.go +++ b/node_quic.go @@ -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 } - diff --git a/node_quic_recover_test.go b/node_quic_recover_test.go new file mode 100644 index 0000000..c48752f --- /dev/null +++ b/node_quic_recover_test.go @@ -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) + } +} diff --git a/node_recover_test.go b/node_recover_test.go new file mode 100644 index 0000000..9942614 --- /dev/null +++ b/node_recover_test.go @@ -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:]) +} diff --git a/zap_crosswire_test.go b/zap_crosswire_test.go new file mode 100644 index 0000000..62068d9 --- /dev/null +++ b/zap_crosswire_test.go @@ -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") + } +}