Skip to content
quarry Artifact [894a1d278c]
Login

Artifact [894a1d278c]

Artifact 894a1d278c78081717e18d10f59d0554398a776d4a79c4c5e840007dc46456d2:


// fetchusers.go — the outbound half of the sync protocol: ask a remote
// Fossil (or zit) peer for its user table with an admin credential.
//
// RFC 0004 §6 wants a repository's people to arrive with it. For a
// MIRROR the users are not in the artifact DAG — Fossil keeps them in
// the repository database and serves them only over the configuration
// exchange, only to a caller with Admin. That gate is the REMOTE's, and
// this code does not try to be clever about it: quarry presents the
// asking operator's own credential and the peer decides.
//
// Wire shape (draft-fossil-sync-protocol-00 §6, §9.3), one round trip:
//
//	login USER NONCE SIGNATURE
//	reqconfig /user
//
// answered with `config /user SIZE` + SIZE payload bytes + one LF.
//
// The payload's row serialisation is explicitly OUTSIDE the protocol
// profile (§3), so it is parsed defensively and verified against a real
// fossil in the tests rather than assumed.
package xfer

import (
	"bytes"
	"crypto/sha1"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// RemoteUser is one row of a peer's user table. Caps is the fossil
// capability string, carried verbatim — quarry never reinterprets a
// letter it does not implement (RFC 0004 §4).
type RemoteUser struct {
	Login string
	Caps  string
	Info  string
	// PWHash is the peer's stored credential. For a Fossil peer this is
	// the §6.3 shared secret, which quarry can verify directly at login
	// and rehash to argon2 (RFC 0004 §6). Empty when the peer withheld
	// it.
	PWHash string
	MTime  int64
}

// FetchUsers asks peer for its user table as login/password. The URL is
// the repository's sync root (the same URL a clone would use).
//
// A peer that refuses returns an error carrying its own words: the
// caller is an operator who can act on "not authorized", and inventing
// a friendlier message would hide which side said no.
// buildLoginBody frames one authenticated request: the login card
// (§6.1) over a nonce that covers every byte after its LF (§6.2).
func buildLoginBody(projectCode, login, password, rest string) []byte {
	nonce := sha1.Sum([]byte(rest))
	nonceHex := hex.EncodeToString(nonce[:])
	sig := sha1.Sum([]byte(nonceHex + SharedSecret(projectCode, login, password)))
	var body bytes.Buffer
	fmt.Fprintf(&body, "login %s %s %s\n", fossilize(login), nonceHex, hex.EncodeToString(sig[:]))
	body.WriteString(rest)
	return body.Bytes()
}

// projectCode is the peer's project code, which the shared secret is
// derived from (§6.3). A mirror always knows it — it has a clone of
// that very repository — so it is a parameter rather than something
// discovered over the wire, where the discovery card itself would
// need the code it is looking for.
func FetchUsers(client *http.Client, peerURL, projectCode, login, password string) ([]RemoteUser, error) {
	if client == nil {
		client = &http.Client{Timeout: 30 * time.Second}
	}
	code := projectCode
	// The nonce covers every body byte after the login line's LF (§6.2),
	// so the rest of the message is built first.
	rest := []byte("reqconfig /user\n")
	nonce := sha1.Sum(rest)
	nonceHex := hex.EncodeToString(nonce[:])
	secret := SharedSecret(code, login, password)
	sigIn := sha1.Sum([]byte(nonceHex + secret))

	_ = nonceHex
	_ = sigIn
	reply, err := post(client, peerURL, buildLoginBody(code, login, password, string(rest)))
	if err != nil {
		return nil, err
	}
	if msg := firstError(reply); msg != "" {
		return nil, fmt.Errorf("peer refused: %s", msg)
	}
	return parseUserConfig(reply)
}

func post(client *http.Client, peerURL string, body []byte) ([]byte, error) {
	if client == nil {
		client = &http.Client{Timeout: 30 * time.Second}
	}
	url := strings.TrimSuffix(peerURL, "/") + "/xfer"
	req, err := http.NewRequest("POST", url, bytes.NewReader(encodeContainer(body)))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-fossil")
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("peer %s: HTTP %d", url, resp.StatusCode)
	}
	return decodeContainer(raw), nil
}

// firstError returns the peer's first error card message, if any.
func firstError(reply []byte) string {
	for _, line := range strings.Split(string(reply), "\n") {
		if strings.HasPrefix(line, "error ") {
			return defossilize(strings.TrimPrefix(line, "error "))
		}
	}
	return ""
}

// parseUserConfig walks the reply for `config /user SIZE` cards and
// reads exactly SIZE payload bytes after each (§9.3). Fossil emits ONE
// CARD PER ROW, so every card is collected, not just the first.
//
// Verified against fossil 2.28; the row serialisation is outside the
// protocol profile, so anything unrecognised is reported rather than
// guessed at — a half-read user table would grant or withhold access
// on invented data.
func parseUserConfig(reply []byte) ([]RemoteUser, error) {
	var out []RemoteUser
	rest, sawCard := reply, false
	for len(rest) > 0 {
		nl := bytes.IndexByte(rest, '\n')
		if nl < 0 {
			break
		}
		line := string(rest[:nl])
		rest = rest[nl+1:]
		tok := strings.Fields(line)
		if len(tok) != 3 || tok[0] != "config" || tok[1] != "/user" {
			continue
		}
		size, err := strconv.Atoi(tok[2])
		if err != nil || size < 0 || size > len(rest) {
			return nil, fmt.Errorf("config /user: bad size %q", tok[2])
		}
		sawCard = true
		u, err := parseUserRow(string(rest[:size]))
		if err != nil {
			return nil, err
		}
		// The payload is followed by one mandatory LF (§9.3).
		rest = rest[size:]
		if len(rest) > 0 && rest[0] == '\n' {
			rest = rest[1:]
		}
		out = append(out, u)
	}
	if !sawCard {
		return nil, fmt.Errorf("peer sent no /user configuration (is the credential an admin there?)")
	}
	return out, nil
}

// parseUserRow reads one row: MTIME then quoted key/value pairs, e.g.
//
//	1787417078 'root' pw 'a5df…' cap 's' info '' jx '{}'
//
// The second token is the login; the rest are pairs. Fossil's quoting
// surrounds a value with ' and doubles an internal ' (§9.3).
func parseUserRow(payload string) (RemoteUser, error) {
	f := strings.Fields(strings.TrimSpace(payload))
	if len(f) < 2 {
		return RemoteUser{}, fmt.Errorf("user row %q: too short", payload)
	}
	u := RemoteUser{}
	u.MTime, _ = strconv.ParseInt(f[0], 10, 64)
	rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(payload), f[0]))
	login, rest, ok := takeQuoted(rest)
	if !ok {
		return RemoteUser{}, fmt.Errorf("user row %q: no quoted login", payload)
	}
	u.Login = login
	for {
		rest = strings.TrimLeft(rest, " \t")
		if rest == "" {
			break
		}
		sp := strings.IndexAny(rest, " \t")
		if sp < 0 {
			break
		}
		key := rest[:sp]
		val, remain, ok := takeQuoted(strings.TrimLeft(rest[sp:], " \t"))
		if !ok {
			break
		}
		switch key {
		case "pw":
			u.PWHash = val
		case "cap":
			u.Caps = val
		case "info":
			u.Info = val
		}
		rest = remain
	}
	if u.Login == "" {
		return RemoteUser{}, fmt.Errorf("user row %q: empty login", payload)
	}
	return u, nil
}

// takeQuoted reads one 'quoted' value, undoubling internal quotes, and
// returns it with whatever follows.
func takeQuoted(s string) (string, string, bool) {
	if !strings.HasPrefix(s, "'") {
		return "", s, false
	}
	var b strings.Builder
	for i := 1; i < len(s); i++ {
		if s[i] != '\'' {
			b.WriteByte(s[i])
			continue
		}
		if i+1 < len(s) && s[i+1] == '\'' { // doubled: a literal quote
			b.WriteByte('\'')
			i++
			continue
		}
		return b.String(), s[i+1:], true
	}
	return "", s, false
}

// fossilize escapes a card token's spaces (§5.2). defossilize, its
// inverse, already lives in container.go and is shared.
func fossilize(s string) string { return strings.ReplaceAll(s, " ", `\s`) }