package xfer
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"strconv"
"testing"
)
// A pushed file's CONTENT is never read as cards (zit#213). A Go file
// holding an SQL column named "login" became a login card for user
// "TEXT" that beat the real x-f-l-c cookie, so every push carrying
// that file was refused as TEXT. The payload here carries the exact
// line, plus a "clone" and a "push" line that used to flip newClone and
// overwrite the project code.
func TestFileContentIsNeverReadAsCards(t *testing.T) {
payload := "package oltp\n\tlog" + "in TEXT NOT NULL,\n" +
"clo" + "ne 3 1\n" + "pu" + "sh evil WRONGCODE\n"
body := []byte("pragma client-version 20260926 x y\n" +
"push SRV PROJ123\n" +
"file 0123456789abcdef0123456789abcdef01234567 " + strconv.Itoa(len(payload)) + "\n" +
payload +
"igot 89abcdef0123456789abcdef0123456789abcdef\n")
pr := scanRequest(body)
for what, bad := range map[string]bool{
"took a login card out of the file content": pr.user != "",
"read a clone card out of the file content": pr.newClone,
"took the project code out of the file": pr.projectCode != "PROJ123",
"lost the real push card": !pr.hasPush,
} {
if bad {
t.Errorf("FAIL: the scan %s", what)
} else {
t.Logf("ok: the scan never %s", what)
}
}
// Round 2+ of a push authenticates ONLY by the x-f-l-c cookie, whose
// nonce covers the whole body. The pusher must be the cookie's user,
// which is the path that failed: a test with a real body login card
// would pass even with the old preference for the body.
const secret = "8d4a1f2b9c0e7a6d5b3f8e1c2a9d4b7e6f0c3a15"
sum := sha1.Sum(body)
nonce := hex.EncodeToString(sum[:])
sig := sha1.Sum([]byte(nonce + secret))
req := probe(fossilUA)
req.AddCookie(&http.Cookie{Name: "x-f-l-c", Value: "login+alice+" + nonce + "+" + hex.EncodeToString(sig[:])})
f := New()
f.SetAuth(secrets{"alice": secret})
if r := f.reader("alpha", pr, requestLogin(req, pr, body), req, false); r.Account != "alice" {
t.Errorf("FAIL: the round's reader is %q (claimed %q), want alice from the cookie", r.Account, r.Claimed)
} else {
t.Log("ok: the cookie's user is the reader, not a line inside a file")
}
}
// A payload card whose framing this gate cannot follow exactly as zit
// would makes the request a push, so it needs a verified credential.
// zit aborts such a request anyway; the gate must never let a
// difference in parsing become a write it did not authenticate.
func TestUnframableRequestsFailClosed(t *testing.T) {
for name, body := range map[string]string{
"a signed size": "file 0123456789abcdef0123456789abcdef01234567 +5\nhello",
"a size with a gap": "file 0123456789abcdef0123456789abcdef01234567 1_0\n0123456789",
"a size past the end": "file 0123456789abcdef0123456789abcdef01234567 99\nshort",
"a bad arity": "file 5\nhello",
"config without LF": "config /x 3\nabcZ",
"a cfile in a request": "cfile 0123456789abcdef0123456789abcdef01234567 5 3\nabc",
} {
if pr := scanRequest([]byte(body)); !pr.hasPush {
t.Errorf("FAIL: %s did not fail closed", name)
} else {
t.Logf("ok: %s fails closed", name)
}
}
// A well-framed config card is read past, and a card after it counts.
if pr := scanRequest([]byte("config /x 3\nabc\npull aaaa bbbb\n")); !pr.hasPull || len(pr.pushCards) > 0 {
t.Errorf("FAIL: a framed config card threw the scan off (pull=%v push cards=%d)", pr.hasPull, len(pr.pushCards))
} else {
t.Log("ok: a framed config card is skipped exactly")
}
}
// The byte counts are exact, because an off-by-one in either direction
// is an auth bypass: a card that zit reads right after a payload but
// the gate swallows (or misreads) is a push the gate never saw. Each
// case puts a real card IMMEDIATELY after the payload, with no
// separator, the way zit frames a file (sync.zig consumeContentPayload).
func TestPayloadFramingIsExactToTheByte(t *testing.T) {
const h = "0123456789abcdef0123456789abcdef01234567"
for _, tc := range []struct {
name, body string
push bool
code string
}{
// Skipping SIZE+1 swallows the "p"; SIZE-1 leaves an "o" in front.
// Either way the push card vanishes and the push goes unauthenticated.
{"a file followed at once by a push", "file " + h + " 5\nhello" + "push SRV PROJ\n", true, "PROJ"},
// The delta form: file HASH DELTASRC SIZE.
{"a delta-form file followed at once by a push", "file " + h + " " + h + " 5\nhello" + "push SRV PROJ\n", true, "PROJ"},
// A config payload must END in its LF. Without the check, the
// gate would skip "abcX" and then read the push card that zit
// never reaches, because zit refuses the request at the missing LF.
{"a config without its LF fails closed before any later card", "config /x 3\nabcXpush SRV PROJ\n", true, ""},
// With its LF, the card after it is read.
{"a config with its LF, then a push", "config /x 3\nabc\npush SRV PROJ\n", true, "PROJ"},
} {
pr := scanRequest([]byte(tc.body))
if pr.hasPush != tc.push || pr.projectCode != tc.code {
t.Errorf("FAIL: %s: push=%v code=%q, want push=%v code=%q", tc.name, pr.hasPush, pr.projectCode, tc.push, tc.code)
} else {
t.Logf("ok: %s", tc.name)
}
}
}
// The same case through the PUSH gate itself (xfer.go, the pusher
// check), not only the reader: a round authenticated solely by the
// x-f-l-c cookie, carrying the fossiluser.go line inside a file
// payload, must reach the child as the cookie's user. Without the
// cookie the same push is refused, so the gate is still a gate.
func TestACookiePushCarryingALoginLineInAFileGoesThrough(t *testing.T) {
const secret = "8d4a1f2b9c0e7a6d5b3f8e1c2a9d4b7e6f0c3a15"
const h = "0123456789abcdef0123456789abcdef01234567"
payload := "\tlog" + "in TEXT NOT NULL,\n"
body := []byte("pragma client-version 20260926 x y\n" +
"push SRV PROJ123\n" +
"file " + h + " " + strconv.Itoa(len(payload)) + "\n" + payload)
for _, withCookie := range []bool{true, false} {
reached := false
child := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/x-fossil")
w.Write(encodeContainer(nil))
}))
f := New()
f.SetAuth(secrets{"alice": secret})
f.Register("alpha", t.TempDir())
f.mu.Lock()
f.repos["alpha"].port = childPort(t, child)
f.repos["alpha"].cmd = &exec.Cmd{}
f.mu.Unlock()
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
srv := httptest.NewServer(mux)
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha", bytes.NewReader(encodeContainer(body)))
req.Header.Set("Content-Type", "application/x-fossil")
if withCookie {
sum := sha1.Sum(body)
nonce := hex.EncodeToString(sum[:])
sig := sha1.Sum([]byte(nonce + secret))
req.AddCookie(&http.Cookie{Name: "x-f-l-c", Value: "login+alice+" + nonce + "+" + hex.EncodeToString(sig[:])})
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
reply, _ := io.ReadAll(resp.Body)
resp.Body.Close()
srv.Close()
f.Close()
child.Close()
switch {
case withCookie && !reached:
t.Errorf("FAIL: a cookie-signed push was refused: %q", firstError(decodeContainer(reply)))
case !withCookie && reached:
t.Error("FAIL: a push with no credential reached the child")
default:
t.Logf("ok: cookie=%v reached child=%v", withCookie, reached)
}
}
}