main pico / pkg / rsync / wire_test.go
Antonio Mika  ·  2026-10-10
  1package rsync
  2
  3import (
  4	"bytes"
  5	"errors"
  6	"io"
  7	"path"
  8	"slices"
  9	"strings"
 10	"testing"
 11	"time"
 12)
 13
 14type loopback struct {
 15	bytes.Buffer
 16}
 17
 18func newTestConn(t *testing.T, protocol int) (*conn, *loopback) {
 19	var buf loopback
 20	c := newConn(&buf)
 21	c.protocol = protocol
 22	t.Cleanup(func() { _ = c.close() })
 23	return c, &buf
 24}
 25
 26func TestVarintRoundTrip(t *testing.T) {
 27	values := []int32{0, 1, 0x7f, 0x80, 0xff, 0x3fff, 0x4000, 0x1fffff, 0x200000, 0x0fffffff, 0x10000000, 0x7fffffff, -1, -2, -0x80000000}
 28	for _, v := range values {
 29		c, _ := newTestConn(t, 31)
 30		if err := c.writeVarint(v); err != nil {
 31			t.Fatal(err)
 32		}
 33		if err := c.flush(); err != nil {
 34			t.Fatal(err)
 35		}
 36		got, err := c.readVarint()
 37		if err != nil || got != v {
 38			t.Errorf("varint %d: got %d, %v", v, got, err)
 39		}
 40	}
 41	if got := appendVarint(nil, 0x80); !bytes.Equal(got, []byte{0x80, 0x80}) {
 42		t.Errorf("varint 0x80 encodes as % x", got)
 43	}
 44}
 45
 46func TestVarlongRoundTrip(t *testing.T) {
 47	values := []int64{0, 1, 0xffffff, 0x1000000, 0x7fffffff, 0x80000000, 1 << 40, 1<<62 + 12345, -1}
 48	for _, min := range []int{3, 4} {
 49		for _, v := range values {
 50			c, _ := newTestConn(t, 31)
 51			if err := c.writeVarlong(v, min); err != nil {
 52				t.Fatal(err)
 53			}
 54			if err := c.flush(); err != nil {
 55				t.Fatal(err)
 56			}
 57			got, err := c.readVarlong(min)
 58			if err != nil || got != v {
 59				t.Errorf("varlong(%d) %d: got %d, %v", min, v, got, err)
 60			}
 61		}
 62	}
 63}
 64
 65func TestLongintRoundTrip(t *testing.T) {
 66	for _, v := range []int64{0, 0x7fffffff, 0x80000000, 1 << 40, -5} {
 67		c, _ := newTestConn(t, 29)
 68		if err := c.writeLongint(v); err != nil {
 69			t.Fatal(err)
 70		}
 71		if err := c.flush(); err != nil {
 72			t.Fatal(err)
 73		}
 74		got, err := c.readLongint()
 75		if err != nil || got != v {
 76			t.Errorf("longint %d: got %d, %v", v, got, err)
 77		}
 78	}
 79}
 80
 81func TestNdxRoundTrip(t *testing.T) {
 82	seq := []int32{0, 1, 2, 5, 300, 299, 40000, 70000, 3, ndxDone, ndxDelStats, ndxFlistEOF, 7, ndxDone}
 83	for _, proto := range []int{29, 30} {
 84		c, _ := newTestConn(t, proto)
 85		for _, v := range seq {
 86			if err := c.writeNdx(v); err != nil {
 87				t.Fatal(err)
 88			}
 89		}
 90		if err := c.flush(); err != nil {
 91			t.Fatal(err)
 92		}
 93		for _, v := range seq {
 94			got, err := c.readNdx()
 95			if err != nil || got != v {
 96				t.Fatalf("protocol %d: ndx %d: got %d, %v", proto, v, got, err)
 97			}
 98		}
 99	}
100}
101
102func TestMultiplexedMessages(t *testing.T) {
103	c, buf := newTestConn(t, 31)
104	if err := c.w.setMultiplex(true); err != nil {
105		t.Fatal(err)
106	}
107	_ = c.writeInt32(42)
108	_ = c.writeMsg(msgInfo, []byte("hello\n"))
109	_ = c.writeInt32(43)
110	if err := c.flush(); err != nil {
111		t.Fatal(err)
112	}
113
114	r, _ := newTestConn(t, 31)
115	r.r.r.Reset(bytes.NewReader(buf.Bytes()))
116	r.r.multiplex = true
117	var msgs []string
118	r.r.onMsg = func(tag byte, data []byte) error {
119		msgs = append(msgs, string(data))
120		return nil
121	}
122	a, err := r.readInt32()
123	if err != nil {
124		t.Fatal(err)
125	}
126	b, err := r.readInt32()
127	if err != nil {
128		t.Fatal(err)
129	}
130	if a != 42 || b != 43 || !slices.Equal(msgs, []string{"hello\n"}) {
131		t.Fatalf("got %d %d %q", a, b, msgs)
132	}
133	if _, err := r.readByte(); err != io.ErrUnexpectedEOF {
134		t.Fatalf("want EOF, got %v", err)
135	}
136}
137
138// A generator can still be writing when the session gives up and closes the
139// writer; it has to get an error instead of waiting on a queue nobody drains.
140func TestMuxWriterAfterClose(t *testing.T) {
141	m := newMuxWriter(io.Discard)
142	if err := m.Close(); err != nil {
143		t.Fatal(err)
144	}
145	done := make(chan error, 1)
146	go func() {
147		chunk := make([]byte, muxBatchSize)
148		for range 2 * muxMaxQueued / muxBatchSize {
149			if _, err := m.Write(chunk); err != nil {
150				done <- err
151				return
152			}
153		}
154		done <- m.Flush()
155	}()
156	select {
157	case err := <-done:
158		if err == nil {
159			t.Fatal("writes after Close succeeded")
160		}
161	case <-time.After(5 * time.Second):
162		t.Fatal("write after Close blocked")
163	}
164}
165
166func TestChecksum1(t *testing.T) {
167	// Bytes above 0x7f count as negative, as in rsync's C code.
168	if got := checksum1([]byte{0xff, 1, 2, 3, 4, 5}); got != 0x001d000e {
169		t.Errorf("checksum1 = %#x", got)
170	}
171	if got := checksum1(nil); got != 0 {
172		t.Errorf("checksum1(nil) = %#x", got)
173	}
174}
175
176func TestSumSizes(t *testing.T) {
177	tests := []struct {
178		length int64
179		proto  int
180		want   sumHead
181	}{
182		{0, 31, sumHead{0, 700, 2, 0}},
183		{1, 31, sumHead{1, 700, 2, 1}},
184		{1_000_000, 31, sumHead{1000, 1000, 2, 0}},
185		{100 << 20, 31, sumHead{10240, 10240, 3, 0}},
186		{100<<20 + 1, 29, sumHead{10241, 10240, 3, 1}},
187	}
188	for _, tt := range tests {
189		got, err := sumSizesSqroot(tt.length, tt.proto, 0, shortSumLength, 16)
190		if err != nil || got != tt.want {
191			t.Errorf("sumSizes(%d, %d) = %+v, %v; want %+v", tt.length, tt.proto, got, err, tt.want)
192		}
193	}
194	got, _ := sumSizesSqroot(1_000_000, 31, 0, sumLength, 8)
195	if got.s2length != 8 {
196		t.Errorf("full checksum length capped at digest size: got %d", got.s2length)
197	}
198}
199
200func TestFileNameOrder(t *testing.T) {
201	mk := func(name string, dir bool) *fileEntry {
202		f := newFileEntry(name)
203		if name == "." {
204			f.dirname, f.basename = "", "."
205		}
206		f.mode = sIFREG
207		if dir {
208			f.mode = sIFDIR
209		}
210		return f
211	}
212	entries := func() []*fileEntry {
213		return []*fileEntry{
214			mk("b.txt", false), mk("a/x", false), mk("a", true), mk(".", true),
215			mk("a.b", false), mk("a-b", false), mk("a/y/z", false), mk("a/y", true), mk("a/w", false),
216		}
217	}
218	names := func(fs []*fileEntry) []string {
219		var out []string
220		for _, f := range fs {
221			out = append(out, f.name())
222		}
223		return out
224	}
225
226	modern := entries()
227	sortFileList(modern, 31, false)
228	want := []string{".", "a-b", "a.b", "b.txt", "a", "a/w", "a/x", "a/y", "a/y/z"}
229	if got := names(modern); !slices.Equal(got, want) {
230		t.Errorf("protocol 31 order:\n got %q\nwant %q", got, want)
231	}
232
233	old := entries()
234	sortFileList(old, 28, false)
235	want = []string{".", "a", "a-b", "a.b", "a/w", "a/x", "a/y", "a/y/z", "b.txt"}
236	if got := names(old); !slices.Equal(got, want) {
237		t.Errorf("protocol 28 order:\n got %q\nwant %q", got, want)
238	}
239}
240
241func TestDuplicateNames(t *testing.T) {
242	a := newFileEntry("x")
243	a.mode = sIFREG
244	b := newFileEntry("x")
245	b.mode = sIFDIR
246	c := newFileEntry("x")
247	c.mode = sIFREG
248	files := []*fileEntry{a, b, c}
249	sortFileList(files, 31, true)
250	var active []*fileEntry
251	for _, f := range files {
252		if !f.inactive {
253			active = append(active, f)
254		}
255	}
256	if len(active) != 1 || !active[0].isDir() {
257		t.Fatalf("want only the directory kept, got %d entries", len(active))
258	}
259}
260
261func TestWildmatch(t *testing.T) {
262	tests := []struct {
263		pattern, text string
264		want          bool
265	}{
266		{"foo", "foo", true},
267		{"foo", "bar", false},
268		{"???", "foo", true},
269		{"??", "foo", false},
270		{"*", "foo", true},
271		{"f*", "foo", true},
272		{"*f", "foo", false},
273		{"*ob*a*r*", "foobar", true},
274		{"*ab", "aaaaaaabababab", true},
275		{`foo\*`, "foo*", true},
276		{`foo\*bar`, "foobar", false},
277		{"*[al]?", "ball", true},
278		{"[ten]", "ten", false},
279		{"**[!te]", "ten", true},
280		{"**[!ten]", "ten", false},
281		{"t[a-g]n", "ten", true},
282		{"t[!a-g]n", "ten", false},
283		{"t[^a-g]n", "ton", true},
284		{"a[]]b", "a]b", true},
285		{"[[:digit:]]x", "7x", true},
286		{"[[:alpha:]]x", "7x", false},
287		{"[[:bogus:]]", "a", false},
288		{"*", "a/b", false},
289		{"**", "a/b", true},
290		{"a/*", "a/b/c", false},
291		{"a/**", "a/b/c", true},
292		{"**/c", "a/b/c", true},
293		{"a?b", "a/b", false},
294		{"[a/]b", "/b", false},
295	}
296	for _, tt := range tests {
297		if got := wildmatch(tt.pattern, tt.text); got != tt.want {
298			t.Errorf("wildmatch(%q, %q) = %v, want %v", tt.pattern, tt.text, got, tt.want)
299		}
300	}
301}
302
303func mapReader(files map[string]string) func(string) ([]byte, error) {
304	return func(name string) ([]byte, error) {
305		data, ok := files[name]
306		if !ok {
307			return nil, nil
308		}
309		return []byte(data), nil
310	}
311}
312
313func TestPerDirMergeRules(t *testing.T) {
314	files := map[string]string{
315		".rsync-filter":            "- /site/scanned.txt\n- /outside.txt\n",
316		"site/.rsync-filter":       "# comment\n; comment\n- *.log\nP protected.txt\nH hidden.txt\nexclude long.txt\r\n",
317		"site/sub/.rsync-filter":   "+ important.log\n- /anchored.txt\n",
318		"site/clear/.rsync-filter": "!\n- z\n",
319		"site/.cvsignore":          "a.o  b.o\n",
320		"site/sub/.cvsignore":      "! c.o",
321	}
322	tests := []struct {
323		otherSide int
324		name      string
325		want      int
326	}{
327		{ruleSenderSide, "a.log", -1},
328		{ruleSenderSide, "sub/x.log", -1},
329		{ruleSenderSide, "sub/important.log", 1},
330		{ruleSenderSide, "long.txt", -1},
331		{ruleSenderSide, "scanned.txt", -1},
332		{ruleSenderSide, "outside.txt", 0},
333		{ruleSenderSide, "sub/anchored.txt", -1},
334		{ruleSenderSide, "anchored.txt", 0},
335		{ruleSenderSide, "sub/deeper/anchored.txt", 0},
336		{ruleSenderSide, "clear/a.log", 0},
337		{ruleSenderSide, "clear/z", -1},
338		{ruleSenderSide, ".rsync-filter", -1},
339		{ruleSenderSide, "a.o", -1},
340		{ruleSenderSide, "sub/a.o", 0},
341		{ruleSenderSide, "sub/c.o", -1},
342		{ruleSenderSide, "protected.txt", -1},
343		{ruleSenderSide, "hidden.txt", 0},
344		{ruleReceiverSide, "protected.txt", 0},
345		{ruleReceiverSide, "hidden.txt", -1},
346	}
347	for _, tt := range tests {
348		l := filterList{otherSide: tt.otherSide}
349		for _, rule := range []string{":e /.rsync-filter", ": .rsync-filter", ":C"} {
350			if err := l.parse(rule, false); err != nil {
351				t.Fatalf("parse %q: %v", rule, err)
352			}
353		}
354		if len(l.merges) != 2 {
355			t.Fatalf("duplicate merge rule kept: %d merges", len(l.merges))
356		}
357		sc, err := l.newScopeTree("site", "site", mapReader(files)).scope(path.Dir(tt.name))
358		if err != nil {
359			t.Fatal(err)
360		}
361		if got := l.check(tt.name, false, sc); got != tt.want {
362			t.Errorf("otherSide=%d check(%q) = %d, want %d", tt.otherSide, tt.name, got, tt.want)
363		}
364	}
365}
366
367func TestPerDirMergeDeleteExcluded(t *testing.T) {
368	l := filterList{otherSide: ruleSenderSide, deleteExcluded: true}
369	if err := l.parse(": .rsync-filter", false); err != nil {
370		t.Fatal(err)
371	}
372	files := map[string]string{"site/.rsync-filter": "- excluded.txt\nP protected.txt\n"}
373	sc, err := l.newScopeTree("site", "site", mapReader(files)).scope(".")
374	if err != nil {
375		t.Fatal(err)
376	}
377	if l.excluded("excluded.txt", false, sc) {
378		t.Error("--delete-excluded should let the receiver delete excluded files")
379	}
380	if !l.excluded("protected.txt", false, sc) {
381		t.Error("protect rules still apply with --delete-excluded")
382	}
383}
384
385func TestPerDirMergeNoInherit(t *testing.T) {
386	l := filterList{otherSide: ruleSenderSide}
387	if err := l.parse(":n /.rsync-filter", false); err != nil {
388		t.Fatal(err)
389	}
390	files := map[string]string{
391		".rsync-filter":      "- *.root\n",
392		"site/.rsync-filter": "- *.tmp\n",
393	}
394	scopes := l.newScopeTree("site", "site", mapReader(files))
395	for _, tt := range []struct {
396		name string
397		want int
398	}{{"x.tmp", -1}, {"sub/x.tmp", 0}, {"x.root", 0}} {
399		sc, err := scopes.scope(path.Dir(tt.name))
400		if err != nil {
401			t.Fatal(err)
402		}
403		if got := l.check(tt.name, false, sc); got != tt.want {
404			t.Errorf("check(%q) = %d, want %d", tt.name, got, tt.want)
405		}
406	}
407}
408
409func TestPerDirMergeErrors(t *testing.T) {
410	for _, rule := range []string{". rules", "merge rules", ":! .rsync-filter", ": x/"} {
411		var l filterList
412		if err := l.parse(rule, false); err == nil {
413			t.Errorf("parse %q: want an error", rule)
414		}
415	}
416	for _, contents := range []string{
417		"merge other\n",
418		": .nested\n",
419		"dir-merge .nested\n",
420		"bogus\n",
421		"-foo\n",
422		"! trailing\n",
423		"- " + strings.Repeat("x", maxPathLen) + "\n",
424	} {
425		l := filterList{otherSide: ruleSenderSide}
426		if err := l.parse(": .rsync-filter", false); err != nil {
427			t.Fatal(err)
428		}
429		files := map[string]string{"site/.rsync-filter": contents}
430		_, err := l.newScopeTree("site", "site", mapReader(files)).scope(".")
431		var mergeErr *mergeFileError
432		if !errors.As(err, &mergeErr) {
433			t.Errorf("merge file %q: want a mergeFileError, got %v", contents, err)
434		}
435	}
436
437	l := filterList{otherSide: ruleSenderSide}
438	if err := l.parse(": .rsync-filter", false); err != nil {
439		t.Fatal(err)
440	}
441	readErr := errors.New("backend down")
442	_, err := l.newScopeTree("site", "site", func(string) ([]byte, error) { return nil, readErr }).scope(".")
443	if !errors.Is(err, readErr) {
444		t.Errorf("read error not reported: %v", err)
445	}
446
447	for _, pattern := range []string{". rules", ": rules"} {
448		var l filterList
449		if err := l.parse(pattern, true); err != nil {
450			t.Fatal(err)
451		}
452		if l.check(pattern, false, nil) != -1 {
453			t.Errorf("protocol 28 pattern %q should remain an exclusion", pattern)
454		}
455	}
456}
457
458func TestFilterRules(t *testing.T) {
459	var l filterList
460	for _, rule := range []string{"+ keep.log", "- *.log", "- /build/", "- node_modules/", "- docs/*.tmp", "-! *.html"} {
461		if err := l.parse(rule, false); err != nil {
462			t.Fatalf("parse %q: %v", rule, err)
463		}
464	}
465	tests := []struct {
466		name  string
467		isDir bool
468		want  int
469	}{
470		{"keep.log", false, 1},
471		{"sub/keep.log", false, 1},
472		{"x.log", false, -1},
473		{"sub/x.log", false, -1},
474		{"build", true, -1},
475		{"build", false, -1}, // "-! *.html" excludes anything not ending in .html
476		{"sub/build", true, -1},
477		{"src/node_modules", true, -1},
478		{"docs/a.tmp", false, -1},
479		{"index.html", false, 0},
480	}
481	for _, tt := range tests {
482		if got := l.check(tt.name, tt.isDir, nil); got != tt.want {
483			t.Errorf("check(%q, dir=%v) = %d, want %d", tt.name, tt.isDir, got, tt.want)
484		}
485	}
486
487	var old filterList
488	_ = old.parse("*.o", true)
489	_ = old.parse("+ keep.o", true)
490	if old.check("x.o", false, nil) != -1 {
491		t.Error("protocol 28 bare pattern is an exclude")
492	}
493	_ = old.parse("!", true)
494	if len(old.rules) != 0 {
495		t.Error("! clears the list")
496	}
497}