Artifact 7d35def3473082d25311dbec628b5e0e0fffd8f363eff00e20c6b49e7f7e1fa2:
- File
conformance/sync_client_content_test.go
— part of check-in
[056f36e0bc]
at
2026-10-13 12:56:03
on branch main
— fix(sync): verify a dangling delta when its basis arrives in the same pass (#417)
Tracked in #412. (user: [email protected], size: 12476) [annotate] [blame] [check-ins using]
package conformance // misnameA1 gives the client's A1 the bytes of A2, which is the same // length, so A1's row is held but its bytes are not its name. var misnameA1 = []string{ "DELETE FROM delta WHERE rid=(SELECT rid FROM blob WHERE uuid='{A1}')", "UPDATE blob SET content=(SELECT content FROM blob WHERE uuid='{A2}'), " + "size=(SELECT size FROM blob WHERE uuid='{A2}') WHERE uuid='{A1}'", } // unreadA1 leaves the client's A1 held, with a size, but with bytes that // are not a compressed artifact at all. var unreadA1 = []string{ "DELETE FROM delta WHERE rid=(SELECT rid FROM blob WHERE uuid='{A1}')", "UPDATE blob SET content=x'0000001000112233' WHERE uuid='{A1}'", } // clientFileCases are Section 7.1 and the integrity check of 7.7. func clientFileCases() []syncClientCase { return []syncClientCase{{ Case: Case{ID: "sync.7.1.file-text", Req: "SP-7.1-1"}, op: opPull, replies: [][]string{reply("file {N1} {N1.size}", "{N1.data}")}, want: expands("N1"), fc: true, }, { Case: Case{ID: "sync.7.1.file-empty", Req: "SP-7.1-1"}, op: opPull, replies: [][]string{reply("file {E} 0")}, want: expands("E"), fc: true, }, { Case: Case{ID: "sync.7.1.file-sha3", Req: "SP-7.1-1"}, op: opPull, replies: [][]string{reply("file {N3} {N3.size}", "{N3.data}")}, want: expands("N3"), fc: true, }, { // The payload is not N1's content. The LF after it is an empty // line. Case: Case{ID: "sync.7.7.hash-mismatch", Req: "SP-7.7-4"}, op: opPull, replies: [][]string{reply("file {N1} 3", "abc")}, want: every(notStored("N1"), mentions("mismatch", "wrong hash")), fc: true, }, { // A delta against a held basis is reconstructable, so it is applied // and checked against HASH before storage: DTA1 turns A1 into T, // not N1. fossil's xfer_accept_file reports the wrong hash and its // commit hook rolls the store back. Case: Case{ID: "sync.7.7.file-delta-wrong-hash", Req: "SP-7.7-4"}, op: opPull, replies: [][]string{reply("file {N1} {A1} {DTA1.size}", "{DTA1.data}")}, want: every(notStored("N1"), mentions("mismatch", "wrong hash")), fc: true, }, { // The same delta as a cfile: its complete bytes are available in the // pass that stores it, so a mismatch must not be stored either. // fossil catches it in the commit hook. Case: Case{ID: "sync.7.7.cfile-delta-wrong-hash", Req: "SP-7.7-4"}, op: opPull, replies: [][]string{reply("cfile {N1} {A1} {DTA1.size} {DTA1.zsize}", "{DTA1.z}", "{DTA1.pad}")}, want: every(notStored("N1"), mentions("mismatch", "wrong hash", "does not match")), fc: true, }, { // A delta whose basis arrives later in the same reply: its bytes are // unavailable when it is stored but available in the pass that // stores it, so the mismatch must still not be kept. DTS turns S // into T, not N1; fossil's commit hook catches it once S is in. Case: Case{ID: "sync.7.7.late-basis-wrong-hash", Req: "SP-7.7-4"}, op: opPull, replies: [][]string{reply("file {N1} {S} {DTS.size}", "{DTS.data}", "file {S} {S.size}", "{S.data}")}, want: every(notStored("N1"), mentions("mismatch", "wrong hash", "does not match")), fc: true, }, { // The draft's commit hook aborts the whole pass, not only N1: // fossil keeps neither N1 nor S. go-libfossil commits each card on its // own, so it keeps S and returns only the bad delta to what it was. Case: Case{ID: "sync.7.7.late-basis-aborts-pass", Req: "SP-7.7-4"}, op: opPull, replies: [][]string{reply("file {N1} {S} {DTS.size}", "{DTS.data}", "file {S} {S.size}", "{S.data}")}, want: every(notStored("N1"), notStored("S")), fc: true, }, { // Integrity hashes the reconstructed artifact, not its basis: a held // basis whose bytes are not its name is still reconstructed, as // fossil's content_get does without checking it, and the delta's // result checked against HASH. The client's A1 holds A2's bytes, // and DTA1 against them gives T, not N1. Case: Case{ID: "sync.7.7.misnamed-basis-wrong-hash", Req: "SP-7.7-4"}, op: opPull, setup: misnameA1, replies: [][]string{reply("file {N1} {A1} {DTA1.size}", "{DTA1.data}")}, want: every(notStored("N1"), mentions("mismatch", "wrong hash", "does not match")), fc: true, }, { // A held basis whose bytes cannot be rebuilt at all gives the delta // no HASH either. fossil's content_get keeps what it has and // xfer_accept_file applies the delta to that, so the result is // refused, not stored unchecked. Case: Case{ID: "sync.7.7.unreadable-basis-wrong-hash", Req: "SP-7.7-4"}, op: opPull, setup: unreadA1, replies: [][]string{reply("file {N1} {A1} {DTA1.size}", "{DTA1.data}")}, want: every(notStored("N1"), mentions("mismatch", "wrong hash", "does not match")), fc: true, }, { // And a result that is HASH is kept: DTA1 copies nothing from A1, so // it gives T against A2's bytes too. Case: Case{ID: "sync.7.7.misnamed-basis-verified", Req: "SP-7.7-4"}, op: opPull, setup: misnameA1, replies: [][]string{reply("file {T} {A1} {DTA1.size}", "{DTA1.data}")}, want: every(noFailure, expands("T")), fc: true, }, { // The verification passes a delta whose result is HASH. Case: Case{ID: "sync.7.7.file-delta-verified", Req: "SP-7.7-4"}, op: opPull, replies: [][]string{reply("file {T} {A1} {DTA1.size}", "{DTA1.data}")}, want: expands("T"), fc: true, }} } // clientCFileCases are Section 7.2 and the cfile padding of 5.1, in pull // replies: a client receives cfile in any reply, and a pull keeps the // repository for the checks, where a clone that fails removes it. func clientCFileCases() []syncClientCase { cfile := func(key string) []string { return []string{"cfile {" + key + "} {" + key + ".size} {" + key + ".zsize}", "{" + key + ".z}"} } join := func(parts ...[]string) []string { var all []string for _, p := range parts { all = append(all, p...) } return reply(all...) } return []syncClientCase{{ Case: Case{ID: "sync.5.1.cfile-padding-received", Req: "SP-5.1-2"}, op: opPull, replies: [][]string{join(cfile("N1"), []string{"{N1.pad}"}, cfile("N3"), []string{"{N3.pad}"})}, want: every(expands("N1"), expands("N3")), fc: true, }, { Case: Case{ID: "sync.7.2.cfile-full-adjacent", Req: "SP-7.2-1"}, op: opPull, replies: [][]string{join(cfile("N1"), cfile("N3"))}, want: every(expands("N1"), expands("N3")), fc: true, }, { Case: Case{ID: "sync.7.2.cfile-delta", Req: "SP-7.2-1"}, op: opPull, replies: [][]string{join(cfile("S"), []string{"cfile {T} {S} {T.size} {DTS.zsize}", "{DTS.z}"})}, want: expands("T"), fc: true, }, { Case: Case{ID: "sync.7.2.cfile-usize-metadata", Req: "SP-7.2-2"}, op: opPull, replies: [][]string{reply("cfile {N1} {N1.size1} {N1.zsize}", "{N1.z}", "file {N3} {N3.size}", "{N3.data}")}, want: every(expands("N1"), expands("N3")), fc: true, }, { // CSIZE counts four bytes after the zlib stream; the next card // starts after them. Case: Case{ID: "sync.7.2.cfile-trailing-stream", Req: "SP-7.2-1"}, op: opPull, replies: [][]string{join([]string{"cfile {N1} {N1.size} {N1J.zsize}", "{N1J.z}"}, cfile("N3"))}, want: every(expands("N1"), expands("N3")), fc: true, }} } // clientUVFileCases are Section 7.3 on the client. A client that only // pulls unversioned files has no fossil(1) run: fossil pull takes no -u. func clientUVFileCases() []syncClientCase { uvPull := clientOp{pull: true, uv: true} return []syncClientCase{{ // FLAGS 1 suppresses the payload: the five bytes SIZE claims are // not read, and the igot after the card is. Case: Case{ID: "sync.7.3.uvfile-no-payload", Req: "SP-7.3-1"}, op: uvPull, replies: [][]string{reply("uvfile x.txt {UVT} - 5 1", "igot {U}")}, want: sent(2, "gimme {U}"), }, { // NAME is fossilized: the row is named with the space restored. Case: Case{ID: "sync.7.3.uvfile-name-client", Req: "SP-7.3-1"}, op: uvPull, replies: [][]string{reply(`uvfile a\sb.txt {UVT} {N1} {N1.size} 0`, "{N1.data}")}, want: clientRows("SELECT count(*) FROM unversioned WHERE name='a b.txt'"+ " AND hash='{N1}'", 1), }, { // Accepted, the card would delete u.txt, being newer. Case: Case{ID: "sync.7.3.flags-negative-client", Req: "SP-7.3-2"}, op: uvPull, replies: [][]string{reply("uvfile u.txt {UVT1} - 0 -1")}, want: clientRows( "SELECT count(*) FROM unversioned WHERE name='u.txt' AND hash='{UVH}'", 1), }, { Case: Case{ID: "sync.7.3.status2-mtime-client", Req: "SP-7.3-5"}, op: uvPull, replies: [][]string{reply("uvfile u.txt {UVT1} {UVH} 11 4")}, want: clientRows("SELECT count(*) FROM unversioned WHERE name='u.txt' AND mtime={UVT1}"+ " AND hash='{UVH}' AND sz=11", 1), }, { // t.txt is deleted here, later than the peer's copy: the client // sends the deletion. Case: Case{ID: "sync.7.3.deletion-form", Req: "SP-7.3-3"}, op: clientOp{pull: true, push: true, uv: true}, setup: append(append([]string{}, quiet...), "INSERT INTO unversioned(name, rcvid, mtime, hash, sz, encoding, content)"+ " VALUES('t.txt', 1, {UVT1}, NULL, 0, 0, NULL)", "DELETE FROM config WHERE name='uv-hash'"), replies: [][]string{reply("pragma uv-push-ok", "uvigot t.txt {UVT} {N1} {N1.size}")}, want: sent(2, "uvfile t.txt {UVT1} - 0 1"), fc: true, }} } // clientDeltaCases are Section 7.4. func clientDeltaCases() []syncClientCase { return []syncClientCase{{ // A2 is held, so the delta is consumed and ignored: A2 stays // stored in full. Whether a phantom is made for the delta's basis // is the receiver's choice (Section 7.4); fossil(1) 2.28 makes one. Case: Case{ID: "sync.7.4.verified-duplicate", Req: "SP-7.4-1"}, op: opPull, replies: [][]string{reply("file {A2} {U} {DA2U.size}", "{DA2U.data}")}, want: every(expands("A2"), clientRows("SELECT count(*) FROM delta JOIN blob USING(rid) WHERE uuid='{A2}'", 0)), fc: true, }, { Case: Case{ID: "sync.7.4.delta-held-basis", Req: "SP-7.4-2"}, op: opPull, replies: [][]string{reply("file {T} {A1} {DTA1.size}", "{DTA1.data}")}, want: expands("T"), fc: true, }, { Case: Case{ID: "sync.7.4.private-basis-phantom", Req: "SP-7.4-2"}, op: clientOp{pull: true, private: true}, replies: [][]string{reply("private", "file {T} {U} {DTS.size}", "{DTS.data}")}, want: every(phantom("U"), private("U", 1)), fc: true, }} } // clientPrivateCases are Section 7.5 on the client. func clientPrivateCases() []syncClientCase { priv := clientOp{pull: true, private: true} return []syncClientCase{{ Case: Case{ID: "sync.7.5.private-once", Req: "SP-7.5-3"}, op: priv, replies: [][]string{reply("private", "file {N1} {N1.size}", "{N1.data}", "file {N3} {N3.size}", "{N3.data}")}, want: every(private("N1", 1), stored("N3"), private("N3", 0)), fc: true, }, { Case: Case{ID: "sync.7.5.marker-survives", Req: "SP-7.5-1"}, op: priv, replies: [][]string{reply("igot {N1}", "private"), reply("file {N1} {N1.size}", "{N1.data}")}, want: every(stored("N1"), private("N1", 1)), fc: true, }, { Case: Case{ID: "sync.7.5.marker-skips-uvfile", Req: "SP-7.5-1"}, op: clientOp{pull: true, push: true, private: true, uv: true}, setup: quiet, replies: [][]string{reply("private", "uvfile x.txt {UVT} - 0 1", "# c", "private", "file {N1} {N1.size}", "{N1.data}")}, want: every(stored("N1"), private("N1", 1)), fc: true, }, { Case: Case{ID: "sync.7.5.unauthorized-private-igot", Req: "SP-7.5-5"}, op: opPull, replies: [][]string{reply("igot {A1} 1", "igot {U} 1")}, want: every(rounds(1), private("A1", 1), notStored("U")), fc: true, }, { // P is private. The first request goes before any server date is // known, and the reply dates the server before 2020-04-13, which // would withhold igot P 1 outside a private sync: neither stops the // listing. The gimme for B1 makes the second request. Case: Case{ID: "sync.8.6.private-inventory", Req: "SP-8.6-1"}, op: clientOp{push: true, private: true}, setup: quiet, replies: [][]string{{"pragma server-version 22800 20200101 000000", "gimme {B1}", "{TS}"}}, want: every(rounds(2), sent(1, "igot {P} 1"), sent(2, "igot {P} 1"), sent(2, "file {B1} {B1.size}")), fc: true, }} }