bundle_test.go
1 package memory 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "encoding/hex" 7 "encoding/json" 8 "net/http" 9 "net/http/httptest" 10 "os" 11 "path/filepath" 12 "sort" 13 "strings" 14 "syscall" 15 "testing" 16 ) 17 18 func holdFlock(f *os.File) error { 19 return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) 20 } 21 22 func TestPuller_VersionOutOfRange(t *testing.T) { 23 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 _ = json.NewEncoder(w).Encode(Manifest{ 25 BundleTs: "2026-04-19T03-14-00Z", 26 BundleVersion: "0.5.0", 27 Files: []ManifestFile{}, 28 }) 29 })) 30 defer srv.Close() 31 root := t.TempDir() 32 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 33 err := p.tick(context.Background()) 34 if err == nil || !strings.Contains(err.Error(), "version") { 35 t.Fatalf("err=%v", err) 36 } 37 } 38 39 func TestPuller_VersionInRange(t *testing.T) { 40 for _, v := range []string{"0.4.0", "0.4.5", "0.4.99"} { 41 if !versionInRange(v) { 42 t.Fatalf("%q should be in range", v) 43 } 44 } 45 for _, v := range []string{"0.3.9", "0.5.0", "1.0.0", "garbage"} { 46 if versionInRange(v) { 47 t.Fatalf("%q should NOT be in range", v) 48 } 49 } 50 } 51 52 func TestPuller_NoopWhenSameTs(t *testing.T) { 53 root := t.TempDir() 54 if err := os.MkdirAll(filepath.Join(root, "bundles", "2026-04-19T03-14-00Z"), 0o755); err != nil { 55 t.Fatal(err) 56 } 57 if err := os.Symlink(filepath.Join(root, "bundles", "2026-04-19T03-14-00Z"), filepath.Join(root, "current")); err != nil { 58 t.Fatal(err) 59 } 60 if err := WriteFingerprint(root, "k"); err != nil { 61 t.Fatal(err) 62 } 63 fetched := false 64 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 if strings.HasSuffix(r.URL.Path, "/manifest") { 66 _ = json.NewEncoder(w).Encode(Manifest{ 67 BundleTs: "2026-04-19T03-14-00Z", 68 BundleVersion: "0.4.0", 69 Files: []ManifestFile{}, 70 }) 71 return 72 } 73 fetched = true 74 w.WriteHeader(404) 75 })) 76 defer srv.Close() 77 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 78 if err := p.tick(context.Background()); err != nil { 79 t.Fatal(err) 80 } 81 if fetched { 82 t.Fatal("should not fetch files for same ts") 83 } 84 } 85 86 func TestPuller_TenantSwitch(t *testing.T) { 87 root := t.TempDir() 88 if err := os.MkdirAll(filepath.Join(root, "bundles", "old"), 0o755); err != nil { 89 t.Fatal(err) 90 } 91 if err := WriteFingerprint(root, "old-key"); err != nil { 92 t.Fatal(err) 93 } 94 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 _ = json.NewEncoder(w).Encode(Manifest{ 96 BundleTs: "2026-04-19T03-14-00Z", 97 BundleVersion: "0.4.0", 98 Files: nil, 99 }) 100 })) 101 defer srv.Close() 102 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "new-key"}, nil, nil) 103 _ = p.tick(context.Background()) 104 if _, err := os.Stat(filepath.Join(root, "bundles", "old")); !os.IsNotExist(err) { 105 t.Fatal("old bundles should have been wiped on tenant switch") 106 } 107 fp, _ := ReadFingerprint(root) 108 if fp != Fingerprint("new-key") { 109 t.Fatalf("fp=%q", fp) 110 } 111 } 112 113 func TestPuller_EscapesManifestPathInDownload(t *testing.T) { 114 root := t.TempDir() 115 if err := WriteFingerprint(root, "k"); err != nil { 116 t.Fatal(err) 117 } 118 digest := sha256.Sum256([]byte("hello")) 119 sum := hex.EncodeToString(digest[:]) 120 121 var gotPath string 122 var gotRawQuery string 123 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 124 if strings.HasSuffix(r.URL.Path, "/manifest") { 125 _ = json.NewEncoder(w).Encode(Manifest{ 126 BundleTs: "2026-04-19T06-00-00Z", 127 BundleVersion: "0.4.0", 128 Files: []ManifestFile{ 129 {Path: "dir/a b?x#c", Size: 5, Sha256: sum}, 130 }, 131 }) 132 return 133 } 134 gotPath = r.URL.EscapedPath() 135 gotRawQuery = r.URL.RawQuery 136 _, _ = w.Write([]byte("hello")) 137 })) 138 defer srv.Close() 139 140 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 141 if err := p.tick(context.Background()); err != nil { 142 t.Fatal(err) 143 } 144 if gotPath == "" { 145 t.Fatal("file download request not observed") 146 } 147 if !strings.Contains(gotPath, "a%20b%3Fx%23c") { 148 t.Fatalf("expected escaped segment in file request path, got %q", gotPath) 149 } 150 if gotRawQuery != "" { 151 t.Fatalf("expected empty raw query, got %q", gotRawQuery) 152 } 153 } 154 155 func TestPuller_FlockContentionSkips(t *testing.T) { 156 root := t.TempDir() 157 WriteFingerprint(root, "k") 158 // Acquire the lock externally and never release it during the test. 159 lockPath := filepath.Join(root, "bundle.lock") 160 if err := os.MkdirAll(root, 0o755); err != nil { 161 t.Fatal(err) 162 } 163 f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) 164 if err != nil { 165 t.Fatal(err) 166 } 167 defer f.Close() 168 if err := holdFlock(f); err != nil { 169 t.Fatal(err) 170 } 171 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 172 t.Fatal("manifest must not be fetched while lock contended") 173 })) 174 defer srv.Close() 175 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 176 if err := p.tick(context.Background()); err != nil { 177 t.Fatalf("contention should be silent: %v", err) 178 } 179 } 180 181 func TestPuller_RejectsUnsafePaths(t *testing.T) { 182 cases := []string{"/etc/passwd", "../escape", "x/../../y", "with\x00null", ""} 183 for _, bad := range cases { 184 root := t.TempDir() 185 WriteFingerprint(root, "k") 186 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 187 if strings.HasSuffix(r.URL.Path, "/manifest") { 188 _ = json.NewEncoder(w).Encode(Manifest{ 189 BundleTs: "2026-04-19T04-00-00Z", 190 BundleVersion: "0.4.0", 191 Files: []ManifestFile{{Path: bad, Size: 1, Sha256: "deadbeef"}}, 192 }) 193 return 194 } 195 t.Fatalf("file fetch must NOT be issued for unsafe path %q", bad) 196 })) 197 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 198 err := p.tick(context.Background()) 199 srv.Close() 200 if err == nil || !strings.Contains(err.Error(), "unsafe") { 201 t.Fatalf("path=%q expected unsafe error, got %v", bad, err) 202 } 203 } 204 } 205 206 func TestPuller_HashMismatchAborts(t *testing.T) { 207 root := t.TempDir() 208 WriteFingerprint(root, "k") 209 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 210 if strings.HasSuffix(r.URL.Path, "/manifest") { 211 _ = json.NewEncoder(w).Encode(Manifest{ 212 BundleTs: "2026-04-19T04-00-00Z", 213 BundleVersion: "0.4.0", 214 Files: []ManifestFile{{ 215 Path: "data.bin", Size: 4, 216 Sha256: "deadbeef00000000000000000000000000000000000000000000000000000000", 217 }}, 218 }) 219 return 220 } 221 _, _ = w.Write([]byte("xxxx")) 222 })) 223 defer srv.Close() 224 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 225 err := p.tick(context.Background()) 226 if err == nil { 227 t.Fatal("expected sha mismatch error") 228 } 229 if _, e := os.Stat(filepath.Join(root, "bundles", "2026-04-19T04-00-00Z")); !os.IsNotExist(e) { 230 t.Fatal("bundle should not be installed on hash mismatch") 231 } 232 // Staging should be cleaned up. 233 if _, e := os.Stat(filepath.Join(root, "staging", "2026-04-19T04-00-00Z")); !os.IsNotExist(e) { 234 t.Fatal("staging dir should be removed on abort") 235 } 236 } 237 238 func TestPuller_AuditOnUnsafePath(t *testing.T) { 239 root := t.TempDir() 240 WriteFingerprint(root, "k") 241 captured := []string{} 242 a := AuditFunc(func(ev string, _ map[string]any) { captured = append(captured, ev) }) 243 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 244 if strings.HasSuffix(r.URL.Path, "/manifest") { 245 _ = json.NewEncoder(w).Encode(Manifest{ 246 BundleTs: "2026-04-19T04-00-00Z", 247 BundleVersion: "0.4.0", 248 Files: []ManifestFile{{Path: "../escape", Size: 1, Sha256: "x"}}, 249 }) 250 } 251 })) 252 defer srv.Close() 253 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, a) 254 _ = p.tick(context.Background()) 255 found := false 256 for _, e := range captured { 257 if e == "memory_bundle_unsafe_path" { 258 found = true 259 } 260 } 261 if !found { 262 t.Fatalf("expected memory_bundle_unsafe_path audit, got %v", captured) 263 } 264 } 265 266 func TestPuller_AtomicInstallAndRetention(t *testing.T) { 267 root := t.TempDir() 268 // Pre-install 4 old bundles + current symlink. 269 bundles := []string{ 270 "2026-04-15T00-00-00Z", 271 "2026-04-16T00-00-00Z", 272 "2026-04-17T00-00-00Z", 273 "2026-04-18T00-00-00Z", 274 } 275 for _, ts := range bundles { 276 if err := os.MkdirAll(filepath.Join(root, "bundles", ts), 0o755); err != nil { 277 t.Fatal(err) 278 } 279 } 280 if err := os.Symlink(filepath.Join(root, "bundles", "2026-04-18T00-00-00Z"), filepath.Join(root, "current")); err != nil { 281 t.Fatal(err) 282 } 283 WriteFingerprint(root, "k") 284 285 const dataSha = "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7" 286 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 287 if strings.HasSuffix(r.URL.Path, "/manifest") { 288 _ = json.NewEncoder(w).Encode(Manifest{ 289 BundleTs: "2026-04-19T00-00-00Z", 290 BundleVersion: "0.4.0", 291 Files: []ManifestFile{{Path: "data", Size: 4, Sha256: dataSha}}, 292 }) 293 return 294 } 295 _, _ = w.Write([]byte("data")) 296 })) 297 defer srv.Close() 298 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: srv.URL, APIKey: "k"}, nil, nil) 299 if err := p.tick(context.Background()); err != nil { 300 t.Fatal(err) 301 } 302 303 target, err := os.Readlink(filepath.Join(root, "current")) 304 if err != nil { 305 t.Fatal(err) 306 } 307 if filepath.Base(target) != "2026-04-19T00-00-00Z" { 308 t.Fatalf("current=%q", target) 309 } 310 if _, err := os.Stat(filepath.Join(root, "bundles", "2026-04-19T00-00-00Z", "data")); err != nil { 311 t.Fatal("installed bundle missing data file") 312 } 313 // Retention: newest 3 by ts (keeps 04-19, 04-18, 04-17). Plus current symlink target 314 // is preserved if it falls outside newest-3 (here it does NOT — 04-19 is current). 315 // 04-15 and 04-16 should be removed. 316 entries, _ := os.ReadDir(filepath.Join(root, "bundles")) 317 var names []string 318 for _, e := range entries { 319 if e.IsDir() { 320 names = append(names, e.Name()) 321 } 322 } 323 sort.Strings(names) 324 want := []string{"2026-04-17T00-00-00Z", "2026-04-18T00-00-00Z", "2026-04-19T00-00-00Z"} 325 if len(names) != 3 { 326 t.Fatalf("kept %v want %v", names, want) 327 } 328 for i, n := range names { 329 if n != want[i] { 330 t.Fatalf("kept[%d]=%s want %s", i, n, want[i]) 331 } 332 } 333 } 334 335 func TestPuller_RetentionPreservesCurrent(t *testing.T) { 336 // Edge case: current symlink targets a bundle that falls OUTSIDE the 337 // newest-3 by ts. Retention must still keep it (defensive). 338 root := t.TempDir() 339 for _, ts := range []string{"a", "b", "c", "d", "e"} { 340 os.MkdirAll(filepath.Join(root, "bundles", ts), 0o755) 341 } 342 // current → "a" (oldest by lexicographic sort) 343 os.Symlink(filepath.Join(root, "bundles", "a"), filepath.Join(root, "current")) 344 WriteFingerprint(root, "k") 345 // Manifest reports same ts as current ("a") → tick is noop and won't trigger 346 // install, but we want to test retention in isolation. Call retain directly. 347 p := NewPuller(Config{Provider: "cloud", BundleRoot: root, Endpoint: "http://x", APIKey: "k"}, nil, nil) 348 p.retain(3) 349 if _, err := os.Stat(filepath.Join(root, "bundles", "a")); err != nil { 350 t.Fatal("retain wiped current symlink target") 351 } 352 }