1package updater
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func TestCompareVersions(t *testing.T) {
13 tests := []struct {
14 name string
15 v1 string
16 v2 string
17 expected int
18 }{
19 // Equal versions
20 {"equal simple", "1.0.0", "1.0.0", 0},
21 {"equal two parts", "1.0", "1.0", 0},
22 {"equal one part", "1", "1", 0},
23
24 // v1 < v2 (should update)
25 {"major less", "1.0.0", "2.0.0", -1},
26 {"minor less", "1.1.0", "1.2.0", -1},
27 {"patch less", "1.1.1", "1.1.2", -1},
28 {"complex less", "0.20.1", "0.21.0", -1},
29 {"real world less", "0.20.1", "0.20.2", -1},
30
31 // v1 > v2 (no update needed)
32 {"major greater", "2.0.0", "1.0.0", 1},
33 {"minor greater", "1.2.0", "1.1.0", 1},
34 {"patch greater", "1.1.2", "1.1.1", 1},
35
36 // Pre-release versions (suffix stripped)
37 {"prerelease equal", "1.0.0-alpha", "1.0.0", 0},
38 {"prerelease less", "1.0.0-alpha", "1.0.1", -1},
39 {"prerelease greater", "1.0.1-beta", "1.0.0", 1},
40
41 // Partial versions
42 {"partial v1", "1", "1.0.0", 0},
43 {"partial v2", "1.0.0", "1", 0},
44 {"partial less", "1", "2.0.0", -1},
45 {"partial greater", "2", "1.0.0", 1},
46
47 // Edge cases
48 {"zero versions", "0.0.0", "0.0.0", 0},
49 {"zero less", "0.0.0", "0.0.1", -1},
50 }
51
52 for _, tt := range tests {
53 t.Run(tt.name, func(t *testing.T) {
54 result := compareVersions(tt.v1, tt.v2)
55 if result != tt.expected {
56 t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.v1, tt.v2, result, tt.expected)
57 }
58 })
59 }
60}
61
62func TestGetArchName(t *testing.T) {
63 // This test just ensures the function doesn't panic and returns something.
64 arch := getArchName()
65 if arch == "" {
66 t.Error("getArchName() returned empty string")
67 }
68}
69
70func TestValidateDownloadURL(t *testing.T) {
71 tests := []struct {
72 name string
73 url string
74 wantErr bool
75 errMsg string
76 }{
77 // Valid URLs
78 {
79 name: "valid latest release URL",
80 url: "https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz",
81 wantErr: false,
82 },
83 {
84 name: "valid versioned release URL",
85 url: "https://github.com/bnema/dumber/releases/download/v0.15.0/dumber_linux_x86_64.tar.gz",
86 wantErr: false,
87 },
88
89 // Invalid scheme
90 {
91 name: "http not allowed",
92 url: "http://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz",
93 wantErr: true,
94 errMsg: "must use HTTPS",
95 },
96 {
97 name: "ftp not allowed",
98 url: "ftp://github.com/bnema/dumber/releases/latest/download/file.tar.gz",
99 wantErr: true,
100 errMsg: "must use HTTPS",
101 },
102
103 // Invalid host
104 {
105 name: "wrong host",
106 url: "https://evil.com/bnema/dumber/releases/latest/download/file.tar.gz",
107 wantErr: true,
108 errMsg: "must be from github.com",
109 },
110 {
111 name: "subdomain not allowed",
112 url: "https://raw.github.com/bnema/dumber/releases/latest/download/file.tar.gz",
113 wantErr: true,
114 errMsg: "must be from github.com",
115 },
116 {
117 name: "typosquat domain",
118 url: "https://githuh.com/bnema/dumber/releases/latest/download/file.tar.gz",
119 wantErr: true,
120 errMsg: "must be from github.com",
121 },
122
123 // Invalid path
124 {
125 name: "not a releases URL",
126 url: "https://github.com/bnema/dumber/raw/main/README.md",
127 wantErr: true,
128 errMsg: "must be a GitHub releases URL",
129 },
130 {
131 name: "blob URL not allowed",
132 url: "https://github.com/bnema/dumber/blob/main/cmd/dumber/main.go",
133 wantErr: true,
134 errMsg: "must be a GitHub releases URL",
135 },
136
137 // Malformed URLs
138 {
139 name: "empty URL",
140 url: "",
141 wantErr: true,
142 },
143 {
144 name: "invalid URL format",
145 url: "://not-a-url",
146 wantErr: true,
147 },
148 }
149
150 for _, tt := range tests {
151 t.Run(tt.name, func(t *testing.T) {
152 err := validateDownloadURL(tt.url)
153 if tt.wantErr {
154 if err == nil {
155 t.Errorf("validateDownloadURL(%q) expected error, got nil", tt.url)
156 } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
157 t.Errorf("validateDownloadURL(%q) error = %q, want to contain %q", tt.url, err.Error(), tt.errMsg)
158 }
159 } else {
160 if err != nil {
161 t.Errorf("validateDownloadURL(%q) unexpected error: %v", tt.url, err)
162 }
163 }
164 })
165 }
166}
167
168func TestSanitizeTarPath(t *testing.T) {
169 destDir := "/tmp/extract"
170
171 tests := []struct {
172 name string
173 path string
174 wantErr bool
175 errMsg string
176 wantPath string
177 }{
178 // Valid paths
179 {
180 name: "simple filename",
181 path: "dumber",
182 wantErr: false,
183 wantPath: "/tmp/extract/dumber",
184 },
185 {
186 name: "nested path",
187 path: "dumber_v0.15.0/dumber",
188 wantErr: false,
189 wantPath: "/tmp/extract/dumber_v0.15.0/dumber",
190 },
191 {
192 name: "deep nested path",
193 path: "release/bin/dumber",
194 wantErr: false,
195 wantPath: "/tmp/extract/release/bin/dumber",
196 },
197
198 // Path traversal attempts
199 {
200 name: "parent directory escape",
201 path: "../../../etc/passwd",
202 wantErr: true,
203 errMsg: "path traversal",
204 },
205 {
206 name: "hidden parent escape",
207 path: "dumber_v0.15.0/../../etc/passwd",
208 wantErr: true,
209 errMsg: "path traversal",
210 },
211 {
212 name: "double dot at start of filename",
213 path: "..dumber",
214 wantErr: false, // Valid filename, not a path traversal (.. is not a path component)
215 wantPath: "/tmp/extract/..dumber",
216 },
217 {
218 name: "absolute path unix",
219 path: "/etc/passwd",
220 wantErr: true,
221 errMsg: "absolute path",
222 },
223 {
224 name: "dot dot in middle",
225 path: "foo/../../../bar",
226 wantErr: true,
227 errMsg: "path traversal",
228 },
229
230 // Edge cases
231 {
232 name: "current directory",
233 path: "./dumber",
234 wantErr: false,
235 wantPath: "/tmp/extract/dumber",
236 },
237 {
238 name: "multiple slashes",
239 path: "dumber_v0.15.0//dumber",
240 wantErr: false,
241 wantPath: "/tmp/extract/dumber_v0.15.0/dumber",
242 },
243 }
244
245 for _, tt := range tests {
246 t.Run(tt.name, func(t *testing.T) {
247 result, err := sanitizeTarPath(tt.path, destDir)
248 if tt.wantErr {
249 if err == nil {
250 t.Errorf("sanitizeTarPath(%q, %q) expected error, got nil", tt.path, destDir)
251 } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
252 t.Errorf("sanitizeTarPath(%q, %q) error = %q, want to contain %q", tt.path, destDir, err.Error(), tt.errMsg)
253 }
254 } else {
255 if err != nil {
256 t.Errorf("sanitizeTarPath(%q, %q) unexpected error: %v", tt.path, destDir, err)
257 } else if tt.wantPath != "" && result != tt.wantPath {
258 t.Errorf("sanitizeTarPath(%q, %q) = %q, want %q", tt.path, destDir, result, tt.wantPath)
259 }
260 }
261 })
262 }
263}
264
265func TestVerifyChecksum(t *testing.T) {
266 // Create a temporary file with known content
267 tmpDir := t.TempDir()
268 testFile := filepath.Join(tmpDir, "testfile")
269 testContent := []byte("hello world\n")
270
271 if err := os.WriteFile(testFile, testContent, 0o644); err != nil {
272 t.Fatalf("Failed to create test file: %v", err)
273 }
274
275 // Calculate expected SHA256 hash
276 hash := sha256.Sum256(testContent)
277 expectedHash := hex.EncodeToString(hash[:])
278
279 tests := []struct {
280 name string
281 filePath string
282 hash string
283 wantErr bool
284 errMsg string
285 }{
286 {
287 name: "valid checksum lowercase",
288 filePath: testFile,
289 hash: expectedHash,
290 wantErr: false,
291 },
292 {
293 name: "valid checksum uppercase",
294 filePath: testFile,
295 hash: hex.EncodeToString(hash[:]),
296 wantErr: false,
297 },
298 {
299 name: "invalid checksum",
300 filePath: testFile,
301 hash: "0000000000000000000000000000000000000000000000000000000000000000",
302 wantErr: true,
303 errMsg: "checksum mismatch",
304 },
305 {
306 name: "wrong length hash",
307 filePath: testFile,
308 hash: "abc123",
309 wantErr: true,
310 errMsg: "checksum mismatch",
311 },
312 {
313 name: "file not found",
314 filePath: filepath.Join(tmpDir, "nonexistent"),
315 hash: expectedHash,
316 wantErr: true,
317 errMsg: "failed to open",
318 },
319 }
320
321 for _, tt := range tests {
322 t.Run(tt.name, func(t *testing.T) {
323 err := verifyChecksum(tt.filePath, tt.hash)
324 if tt.wantErr {
325 if err == nil {
326 t.Errorf("verifyChecksum() expected error, got nil")
327 } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
328 t.Errorf("verifyChecksum() error = %q, want to contain %q", err.Error(), tt.errMsg)
329 }
330 } else {
331 if err != nil {
332 t.Errorf("verifyChecksum() unexpected error: %v", err)
333 }
334 }
335 })
336 }
337}
338
339func TestSizeLimits(t *testing.T) {
340 // Verify our size constants are sensible
341 if maxArchiveSize <= 0 {
342 t.Error("maxArchiveSize should be positive")
343 }
344 if maxBinarySize <= 0 {
345 t.Error("maxBinarySize should be positive")
346 }
347 if minBinarySize <= 0 {
348 t.Error("minBinarySize should be positive")
349 }
350 if minBinarySize >= maxBinarySize {
351 t.Errorf("minBinarySize (%d) should be less than maxBinarySize (%d)", minBinarySize, maxBinarySize)
352 }
353 if maxBinarySize >= maxArchiveSize {
354 t.Errorf("maxBinarySize (%d) should be less than maxArchiveSize (%d)", maxBinarySize, maxArchiveSize)
355 }
356}
357
358// contains checks if substr is in s (helper for error message checks).
359func contains(s, substr string) bool {
360 return strings.Contains(s, substr)
361}