Skip to content
go-libfossil Artifact [c998045f35]
Login

Artifact [c998045f35]

Artifact c998045f35d516e4fdc3e2978adb53879d12b424213f61fb4d0fbb769d01a936:


package conformance

import (
	"fmt"
	"strings"

	"github.com/danmestas/go-libfossil/db"
)

// uvRows adds unversioned rows to the client, then drops its cached
// catalog hash so they count. Content is stored uncompressed.
func uvRows(rows ...string) []string {
	out := append([]string{}, quiet...)
	for _, r := range rows {
		out = append(out, "INSERT INTO unversioned"+
			"(name, rcvid, mtime, hash, sz, encoding, content) VALUES("+r+")")
	}
	return append(out, "DELETE FROM config WHERE name='uv-hash'")
}

// uvN1 is the values of an unversioned row named name holding N1's
// content, uncompressed, at mtime.
func uvN1(name, mtime string) string {
	return "'" + name + "', 1, " + mtime +
		", '{N1}', 15, 0, CAST('a new artifact'||char(10) AS BLOB)"
}

// catalogHash checks request 1's uv-hash against Section 8.5.1's formula,
// computed here from the client's rows: the SHA-1 of "NAME YYYY-MM-DD
// HH:MM:SS HASH" LF per live row, sorted by name bytes.
func catalogHash(r *clientRun) error {
	conn, err := db.OpenSQL(r.repo, db.OpenConfig{}, nil)
	if err != nil {
		return err
	}
	defer conn.Close()
	rows, err := conn.Query("SELECT name, datetime(mtime, 'unixepoch'), hash FROM unversioned" +
		" WHERE hash IS NOT NULL ORDER BY name COLLATE BINARY")
	if err != nil {
		return err
	}
	defer rows.Close()
	var text strings.Builder
	for rows.Next() {
		var name, when, hash string
		if err := rows.Scan(&name, &when, &hash); err != nil {
			return err
		}
		fmt.Fprintf(&text, "%s %s %s\n", name, when, hash)
	}
	if err := rows.Err(); err != nil {
		return err
	}
	return sent(1, "pragma uv-hash "+sha1Hex(text.String()))(r)
}

// clientUVCases are Section 8.5.1's send table, uv-hash and catalog hash.
func clientUVCases() []syncClientCase {
	uvSync := clientOp{pull: true, push: true, uv: true}
	return []syncClientCase{{
		Case:    Case{ID: "sync.8.5.1.seed-excludes-tombstones", Req: "SP-8.5.1-1"},
		op:      uvSync,
		setup:   uvRows("'t.txt', 1, {UVT}, NULL, 0, 0, NULL"),
		replies: [][]string{reply("pragma uv-push-ok")},
		want: every(sent(2, "uvfile u.txt {UVT} {UVH} 11 0"),
			neverSent("uvfile t.txt")),
		fc: true,
	}, {
		// uvfile, with six tokens, is the longest card a client sends.
		Case:    Case{ID: "sync.3.9.sender-six-tokens-client", Req: "SP-3.9-1"},
		op:      clientOp{pull: true, push: true, uv: true, login: true},
		setup:   quiet,
		replies: [][]string{reply("pragma uv-push-ok")},
		want:    every(sent(2, "uvfile u.txt {UVT} {UVH} 11 0"), eachCardSent(sixTokens)),
		fc:      true,
	}, {
		Case:    Case{ID: "sync.8.5.1.uv-hash-once", Req: "SP-8.5.1-2"},
		op:      uvSync,
		setup:   quiet,
		replies: [][]string{reply("igot {U}"), reply("igot {U2}")},
		want: every(atLeast(3), sent(1, "pragma uv-hash {UVCAT}"),
			notSent(2, "pragma uv-hash"), notSent(3, "pragma uv-hash")),
		fc: true,
	}, {
		// Names that sort differently by bytes and by case, and a
		// tombstone, which the hash leaves out.
		Case: Case{ID: "sync.8.5.1.catalog-hash", Req: "SP-8.5.1-3"},
		op:   uvSync,
		setup: uvRows(uvN1("a", "{UVT}"),
			uvN1("B", "{UVT1}"),
			"'t.txt', 1, {UVT}, NULL, 0, 0, NULL"),
		replies: [][]string{reply()},
		want:    catalogHash,
		fc:      true,
	}, {
		Case:  Case{ID: "sync.8.5.1.pull-only-drops", Req: "SP-8.5.1-6"},
		op:    uvSync,
		setup: uvRows(uvN1("v.txt", "{UVT1}")),
		replies: [][]string{reply("pragma uv-pull-only", "uvigot u.txt 1767225000 {UVH} 11",
			"uvigot v.txt {UVT} {UVH} 11")},
		want: neverSent("uvfile "),
		fc:   true,
	}}
}

// clientUVStatusCases are Table 6: what a client does for each status a
// uvigot has against its own row.
func clientUVStatusCases() []syncClientCase {
	return []syncClientCase{{
		// s0.txt is not held (status 0); u.txt is held with other content
		// and is older (status 1), so it is deleted and asked for.
		Case: Case{ID: "sync.8.5.1.status-0-1", Req: "SP-8.5.1-5"},
		op:   clientOp{pull: true, uv: true},
		replies: [][]string{reply("pragma uv-pull-only", "uvigot s0.txt {UVT} {N1} 15",
			"uvigot u.txt {UVT1} {N1} 15")},
		want: every(sent(2, "uvgimme s0.txt"), sent(2, "uvgimme u.txt"),
			clientRows("SELECT count(*) FROM unversioned WHERE name='u.txt'", 0)),
	}, {
		Case:    Case{ID: "sync.8.5.1.status-2", Req: "SP-8.5.1-5"},
		op:      clientOp{pull: true, uv: true},
		replies: [][]string{reply("pragma uv-pull-only", "uvigot u.txt {UVT1} {UVH} 11")},
		want: every(neverSent("uvgimme "),
			clientRows("SELECT count(*) FROM unversioned WHERE name='u.txt' AND mtime={UVT1}", 1)),
	}, {
		// u.txt is the peer's content, older (status 4): only its mtime is
		// sent. v.txt is other content, newer (status 5): it is sent
		// whole. w.txt matches (status 3): nothing is sent.
		Case: Case{ID: "sync.8.5.1.status-3-4-5", Req: "SP-8.5.1-5"},
		op:   clientOp{pull: true, push: true, uv: true},
		setup: uvRows(uvN1("v.txt", "{UVT1}"),
			uvN1("w.txt", "{UVT}")),
		replies: [][]string{reply("pragma uv-push-ok", "uvigot u.txt 1767225000 {UVH} 11",
			"uvigot v.txt {UVT} {UVH} 11", "uvigot w.txt {UVT} {N1} 15")},
		want: every(sent(2, "uvfile u.txt {UVT} {UVH} 11 4"),
			sentPayload(2, "uvfile v.txt {UVT1} {N1} 15 0", "{N1.data}"),
			notSent(2, "uvfile w.txt")),
		fc: true,
	}}
}

// clientMessageCases are Sections 9 and 10 on the client: pragmas, error
// and message cards.
func clientMessageCases() []syncClientCase {
	stored1 := []string{"file {N1} {N1.size}", "{N1.data}"}
	return []syncClientCase{{
		Case:    Case{ID: "sync.9.unknown-pragma", Req: "SP-9-1"},
		op:      opPull,
		replies: [][]string{reply(append([]string{"pragma zzz a b c d e"}, stored1...)...)},
		want:    stored("N1"),
		fc:      true,
	}, {
		// The byte after a config payload is consumed whatever it is.
		Case: Case{ID: "sync.9.3.config-opaque", Req: "SP-9.3-4"},
		op:   opPull,
		replies: [][]string{reply(append([]string{"config /zzz 3", "{CFGX.data}"},
			stored1...)...)},
		want: stored("N1"),
		fc:   true,
	}, {
		Case:    Case{ID: "sync.9.wrong-direction-client", Req: "SP-9-2"},
		op:      opPull,
		replies: [][]string{reply(append([]string{"pragma client-version 1"}, stored1...)...)},
		want:    stored("N1"),
		fc:      true,
	}, {
		Case:    Case{ID: "sync.9.5.send-private-every-round", Req: "SP-9.5-2"},
		op:      clientOp{pull: true, private: true},
		replies: [][]string{reply("igot {U}"), reply("igot {U2}")},
		want: every(atLeast(3), eachRequest(func(i int, req peerRequest) error {
			if index(req.cards, "pragma send-private") < 0 {
				return expectf("request %d has no send-private", i)
			}
			return nil
		})),
		fc: true,
	}, {
		Case:    Case{ID: "sync.10.1.error-prefix", Req: "SP-10.1-1"},
		op:      opPull,
		replies: [][]string{reply(append(stored1, "error boom", "igot {U}")...)},
		want:    every(stored("N1"), notStored("U"), neverSent("gimme {U}")),
		fc:      true,
	}, {
		// N1 came in the first round; an error in the second keeps it,
		// and nothing continues the session.
		Case:    Case{ID: "sync.10.1.later-error-prefix", Req: "SP-10.1-1"},
		op:      opSync,
		setup:   quiet,
		replies: [][]string{reply(append(stored1, "gimme {A1}")...), reply("error x")},
		want:    every(stored("N1"), rounds(2)),
		fc:      true,
	}, {
		// A wrong hash found before storage is a handler error, not a failed
		// commit: the igot before it still leaves its phantom (#418).
		Case:    Case{ID: "sync.10.1.integrity-error-prefix", Req: "SP-10.1-6"},
		op:      opPull,
		replies: [][]string{reply("igot {U}", "file {N1} {A1} {DTA1.size}", "{DTA1.data}")},
		want:    every(phantom("U"), notStored("N1"), mentions("mismatch", "wrong hash")),
		fc:      true,
		fossil: "fossil(1) 2.28 stores the content after the error and aborts at " +
			"commit when verify_before_commit sees the mismatch, which rolls back the " +
			"reply's prefix with it",
	}, {
		// The same reply, but a continuation condition (the igot's phantom)
		// holds, so the second request must follow the error (§10.1, §3.7).
		Case:    Case{ID: "sync.10.1.integrity-error-continues", Req: "SP-10.1-7"},
		op:      opPull,
		replies: [][]string{reply("igot {U}", "file {N1} {A1} {DTA1.size}", "{DTA1.data}")},
		want:    sent(2, "gimme {U}"),
		fossil: "fossil(1) 2.28 aborts the round at commit when verify_before_commit " +
			"sees the mismatch, so it never reaches the continuation",
	}, {
		Case:    Case{ID: "sync.10.1.error-continues-when-due", Req: "SP-10.1-1"},
		op:      opPull,
		replies: [][]string{reply("igot {U}", "error x")},
		want:    sent(2, "gimme {U}"),
		fc:      true,
	}, {
		Case:    Case{ID: "sync.10.2.message-advisory", Req: "SP-10.2-1"},
		op:      opPull,
		replies: [][]string{reply(append([]string{`message hello\sworld`}, stored1...)...)},
		want:    every(stored("N1"), reported("hello world")),
		fc:      true,
	}, {
		Case:    Case{ID: "sync.10.2.message-arity", Req: "SP-10.2-1"},
		op:      opPull,
		replies: [][]string{reply("message a b", "igot {U}")},
		want:    every(rounds(1), notStored("U")),
		fc:      true,
	}}
}