// 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). It is
// fossil's http_build_login_card (src/http.c): NONCE is the SHA1 of the
// bytes after the card, SIGNATURE the SHA1 of the nonce followed by
// sha1_shared_secret's SHA1 of "PROJECTCODE/LOGIN/PASSWORD". As there,
// no card is sent for no login or "anonymous", and a password that is
// empty, or already 40 hex digits, is signed as it is.
func buildLoginBody(projectCode, login, password, rest string) []byte {
if login == "" || login == "anonymous" {
return []byte(rest)
}
nonce := sha1.Sum([]byte(rest))
nonceHex := hex.EncodeToString(nonce[:])
secret := password
if password != "" && (len(password) != 40 || !isHex(password)) {
secret = SharedSecret(projectCode, login, password)
}
sig := sha1.Sum([]byte(nonceHex + secret))
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")
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)
}
// The peer is untrusted too, and its container claims its own length.
reply, _, err := openContainer(raw, maxRequest)
if err != nil {
return nil, fmt.Errorf("peer %s: %w", url, err)
}
return reply, 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 as fossil's src/encode.c fossilize
// does: a backslash, NUL and whitespace (§5.2). defossilize, which
// undoes the space, lives in container.go and is shared.
func fossilize(s string) string {
return strings.NewReplacer(`\`, `\\`, "\x00", `\0`, " ", `\s`, "\n", `\n`,
"\t", `\t`, "\r", `\r`, "\v", `\v`, "\f", `\f`).Replace(s)
}
// isHex is fossil's validate16 over a whole string: hex digits of
// either case.
func isHex(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return false
}
}
return true
}