// container.go — the sync protocol's compressed message container and
// the card peeks quarry's edge needs (sync RFC §4.1, §6): a 4-byte
// big-endian pre-compression length followed by a zlib stream. Quarry
// inspects requests only enough to gate pushes; payload semantics stay
// zit's business.
package xfer
import (
"bytes"
"compress/zlib"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// decodeContainer returns the decompressed body of an
// application/x-fossil message. Malformed containers return the bytes
// as-is (the RFC: the receiver retains the original buffer and applies
// ordinary card processing to it).
func decodeContainer(raw []byte) []byte {
if len(raw) < 6 {
return raw
}
capacity := binary.BigEndian.Uint32(raw[:4])
zr, err := zlib.NewReader(bytes.NewReader(raw[4:]))
if err != nil {
return raw
}
defer zr.Close()
out, err := io.ReadAll(io.LimitReader(zr, int64(capacity)+1))
if err != nil || uint64(len(out)) > uint64(capacity) {
return raw
}
return out
}
// encodeContainer wraps a plaintext message for the wire.
func encodeContainer(body []byte) []byte {
var buf bytes.Buffer
var lenb [4]byte
binary.BigEndian.PutUint32(lenb[:], uint32(len(body)))
buf.Write(lenb[:])
zw := zlib.NewWriter(&buf)
zw.Write(body)
zw.Close()
return buf.Bytes()
}
// pushRequest holds what the gate needs from a request: whether it
// mutates, and the login card's parts with the nonce-covered region.
type pushRequest struct {
hasPush bool
// newClone is a clone card opening a NEW clone, not a later round of
// one. The protocol says "first sequence is 1" (sync draft §8), so
// SEQNO 1 is the only round that means somebody started cloning —
// counting every clone card would count ROUNDS, and a big store
// takes many.
newClone bool
// hasPull is a pull card. Unlike newClone this is per ROUND, because
// the protocol gives a fetch no opening marker: "push, pull, and
// sync requests carry push, pull, or both each round".
hasPull bool
// version is the client's self-reported build, from the
// "pragma client-version SEQ DATE TIME" card. Self-reported and
// unverifiable — good enough to answer "may I retire the old
// protocol path", never evidence of anything.
version string
projectCode string // from the push card
user string
nonce string
sig string
covered []byte // bytes after the login line's LF, through end
}
// isFossilWS matches the sync protocol's card-token separators (§5.2:
// SP, HTAB, VT, FF, CR — LF terminates the line, never separates). The
// gate MUST tokenize exactly as zit's tokenizeCard does: a card the
// backend honors but the gate misses is a full auth bypass. In
// particular leading whitespace is skippable, so a card keyword is not
// anchored at byte 0.
func isFossilWS(r rune) bool {
switch r {
case ' ', '\t', 0x0b, 0x0c, '\r':
return true
}
return false
}
// cardTokens tokenizes one card line the way zit does — runs of Fossil
// whitespace (including a leading run) separate tokens.
func cardTokens(line []byte) []string {
return strings.FieldsFunc(string(line), isFossilWS)
}
// scanRequest reads the decompressed card stream. The RFC places the
// login card first; zit emits it after the client-version pragma, so
// the scan accepts a login card in any position (the nonce covers the
// bytes after that card's LF either way). Push cards may appear
// anywhere among the operation cards. Classification is by first token
// after Fossil-whitespace skipping — never a raw byte-prefix, which an
// indented card would defeat while the backend still honored it.
func scanRequest(body []byte) pushRequest {
var pr pushRequest
rest := body
for {
line, tail, found := bytes.Cut(rest, []byte("\n"))
f := cardTokens(line)
if len(f) > 0 && f[0] == "login" {
if len(f) == 4 {
pr.user, pr.nonce, pr.sig = defossilize(f[1]), f[2], f[3]
pr.covered = tail
}
break
}
if !found {
break
}
rest = tail
}
for _, line := range bytes.Split(body, []byte("\n")) {
f := cardTokens(line)
if len(f) == 0 {
continue
}
switch f[0] {
case "push":
pr.hasPush = true
if len(f) == 3 {
pr.projectCode = f[2]
}
case "pull":
pr.hasPull = true
case "pragma":
// pragma client-version SEQ [DATE TIME]. Only the sequence
// is kept: it is the part that orders builds, and the
// date pair adds nothing a version does not already say.
if len(f) >= 3 && f[1] == "client-version" {
pr.version = f[2]
}
case "clone":
// clone VERSION SEQNO. Arity is checked because a malformed
// card must not be counted as a clone, and SEQNO is compared
// as a string: the spec's own rule is digit-only recognition,
// so "01" is not sequence one and neither is " 1".
if len(f) == 3 && f[2] == "1" {
pr.newClone = true
}
}
}
return pr
}
// cookieLogin parses the x-f-l-c cookie (RFC §6.5): the body-login
// card with spaces as + and the USER percent-encoded. Its nonce covers
// the entire decompressed body.
func cookieLogin(req *http.Request, body []byte) (pushRequest, bool) {
c, err := req.Cookie("x-f-l-c")
if err != nil {
return pushRequest{}, false
}
f := strings.Split(c.Value, "+")
if len(f) != 4 || f[0] != "login" {
return pushRequest{}, false
}
user, err := url.QueryUnescape(f[1])
if err != nil {
return pushRequest{}, false
}
return pushRequest{user: defossilize(user), nonce: f[2], sig: f[3], covered: body}, true
}
// verifyLogin checks the RFC §6 chain: NONCE is SHA-1 over the covered
// bytes; SIGNATURE is SHA-1 over nonce-hex || shared-secret-hex.
func verifyLogin(pr pushRequest, secret string) bool {
if pr.user == "" || len(pr.nonce) != 40 || len(pr.sig) != 40 {
return false
}
nonce := sha1.Sum(pr.covered)
if hex.EncodeToString(nonce[:]) != pr.nonce {
return false
}
sig := sha1.Sum([]byte(pr.nonce + secret))
return hex.EncodeToString(sig[:]) == pr.sig
}
// SharedSecret derives the stored credential (RFC §6.3): SHA-1 of
// "projectcode/user/password". Plaintext never persists.
func SharedSecret(projectCode, user, password string) string {
sum := sha1.Sum([]byte(projectCode + "/" + user + "/" + password))
return hex.EncodeToString(sum[:])
}
// errorReply builds a complete application/x-fossil reply holding one
// error card (message spaces fossilized as \s).
func errorReply(msg string) []byte {
return encodeContainer([]byte("error " + strings.ReplaceAll(msg, " ", `\s`) + "\n"))
}
// defossilize undoes \s escaping in a card token.
func defossilize(s string) string { return strings.ReplaceAll(s, `\s`, " ") }
// ErrNoProjectCode reports a handshake that produced no push card.
var ErrNoProjectCode = fmt.Errorf("handshake reply carried no project code")