Artifact fceeed4212b15a65719a04e87207ca5027898b3a5f9e88ae88a4acbcad4f2f0f:
- File
transport.go
— part of check-in
[21a447e620]
at
2026-07-25 02:15:37
on branch trunk
— fix: decode sync replies by the wire's real Content-Type, not a hardcoded assumption (#165)
The public byte-oriented Transport discarded the reply's Content-Type, and the adapter bridging it to the internal xfer decoder hardcoded the §4.1 compressed container framing unconditionally. That assumption holds only when both peers are this implementation (Message.Encode always emits the compressed container). Against a peer that legitimately replies uncompressed -- a real Fossil clone-v3 reply uses plain card text -- the adapter read the first four bytes of the card text (`prag` from `pragma server-version`) as a big-endian length prefix and failed on exchange round 0 with "declared container length ... exceeds ... bytes".
Add an optional FramedTransport interface a Transport may also implement to report the framing signal (e.g. an HTTP Content-Type) it observed on a reply, via a per-call RoundTripFramed return value -- no shared mutable state, so a reused transport stays safe. httpTransport implements it by returning the HTTP response Content-Type. transportAdapter.Exchange decodes by that signal when the transport reports one and otherwise falls back to the compressed framing, so MockTransport / TransportFunc and any fixed-framing peer transport keep working unchanged.
The low-level xfer.Decode framing selection is unchanged. No trial-and-error framing detection is reintroduced. (user: [email protected], size: 4281) [annotate] [blame] [check-ins using]
package libfossil import ( "bytes" "context" "fmt" "io" "net/http" ) // Transport delivers sync payloads between peers. // Implementations handle the network layer (HTTP, NATS, etc.). // Payloads are opaque zlib-compressed xfer card streams. type Transport interface { RoundTrip(ctx context.Context, payload []byte) ([]byte, error) } // FramedTransport is an optional interface a Transport may also implement when // it can report the framing signal — such as an HTTP Content-Type header — that // accompanied a reply. Sync uses that signal to select the reply's §4 framing // instead of assuming the §4.1 compressed container, which matters when the // peer is a different Fossil-protocol implementation that legitimately replies // with the uncompressed plain-card framing (e.g. a real Fossil server's clone // reply). // // It is optional in the same way http.Flusher is optional on an // http.ResponseWriter: a Transport whose framing is fixed by convention (both // ends always emit the compressed container) need not implement it, and sync // falls back to assuming the compressed framing for such a Transport. Callers // type-assert a Transport to FramedTransport and use RoundTripFramed when it is // available. Reporting the signal as a return value keeps it per-call, with no // shared mutable state to make a reused Transport unsafe across concurrent // calls. type FramedTransport interface { Transport // RoundTripFramed behaves like RoundTrip but also returns the framing // signal (e.g. the reply's Content-Type) the wire carried for this reply, // or "" when the transport observed none. RoundTripFramed(ctx context.Context, payload []byte) (resp []byte, contentType string, err error) } // NewHTTPTransport creates a Transport that speaks Fossil's HTTP /xfer protocol. func NewHTTPTransport(url string, opts ...HTTPOption) Transport { t := &httpTransport{url: url, client: http.DefaultClient} for _, o := range opts { o(t) } return t } // HTTPOption configures an HTTP transport. type HTTPOption func(*httpTransport) // WithHTTPClient sets a custom http.Client for the transport. func WithHTTPClient(c *http.Client) HTTPOption { return func(t *httpTransport) { t.client = c } } type httpTransport struct { url string client *http.Client } func (t *httpTransport) RoundTrip(ctx context.Context, payload []byte) ([]byte, error) { body, _, err := t.roundTrip(ctx, payload) return body, err } // RoundTripFramed implements FramedTransport: it returns the reply body along // with the reply's Content-Type, which selects the §4 framing for decoding. A // peer that replies uncompressed (e.g. a real Fossil clone reply) is decoded // correctly instead of being force-read as the compressed container. func (t *httpTransport) RoundTripFramed(ctx context.Context, payload []byte) ([]byte, string, error) { return t.roundTrip(ctx, payload) } func (t *httpTransport) roundTrip(ctx context.Context, payload []byte) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, "POST", t.url, bytes.NewReader(payload)) if err != nil { return nil, "", fmt.Errorf("libfossil: http transport: %w", err) } req.Header.Set("Content-Type", "application/x-fossil") resp, err := t.client.Do(req) if err != nil { return nil, "", fmt.Errorf("libfossil: http transport: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, "", fmt.Errorf("libfossil: http transport read: %w", err) } return body, resp.Header.Get("Content-Type"), nil } // MockTransport is a test double that delegates to a handler function. type MockTransport struct { Handler func(req []byte) []byte } // RoundTrip calls the Handler function if set, otherwise returns empty bytes. func (t *MockTransport) RoundTrip(_ context.Context, payload []byte) ([]byte, error) { if t.Handler == nil { return []byte{}, nil } return t.Handler(payload), nil } // TransportFunc adapts a plain function to the Transport interface. // This is the Transport equivalent of http.HandlerFunc. type TransportFunc func(ctx context.Context, payload []byte) ([]byte, error) // RoundTrip calls the function. func (f TransportFunc) RoundTrip(ctx context.Context, payload []byte) ([]byte, error) { return f(ctx, payload) }