Artifact c768991848fddc138bd471257af243e410993ee103ef79e2877ea0a6fa30c201:
- File
conformance/sync_client_clone_test.go
— part of check-in
[b3914d95b3]
at
2026-10-13 11:14:19
on branch main
— fix(sync): verify a received delta against HASH before storing it (#410)
sync-protocol-01 Section 7.7 verifies a reconstructable non-clone file before storage, and SP-7.7-4 has every newly stored artifact whose bytes are available hashed before commit. The client and a clone stored a delta against a held basis as given, and the server checked only a file delta, so a delta that did not give HASH stayed stored under that name until something expanded it.
The check moves to storeReceived, the one choke point every receive path shares, so the client, the server (file and cfile) and a clone refuse a delta whose result is not HASH, or that does not apply. An unheld basis still leaves the delta stored dangling.
Tracked in #405. (user: [email protected], size: 6009) [annotate] [blame] [check-ins using]
package conformance import "slices" // cloneReply is a clone reply delivering artifacts by placeholder, each as // a cfile padded as Section 5.1 says, then the cards after them. func cloneReply(keys []string, after ...string) []string { var cards []string for _, k := range keys { cards = append(cards, "cfile {"+k+"} {"+k+".size} {"+k+".zsize}", "{"+k+".z}", "{"+k+".pad}") } return reply(append(cards, after...)...) } // noOperation checks request i carries no clone, pull or push card. func noOperation(i int) clientCheck { return every(notSent(i, "clone "), notSent(i, "pull "), notSent(i, "push ")) } // clientCloneCases are Sections 3.7(e), 8 and 8.2 on the client. func clientCloneCases() []syncClientCase { return []syncClientCase{{ Case: Case{ID: "sync.8.first-request", Req: "SP-8-1"}, op: clientOp{clone: true, login: true}, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno 0", pushCard)}, want: func(r *clientRun) error { req, err := r.request(1) if err != nil { return err } got := lines(req.cards) if len(got) != 3 || !slices.Contains(got, "clone 3 1") || index(req.cards, "pragma client-version ") != 0 || !nonceComment.MatchString(got[2]) { return expectf("%s: first request %q, want client-version, clone 3 1, randomness", r.who, got) } return nil }, fc: true, }, { // Neither reqconfig nor uv-hash goes in round 0. fossil(1)'s clone // asks for configuration in the next round; go-libfossil's Clone // asks for neither in any round, having no option for either. Case: Case{ID: "sync.8.4.round-0-defers-config", Req: "SP-8.4-1"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno 0", pushCard)}, want: every(atLeast(2), notSent(1, "reqconfig "), notSent(1, "pragma uv-hash ")), fc: true, }, { // SP-7.7-4 holds in a clone too: the cfile delta DTA1 against the // A1 just received does not give N1, so the clone fails rather than // keep an artifact under a name it does not have. Case: Case{ID: "sync.7.7.clone-delta-wrong-hash", Req: "SP-7.7-4"}, op: clientOp{clone: true, login: true}, replies: [][]string{append(cloneReply([]string{"A1"}), "cfile {N1} {A1} {DTA1.size} {DTA1.zsize}", "{DTA1.z}", "{DTA1.pad}", "clone_seqno 0", pushCard)}, want: mentions("mismatch", "wrong hash", "does not match"), }, { Case: Case{ID: "sync.8.2.clone-advancing-cursor", Req: "SP-8.2-2"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno 2", pushCard), cloneReply([]string{"N3"}, "clone_seqno 3", pushCard), cloneReply([]string{"S"}, "clone_seqno 0", pushCard)}, want: every(sent(2, "clone 3 2"), sent(3, "clone 3 3"), noOperation(4)), fc: true, }, { // A NEXT that is not decimal leaves the sequence at 1, and the // push after it is still read. Case: Case{ID: "sync.8.2.nondecimal-next", Req: "SP-8.2-2"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno x7", pushCard)}, want: sent(2, "clone 3 1"), fc: true, }, { Case: Case{ID: "sync.8.2.clone-missing-push", Req: "SP-8.2-4"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno 2", pushCard), cloneReply([]string{"N3"}, "clone_seqno 3")}, want: every(sent(2, "clone 3 2"), noOperation(3), rounds(3)), fc: true, }, { Case: Case{ID: "sync.8.2.clone-zero-cleanup", Req: "SP-8.2-5"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno 0", pushCard)}, want: every(rounds(2), noOperation(2)), fc: true, }, { // T arrives as a delta against S, which does not: the cleanup may // ask for S, but no pull follows it. go-libfossil's Clone pulls S // in a session of its own, which the third reply answers. Case: Case{ID: "sync.8.2.clone-zero-phantom", Req: "SP-8.2-5"}, op: opClone, replies: [][]string{reply("cfile {T} {S} {T.size} {DTS.zsize}", "{DTS.z}", "{DTS.pad}", "clone_seqno 0", pushCard), reply(), reply("file {S} {S.size}", "{S.data}")}, want: every(rounds(2), noOperation(2)), deviates: every(noFailure, noOperation(2), sentPrefix(3, "pull "), sent(3, "gimme {S}"), notSent(3, "clone "), notSent(3, "push "), expands("T")), fc: true, }} } // clientCloneStallCases are Section 3.7(e)'s stop for a clone that does // not advance. func clientCloneStallCases() []syncClientCase { return []syncClientCase{{ // The second reply stores nothing and repeats the sequence: a // positive sequence alone does not continue. Case: Case{ID: "sync.8.2.clone-stalled-cursor", Req: "SP-3.7-5"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "clone_seqno 2", pushCard), reply("clone_seqno 2", pushCard)}, want: rounds(2), fc: true, }} } // clientCloneErrorCases are Sections 8.3 and 10.1: errors in a clone. func clientCloneErrorCases() []syncClientCase { return []syncClientCase{{ // No push came, so the forced second round is the cleanup. Case: Case{ID: "sync.8.3.clone-first-error-only", Req: "SP-8.3-1"}, op: opClone, replies: [][]string{reply(`error not\sauthorized\sto\sclone`)}, want: every(rounds(2), noOperation(2)), fc: true, }, { Case: Case{ID: "sync.8.3.clone-first-error-content", Req: "SP-8.3-1"}, op: opClone, replies: [][]string{cloneReply([]string{"N1"}, "error x", "cfile {N3} {N3.size} {N3.zsize}", "{N3.z}", "{N3.pad}", "clone_seqno 3", pushCard)}, want: sent(2, "clone 3 3"), fc: true, }, { // After the first round an error ends the reply: N3 and the push // after it are not read, but N1 was stored, so a cleanup follows. Case: Case{ID: "sync.8.3.later-error", Req: "SP-10.1-1"}, op: opClone, replies: [][]string{cloneReply([]string{"S"}, "clone_seqno 2", pushCard), cloneReply([]string{"N1"}, "error boom", "cfile {N3} {N3.size} {N3.zsize}", "{N3.z}", "{N3.pad}", "clone_seqno 3", pushCard)}, want: every(rounds(3), noOperation(3)), fc: true, }} }