/ internal / memory / bundle_test.go
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  }