Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
package clientupdate
import ( "archive/tar" "compress/gzip" "encoding/json" "fmt" "io/fs" "maps" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "slices" "sort" "strings" "testing")
func TestUpdateDebianAptSourcesListBytes(t *testing.T) { tests := []struct { name string toTrack string in string want string // empty means want no change wantErr string }{ { name: "stable-to-unstable", toTrack: UnstableTrack, in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n", want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n", }, { name: "stable-unchanged", toTrack: StableTrack, in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n", }, { name: "if-both-stable-and-unstable-dont-change", toTrack: StableTrack, in: "# Tailscale packages for debian buster\n" + "deb https://pkgs.tailscale.com/stable/debian bullseye main\n" + "deb https://pkgs.tailscale.com/unstable/debian bullseye main\n", }, { name: "if-both-stable-and-unstable-dont-change-unstable", toTrack: UnstableTrack, in: "# Tailscale packages for debian buster\n" + "deb https://pkgs.tailscale.com/stable/debian bullseye main\n" + "deb https://pkgs.tailscale.com/unstable/debian bullseye main\n", }, { name: "signed-by-form", toTrack: UnstableTrack, in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n", want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n", }, { name: "unsupported-lines", toTrack: UnstableTrack, in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n", wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack) if err != nil { if err.Error() != tt.wantErr { t.Fatalf("error = %v; want %q", err, tt.wantErr) } return } if tt.wantErr != "" { t.Fatalf("got no error; want %q", tt.wantErr) } var gotChange string if string(newContent) != tt.in { gotChange = string(newContent) } if gotChange != tt.want { t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want) } }) }}
var YUMRepos = map[string]string{ StableTrack: `[tailscale-stable]name=Tailscale stablebaseurl=https://pkgs.tailscale.com/stable/fedora/$basearchenabled=1type=rpmrepo_gpgcheck=1gpgcheck=0gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg`,
UnstableTrack: `[tailscale-unstable]name=Tailscale unstablebaseurl=https://pkgs.tailscale.com/unstable/fedora/$basearchenabled=1type=rpmrepo_gpgcheck=1gpgcheck=0gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg`,
ReleaseCandidateTrack: `[tailscale-release-candidate]name=Tailscale release-candidatebaseurl=https://pkgs.tailscale.com/release-candidate/fedora/$basearchenabled=1type=rpmrepo_gpgcheck=1gpgcheck=0gpgkey=https://pkgs.tailscale.com/release-candidate/fedora/repo.gpg`,
"FakeRepo": `[fedora]name=Fedora $releasever - $basearch#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearchenabled=1countme=1metadata_expire=7drepo_gpgcheck=0type=rpmgpgcheck=1gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearchskip_if_unavailable=False`,}
func TestUpdateYUMRepoTrack(t *testing.T) { tests := []struct { desc string before string track string after string rewrote bool wantErr bool }{ { desc: "same-track", before: YUMRepos[StableTrack], track: StableTrack, after: YUMRepos[StableTrack], }, { desc: "change-track", before: YUMRepos[StableTrack], track: UnstableTrack, after: YUMRepos[UnstableTrack], rewrote: true, }, { desc: "change-track-RC", before: YUMRepos[StableTrack], track: ReleaseCandidateTrack, after: YUMRepos[ReleaseCandidateTrack], rewrote: true, }, { desc: "non-tailscale-repo-file", before: YUMRepos["FakeRepo"], track: StableTrack, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { path := filepath.Join(t.TempDir(), "tailscale.repo") if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil { t.Fatal(err) }
rewrote, err := updateYUMRepoTrack(path, tt.track) if err == nil && tt.wantErr { t.Fatal("got nil error, want non-nil") } if err != nil && !tt.wantErr { t.Fatalf("got error %q, want nil", err) } if err != nil { return } if rewrote != tt.rewrote { t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote) }
after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(after) != tt.after { t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after) } }) }}
func TestParseAlpinePackageVersion(t *testing.T) { tests := []struct { desc string out string want string wantErr bool }{ { desc: "valid-version", out: `tailscale-1.44.2-r0 description:The easiest, most secure way to use WireGuard and 2FA
tailscale-1.44.2-r0 webpage:https://tailscale.com/
tailscale-1.44.2-r0 installed size:32 MiB`, want: "1.44.2", }, { desc: "wrong-package-output", out: `busybox-1.36.1-r0 description:Size optimized toolbox of many common UNIX utilities
busybox-1.36.1-r0 webpage:https://busybox.net/
busybox-1.36.1-r0 installed size:924 KiB`, wantErr: true, }, { desc: "missing-version", out: `tailscale description:The easiest, most secure way to use WireGuard and 2FA
tailscale webpage:https://tailscale.com/
tailscale installed size:32 MiB`, wantErr: true, }, { desc: "empty-output", out: "", wantErr: true, }, { desc: "multiple-versions", out: `tailscale-1.54.1-r0 description:The easiest, most secure way to use WireGuard and 2FA
tailscale-1.54.1-r0 webpage:https://tailscale.com/
tailscale-1.54.1-r0 installed size:34 MiB
tailscale-1.58.2-r0 description:The easiest, most secure way to use WireGuard and 2FA
tailscale-1.58.2-r0 webpage:https://tailscale.com/
tailscale-1.58.2-r0 installed size:35 MiB`, want: "1.58.2", }, }
for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { got, err := parseAlpinePackageVersion([]byte(tt.out)) if err == nil && tt.wantErr { t.Fatalf("got nil error and version %q, want non-nil error", got) } if err != nil && !tt.wantErr { t.Fatalf("got error: %q, want nil", err) } if got != tt.want { t.Fatalf("got version: %q, want %q", got, tt.want) } }) }}
func TestCheckOutdatedAlpineRepo(t *testing.T) { anyToString := func(a any) string { str, ok := a.(string) if !ok { panic("failed to parse param as string") } return str }
tests := []struct { name string fileContent string latestHTTPVersion string latestApkVersion string wantHTTPVersion string wantApkVersion string wantAlpineVersion string track string }{ { name: "up-to-date", fileContent: "https://dl-cdn.alpinelinux.org/alpine/v3.20/main", latestHTTPVersion: "1.95.3", latestApkVersion: "1.95.3", track: "unstable", }, { name: "behind-unstable", fileContent: "https://dl-cdn.alpinelinux.org/alpine/v3.20/main", latestHTTPVersion: "1.95.4", latestApkVersion: "1.95.3", wantHTTPVersion: "1.95.4", wantApkVersion: "1.95.3", wantAlpineVersion: "v3.20", track: "unstable", }, { name: "behind-stable", fileContent: "https://dl-cdn.alpinelinux.org/alpine/v2.40/main", latestHTTPVersion: "1.94.3", latestApkVersion: "1.92.1", wantHTTPVersion: "1.94.3", wantApkVersion: "1.92.1", wantAlpineVersion: "v2.40", track: "stable", }, { name: "nothing-in-dist-file", fileContent: "", latestHTTPVersion: "1.94.3", latestApkVersion: "1.92.1", wantHTTPVersion: "1.94.3", wantApkVersion: "1.92.1", track: "stable", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir, err := os.MkdirTemp("", "example") if err != nil { t.Fatalf("error creating temp dir: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // clean up
file := filepath.Join(dir, "distfile") if err := os.WriteFile(file, []byte(tt.fileContent), 0o666); err != nil { t.Fatalf("error creating dist file: %v", err) }
testServ := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { version := TrackPackages{ MSIsVersion: tt.latestHTTPVersion, MacZipsVersion: tt.latestHTTPVersion, TarballsVersion: tt.latestHTTPVersion, SPKsVersion: tt.latestHTTPVersion, } jsonData, err := json.Marshal(version) if err != nil { t.Errorf("failed to marshal version string: %v", err) } w.Header().Set("Content-Type", "application/json") if _, err := w.Write(jsonData); err != nil { t.Errorf("failed to write json blob: %v", err) } }, )) defer testServ.Close()
oldEndpoint := tailscaleHTTPEndpoint tailscaleHTTPEndpoint = testServ.URL defer func() { tailscaleHTTPEndpoint = oldEndpoint }()
var paramLatest string var paramApkVer string var paramAlpineVer string logf := func(_ string, params ...any) { paramLatest = anyToString(params[0]) paramApkVer = anyToString(params[1]) if len(params) > 2 { paramAlpineVer = anyToString(params[2]) } }
err = checkOutdatedAlpineRepo(logf, []string{file}, tt.latestApkVersion, tt.track) if err != nil { t.Errorf("did not expect error, got: %v", err) } if paramLatest != tt.wantHTTPVersion { t.Errorf("expected HTTP version '%s', got '%s'", tt.wantHTTPVersion, paramLatest) } if paramApkVer != tt.wantApkVersion { t.Errorf("expected APK version '%s', got '%s'", tt.wantApkVersion, paramApkVer) } if paramAlpineVer != tt.wantAlpineVersion { t.Errorf("expected alpine version '%s', got '%s'", tt.wantAlpineVersion, paramAlpineVer) } }) }}
func TestSynoArch(t *testing.T) { tests := []struct { goarch string synoinfoUnique string want string wantErr bool }{ {goarch: "amd64", synoinfoUnique: "synology_x86_224", want: "x86_64"}, {goarch: "arm64", synoinfoUnique: "synology_armv8_124", want: "armv8"}, {goarch: "386", synoinfoUnique: "synology_i686_415play", want: "i686"}, {goarch: "arm", synoinfoUnique: "synology_88f6281_213air", want: "88f6281"}, {goarch: "arm", synoinfoUnique: "synology_88f6282_413j", want: "88f6282"}, {goarch: "arm", synoinfoUnique: "synology_hi3535_NVR1218", want: "hi3535"}, {goarch: "arm", synoinfoUnique: "synology_alpine_1517", want: "alpine"}, {goarch: "arm", synoinfoUnique: "synology_armada370_216se", want: "armada370"}, {goarch: "arm", synoinfoUnique: "synology_armada375_115", want: "armada375"}, {goarch: "arm", synoinfoUnique: "synology_armada38x_419slim", want: "armada38x"}, {goarch: "arm", synoinfoUnique: "synology_armadaxp_RS815", want: "armadaxp"}, {goarch: "arm", synoinfoUnique: "synology_comcerto2k_414j", want: "comcerto2k"}, {goarch: "arm", synoinfoUnique: "synology_monaco_216play", want: "monaco"}, {goarch: "ppc64", synoinfoUnique: "synology_qoriq_413", wantErr: true}, }
for _, tt := range tests { t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.synoinfoUnique), func(t *testing.T) { synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf") if err := os.WriteFile( synoinfoConfPath, fmt.Appendf(nil, "unique=%q\n", tt.synoinfoUnique), 0600, ); err != nil { t.Fatal(err) } got, err := synoArch(tt.goarch, synoinfoConfPath) if err != nil { if !tt.wantErr { t.Fatalf("got unexpected error %v", err) } return } if tt.wantErr { t.Fatalf("got %q, expected an error", got) } if got != tt.want { t.Errorf("got %q, want %q", got, tt.want) } }) }}
func TestParseSynoinfo(t *testing.T) { tests := []struct { desc string content string want string wantErr bool }{ { desc: "double-quoted", content: `company_title="Synology"unique="synology_88f6281_213air"`, want: "88f6281", }, { desc: "single-quoted", content: `company_title="Synology"unique='synology_88f6281_213air'`, want: "88f6281", }, { desc: "unquoted", content: `company_title="Synology"unique=synology_88f6281_213air`, want: "88f6281", }, { desc: "missing-unique", content: `company_title="Synology"`, wantErr: true, }, { desc: "empty-unique", content: `company_title="Synology"unique=`, wantErr: true, }, { desc: "empty-unique-double-quoted", content: `company_title="Synology"unique=""`, wantErr: true, }, { desc: "empty-unique-single-quoted", content: `company_title="Synology"unique=''`, wantErr: true, }, { desc: "malformed-unique", content: `company_title="Synology"unique="synology_88f6281"`, wantErr: true, }, { desc: "empty-file", content: ``, wantErr: true, }, { desc: "empty-lines-and-comments", content: `
# In a file named synoinfo? Shocking!company_title="Synology"
# unique= is_a_field_that_followsunique="synology_88f6281_213air"
`, want: "88f6281", }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf") if err := os.WriteFile(synoinfoConfPath, []byte(tt.content), 0600); err != nil { t.Fatal(err) } got, err := parseSynoinfo(synoinfoConfPath) if err != nil { if !tt.wantErr { t.Fatalf("got unexpected error %v", err) } return } if tt.wantErr { t.Fatalf("got %q, expected an error", got) } if got != tt.want { t.Errorf("got %q, want %q", got, tt.want) } }) }}
func TestDownloadLinuxTarballDirectory(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("Linux tarball downloads are only supported on Linux") } for _, name := range []string{"cache", "missing-cache", "temp"} { t.Run(name, func(t *testing.T) { base := t.TempDir() t.Setenv("HOME", "") t.Setenv("XDG_CACHE_HOME", "") t.Setenv("TMPDIR", base) if name != "temp" { if name == "missing-cache" { base = filepath.Join(base, "cache") } t.Setenv("XDG_CACHE_HOME", base) } // Leave an existing update directory and file untouched. legacyDir := filepath.Join(base, "tailscale-update") if name != "missing-cache" { if err := os.Mkdir(legacyDir, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(legacyDir, "keep"), []byte("keep"), 0600); err != nil { t.Fatal(err) } }
dirs := make(chan []fs.FileInfo, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/distsign.pub" { http.Error(w, "unavailable", http.StatusServiceUnavailable) return } entries, err := os.ReadDir(base) if err != nil { t.Error(err) } var created []fs.FileInfo for _, e := range entries { if e.Name() == "tailscale-update" { continue } info, err := e.Info() if err != nil { t.Error(err) continue } created = append(created, info) } dirs <- created http.Error(w, "unavailable", http.StatusServiceUnavailable) })) defer srv.Close() up := &Updater{Arguments: Arguments{Logf: t.Logf, PkgsAddr: srv.URL, Track: StableTrack}} var previous string for range 2 { if _, err := up.downloadLinuxTarball("1.0.0"); err == nil { t.Fatal("download unexpectedly succeeded") } var created []fs.FileInfo select { case created = <-dirs: default: t.Fatal("download failed before reaching the package server") } if len(created) != 1 { t.Errorf("got %d new download directories, want 1", len(created)) } else { info := created[0] if !info.IsDir() || info.Mode().Perm() != 0700 { t.Errorf("download directory mode = %v, want drwx------", info.Mode()) } if info.Name() == previous { t.Errorf("reused download directory %q", previous) } previous = info.Name() } entries, err := os.ReadDir(base) if err != nil { t.Fatal(err) } for _, e := range entries { if name == "missing-cache" || e.Name() != "tailscale-update" { t.Errorf("download left behind %q", e.Name()) } } } if name != "missing-cache" { if got, err := os.ReadFile(filepath.Join(legacyDir, "keep")); err != nil || string(got) != "keep" { t.Errorf("existing file = %q, %v; want keep, nil", got, err) } } }) }}
func TestUnpackLinuxTarball(t *testing.T) { oldBinaryPaths := binaryPaths t.Cleanup(func() { binaryPaths = oldBinaryPaths })
tests := []struct { desc string tarball map[string]string before map[string]string after map[string]string wantErr bool }{ { desc: "success", before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, tarball: map[string]string{ "/usr/bin/tailscale": "v2", "/usr/bin/tailscaled": "v2", }, after: map[string]string{ "tailscale": "v2", "tailscaled": "v2", }, }, { desc: "skip-unrelated-files", // don't touch unrelated files before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", "foo": "bar", }, tarball: map[string]string{ "/usr/bin/tailscale": "v2", "/usr/bin/tailscaled": "v2", }, after: map[string]string{ "tailscale": "v2", "tailscaled": "v2", "foo": "bar", }, }, { desc: "unmodified", before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, tarball: map[string]string{ "/usr/bin/tailscale": "v1", "/usr/bin/tailscaled": "v1", }, after: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, }, { desc: "ignore-extra-tarball-files", before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, tarball: map[string]string{ "/usr/bin/tailscale": "v2", "/usr/bin/tailscaled": "v2", "/systemd/tailscaled.service": "v2", }, after: map[string]string{ "tailscale": "v2", "tailscaled": "v2", }, }, { desc: "tarball-missing-tailscaled", before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, tarball: map[string]string{ "/usr/bin/tailscale": "v2", }, after: map[string]string{ "tailscale": "v1", "tailscale.new": "v2", "tailscaled": "v1", }, wantErr: true, }, { desc: "duplicate-tailscale-binary", before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, tarball: map[string]string{ "/usr/bin/tailscale": "v2", "/usr/sbin/tailscale": "v2", "/usr/bin/tailscaled": "v2", }, after: map[string]string{ "tailscale": "v1", "tailscale.new": "v2", "tailscaled": "v1", "tailscaled.new": "v2", }, wantErr: true, }, { desc: "empty-archive", before: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, tarball: map[string]string{}, after: map[string]string{ "tailscale": "v1", "tailscaled": "v1", }, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { // Swap out binaryPaths function to point at dummy file paths. tmp := t.TempDir() tailscalePath := filepath.Join(tmp, "tailscale") tailscaledPath := filepath.Join(tmp, "tailscaled") binaryPaths = func() (string, string, error) { return tailscalePath, tailscaledPath, nil } for name, content := range tt.before { if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0755); err != nil { t.Fatal(err) } } tarPath := filepath.Join(tmp, "tailscale.tgz") genTarball(t, tarPath, tt.tarball)
up := &Updater{Arguments: Arguments{Logf: t.Logf}} err := up.unpackLinuxTarball(tarPath) if err != nil { if !tt.wantErr { t.Fatalf("unexpected error: %v", err) } } else if tt.wantErr { t.Fatalf("unpack succeeded, expected an error") }
gotAfter := make(map[string]string) err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.Type().IsDir() { return nil } if path == tarPath { return nil } content, err := os.ReadFile(path) if err != nil { return err } path = filepath.ToSlash(path) base := filepath.ToSlash(tmp) gotAfter[strings.TrimPrefix(path, base+"/")] = string(content) return nil }) if err != nil { t.Fatal(err) }
if !maps.Equal(gotAfter, tt.after) { t.Errorf("files after unpack: %+v, want %+v", gotAfter, tt.after) } }) }}
func genTarball(t *testing.T, path string, files map[string]string) { f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() gw := gzip.NewWriter(f) defer gw.Close() tw := tar.NewWriter(gw) defer tw.Close() for file, content := range files { if err := tw.WriteHeader(&tar.Header{ Name: file, Size: int64(len(content)), Mode: 0755, }); err != nil { t.Fatal(err) } if _, err := tw.Write([]byte(content)); err != nil { t.Fatal(err) } }}
func TestWriteFileOverwrite(t *testing.T) { path := filepath.Join(t.TempDir(), "test") for i := range 2 { content := fmt.Sprintf("content %d", i) if err := writeFile(strings.NewReader(content), path, 0600); err != nil { t.Fatal(err) } got, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(got) != content { t.Errorf("got content: %q, want: %q", got, content) } }}
func TestWriteFileSymlink(t *testing.T) { // Test for a malicious symlink at the destination path. // f2 points to f1 and writeFile(f2) should not end up overwriting f1. tmp := t.TempDir() f1 := filepath.Join(tmp, "f1") if err := os.WriteFile(f1, []byte("old"), 0600); err != nil { t.Fatal(err) } f2 := filepath.Join(tmp, "f2") if err := os.Symlink(f1, f2); err != nil { t.Fatal(err) }
if err := writeFile(strings.NewReader("new"), f2, 0600); err != nil { t.Errorf("writeFile(%q) failed: %v", f2, err) } want := map[string]string{ f1: "old", f2: "new", } for f, content := range want { got, err := os.ReadFile(f) if err != nil { t.Fatal(err) } if string(got) != content { t.Errorf("%q: got content %q, want %q", f, got, content) } }}
func TestCleanupOldDownloads(t *testing.T) { tests := []struct { desc string before []string symlinks map[string]string glob string after []string }{ { desc: "MSIs", before: []string{ "MSICache/tailscale-1.0.0.msi", "MSICache/tailscale-1.1.0.msi", "MSICache/readme.txt", }, glob: "MSICache/*.msi", after: []string{ "MSICache/readme.txt", }, }, { desc: "SPKs", before: []string{ "tmp/tailscale-update-1/tailscale-1.0.0.spk", "tmp/tailscale-update-2/tailscale-1.1.0.spk", "tmp/readme.txt", "tmp/tailscale-update-3", "tmp/tailscale-update-4/tailscale-1.3.0", }, glob: "tmp/tailscale-update*/*.spk", after: []string{ "tmp/readme.txt", "tmp/tailscale-update-3", "tmp/tailscale-update-4/tailscale-1.3.0", }, }, { desc: "empty-target", before: []string{}, glob: "tmp/tailscale-update*/*.spk", after: []string{}, }, { desc: "keep-dirs", before: []string{ "tmp/tailscale-update-1/tailscale-1.0.0.spk", }, glob: "tmp/tailscale-update*", after: []string{ "tmp/tailscale-update-1/tailscale-1.0.0.spk", }, }, { desc: "no-follow-symlinks", before: []string{ "MSICache/tailscale-1.0.0.msi", "MSICache/tailscale-1.1.0.msi", "MSICache/readme.txt", }, symlinks: map[string]string{ "MSICache/tailscale-1.3.0.msi": "MSICache/tailscale-1.0.0.msi", "MSICache/tailscale-1.4.0.msi": "MSICache/readme.txt", }, glob: "MSICache/*.msi", after: []string{ "MSICache/tailscale-1.3.0.msi", "MSICache/tailscale-1.4.0.msi", "MSICache/readme.txt", }, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { dir := t.TempDir() for _, p := range tt.before { if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, p), []byte(tt.desc), 0600); err != nil { t.Fatal(err) } } for from, to := range tt.symlinks { if err := os.Symlink(filepath.Join(dir, to), filepath.Join(dir, from)); err != nil { t.Fatal(err) } }
up := &Updater{Arguments: Arguments{Logf: t.Logf}} up.cleanupOldDownloads(filepath.Join(dir, tt.glob))
var after []string if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if !d.IsDir() { after = append(after, strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(dir)+"/")) } return nil }); err != nil { t.Fatal(err) }
sort.Strings(after) sort.Strings(tt.after) if !slices.Equal(after, tt.after) { t.Errorf("got files after cleanup: %q, want: %q", after, tt.after) } }) }}
func TestParseUnraidPluginVersion(t *testing.T) { tests := []struct { name string plgPath string wantVer string wantErr string }{ {name: "v1_52_0", plgPath: "testdata/tailscale-1.52.0.plg", wantVer: "1.52.0"}, {name: "v1_54_0", plgPath: "testdata/tailscale-1.54.0.plg", wantVer: "1.54.0"}, {name: "nover", plgPath: "testdata/tailscale-nover.plg", wantErr: "version not found in plg file"}, {name: "nover-path-mentioned", plgPath: "testdata/tailscale-nover-path-mentioned.plg", wantErr: "version not found in plg file"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseUnraidPluginVersion(tt.plgPath) if got != tt.wantVer { t.Errorf("got version: %q, want %q", got, tt.wantVer) } var gotErr string if err != nil { gotErr = err.Error() } if gotErr != tt.wantErr { t.Errorf("got error: %q, want %q", gotErr, tt.wantErr) } }) }}
func TestConfirm(t *testing.T) { curTrack := CurrentTrack defer func() { CurrentTrack = curTrack }()
tests := []struct { desc string fromTrack string toTrack string fromVer string toVer string confirm func(string) bool want bool }{ { desc: "on-latest-stable", fromTrack: StableTrack, toTrack: StableTrack, fromVer: "1.66.0", toVer: "1.66.0", want: false, }, { desc: "stable-upgrade", fromTrack: StableTrack, toTrack: StableTrack, fromVer: "1.66.0", toVer: "1.68.0", want: true, }, { desc: "unstable-upgrade", fromTrack: UnstableTrack, toTrack: UnstableTrack, fromVer: "1.67.1", toVer: "1.67.2", want: true, }, { desc: "from-stable-to-unstable", fromTrack: StableTrack, toTrack: UnstableTrack, fromVer: "1.66.0", toVer: "1.67.1", want: true, }, { desc: "from-unstable-to-stable", fromTrack: UnstableTrack, toTrack: StableTrack, fromVer: "1.67.1", toVer: "1.66.0", want: true, }, { desc: "confirm-callback-rejects", fromTrack: StableTrack, toTrack: StableTrack, fromVer: "1.66.0", toVer: "1.66.1", confirm: func(string) bool { return false }, want: false, }, { desc: "confirm-callback-allows", fromTrack: StableTrack, toTrack: StableTrack, fromVer: "1.66.0", toVer: "1.66.1", confirm: func(string) bool { return true }, want: true, }, { desc: "downgrade", fromTrack: StableTrack, toTrack: StableTrack, fromVer: "1.66.1", toVer: "1.66.0", want: false, }, }
for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { CurrentTrack = tt.fromTrack up := Updater{ currentVersion: tt.fromVer, Arguments: Arguments{ Track: tt.toTrack, Confirm: tt.confirm, Logf: t.Logf, }, }
if got := up.confirm(tt.toVer); got != tt.want { t.Errorf("got %v, want %v", got, tt.want) } }) }}