Artifact 894a1d278c78081717e18d10f59d0554398a776d4a79c4c5e840007dc46456d2:
- File
internal/xfer/fetchusers.go
— part of check-in
[b2ef1ce14d]
at
2026-08-22 16:51:19
on branch trunk
— fetch a remote fossil's user table over the wire, admin-credentialed
reqconfig /user in one round trip (sync RFC §6, §9.3). The REMOTE enforces admin — a developer gets no rows, a bad password fails login, both pinned against a real fossil. Row format verified against fossil 2.28: one config card per row, quoted key/value pairs. (user: dmestas, size: 7863) [annotate] [blame] [check-ins using] [more...]
// 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`) }