// uvclient.go is RFC 0020 rung 6's wire half: quarry as a fossil
// CLIENT of an upstream's unversioned files, the other side of the
// cards unversioned.go answers. A mirror's import round sends
// `pragma uv-hash` and `uvgimme` and reads `uvigot` and `uvfile` back;
// it never sends a uvfile, because a mirror only reads.
//
// The request is built as fossil's client builds it: src/http.c
// (http_exchange, http_build_login_card, by way of buildLoginBody),
// src/sha1.c (sha1_shared_secret) and src/xfer.c (client_sync: the
// client-version pragma, the line of randomness the login's nonce is
// taken over).
package xfer
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"craftdesign.group/quarry/internal/uv"
)
// uvClientVersion is the release this client announces in
// `pragma client-version`: fossil 2.28's number. A server sends a
// SHA3 hash only to a client at 2.0 or later (send_unversioned_file),
// and a client that announces nothing is taken as older.
const uvClientVersion = "22800"
// DefaultUVReply bounds one decoded reply when UVUpstream names none:
// fossil's own budget is 5 MB a reply, but its first file is sent
// whole whatever its size, so the bound is a generous multiple.
const DefaultUVReply = 512 << 20
// UVUpstream is one upstream as the import round speaks to it.
type UVUpstream struct {
// URL is the upstream's sync address, as fossil's client posts to
// it. A user and password in it are dropped: the round signs with
// User and Password, or as nobody.
URL string
// User and Password sign each request with a login card; an empty
// User is nobody, who sends none.
User, Password string
// ProjectCode is the upstream's, which the login's shared secret
// is derived from. A mirror holds the same one, learned when it was
// cloned.
ProjectCode string
// Client makes the request; nil is a client with no timeout of its
// own, bounded by the context.
Client *http.Client
// MaxReply bounds one decoded reply; 0 is DefaultUVReply.
MaxReply int64
}
// UVReply is what an upstream answered.
type UVReply struct {
// Pragma is "uv-push-ok" or "uv-pull-only", empty when the request's
// uv-hash drew neither: the hashes matched, or the login may not
// read the files.
Pragma string
// Catalog is the uvigot cards in order; a tombstone is Deleted.
Catalog []uv.File
// Files is the uvfile cards in order.
Files []UVReplyFile
// Err is the first error card, in words.
Err string
// Bytes is the reply's size on the wire.
Bytes int64
}
// UVReplyFile is one uvfile card. Bad is fossil's words for a card that
// was framed but cannot be taken (a name quarry refuses, a bad hash),
// and File.Name is then the name as sent.
type UVReplyFile struct {
File uv.File
Content []byte
// Omitted is flag 0x0004: the content was left out.
Omitted bool
Bad string
}
// Exchange sends cards to the upstream and reads its reply. The cards
// go after `pragma client-version` and before a comment line of
// randomness, so no two requests sign the same bytes, as client_sync
// does; with a User the login card goes first and its nonce covers
// everything after it.
func (u UVUpstream) Exchange(ctx context.Context, cards string) (UVReply, error) {
target, err := url.Parse(u.URL)
if err != nil {
return UVReply{}, fmt.Errorf("upstream address: %v", err)
}
// Go would send a user in the URL as HTTP Basic. That is the DAG
// pull's identity, not the round's, and fossil's sync reads only
// the login card.
target.User, target.Fragment = nil, ""
var random [20]byte
if _, err := rand.Read(random[:]); err != nil {
return UVReply{}, err
}
payload := []byte("pragma client-version " + uvClientVersion + "\n" + cards + "# " + hex.EncodeToString(random[:]) + "\n")
body := payload
if u.User != "" {
body = buildLoginBody(u.ProjectCode, u.User, u.Password, string(payload))
}
// Posted to /xfer under the address, as FetchUsers posts: fossil's
// server answers a sync there whatever page the address names.
target.Path = strings.TrimSuffix(target.Path, "/") + "/xfer"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(encodeContainer(body)))
if err != nil {
return UVReply{}, err
}
req.Header.Set("Content-Type", "application/x-fossil")
client := u.Client
if client == nil {
client = &http.Client{}
}
// A redirect is not followed: Go turns a 301 or 302 into a GET,
// which a fossil server answers with a page, and following one
// silently would send the next login card wherever it pointed.
c := *client
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err := c.Do(req)
if err != nil {
return UVReply{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 == 3 {
return UVReply{}, fmt.Errorf("the upstream redirects to %s; set the mirror's address to where it points", resp.Header.Get("Location"))
}
if resp.StatusCode != http.StatusOK {
return UVReply{}, fmt.Errorf("the upstream answered %s", resp.Status)
}
limit := u.MaxReply
if limit <= 0 {
limit = DefaultUVReply
}
// The container's own length is checked by openContainer; this
// bounds the compressed bytes read to get there.
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return UVReply{}, err
}
if int64(len(raw)) > limit {
return UVReply{}, fmt.Errorf("the upstream's reply is over %d bytes", limit)
}
plain, ok := openContainer(raw)
if !ok || int64(len(plain)) > limit {
return UVReply{}, errors.New("the upstream's reply is not a fossil sync message; is the address a fossil repository?")
}
r, err := scanReply(plain)
r.Bytes = int64(len(raw))
return r, err
}
// scanReply reads an upstream's reply for the unversioned cards, framed
// as scanRequest frames a request: a uvfile's payload, and a file or
// config card's, is skipped by its byte count and never read as cards.
// What cannot be framed refuses the whole reply. cfile is refused too:
// a server sends one only to a client that asked for artifacts, and
// this client never does.
func scanReply(body []byte) (UVReply, error) {
var r UVReply
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 "pragma":
if len(f) == 2 && (f[1] == "uv-push-ok" || f[1] == "uv-pull-only") && r.Pragma == "" {
r.Pragma = f[1]
}
case "uvigot":
x, ok := uvigotCard(f)
if !ok {
return r, fmt.Errorf("malformed uvigot line: %.200s", line)
}
r.Catalog = append(r.Catalog, x)
case "uvfile":
c, ok := frameUVFile(f, body, start, pos)
if !ok {
return r, fmt.Errorf("malformed uvfile line: %.200s", line)
}
pos = c.end
in, why := uvFileCard(c)
if why != "" {
in.Name = f[1]
}
r.Files = append(r.Files, UVReplyFile{File: in, Content: c.payload, Omitted: c.omitted(), Bad: why})
case "error":
if r.Err == "" {
r.Err = strings.Join(strings.Fields(defossilize(strings.Join(f[1:], " "))), " ")
}
case "file":
size, ok := payloadSize(f, 3, 4)
if !ok || size > len(body)-pos {
return r, errors.New("malformed file card")
}
pos += size
case "config":
size, ok := payloadSize(f, 3, 3)
if !ok || size >= len(body)-pos || body[pos+size] != '\n' {
return r, errors.New("malformed config card")
}
pos += size + 1
case "cfile":
return r, errors.New("the upstream sent a cfile card, which this client never asks for")
}
}
return r, nil
}
// uvigotCard reads "uvigot NAME MTIME HASH SIZE" as the client half of
// xfer.c reads it: MTIME and SIZE not negative, HASH a hash or "-" for
// a tombstone. The name is kept as sent; the round decides whether it
// can carry it.
func uvigotCard(f []string) (uv.File, bool) {
if len(f) != 5 {
return uv.File{}, false
}
mtime, err := strconv.ParseInt(f[2], 10, 64)
if err != nil || mtime < 0 {
return uv.File{}, false
}
size, err := strconv.ParseInt(f[4], 10, 64)
if err != nil || size < 0 {
return uv.File{}, false
}
if f[3] == "-" {
return uv.File{Name: f[1], Mtime: mtime, Deleted: true}, true
}
if !isHname(f[3]) {
return uv.File{}, false
}
return uv.File{Name: f[1], Mtime: mtime, Hash: f[3], Size: size}, true
}