Artifact e0bc15864885b9f3614b809cd76691cbf30ccd19ad30d7d6a5f5770dd11666fc:
- File
clone_realfossil_content_type_test.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: 3998) [annotate] [blame] [check-ins using]
package libfossil_test import ( "context" "fmt" "net" "os" "os/exec" "path/filepath" "testing" "time" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/testutil" ) // TestCloneFromRealFossilUncompressedReply is the end-to-end regression for // issue #164: cloning through the public HTTP Transport against a real fossil // server must succeed on the very first exchange round, even though that // server's clone reply uses the §4 uncompressed/plain-card framing rather than // the §4.1 compressed container. // // Before the fix, the adapter bridging the public byte-oriented Transport to // the internal xfer decoder hardcoded the compressed framing. Against a real // fossil server it read the first four bytes of the plain card text // (`prag`... from `pragma server-version ...`) as a big-endian length prefix, // so decode failed immediately with "declared container length ... exceeds ... // bytes" — round one, before a single card was parsed. // // This drives a genuine clone against a real fossil-served repository and is // skipped cleanly when no fossil binary is present. func TestCloneFromRealFossilUncompressedReply(t *testing.T) { bin := testutil.RequireFossilBin(t) dir := t.TempDir() srcRepo := filepath.Join(dir, "source.fossil") // Build a small real fossil repository with content to clone. runFossil(t, bin, dir, "init", srcRepo) work := filepath.Join(dir, "work") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatalf("mkdir work: %v", err) } runFossil(t, bin, work, "open", srcRepo) if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("hello from real fossil\n"), 0o644); err != nil { t.Fatalf("write hello.txt: %v", err) } runFossil(t, bin, work, "add", "hello.txt") runFossil(t, bin, work, "commit", "-m", "initial commit", "--no-warnings") // Serve it over HTTP. --localhost auto-authenticates localhost requests as // the setup user, so an anonymous clone has read access. port := freeTCPPort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() server := exec.CommandContext(ctx, bin, "server", srcRepo, "--localhost", "--port", fmt.Sprintf("%d", port)) server.Stdout = os.Stderr server.Stderr = os.Stderr if err := server.Start(); err != nil { t.Fatalf("start fossil server: %v", err) } defer func() { _ = server.Process.Kill() }() addr := fmt.Sprintf("127.0.0.1:%d", port) waitTCP(t, addr) // Clone through the public HTTP transport — the path that carried the bug. url := "http://" + addr + "/" clonePath := filepath.Join(dir, "clone.fossil") repo, result, err := libfossil.Clone(ctx, clonePath, libfossil.NewHTTPTransport(url), libfossil.CloneOpts{}) if err != nil { t.Fatalf("clone from real fossil server failed: %v", err) } defer repo.Close() if result.Rounds < 1 { t.Fatalf("clone reported %d rounds, want >= 1", result.Rounds) } if result.BlobsRecvd < 1 { t.Fatalf("clone received %d blobs, want >= 1 (the committed artifacts)", result.BlobsRecvd) } } // runFossil runs a fossil subcommand in dir and fails the test on error. func runFossil(t *testing.T, bin, dir string, args ...string) { t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("fossil %v: %v\n%s", args, err, out) } } // freeTCPPort reserves an ephemeral TCP port and returns it. func freeTCPPort(t *testing.T) int { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserve port: %v", err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port } // waitTCP dials addr until it accepts or the deadline passes. func waitTCP(t *testing.T, addr string) { t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err == nil { c.Close() return } time.Sleep(20 * time.Millisecond) } t.Fatalf("port %s never accepted", addr) }