Skip to content
quarry Artifact [af8fffd5c5]
Login

Artifact [af8fffd5c5]

Artifact af8fffd5c595e235e2989c54be2568df41bf862cecf7cd8a7527021887cfb8ce:


// 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"
	"strconv"
	"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
	// coveredFrom is where covered starts in the body: a card before it
	// is not signed by the login, whatever the login verifies as.
	coveredFrom int
	// firstWrite is where the first write-bearing card (push, file,
	// cfile, config) starts in the body, or -1 for none. One before
	// coveredFrom is nobody's, as fossil reads it: its page_xfer runs
	// the cards in order, and before the login card it is nobody.
	firstWrite int
}

// 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 CARD BY CARD, framed
// the way zit's strict request scan frames it (sync.zig, request pass 1):
//
//   - a "file" card (3 or 4 tokens, SIZE last) is followed by exactly SIZE
//     payload bytes and no separator; the next card starts right after;
//   - a "config" card (NAME SIZE) is followed by SIZE bytes and one
//     mandatory LF;
//   - "cfile" and "uvfile" are outside a request's vocabulary, and zit
//     aborts the whole request on them.
//
// Payload bytes are never read as cards. Reading them as cards is how a
// pushed Go file holding an SQL column named "login" became a login
// card for user "TEXT", which beat the real x-f-l-c cookie and refused
// every push carrying that file (zit#213). A line in a payload reading
// "clone 3 1" or a "push" card with a project code did the same to the
// other fields.
//
// When the framing cannot be followed (bad arity, a SIZE this parser
// does not read exactly as zit would, a payload running past the end)
// the scan FAILS CLOSED: it marks the request a push, so it needs a
// verified credential, and stops. zit aborts such a request anyway, so
// no valid client is affected, and a parsing difference between this
// gate and zit can only ever refuse, never let a write through
// unauthenticated. (See isFossilWS: a card the backend honours but the
// gate misses is an auth bypass.)
//
// The RFC places the login card first; zit emits it after the
// client-version pragma, so the first login card in any position is
// the one used, and its nonce covers the bytes after its LF.
// Classification is by first token after Fossil-whitespace skipping,
// never a raw byte-prefix, which an indented card would defeat while
// the backend still honoured it.
func scanRequest(body []byte) pushRequest {
	pr := pushRequest{firstWrite: -1}
	seenLogin := false
	failClosed := func() pushRequest {
		pr.hasPush = true
		return pr
	}
	pos := 0
	for pos < len(body) {
		start := pos
		line := body[pos:]
		next := len(body)
		if k := bytes.IndexByte(line, '\n'); k >= 0 {
			line, next = line[:k], pos+k+1
		}
		pos = next
		f := cardTokens(line)
		if len(f) == 0 {
			continue
		}
		switch f[0] {
		case "push", "file", "cfile", "config":
			if pr.firstWrite < 0 {
				pr.firstWrite = start
			}
		}
		switch f[0] {
		case "login":
			if !seenLogin {
				seenLogin = true
				if len(f) == 4 {
					pr.user, pr.nonce, pr.sig = defossilize(f[1]), f[2], f[3]
					pr.covered, pr.coveredFrom = body[next:], next
				}
			}
		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
			}
		case "file":
			size, ok := payloadSize(f, 3, 4)
			if !ok || size > len(body)-pos {
				return failClosed()
			}
			pos += size
		case "config":
			size, ok := payloadSize(f, 3, 3)
			if !ok || size >= len(body)-pos || body[pos+size] != '\n' {
				return failClosed()
			}
			pos += size + 1
		case "cfile", "uvfile":
			return failClosed()
		}
	}
	return pr
}

// payloadSize reads a payload card's SIZE, its last token, when the
// card has between min and max tokens. Only plain decimal digits are
// accepted: zit parses SIZE as a u32, and anything this does not read
// exactly the same way (a sign, a separator, an overflow) is reported
// as unreadable so the caller fails closed rather than guessing.
func payloadSize(f []string, min, max int) (int, bool) {
	if len(f) < min || len(f) > max {
		return 0, false
	}
	s := f[len(f)-1]
	if s == "" || len(s) > 10 {
		return 0, false
	}
	for i := 0; i < len(s); i++ {
		if s[i] < '0' || s[i] > '9' {
			return 0, false
		}
	}
	n, err := strconv.ParseUint(s, 10, 32)
	if err != nil {
		return 0, false
	}
	return int(n), true
}

// 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")