package xfer
import (
"bytes"
"crypto/sha1"
"crypto/sha3"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
// A login card signs only the bytes after it (sync RFC ยง6.2), and
// fossil reads a request in order, so a card before the login runs as
// nobody. A push gate that verified the login and then credited every
// write card in the body to it would let anyone holding one signed
// request from a person with the check-in letter put their own
// artifacts in front of it and replay it under that person's name.
// preloginRig is a gated store with one account that may write, behind
// a front, and a proxy in front of that which records each request and
// may rewrite it on the way.
type preloginRig struct {
t *testing.T
origin string
code string
front string
proxy string
mu sync.Mutex
seen [][]byte
rewrite func([]byte) []byte
}
func newPreloginRig(t *testing.T) *preloginRig {
t.Helper()
for _, bin := range []string{"zit", "fossil"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skip(bin + " not on PATH")
}
}
g := &preloginRig{t: t, origin: t.TempDir()}
zrun(t, g.origin, "init")
if err := os.WriteFile(filepath.Join(g.origin, "README.md"), []byte("# gated\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, g.origin, "add", "README.md")
zrun(t, g.origin, "commit", "-m", "first")
code, err := ProjectCode(g.origin)
if err != nil {
t.Fatal(err)
}
g.code = code
f := New()
t.Cleanup(f.Close)
f.Register("alpha", g.origin)
f.SetAuth(&mapAuth{users: map[string]string{"dan": SharedSecret(code, "dan", "hunter2")}})
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
front := httptest.NewServer(mux)
t.Cleanup(front.Close)
g.front = front.URL + "/r/alpha"
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
body := decodeContainer(raw)
g.mu.Lock()
g.seen = append(g.seen, body)
rewrite := g.rewrite
g.mu.Unlock()
if rewrite != nil {
body = rewrite(body)
}
resp, err := postSync(g.front, body, r.Header.Get("Cookie"))
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/x-fossil")
w.Write(encodeContainer(resp))
}))
t.Cleanup(proxy.Close)
g.proxy = proxy.URL + "/r/alpha"
return g
}
func (g *preloginRig) setRewrite(r func([]byte) []byte) {
g.mu.Lock()
defer g.mu.Unlock()
g.rewrite = r
}
// postSync sends body to url as a compressed fossil request and returns the
// decoded reply.
func postSync(url string, body []byte, cookie string) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(encodeContainer(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-fossil")
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return decodeContainer(raw), err
}
func (g *preloginRig) fossil(dir string, args ...string) (string, error) {
cmd := exec.Command("fossil", args...)
cmd.Dir = dir
home := g.t.TempDir()
cmd.Env = append(os.Environ(), "HOME="+home, "FOSSIL_HOME="+home, "FOSSIL_USER=tester")
out, err := cmd.CombinedOutput()
return string(out), err
}
// checkout clones the store with fossil, opens it, and commits one
// file, returning the checkout ready to push.
func (g *preloginRig) checkout(msg string) string {
g.t.Helper()
work := g.t.TempDir()
repo := filepath.Join(work, "c.fossil")
if out, err := g.fossil(work, "clone", g.front, repo); err != nil {
g.t.Fatalf("fossil clone: %v\n%s", err, out)
}
co := filepath.Join(work, "co")
os.MkdirAll(co, 0o755)
for _, args := range [][]string{{"settings", "autosync", "off", "-R", repo}, {"open", repo, "--nosync"}} {
if out, err := g.fossil(co, args...); err != nil {
g.t.Fatalf("fossil %v: %v\n%s", args, err, out)
}
}
if err := os.WriteFile(filepath.Join(co, "NEW.md"), []byte(msg+"\n"), 0o644); err != nil {
g.t.Fatal(err)
}
g.fossil(co, "add", "NEW.md")
if out, err := g.fossil(co, "commit", "-m", msg, "--no-warnings"); err != nil {
g.t.Fatalf("fossil commit: %v\n%s", err, out)
}
return co
}
func (g *preloginRig) as(base, user, pw string) string {
u, _ := url.Parse(base)
u.User = url.UserPassword(user, pw)
return u.String()
}
// stored reports whether the store holds the artifact with this hash.
func (g *preloginRig) stored(hash string) bool {
cmd := exec.Command("zit", "artifact", hash)
cmd.Dir = g.origin
return cmd.Run() == nil
}
func (g *preloginRig) landed(msg string) bool {
return strings.Contains(zrun(g.t, g.origin, "timeline", "-n", "10"), msg)
}
// forged is a file card carrying content nobody signed, and its hash.
func forged(content string) (card, hash string) {
sum := sha3.Sum256([]byte(content))
hash = hex.EncodeToString(sum[:])
return "file " + hash + " " + strconv.Itoa(len(content)) + "\n" + content, hash
}
// spliceBeforeLogin puts cards in front of a body's login card.
func spliceBeforeLogin(body []byte, cards string) []byte {
i := bytes.Index(body, []byte("login "))
if i < 0 {
return body
}
return append(append(append([]byte{}, body[:i]...), cards...), body[i:]...)
}
// A real fossil push by dan, who may write, with a file card spliced in
// front of its login card: refused, and nothing it carried is stored.
// The same push without the splice lands.
func TestAFileCardBeforeTheLoginIsNotTheLogins(t *testing.T) {
g := newPreloginRig(t)
co := g.checkout("dan was here")
card, hash := forged("not dan's\n")
g.setRewrite(func(body []byte) []byte {
if bytes.Contains(body, []byte("\npush ")) {
return spliceBeforeLogin(body, card)
}
return body
})
out, _ := g.fossil(co, "push", g.as(g.proxy, "dan", "hunter2"), "--once")
if g.stored(hash) || g.landed("dan was here") || !strings.Contains(out, "login card") {
t.Errorf("FAIL: a file before the login: stored %v, landed %v\n%s", g.stored(hash), g.landed("dan was here"), out)
} else {
t.Log("ok: a file card before a writer's login is refused and nothing it carried is stored")
}
g.setRewrite(nil)
if out, err := g.fossil(co, "push", g.as(g.front, "dan", "hunter2"), "--once"); err != nil || !g.landed("dan was here") {
t.Errorf("FAIL: the clean push: %v\n%s", err, out)
} else {
t.Log("ok: the same push without the splice lands")
}
}
// A signed pull, captured and replayed with a push card and a file card
// in front of its login: refused, nothing stored.
func TestAReplayedPullCannotBecomeAPush(t *testing.T) {
g := newPreloginRig(t)
work := t.TempDir()
repo := filepath.Join(work, "c.fossil")
if out, err := g.fossil(work, "clone", g.front, repo); err != nil {
t.Fatalf("fossil clone: %v\n%s", err, out)
}
g.fossil(work, "pull", g.as(g.proxy, "dan", "hunter2"), "-R", repo, "--once")
var captured []byte
g.mu.Lock()
seen := g.seen
g.mu.Unlock()
for _, b := range seen {
if bytes.Contains(b, []byte("login dan ")) && bytes.Contains(b, []byte("\npull ")) {
captured = b
}
}
if captured == nil {
t.Fatalf("FAIL: no signed pull was captured from %d requests", len(seen))
}
card, hash := forged("replayed\n")
reply, err := postSync(g.front, spliceBeforeLogin(captured, "push "+g.code+" "+g.code+"\n"+card), "")
if err != nil {
t.Fatal(err)
}
if g.stored(hash) || !bytes.HasPrefix(reply, []byte("error ")) {
t.Errorf("FAIL: a replayed pull carried a push: stored %v reply %q", g.stored(hash), reply)
} else {
t.Log("ok: a captured pull with a push in front of its login is refused")
}
}
// The x-f-l-c cookie signs the whole body, so with it every card is the
// login's and a push carrying a file is accepted as before.
func TestACookieLoginSignsEveryCard(t *testing.T) {
g := newPreloginRig(t)
card, hash := forged("signed by the cookie\n")
body := []byte("pragma client-version 22800 20260101 000000\npush " + g.code + " " + g.code + "\n" + card)
nonce := sha1.Sum(body)
n := hex.EncodeToString(nonce[:])
sig := sha1.Sum([]byte(n + SharedSecret(g.code, "dan", "hunter2")))
reply, err := postSync(g.front, body, "x-f-l-c=login+dan+"+n+"+"+hex.EncodeToString(sig[:]))
if err != nil {
t.Fatal(err)
}
if !g.stored(hash) || bytes.Contains(reply, []byte("error ")) {
t.Errorf("FAIL: a cookie-signed push: stored %v reply %q", g.stored(hash), reply)
} else {
t.Log("ok: a cookie login covers the whole body and its push lands")
}
}