| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "archive/tar" |
| 5 | "bytes" |
| 6 | "compress/gzip" |
| 7 | "math" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/installlayout" |
| 14 | ) |
| 15 | |
| 16 | func TestLinuxShellRejectsExpandedSizeOverflow(t *testing.T) { |
| 17 | var archive bytes.Buffer |
| 18 | gz := gzip.NewWriter(&archive) |
| 19 | tw := tar.NewWriter(gz) |
| 20 | if err := tw.WriteHeader(&tar.Header{Name: "reasonix", Mode: 0755, Size: 1}); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | if _, err := tw.Write([]byte("x")); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if err := tw.WriteHeader(&tar.Header{Name: "app/large", Mode: 0644, Size: math.MaxInt64}); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | // The oversized member has no body: the limit must reject its header |
| 30 | // before attempting to copy, even when the summed sizes would overflow. |
| 31 | if err := gz.Close(); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | _, _, err := stageLinuxShellRelease(archive.Bytes(), t.TempDir()) |
| 35 | if err == nil || !strings.Contains(err.Error(), "extraction limit") { |
| 36 | t.Fatalf("oversized header must fail before body copy, got %v", err) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func linuxShellArchive(t *testing.T, extra *tar.Header, omit string) []byte { |
| 41 | t.Helper() |
| 42 | var out bytes.Buffer |
| 43 | gz := gzip.NewWriter(&out) |
| 44 | tw := tar.NewWriter(gz) |
| 45 | names := append([]string{"reasonix-desktop", "reasonix", "reasonix-launcher", "reasonix-guard"}, installlayout.ShellRequiredNames("linux")...) |
| 46 | names = append(names, "app/chrome-sandbox", "app/locales/en-US.pak") |
| 47 | for _, name := range names { |
| 48 | if name == omit { |
| 49 | continue |
| 50 | } |
| 51 | data := []byte("new-" + name) |
| 52 | h := &tar.Header{Name: name, Mode: 0755, Size: int64(len(data)), Typeflag: tar.TypeReg} |
| 53 | if err := tw.WriteHeader(h); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | if _, err := tw.Write(data); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | } |
| 60 | if extra != nil { |
| 61 | if err := tw.WriteHeader(extra); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | } |
| 65 | if err := tw.Close(); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if err := gz.Close(); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | return out.Bytes() |
| 72 | } |
| 73 | |
| 74 | func TestLinuxShellUpdateAtomicallyPublishesResourcesAndLauncher(t *testing.T) { |
| 75 | root := t.TempDir() |
| 76 | archive := linuxShellArchive(t, nil, "") |
| 77 | for _, version := range []string{"v1.39.0", "v1.39.1"} { |
| 78 | if err := activateLinuxShellRelease(archive, version, root); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | } |
| 82 | ptr, err := installlayout.ReadCurrent(root) |
| 83 | if err != nil || ptr.ActiveVersion != "v1.39.1" { |
| 84 | t.Fatalf("pointer=%+v %v", ptr, err) |
| 85 | } |
| 86 | for _, name := range append(installlayout.ShellRequiredNames("linux"), "app/locales/en-US.pak", "reasonix-desktop", "reasonix") { |
| 87 | p := filepath.Join(root, "versions", ptr.ActiveVersion, filepath.FromSlash(name)) |
| 88 | data, err := os.ReadFile(p) |
| 89 | if err != nil || string(data) != "new-"+name { |
| 90 | t.Errorf("bad %s: %v", name, err) |
| 91 | } |
| 92 | } |
| 93 | if _, err := os.Stat(filepath.Join(root, "reasonix-launcher")); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestLinuxShellUpdateRejectsPartialOrUnsafeTreeWithoutMovingPointer(t *testing.T) { |
| 99 | cases := map[string]struct { |
| 100 | header *tar.Header |
| 101 | omit string |
| 102 | }{ |
| 103 | "missing renderer": {nil, "app/resources/app.asar"}, |
| 104 | "traversal": {&tar.Header{Name: "app/../outside", Typeflag: tar.TypeReg}, ""}, |
| 105 | "symlink": {&tar.Header{Name: "app/link", Linkname: "/tmp", Typeflag: tar.TypeSymlink}, ""}, |
| 106 | "duplicate": {&tar.Header{Name: "app/Reasonix", Typeflag: tar.TypeReg}, ""}, |
| 107 | } |
| 108 | for name, tc := range cases { |
| 109 | t.Run(name, func(t *testing.T) { |
| 110 | root := t.TempDir() |
| 111 | if err := activateLinuxShellRelease(linuxShellArchive(t, nil, ""), "v1.39.0", root); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | if err := activateLinuxShellRelease(linuxShellArchive(t, tc.header, tc.omit), "v1.39.1", root); err == nil { |
| 115 | t.Fatal("accepted invalid release") |
| 116 | } |
| 117 | ptr, err := installlayout.ReadCurrent(root) |
| 118 | if err != nil || ptr.ActiveVersion != "v1.39.0" { |
| 119 | t.Fatalf("changed old pointer: %+v %v", ptr, err) |
| 120 | } |
| 121 | }) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestLinuxShellExtractionCannotFollowPreexistingLinksOutsideStaging(t *testing.T) { |
| 126 | for _, name := range []string{"app", "app/resources", "reasonix-desktop"} { |
| 127 | t.Run(name, func(t *testing.T) { |
| 128 | staging, outside := t.TempDir(), t.TempDir() |
| 129 | sentinel := filepath.Join(outside, "keep") |
| 130 | if err := os.WriteFile(sentinel, []byte("original"), 0600); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | target := outside |
| 134 | if name == "reasonix-desktop" { |
| 135 | target = sentinel |
| 136 | } |
| 137 | link := filepath.Join(staging, filepath.FromSlash(name)) |
| 138 | if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | if err := os.Symlink(target, link); err != nil { |
| 142 | t.Skipf("symlinks unavailable: %v", err) |
| 143 | } |
| 144 | if _, _, err := stageLinuxShellRelease(linuxShellArchive(t, nil, ""), staging); err == nil { |
| 145 | t.Fatal("extraction accepted a symlink outside staging") |
| 146 | } |
| 147 | entries, err := os.ReadDir(outside) |
| 148 | if err != nil || len(entries) != 1 || entries[0].Name() != "keep" { |
| 149 | t.Fatalf("extraction created files outside staging: %v %v", entries, err) |
| 150 | } |
| 151 | data, err := os.ReadFile(sentinel) |
| 152 | if err != nil || string(data) != "original" { |
| 153 | t.Fatalf("extraction changed outside file: %q %v", data, err) |
| 154 | } |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestLinuxShellExtractionRejectsEscapingPathsWithoutOutsideWrites(t *testing.T) { |
| 160 | parent := t.TempDir() |
| 161 | outside := filepath.Join(parent, "outside") |
| 162 | for _, name := range []string{"../outside", "app/../../outside", outside, `app\..\..\outside`} { |
| 163 | t.Run(name, func(t *testing.T) { |
| 164 | staging, err := os.MkdirTemp(parent, "staging-") |
| 165 | if err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | archive := linuxShellArchive(t, &tar.Header{Name: name, Typeflag: tar.TypeReg}, "") |
| 169 | if _, _, err := stageLinuxShellRelease(archive, staging); err == nil { |
| 170 | t.Fatal("extraction accepted an escaping path") |
| 171 | } |
| 172 | if _, err := os.Stat(outside); !os.IsNotExist(err) { |
| 173 | t.Fatalf("extraction created an outside file: %v", err) |
| 174 | } |
| 175 | }) |
| 176 | } |
| 177 | } |
| 178 |