//go:build linux package hostfiles import ( "context" "encoding/json" "errors" "os" "path/filepath" "runtime" "strings" "sync/atomic" "syscall" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // Astra P1 / Opus M1: a FIFO swapped in at a directory Restore is about to // sync (after realParents looked) is refused at once, never waited on. func TestRestoreRefusesAFIFOSwappedInAtADirectoryItSyncs(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) p := open(t, s) stage(t, p, "a/b.txt", "restored") commit(t, p) dir := t.TempDir() prev := syncDir syncDir = func(root *os.Root, rel string) error { if rel == "a" { if err := os.Rename(filepath.Join(dir, "a"), filepath.Join(dir, "a.moved")); err != nil { t.Error(err) } if err := syscall.Mkfifo(filepath.Join(dir, "a"), 0o600); err != nil { t.Error(err) } } return prev(root, rel) } t.Cleanup(func() { syncDir = prev }) done := make(chan error, 1) go func() { _, _, err := Restore(ctx, s, pfx, dir, nil); done <- err }() select { case err := <-done: if err == nil { t.Fatal("Restore synced a FIFO as a directory") } case <-time.After(20 * time.Second): t.Fatal("Restore blocked on a FIFO swapped in at a directory it syncs") } } // Astra P2 / Opus M4: with the caller's evidence of no env file, a // directory renamed away and back while Present looks is never read as // "no env file there" when one is. func TestPresentNeverReadsAVanishingDirectoryAsNoEnvFile(t *testing.T) { none := Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{}} dir := filepath.Join(t.TempDir(), "inst") if err := os.Mkdir(dir, 0o700); err != nil { t.Fatal(err) } env := filepath.Join(dir, "shop.example.env") if err := os.WriteFile(env, []byte("X=1\n"), 0o600); err != nil { t.Fatal(err) } var stop atomic.Bool flapped := make(chan struct{}) go func() { defer close(flapped) for !stop.Load() { os.Rename(dir, dir+".away") os.Rename(dir+".away", dir) } }() deadline := time.Now().Add(1500 * time.Millisecond) for time.Now().Before(deadline) { if ok, _ := Present(t.TempDir(), env, none, true); ok { stop.Store(true) <-flapped t.Fatal("Present read a directory that vanished between its steps as no env file") } } stop.Store(true) <-flapped } // Opus I1 (Fix 4: the byte cap alone): the walk refuses a path past // MaxPathBytes bytes, at the boundary exactly, accepts a deep tree within // it, and a chain far past it is // refused quickly with bounded allocation. func TestSyncDirRefusesATreePastItsCaps(t *testing.T) { build := func(t *testing.T, comps []string, file bool) string { t.Helper() dir := t.TempDir() root, err := os.OpenRoot(dir) if err != nil { t.Fatal(err) } defer root.Close() dirs := comps if file { dirs = comps[:len(comps)-1] } if len(dirs) > 0 { if err := root.MkdirAll(strings.Join(dirs, "/"), 0o700); err != nil { t.Fatal(err) } } if file { if err := root.WriteFile(strings.Join(comps, "/"), []byte("x"), 0o600); err != nil { t.Fatal(err) } } return dir } repeat := func(n int, c string) []string { out := make([]string, n) for i := range out { out[i] = c } return out } sync := func(t *testing.T, ctx context.Context, dir string) error { t.Helper() _, err := SyncDir(ctx, open(t, release.NewDirStore(t.TempDir())), dir, nil) return err } for _, tc := range []struct { name string comps []string file bool ok bool }{ {"a file at 257 one-byte components", repeat(257, "d"), true, true}, {"directories past the byte cap, no file", repeat(2100, "d"), false, false}, {"a path of MaxPathBytes", strings.Split(pathOfBytes(MaxPathBytes), "/"), true, true}, {"a path of MaxPathBytes+1", strings.Split(pathOfBytes(MaxPathBytes+1), "/"), true, false}, } { t.Run(tc.name, func(t *testing.T) { if got := len(strings.Join(tc.comps, "/")); strings.Contains(tc.name, "MaxPathBytes") && got != MaxPathBytes && got != MaxPathBytes+1 { t.Fatalf("built a %d-byte path", got) } err := sync(t, context.Background(), build(t, tc.comps, tc.file)) if tc.ok && err != nil { t.Fatalf("a tree within the caps: %v", err) } if !tc.ok && !errors.Is(err, ErrTooDeep) { t.Fatalf("a tree past the caps: %v; want ErrTooDeep", err) } }) } t.Run("a chain far past the cap", func(t *testing.T) { dir := build(t, repeat(4000, "d"), true) var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) start := time.Now() err := sync(t, context.Background(), dir) runtime.ReadMemStats(&after) if !errors.Is(err, ErrTooDeep) { t.Fatalf("got %v, want ErrTooDeep", err) } if d := time.Since(start); d > 5*time.Second { t.Fatalf("refusing took %v", d) } if alloc := after.TotalAlloc - before.TotalAlloc; alloc > 16<<20 { t.Fatalf("refusing allocated %d bytes", alloc) } }) t.Run("cancellation stops a walk with no files", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() if err := sync(t, ctx, build(t, repeat(20, "d"), false)); !errors.Is(err, context.Canceled) { t.Fatalf("got %v, want context.Canceled", err) } }) } // checkManifest holds a manifest's paths to the walk's caps, so Restore // refuses one a walk could not have published before touching anything. func TestRestoreRefusesAManifestPathPastTheCaps(t *testing.T) { sha := strings.Repeat("a", 64) entry := Entry{Object: pfx + objectKey(1, sha), SHA256: sha, Seq: 1, Size: 1} man := func(p string) Manifest { return Manifest{V: ManifestVersion, Seq: 1, Entries: map[string]Entry{p: entry}, Platform: map[string]Entry{}} } deep := func(n int) string { return strings.TrimSuffix(strings.Repeat("d/", n), "/") } for _, tc := range []struct { path string ok bool }{ {deep(257), true}, {deep(2048), true}, {deep(2049), false}, {pathOfBytes(MaxPathBytes), true}, {pathOfBytes(MaxPathBytes + 1), false}, } { err := checkManifest(man(tc.path), []string{pfx}) if tc.ok && err != nil || !tc.ok && !errors.Is(err, ErrTooDeep) { t.Fatalf("%d bytes, %d components: %v (ok %v)", len(tc.path), strings.Count(tc.path, "/")+1, err, tc.ok) } } ctx, s := context.Background(), release.NewDirStore(t.TempDir()) body, err := json.Marshal(man(pathOfBytes(MaxPathBytes + 1))) if err != nil { t.Fatal(err) } if _, err := s.Put(ctx, ManifestKey(pfx), body, ""); err != nil { t.Fatal(err) } dir := t.TempDir() if _, _, err := Restore(ctx, s, pfx, dir, nil); !errors.Is(err, ErrTooDeep) { t.Fatalf("Restore: %v; want ErrTooDeep", err) } if names, _ := os.ReadDir(dir); len(names) != 0 { t.Fatalf("a refused restore touched the directory: %v", names) } } // pathOfBytes is a relative path of exactly n bytes in components of at // most 200 bytes. func pathOfBytes(n int) string { var b strings.Builder for b.Len() < n { if b.Len() > 0 { b.WriteByte('/') } b.WriteString(strings.Repeat("x", min(200, n-b.Len()))) } return b.String() }