Artifact 23e3dea4421c16c676348c641c52876556f5ae579f37672138c4844c09632910:
- File
conformance/sync_checks_test.go
— part of check-in
[80c72b27c7]
at
2026-10-11 13:57:34
on branch main
— test(conformance): sync-protocol conformance suite (#262) (#311)
* test(conformance): sync-protocol conformance suite (#262)
Checks go-libfossil against draft-fossil-sync-protocol-00, requirement by requirement: 149 requirements, 199 cases. Server cases send raw requests to Repo.XferHandler and, through `fossil http --in`, to fossil(1) on a copy of the same repository; client cases drive the public Sync and Clone against a scripted peer, with fossil(1)'s client run against the same script for most of them; HTTP cases cover redirects, status codes and media types; interop cases run whole exchanges in both directions.
Known failures are marked with the placeholder #0 until their issues are filed, so TestCoverage fails until then.
* test(conformance): sync suite review fixes (#262)
Cases for the requirements that had none where fossil(1) can show the behavior: server-version, the SHA3 gate, shun admission, cookie and cleartext logins, pushed configuration, ci-unlock, cluster brackets, the clone's unversioned catalog and the pull-only message. Two rows added: link ARG handling and the short uvigot.
A fossil(1) disagreement, a harness failure or a deviation other than the one named now fails a case whatever its mark. The server's log output is discarded in the tests.
* test(conformance): mark sync gaps with their issues; drop gaps v0.11.1 closed (#262)
v0.11.1's read gate and redirect policy make seven sync cases conform, so their gap marks are removed. The rest are marked with the issues that track each fix: #296 and #299-#310.
* test(conformance): accept either fossil refusal of a negative clone SEQNO
fossil 2.28's macOS build answers clone 3 -1 with clone_seqno 0 and its Linux build, which CI pins, with the invalid-sequence error alone. The draft leaves the reply open; the case now holds go-libfossil only to sending no content. (user: [email protected], size: 13028) [annotate] [blame] [check-ins using]
package conformance import ( "bytes" "encoding/binary" "net/http" "regexp" "slices" "strconv" "strings" "time" "github.com/danmestas/go-libfossil/db" ) // A server case's expectation is a replyCheck: nil when the reply does what // the case expects, else what it did instead. The same check judges // go-libfossil's reply and fossil(1)'s, so it may name only what the draft // fixes; where the draft fixes no text, a check lists each // implementation's text for the same reason, never "any error". type replyCheck func(r *serverReply) error // all passes when every check passes, and reports the first that does not. func all(checks ...replyCheck) replyCheck { return func(r *serverReply) error { for _, c := range checks { if err := c(r); err != nil { return err } } return nil } } // either passes when any of checks passes, and otherwise reports every // failure. It is for behavior the draft leaves open and fossil(1) builds // resolve differently. func either(checks ...replyCheck) replyCheck { return func(r *serverReply) error { var failures []string for _, c := range checks { err := c(r) if err == nil { return nil } failures = append(failures, err.Error()) } return expectf("%s", strings.Join(failures, "; or ")) } } // syncReply checks that the reply is a sync reply at all: status 200, a // sync media type, and cards that parse. func syncReply(r *serverReply) error { if r.status != http.StatusOK { return expectf("%s: HTTP %d %.120q, want a 200 sync reply", r.who, r.status, r.text) } if !strings.HasPrefix(r.ctype, "application/x-fossil") { return expectf("%s: Content-Type %q, want a sync reply", r.who, r.ctype) } return nil } // completed checks for a sync reply ending in the timestamp comment. func completed(r *serverReply) error { if err := syncReply(r); err != nil { return err } if n := len(r.cards); n == 0 || !strings.HasPrefix(r.cards[n-1].line, "# timestamp ") { return expectf("%s: reply does not end with the timestamp comment: %q", r.who, tail(r)) } return nil } // withCard checks that some card line is exactly line. func withCard(line string) replyCheck { return func(r *serverReply) error { want := r.fx.expand(line) if !slices.Contains(lines(r.cards), want) { return expectf("%s: no card %q in %q", r.who, want, tail(r)) } return nil } } // noCard checks that no card line starts with prefix. func noCard(prefix string) replyCheck { return func(r *serverReply) error { want := r.fx.expand(prefix) if i := index(r.cards, want); i >= 0 { return expectf("%s: unexpected card %q", r.who, r.cards[i].line) } return nil } } // count checks that exactly n card lines start with prefix. func count(prefix string, n int) replyCheck { return func(r *serverReply) error { got := len(find(r.cards, r.fx.expand(prefix))) if got != n { return expectf("%s: %d cards start %q, want %d", r.who, got, prefix, n) } return nil } } // onlyError checks that the reply has exactly one error card and that its // decoded text contains one of reasons: each implementation's wording of // the reason the case is about. Any other error means another rule fired. func onlyError(reasons ...string) replyCheck { return func(r *serverReply) error { errs := errorTexts(r.cards) if len(errs) != 1 { return expectf("%s: error cards %q, want one for %q", r.who, errs, reasons) } for _, reason := range reasons { if strings.Contains(errs[0], r.fx.expand(reason)) { return nil } } return expectf("%s: error %q, want one of %q", r.who, errs[0], reasons) } } // noErrors checks that the reply carries no error card. func noErrors(r *serverReply) error { if errs := errorTexts(r.cards); len(errs) > 0 { return expectf("%s: error cards %q", r.who, errs) } return nil } // before checks that the first card starting with a precedes the first // starting with b, and that both are present. func before(a, b string) replyCheck { return func(r *serverReply) error { i, j := index(r.cards, r.fx.expand(a)), index(r.cards, r.fx.expand(b)) if i < 0 || j < 0 || i >= j { return expectf("%s: want %q (at %d) before %q (at %d) in %q", r.who, a, i, b, j, tail(r)) } return nil } } // exactly checks that the card lines, timestamp comment left out, are // exactly want. func exactly(want ...string) replyCheck { return func(r *serverReply) error { var got []string for _, line := range lines(r.cards) { if !strings.HasPrefix(line, "# timestamp ") { got = append(got, line) } } expanded := make([]string, len(want)) for i := range want { expanded[i] = r.fx.expand(want[i]) } if !slices.Equal(got, expanded) { return expectf("%s: cards %q, want %q", r.who, got, expanded) } return nil } } // repoCount checks that a query on the repository after the request returns n. func repoCount(query string, n int) replyCheck { return func(r *serverReply) error { got, err := queryInt(r.fx, r.repo, query) if err != nil { return err } if got != n { return expectf("%s: %s gives %d, want %d", r.who, query, got, n) } return nil } } // matches checks that the line of the first card starting with prefix // matches pattern. func matches(prefix, pattern string) replyCheck { re := regexp.MustCompile(pattern) return func(r *serverReply) error { i := index(r.cards, r.fx.expand(prefix)) if i < 0 { return expectf("%s: no card %q in %q", r.who, prefix, tail(r)) } if !re.MatchString(r.cards[i].line) { return expectf("%s: %q does not match %s", r.who, r.cards[i].line, pattern) } return nil } } // tail is the reply's card lines for a message, with the long inventory // of igot cards counted rather than listed. func tail(r *serverReply) []string { var out []string igots := 0 for _, line := range lines(r.cards) { if strings.HasPrefix(line, "igot ") && !strings.Contains(line[5:], " ") { igots++ continue } out = append(out, line) } if igots > 0 { out = append(out, "(+"+strconv.Itoa(igots)+" public igot)") } if len(r.cards) == 0 { out = append(out, string(r.text[:min(len(r.text), 120)])) } return out } // mediaType checks the reply's Content-Type, parameters aside. func mediaType(want string) replyCheck { return func(r *serverReply) error { got, _, _ := strings.Cut(r.ctype, ";") if strings.TrimSpace(got) != want { return expectf("%s: Content-Type %q, want %s", r.who, r.ctype, want) } return nil } } // payloadIs checks the payload of the first card starting with prefix. func payloadIs(prefix, data string) replyCheck { return func(r *serverReply) error { i := index(r.cards, r.fx.expand(prefix)) if i < 0 { return expectf("%s: no card %q in %q", r.who, prefix, tail(r)) } if want := r.fx.expand(data); string(r.cards[i].payload) != want { return expectf("%s: payload %q, want %q", r.who, r.cards[i].payload, want) } return nil } } // tokenCount checks the number of tokens of the first card starting with // prefix. func tokenCount(prefix string, n int) replyCheck { return func(r *serverReply) error { i := index(r.cards, r.fx.expand(prefix)) if i < 0 { return expectf("%s: no card %q in %q", r.who, prefix, tail(r)) } if got := len(r.cards[i].tokens()); got != n { return expectf("%s: %q has %d tokens, want %d", r.who, r.cards[i].line, got, n) } return nil } } // eachCard applies check to every card, comments included. func eachCard(check func(c wireCard) error) replyCheck { return func(r *serverReply) error { if len(r.cards) == 0 { return expectf("%s: no cards: %.120q", r.who, r.text) } for _, c := range r.cards { if err := check(c); err != nil { return expectf("%s: %v", r.who, err) } } return nil } } // canonicalLine is the canonical card form of Section 5.2: a lowercase // operator and tokens separated by one SP, or a comment. var canonicalLine = regexp.MustCompile(`^(#.*|[a-z_]+( [^\x00-\x20]+)*)$`) func canonical(c wireCard) error { if !canonicalLine.MatchString(c.line) { return expectf("%q is not a canonical card", c.line) } return nil } // sixTokens checks the Section 3.9 window: no card line has a seventh // token. func sixTokens(c wireCard) error { if !strings.HasPrefix(c.line, "#") && len(c.tokens()) > 6 { return expectf("%q has %d tokens", c.line, len(c.tokens())) } return nil } // cfilePadding checks Section 5.1: a cfile payload that does not end in LF // is followed by one LF, and one that does is not. func cfilePadding(c wireCard) error { if c.op() != "cfile" || len(c.payload) == 0 { return nil } if endsLF := c.payload[len(c.payload)-1] == '\n'; c.padded == endsLF { return expectf("%q: payload ends in LF %v, padded %v", c.line, endsLF, c.padded) } return nil } // container checks the reply entity is the Section 4.1 container: the // big-endian length of the card text, then a zlib stream (CMF 0x78). func container(r *serverReply) error { if err := mediaType(typeCompressed)(r); err != nil { return err } if len(r.raw) < 6 || r.raw[4] != 0x78 { return expectf("%s: entity %.12q does not start a zlib stream at byte 4", r.who, r.raw) } if n := int(binary.BigEndian.Uint32(r.raw[:4])); n != len(r.text) { return expectf("%s: length field %d, card text %d bytes", r.who, n, len(r.text)) } return nil } // recentUTC checks the timestamp comment's DATETIME is the current UTC // time, within the time a test run takes. func recentUTC(r *serverReply) error { i := index(r.cards, "# timestamp ") if i < 0 { return expectf("%s: no timestamp comment in %q", r.who, tail(r)) } fields := strings.Fields(r.cards[i].line) when, err := time.Parse("2006-01-02T15:04:05", fields[2]) if err != nil { return expectf("%s: %q: %v", r.who, r.cards[i].line, err) } if d := time.Since(when); d < -5*time.Minute || d > 5*time.Minute { return expectf("%s: timestamp %s is %v from UTC now", r.who, fields[2], d) } return nil } // cfileRows looks up each cfile card's artifact in the repository after the // request: its rid, stored content, and whether it is stored as a delta. type cfileRow struct { card wireCard rid int content []byte isDelta bool } func cfileRows(r *serverReply) ([]cfileRow, error) { conn, err := db.OpenSQL(r.repo, db.OpenConfig{}, nil) if err != nil { return nil, err } defer conn.Close() var out []cfileRow for _, c := range find(r.cards, "cfile ") { row := cfileRow{card: c} err := conn.QueryRow(`SELECT rid, content, EXISTS(SELECT 1 FROM delta WHERE rid=blob.rid) FROM blob WHERE uuid=?`, c.tokens()[1]).Scan(&row.rid, &row.content, &row.isDelta) if err != nil { return nil, expectf("%s: %q names no stored artifact: %v", r.who, c.line, err) } out = append(out, row) } if len(out) == 0 { return nil, expectf("%s: no cfile card in %q", r.who, tail(r)) } return out, nil } // verbatim checks Section 7.2: a cfile payload is the stored blob as it is // stored. A delta-stored artifact sent in full, as Section 7.5 requires // when its basis is private, has no stored form to compare. func verbatim(r *serverReply) error { rows, err := cfileRows(r) if err != nil { return err } for _, row := range rows { full := len(row.card.tokens()) == 4 if full && row.isDelta { continue } if !bytes.Equal(row.card.payload, row.content) { return expectf("%s: %q: payload of %d bytes is not the %d stored", r.who, row.card.line, len(row.card.payload), len(row.content)) } } return nil } // ridOrder checks the cfile cards come in ascending rid order, starting // at rid first or later and including it. func ridOrder(first int) replyCheck { return func(r *serverReply) error { rows, err := cfileRows(r) if err != nil { return err } if rows[0].rid != first { return expectf("%s: first cfile is rid %d, want %d", r.who, rows[0].rid, first) } for i := 1; i < len(rows); i++ { if rows[i].rid <= rows[i-1].rid { return expectf("%s: rid %d sent after rid %d", r.who, rows[i].rid, rows[i-1].rid) } } return nil } } // nothingBut checks every card other than a comment starts with one of // prefixes. func nothingBut(prefixes ...string) replyCheck { return func(r *serverReply) error { for _, c := range r.cards { if strings.HasPrefix(c.line, "#") || hasAnyPrefix(c.line, prefixes) { continue } return expectf("%s: unexpected card %q", r.who, c.line) } return nil } } func hasAnyPrefix(line string, prefixes []string) bool { for _, p := range prefixes { if strings.HasPrefix(line, p) { return true } } return false } // scalarPayload is Appendix B's config-scalar-payload, then the LF that // ends a config payload. var scalarPayload = regexp.MustCompile(`^[0-9]+ '([^']|'')*' value '([^']|'')*'\n$`) // present checks that some card line starts with prefix. func present(prefix string) replyCheck { return func(r *serverReply) error { if index(r.cards, r.fx.expand(prefix)) < 0 { return expectf("%s: no card %q in %q", r.who, prefix, tail(r)) } return nil } }