carlos / platform Public

host: relocation install writes, restores and publishes the tenant's instance directory only beneath directories opened through instancefs, with hostfiles' RestoreRoot and SyncDirAt as the one implementation behind Restore and SyncDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3194d67411ad394babaf3d674eb83756b07af6dc parent dca370c
17 files changed, +784 −107
  • internal/host/relocationeffects_test.go +0 −89
  • internal/host/relocationflush.go +28 −5
  • internal/host/relocationflush_adoption_test.go +2 −0
  • internal/host/relocationflush_saferead_linux_test.go +29 −0
  • internal/host/relocationflush_wake_linux_test.go +106 −0
  • internal/host/relocationinstall.go +34 −7
  • internal/host/relocationinstall_tenantwrite_linux_test.go +182 −0
  • internal/hostfiles/dir_other.go +6 −2
  • internal/hostfiles/dir_unix.go +16 −0
  • internal/hostfiles/present_test.go +2 −0
  • internal/hostfiles/restore.go +29 −3
  • internal/hostfiles/sync.go +16 −0
  • internal/hostfiles/variants_linux_test.go +108 −0
  • internal/instancefs/instancefs.go +4 −0
  • internal/instancefs/instancefs_linux.go +48 −1
  • internal/instancefs/instancefs_other.go +14 −0
  • internal/instancefs/opendir_linux_test.go +160 −0

CI cancelled — superseded by a newer push — run details

diff --git a/internal/host/relocationeffects_test.go b/internal/host/relocationeffects_test.go
index c82e829dc..77517d050 100644
--- a/internal/host/relocationeffects_test.go
+++ b/internal/host/relocationeffects_test.go
@@ -14,9 +14,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
execctl "github.com/carlosframework/platform/internal/hostexec"
- "github.com/carlosframework/platform/internal/hostfiles"
"github.com/carlosframework/platform/internal/hostguard"
- "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -430,93 +428,6 @@ func TestReleasePositionsFileBindsTheExactStop(t *testing.T) {
}
}
-func TestReleaseFlushFilesCarriesTheLastWakeStateAndEnv(t *testing.T) {
- ctx := context.Background()
- data := release.NewDirStore(t.TempDir())
- reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { reg.Close() })
- const host = "shop.example"
- if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil {
- t.Fatal(err)
- }
- dir, envDir := t.TempDir(), t.TempDir()
- if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(envDir, host+".env"), []byte("KEY=v\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return dir }, EnvPath: func(h string) string { return filepath.Join(envDir, h+".env") }}
- obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Generation: "g6"}}}
- first, err := f.Flush(ctx, obs)
- if err != nil || first == 0 {
- t.Fatalf("first flush: %d %v", first, err)
- }
- // A timer registered immediately before the release, and a new file.
- if err := reg.ReplaceTimers(host, []registry.Timer{{Host: host, Name: "late", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, CreatedAt: 10, DueAt: 5000, Registration: "reg-late"}}); err != nil {
- t.Fatal(err)
- }
- if err := reg.SetNextDue(host, 7777); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("later"), 0o644); err != nil {
- t.Fatal(err)
- }
- seq, err := f.Flush(ctx, obs)
- if err != nil || seq <= first {
- t.Fatalf("second flush: %d %v", seq, err)
- }
- prefix := "instances/" + host + "/inc/files/g6/"
- m, ok, err := hostfiles.Load(ctx, data, prefix)
- if err != nil || !ok || m.Seq != seq {
- t.Fatalf("manifest: %+v %v %v", m, ok, err)
- }
- if _, ok := m.Entries["b.txt"]; !ok {
- t.Fatal("the file written before the release is not in the manifest")
- }
- ws, ok, err := hostfiles.LoadWake(ctx, data, prefix, m, nil)
- if err != nil || !ok || len(ws.Timers) != 1 || ws.Timers[0].Name != "late" || ws.NextDue != 7777 {
- t.Fatalf("wake state: %+v %v %v", ws, ok, err)
- }
- env, err := hostfiles.ReadPlatform(ctx, data, prefix, m, hostfiles.EnvItem, nil)
- if err != nil || string(env) != "KEY=v\n" {
- t.Fatalf("env: %q %v", env, err)
- }
- // Nothing changed: the same sequence, still the final state.
- again, err := f.Flush(ctx, obs)
- if err != nil || again != seq {
- t.Fatalf("repeat: %d %v", again, err)
- }
- // The env file is removed before a later release: the flush stages its
- // deletion, so the final state carries no env, not the old KEY=v.
- if err := os.Remove(filepath.Join(envDir, host+".env")); err != nil {
- t.Fatal(err)
- }
- gone, err := f.Flush(ctx, obs)
- if err != nil || gone <= seq {
- t.Fatalf("flush after the env file was removed: %d %v", gone, err)
- }
- m, ok, err = hostfiles.Load(ctx, data, prefix)
- if err != nil || !ok || m.Seq != gone {
- t.Fatalf("manifest after removal: %+v %v %v", m, ok, err)
- }
- if env, err := hostfiles.ReadPlatform(ctx, data, prefix, m, hostfiles.EnvItem, nil); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("a removed env file was still published: %q %v", env, err)
- }
- if again, err := f.Flush(ctx, obs); err != nil || again != gone {
- t.Fatalf("repeat after removal: %d %v", again, err)
- }
- var raw map[string]any
- body, _, _ := data.Get(ctx, hostfiles.ManifestKey(prefix))
- _ = json.Unmarshal(body, &raw)
- if raw["v"] != float64(hostfiles.ManifestVersion) {
- t.Fatalf("manifest version: %v", raw["v"])
- }
-}
-
// Round 14 finding 2: EnsureLocal verifies the receipt's install, restores
// a retired one under the same stamp and verifies it again, refuses a
// restore that is not the receipt's, and never touches an opened P.
diff --git a/internal/host/relocationflush.go b/internal/host/relocationflush.go
index 83a94567f..dc0aa56b9 100644
--- a/internal/host/relocationflush.go
+++ b/internal/host/relocationflush.go
@@ -122,13 +122,18 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
// root is the platform's own on main); beneath it nothing is
// followed. This root, <instance dir>/<host>, is the tenant's to
// rename, so a link planted at it would publish whatever directory it
- // names (the box's own /data among them) as the tenant's files. It
- // must open as a real directory with no symlink at any component
- // first; the writers are stopped, so nothing swaps it after.
- if _, err := instancefs.StatDir(filesDir); err != nil {
+ // names (the box's own /data among them) as the tenant's files. So the
+ // flush opens it once, with no symlink at any component and never
+ // waiting on a FIFO (instancefs.OpenDirBeneath), and publishes from
+ // that descriptor (SyncDirAt): a link renamed in after the open is
+ // never followed.
+ root, err := instancefs.OpenDirBeneath(filepath.Dir(filesDir), filepath.Base(filesDir))
+ if err != nil {
return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err)
}
- st, err := hostfiles.SyncDir(ctx, p, filesDir, f.Skip)
+ defer root.Close()
+ tenantRootSeam("opened")
+ st, err := hostfiles.SyncDirAt(ctx, p, root, f.Skip)
if err != nil {
return 0, err
}
@@ -141,6 +146,24 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
return p.Commit(ctx)
}
+// tenantRootSeam is a test seam on the tenant's ground: the install calls
+// it with "checked" after it has made the files directory and before it
+// opens it, and the flush and the install call it with "opened" after the
+// open and before they work from it. Those are the two points a path
+// swapped by the tenant would race a check-then-open.
+var tenantRootSeam = func(stage string) {}
+
+// syncTenantDir fsyncs dir, opened as OpenDirBeneath opens it: no symlink
+// at any component, never a FIFO.
+func syncTenantDir(dir string) error {
+ f, err := instancefs.OpenDirBeneath(dir, ".")
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return f.Sync()
+}
+
// readInstanceFile reads name in dir, an instance directory the tenant can
// write, through main's one safe read for that ground: the same
// instancefs.ReadBeneathLimit the activator's instance-key and adoption
diff --git a/internal/host/relocationflush_adoption_test.go b/internal/host/relocationflush_adoption_test.go
index 35417a975..5b57dd8dc 100644
--- a/internal/host/relocationflush_adoption_test.go
+++ b/internal/host/relocationflush_adoption_test.go
@@ -1,3 +1,5 @@
+//go:build linux
+
package host
import (
diff --git a/internal/host/relocationflush_saferead_linux_test.go b/internal/host/relocationflush_saferead_linux_test.go
index 0882bc43c..78f222aea 100644
--- a/internal/host/relocationflush_saferead_linux_test.go
+++ b/internal/host/relocationflush_saferead_linux_test.go
@@ -322,3 +322,32 @@ func TestReleaseFlushReadsAbsenceOnlyFromADirectoryThatOpened(t *testing.T) {
_, err = y.flush(t)
y.refused(t, err, os.ErrNotExist)
}
+
+// The race a check-then-open left: a symlink renamed in at the files
+// directory after the flush opened it is never followed. The flush
+// publishes the directory it opened, never the one the link names.
+func TestReleaseFlushPublishesTheDirectoryItOpenedNotALinkSwappedInAfter(t *testing.T) {
+ x := newFlushFixture(t)
+ swapOnce(t, "opened", func() {
+ if err := os.Rename(x.filesDir, x.filesDir+".moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(x.filesRoot, x.filesDir); err != nil {
+ t.Fatal(err)
+ }
+ })
+ seq, err := x.flush(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m, ok, err := hostfiles.Load(context.Background(), x.data, x.prefix)
+ if err != nil || !ok || m.Seq != seq {
+ t.Fatalf("manifest: %v %v", ok, err)
+ }
+ if _, ok := m.Entries["litestream.env"]; ok {
+ t.Fatal("the flush published the directory a swapped-in link names")
+ }
+ if _, ok := m.Entries["a.txt"]; !ok {
+ t.Fatalf("the flush did not publish the directory it opened: %v", m.Entries)
+ }
+}
diff --git a/internal/host/relocationflush_wake_linux_test.go b/internal/host/relocationflush_wake_linux_test.go
new file mode 100644
index 000000000..b24905683
--- /dev/null
+++ b/internal/host/relocationflush_wake_linux_test.go
@@ -0,0 +1,106 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The release flush opens the files directory and reads the env file
+// through instancefs (openat2), so this test is Linux's.
+func TestReleaseFlushFilesCarriesTheLastWakeStateAndEnv(t *testing.T) {
+ ctx := context.Background()
+ data := release.NewDirStore(t.TempDir())
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ const host = "shop.example"
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ dir, envDir := t.TempDir(), t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(envDir, host+".env"), []byte("KEY=v\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return dir }, EnvPath: func(h string) string { return filepath.Join(envDir, h+".env") }}
+ obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Generation: "g6"}}}
+ first, err := f.Flush(ctx, obs)
+ if err != nil || first == 0 {
+ t.Fatalf("first flush: %d %v", first, err)
+ }
+ // A timer registered immediately before the release, and a new file.
+ if err := reg.ReplaceTimers(host, []registry.Timer{{Host: host, Name: "late", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, CreatedAt: 10, DueAt: 5000, Registration: "reg-late"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetNextDue(host, 7777); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("later"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ seq, err := f.Flush(ctx, obs)
+ if err != nil || seq <= first {
+ t.Fatalf("second flush: %d %v", seq, err)
+ }
+ prefix := "instances/" + host + "/inc/files/g6/"
+ m, ok, err := hostfiles.Load(ctx, data, prefix)
+ if err != nil || !ok || m.Seq != seq {
+ t.Fatalf("manifest: %+v %v %v", m, ok, err)
+ }
+ if _, ok := m.Entries["b.txt"]; !ok {
+ t.Fatal("the file written before the release is not in the manifest")
+ }
+ ws, ok, err := hostfiles.LoadWake(ctx, data, prefix, m, nil)
+ if err != nil || !ok || len(ws.Timers) != 1 || ws.Timers[0].Name != "late" || ws.NextDue != 7777 {
+ t.Fatalf("wake state: %+v %v %v", ws, ok, err)
+ }
+ env, err := hostfiles.ReadPlatform(ctx, data, prefix, m, hostfiles.EnvItem, nil)
+ if err != nil || string(env) != "KEY=v\n" {
+ t.Fatalf("env: %q %v", env, err)
+ }
+ // Nothing changed: the same sequence, still the final state.
+ again, err := f.Flush(ctx, obs)
+ if err != nil || again != seq {
+ t.Fatalf("repeat: %d %v", again, err)
+ }
+ // The env file is removed before a later release: the flush stages its
+ // deletion, so the final state carries no env, not the old KEY=v.
+ if err := os.Remove(filepath.Join(envDir, host+".env")); err != nil {
+ t.Fatal(err)
+ }
+ gone, err := f.Flush(ctx, obs)
+ if err != nil || gone <= seq {
+ t.Fatalf("flush after the env file was removed: %d %v", gone, err)
+ }
+ m, ok, err = hostfiles.Load(ctx, data, prefix)
+ if err != nil || !ok || m.Seq != gone {
+ t.Fatalf("manifest after removal: %+v %v %v", m, ok, err)
+ }
+ if env, err := hostfiles.ReadPlatform(ctx, data, prefix, m, hostfiles.EnvItem, nil); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a removed env file was still published: %q %v", env, err)
+ }
+ if again, err := f.Flush(ctx, obs); err != nil || again != gone {
+ t.Fatalf("repeat after removal: %d %v", again, err)
+ }
+ var raw map[string]any
+ body, _, _ := data.Get(ctx, hostfiles.ManifestKey(prefix))
+ _ = json.Unmarshal(body, &raw)
+ if raw["v"] != float64(hostfiles.ManifestVersion) {
+ t.Fatalf("manifest version: %v", raw["v"])
+ }
+}
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index f635bcbde..4864a14f4 100644
--- a/internal/host/relocationinstall.go
+++ b/internal/host/relocationinstall.go
@@ -20,6 +20,7 @@ import (
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancefs"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -474,11 +475,28 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
}
var m hostfiles.Manifest
if err := work.With(ctx, hostwrite.Identity{}, func() error {
- if err := os.MkdirAll(paths.FilesDir, 0o700); err != nil {
- return err // inside the materialized instance directory
+ // Every write below lands in the instance directory, which the
+ // tenant can write (an install repeated after the app ran there
+ // meets what it left). So nothing here resolves a path the
+ // tenant could have linked: the files directory is made with
+ // mkdirat beneath the instance directory (EnsureDirBeneath, which
+ // refuses a link or a FIFO at the name), opened once with no
+ // symlink at any component (OpenRootBeneath) and restored from
+ // that root (RestoreRoot); the env file is written and removed
+ // beneath the directory (WriteAtomic, RemoveBeneath).
+ instDir, filesName := filepath.Dir(paths.FilesDir), filepath.Base(paths.FilesDir)
+ envDir, envName := filepath.Dir(paths.EnvPath), filepath.Base(paths.EnvPath)
+ if _, err := instancefs.EnsureDirBeneath(instDir, filesName, 0o700); err != nil {
+ return err
+ }
+ tenantRootSeam("checked")
+ root, err := instancefs.OpenRootBeneath(instDir, filesName)
+ if err != nil {
+ return err
}
- var err error
- if m, _, err = hostfiles.Restore(ctx, cfg.Data, prefix, paths.FilesDir, origins); err != nil {
+ defer root.Close()
+ tenantRootSeam("opened")
+ if m, _, err = hostfiles.RestoreRoot(ctx, cfg.Data, prefix, root, origins); err != nil {
return err
}
journal.Steps["files"] = strconv.FormatInt(m.Seq, 10)
@@ -496,13 +514,22 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
if err != nil {
return err
}
- if err := writeFileAtomic(paths.EnvPath, env, 0o600); err != nil {
+ // WriteAtomic fsyncs the file before its rename; the
+ // directory sync makes the rename durable.
+ if err := instancefs.WriteAtomic(envDir, envName, env, 0o600); err != nil {
+ return err
+ }
+ if err := syncTenantDir(envDir); err != nil {
return err
}
journal.Steps["env"] = "written"
- } else if err := os.Remove(paths.EnvPath); err != nil && !errors.Is(err, os.ErrNotExist) {
- return err
} else {
+ if err := instancefs.RemoveBeneath(envDir, envName); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if err := syncTenantDir(envDir); err != nil {
+ return err
+ }
journal.Steps["env"] = "none"
}
// The wake state likewise: no timers only by the pin's evidence.
diff --git a/internal/host/relocationinstall_tenantwrite_linux_test.go b/internal/host/relocationinstall_tenantwrite_linux_test.go
new file mode 100644
index 000000000..7191e7dd8
--- /dev/null
+++ b/internal/host/relocationinstall_tenantwrite_linux_test.go
@@ -0,0 +1,182 @@
+//go:build linux
+
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancefs"
+)
+
+// repeatInstall is a crash before the install's journal: the next install
+// runs every write again over what the first left in the instance
+// directory, which the app may have run in since.
+func repeatInstall(t *testing.T, f *installFixture) {
+ t.Helper()
+ if _, err := f.install(); err != nil {
+ t.Fatal(err)
+ }
+ journal, _ := relocationInstallPath(f.r.DataDir, "rel-1")
+ if err := os.Remove(journal); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// decoy is a directory the tenant must never get written or removed into:
+// the box's own data, say. It holds one file the tests check survives.
+func decoy(t *testing.T) string {
+ t.Helper()
+ d := t.TempDir()
+ if err := os.WriteFile(filepath.Join(d, buildHost+".env"), []byte("NOT=the tenant's\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return d
+}
+
+// untouched fails unless d holds exactly the decoy's own file, unchanged.
+func untouched(t *testing.T, d string) {
+ t.Helper()
+ names, err := os.ReadDir(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 1 || names[0].Name() != buildHost+".env" {
+ t.Fatalf("the install wrote into a directory outside the tenant's: %v", names)
+ }
+ if b, err := os.ReadFile(filepath.Join(d, buildHost+".env")); err != nil || string(b) != "NOT=the tenant's\n" {
+ t.Fatalf("the decoy's file was changed: %q %v", b, err)
+ }
+}
+
+// relink moves path aside and puts a symlink to target in its place.
+func relink(t *testing.T, path, target string) string {
+ t.Helper()
+ moved := path + ".moved"
+ if err := os.Rename(path, moved); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, path); err != nil {
+ t.Fatal(err)
+ }
+ return moved
+}
+
+// A repeated install makes, restores and writes nothing through what the
+// tenant left in its instance directory: a symlink at <instance>/<host>
+// or at the instance directory itself, or a FIFO at <instance>/<host>,
+// refuses the install, never blocks it, and nothing lands outside.
+func TestARepeatedRelocationInstallWritesNothingThroughWhatTheTenantPlanted(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ plant func(t *testing.T, f *installFixture, d string)
+ want error
+ }{
+ {"a symlink at the files directory", func(t *testing.T, f *installFixture, d string) {
+ relink(t, f.paths().FilesDir, d)
+ }, instancefs.ErrSymlink},
+ // Main's typed inventory refuses this one before any stage B write
+ // (the swap test below reaches the writes themselves).
+ {"a symlinked instance directory", func(t *testing.T, f *installFixture, d string) {
+ relink(t, filepath.Dir(f.paths().FilesDir), d)
+ }, nil},
+ {"a FIFO at the files directory", func(t *testing.T, f *installFixture, _ string) {
+ p := f.paths().FilesDir
+ if err := os.RemoveAll(p); err != nil {
+ t.Fatal(err)
+ }
+ if err := syscall.Mkfifo(p, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newInstallFixture(t, nil)
+ repeatInstall(t, f)
+ d := decoy(t)
+ tc.plant(t, f, d)
+ var err error
+ bounded(t, func() error { _, err = f.install(); return nil })
+ if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
+ t.Fatalf("install over a plant: %v; want %v", err, tc.want)
+ }
+ untouched(t, d)
+ if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok {
+ t.Fatal("a refused install left a receipt")
+ }
+ })
+ }
+}
+
+// The race the check-then-open left: a symlink renamed in after the
+// install opened the files directory, or after it opened the instance
+// directory's files root and before it writes the env file, is never
+// followed. The restore lands in the directory that was opened; the env
+// write and the env removal refuse a linked instance directory.
+func TestARelocationInstallFollowsNoLinkSwappedInAfterItOpened(t *testing.T) {
+ t.Run("the files directory, between its check and its open", func(t *testing.T) {
+ f := newInstallFixture(t, nil)
+ repeatInstall(t, f)
+ files := f.paths().FilesDir
+ d := decoy(t)
+ swapOnce(t, "checked", func() { relink(t, files, d) })
+ if _, err := f.install(); !errors.Is(err, instancefs.ErrSymlink) {
+ t.Fatalf("install after the files directory was linked: %v", err)
+ }
+ untouched(t, d)
+ })
+ t.Run("the files directory, after its open", func(t *testing.T) {
+ f := newInstallFixture(t, nil)
+ repeatInstall(t, f)
+ files := f.paths().FilesDir
+ if err := os.RemoveAll(filepath.Join(files, "public")); err != nil {
+ t.Fatal(err)
+ }
+ d := decoy(t)
+ var moved string
+ swapOnce(t, "opened", func() { moved = relink(t, files, d) })
+ if _, err := f.install(); err != nil {
+ t.Fatal(err)
+ }
+ untouched(t, d)
+ if _, err := os.Stat(filepath.Join(moved, "public", "index.html")); err != nil {
+ t.Fatalf("the restore did not land in the directory it opened: %v", err)
+ }
+ })
+ for _, tc := range []struct {
+ name string
+ fixture func(t *testing.T) *installFixture
+ }{
+ {"the env write", func(t *testing.T) *installFixture { return newInstallFixture(t, nil) }},
+ {"the env removal", func(t *testing.T) *installFixture { return newInstallFixtureWith(t, buildOptions{NoFiles: true}) }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := tc.fixture(t)
+ repeatInstall(t, f)
+ d := decoy(t)
+ swapOnce(t, "opened", func() { relink(t, filepath.Dir(f.paths().FilesDir), d) })
+ if _, err := f.install(); !errors.Is(err, instancefs.ErrSymlink) {
+ t.Fatalf("install after the instance directory was linked: %v", err)
+ }
+ untouched(t, d)
+ })
+ }
+}
+
+// swapOnce runs swap the first time the install or the flush reaches
+// stage on the tenant's ground (tenantRootSeam), then never again.
+func swapOnce(t *testing.T, stage string, swap func()) {
+ t.Helper()
+ done := false
+ prev := tenantRootSeam
+ tenantRootSeam = func(at string) {
+ if at == stage && !done {
+ done = true
+ swap()
+ }
+ }
+ t.Cleanup(func() { tenantRootSeam = prev })
+}
diff --git a/internal/hostfiles/dir_other.go b/internal/hostfiles/dir_other.go
index d56a54449..df73c2d5b 100644
--- a/internal/hostfiles/dir_other.go
+++ b/internal/hostfiles/dir_other.go
@@ -2,16 +2,20 @@
package hostfiles
-import "errors"
+import (
+ "errors"
+ "os"
+)
// The publisher reads a files directory by descriptor, without following
// links, which needs openat. The agent runs on Linux; elsewhere this
// package builds, restores and reads manifests, and does not publish.
var errNoPublish = errors.New("hostfiles: publishing a directory is not supported on this OS")
-type dirHandle struct{}
+type dirHandle struct{ f *os.File }
func openTree(path string) (*dirHandle, error) { return nil, errNoPublish }
+func handleOf(f *os.File) (*dirHandle, error) { return nil, errNoPublish }
func (d *dirHandle) Close() error { return nil }
func (d *dirHandle) Name() string { return "" }
diff --git a/internal/hostfiles/dir_unix.go b/internal/hostfiles/dir_unix.go
index 706756567..9ada71af4 100644
--- a/internal/hostfiles/dir_unix.go
+++ b/internal/hostfiles/dir_unix.go
@@ -31,6 +31,22 @@ func openTree(path string) (*dirHandle, error) {
return &dirHandle{os.NewFile(uintptr(fd), path)}, nil
}
+// handleOf wraps a directory the caller opened, and keeps, as the root of
+// a walk: SyncDirAt never closes it. Anything but a directory is refused.
+func handleOf(f *os.File) (*dirHandle, error) {
+ if f == nil {
+ return nil, errors.New("hostfiles: sync of a nil directory")
+ }
+ info, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ return nil, &os.PathError{Op: "sync", Path: f.Name(), Err: unix.ENOTDIR}
+ }
+ return &dirHandle{f}, nil
+}
+
func (d *dirHandle) Close() error { return d.f.Close() }
func (d *dirHandle) Name() string { return d.f.Name() }
diff --git a/internal/hostfiles/present_test.go b/internal/hostfiles/present_test.go
index 4a3be4122..057ef6158 100644
--- a/internal/hostfiles/present_test.go
+++ b/internal/hostfiles/present_test.go
@@ -1,3 +1,5 @@
+//go:build linux
+
package hostfiles
import (
diff --git a/internal/hostfiles/restore.go b/internal/hostfiles/restore.go
index 345c75e2c..aea39a15e 100644
--- a/internal/hostfiles/restore.go
+++ b/internal/hostfiles/restore.go
@@ -57,6 +57,32 @@ type RestoreStats struct {
// whole manifest before anything is touched (ErrOrigin when the key is
// well formed but its origin is not admitted).
func Restore(ctx context.Context, s release.Store, prefix, dir string, origins []string) (Manifest, RestoreStats, error) {
+ return restore(ctx, s, prefix, origins, func() (*os.Root, func(), error) {
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ return nil, nil, fmt.Errorf("hostfiles: restore into %s: %w", dir, err)
+ }
+ return root, func() { root.Close() }, nil
+ })
+}
+
+// RestoreRoot is Restore into a directory the caller has already opened as
+// root, and does not close. os.OpenRoot resolves Restore's dir as a path,
+// following a link anywhere in it and blocking on a FIFO at its last
+// component; a caller restoring into ground someone else can write opens
+// the root itself without either (instancefs.OpenRootBeneath) and restores
+// here. Everything else is Restore's: the one implementation both share.
+func RestoreRoot(ctx context.Context, s release.Store, prefix string, root *os.Root, origins []string) (Manifest, RestoreStats, error) {
+ if root == nil {
+ return Manifest{}, RestoreStats{}, errors.New("hostfiles: restore into a nil root")
+ }
+ return restore(ctx, s, prefix, origins, func() (*os.Root, func(), error) { return root, func() {}, nil })
+}
+
+// restore is Restore and RestoreRoot. open is called only once the
+// manifest is validated and names something to do, so a bad manifest
+// touches no directory and an empty one needs none.
+func restore(ctx context.Context, s release.Store, prefix string, origins []string, open func() (*os.Root, func(), error)) (Manifest, RestoreStats, error) {
var st RestoreStats
if err := checkPrefix(prefix); err != nil {
return Manifest{}, st, err
@@ -83,11 +109,11 @@ func Restore(ctx context.Context, s release.Store, prefix, dir string, origins [
if len(live)+len(dead) == 0 {
return m, st, nil
}
- root, err := os.OpenRoot(dir)
+ root, done, err := open()
if err != nil {
- return Manifest{}, st, fmt.Errorf("hostfiles: restore into %s: %w", dir, err)
+ return Manifest{}, st, err
}
- defer root.Close()
+ defer done()
// Tombstones first, deepest first, so a directory that became a file
// is emptied before the file is written over it.
diff --git a/internal/hostfiles/sync.go b/internal/hostfiles/sync.go
index 474d3e24d..eda413ff0 100644
--- a/internal/hostfiles/sync.go
+++ b/internal/hostfiles/sync.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "os"
"path"
"sort"
)
@@ -69,6 +70,21 @@ func SyncDir(ctx context.Context, p *Publisher, dir string, skip func(rel string
return SyncStats{}, fmt.Errorf("hostfiles: sync %s: %w", dir, err)
}
defer root.Close()
+ return SyncDirAt(ctx, p, root.f, skip)
+}
+
+// SyncDirAt is SyncDir over a directory the caller has already opened, and
+// does not close. SyncDir opens its root by path and follows a link there
+// (the path is the platform's own); a caller syncing ground someone else
+// can write opens the root itself without following anything
+// (instancefs.OpenDirBeneath) and syncs here, so the tree published is the
+// directory it vetted, whatever is renamed into the path afterwards.
+// Beneath the root nothing is followed either way.
+func SyncDirAt(ctx context.Context, p *Publisher, dir *os.File, skip func(rel string, isDir bool) bool) (SyncStats, error) {
+ root, err := handleOf(dir)
+ if err != nil {
+ return SyncStats{}, err
+ }
return syncRoot(ctx, p, root, skip)
}
diff --git a/internal/hostfiles/variants_linux_test.go b/internal/hostfiles/variants_linux_test.go
new file mode 100644
index 000000000..8b09a3978
--- /dev/null
+++ b/internal/hostfiles/variants_linux_test.go
@@ -0,0 +1,108 @@
+//go:build linux
+
+package hostfiles
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// SyncDirAt publishes the directory it was handed, whatever is renamed
+// into its path afterwards, and leaves the caller's descriptor open.
+func TestSyncDirAtPublishesTheDirectoryItWasHanded(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ p := open(t, s)
+ base := t.TempDir()
+ dir, other := filepath.Join(base, "files"), t.TempDir()
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "mine.txt"), []byte("mine"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(other, "secret"), []byte("not the tenant's"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := os.Rename(dir, dir+".moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(other, dir); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SyncDirAt(ctx, p, f, nil); err != nil {
+ t.Fatal(err)
+ }
+ commit(t, p)
+ m, ok, err := Load(ctx, s, pfx)
+ if err != nil || !ok {
+ t.Fatalf("manifest: %v %v", ok, err)
+ }
+ if _, ok := m.Entries["secret"]; ok {
+ t.Fatal("SyncDirAt published the directory the path names now")
+ }
+ if _, ok := m.Entries["mine.txt"]; !ok {
+ t.Fatalf("SyncDirAt did not publish the directory it was handed: %v", m.Entries)
+ }
+ if _, err := f.Stat(); err != nil {
+ t.Fatalf("SyncDirAt closed the caller's directory: %v", err)
+ }
+ reg, err := os.Open(filepath.Join(dir+".moved", "mine.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ if _, err := SyncDirAt(ctx, open(t, release.NewDirStore(t.TempDir())), reg, nil); err == nil {
+ t.Fatal("SyncDirAt took a regular file for a directory")
+ }
+}
+
+// RestoreRoot restores into the root it was handed, whatever is renamed
+// into its path afterwards, and Restore is RestoreRoot on the path.
+func TestRestoreRootWritesIntoTheRootItWasHanded(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)
+ base := t.TempDir()
+ dir, other := filepath.Join(base, "files"), t.TempDir()
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ if err := os.Rename(dir, dir+".moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(other, dir); err != nil {
+ t.Fatal(err)
+ }
+ if _, st, err := RestoreRoot(ctx, s, pfx, root, nil); err != nil || st.Written != 1 {
+ t.Fatalf("RestoreRoot: %+v %v", st, err)
+ }
+ if names, _ := os.ReadDir(other); len(names) != 0 {
+ t.Fatalf("RestoreRoot wrote into the directory the path names now: %v", names)
+ }
+ if b, err := os.ReadFile(filepath.Join(dir+".moved", "a", "b.txt")); err != nil || string(b) != "restored" {
+ t.Fatalf("RestoreRoot did not write into its root: %q %v", b, err)
+ }
+ if _, err := root.Stat("."); err != nil {
+ t.Fatalf("RestoreRoot closed the caller's root: %v", err)
+ }
+ if _, _, err := RestoreRoot(ctx, s, pfx, nil, nil); err == nil {
+ t.Fatal("RestoreRoot took a nil root")
+ }
+}
diff --git a/internal/instancefs/instancefs.go b/internal/instancefs/instancefs.go
index 6cbd747d1..875d5aac7 100644
--- a/internal/instancefs/instancefs.go
+++ b/internal/instancefs/instancefs.go
@@ -40,6 +40,10 @@ var ErrEscapes = errors.New("instancefs: path escapes its directory")
// the files this package reads on a tenant's behalf.
var ErrNotRegular = errors.New("instancefs: not a regular file")
+// ErrNotDir is returned when OpenDirBeneath resolves to anything other
+// than a directory: a file, or a FIFO, which it never waits on.
+var ErrNotDir = errors.New("instancefs: not a directory")
+
// ErrOpenat2Unavailable is what SelfCheck returns when this process cannot
// make the openat2 call every function here is built on. The one cause
// seen in practice is the unit's own hardening: systemd's RestrictSUIDSGID=
diff --git a/internal/instancefs/instancefs_linux.go b/internal/instancefs/instancefs_linux.go
index a1e23f4b5..99459f356 100644
--- a/internal/instancefs/instancefs_linux.go
+++ b/internal/instancefs/instancefs_linux.go
@@ -11,6 +11,7 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"golang.org/x/sys/unix"
@@ -81,6 +82,49 @@ func SelfCheck(dir string) error {
// refused by the fstat check unless it is S_IFREG, so nothing non-regular
// can be read or written through this function at all.
func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error) {
+ return openBeneathAs(dir, name, flag, perm, unix.S_IFREG)
+}
+
+// OpenDirBeneath opens name under dir as a directory, read-only, exactly as
+// OpenBeneath opens a file: openat2 with no symlink at any component of dir
+// or name, O_NONBLOCK, and then fstat, which accepts only a directory. A
+// name of "." is dir itself. The descriptor is the directory that was
+// vetted: a caller that works from it (an *at call, or OpenRootBeneath)
+// never resolves the path again, so a link swapped in after the open is
+// never followed.
+func OpenDirBeneath(dir, name string) (*os.File, error) {
+ return openBeneathAs(dir, name, os.O_RDONLY|unix.O_DIRECTORY, 0, unix.S_IFDIR)
+}
+
+// OpenRootBeneath is OpenDirBeneath as an *os.Root, for code built on
+// os.Root (hostfiles.RestoreRoot). os.OpenRoot takes only a path and opens
+// it without O_NONBLOCK, so it would follow a link at the name and block
+// on a FIFO there. This opens the directory with OpenDirBeneath and hands
+// os.OpenRoot that descriptor's /proc/self/fd entry, a magic link the
+// kernel resolves to the very directory the descriptor holds: no
+// component is resolved twice. Without /proc it is an error, never a
+// fallback to the path.
+func OpenRootBeneath(dir, name string) (*os.Root, error) {
+ f, err := OpenDirBeneath(dir, name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ betweenOpenAndRoot()
+ r, err := os.OpenRoot("/proc/self/fd/" + strconv.Itoa(int(f.Fd())))
+ if err != nil {
+ return nil, fmt.Errorf("instancefs: open %s as a root: %w", filepath.Join(dir, name), err)
+ }
+ return r, nil
+}
+
+// betweenOpenAndRoot is a test seam: where a path swapped after the open
+// would race a root opened by path.
+var betweenOpenAndRoot = func() {}
+
+// openBeneathAs is OpenBeneath's open, accepting only the file type want
+// (S_IFREG or S_IFDIR) from fstat.
+func openBeneathAs(dir, name string, flag int, perm os.FileMode, want uint32) (*os.File, error) {
rel, err := cleanRel(name)
if err != nil {
return nil, err
@@ -111,8 +155,11 @@ func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error)
unix.Close(fd)
return nil, &os.PathError{Op: "fstat", Path: path, Err: err}
}
- if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ if st.Mode&unix.S_IFMT != want {
unix.Close(fd)
+ if want == unix.S_IFDIR {
+ return nil, fmt.Errorf("%w: %s", ErrNotDir, path)
+ }
return nil, fmt.Errorf("%w: %s", ErrNotRegular, path)
}
return os.NewFile(uintptr(fd), path), nil
diff --git a/internal/instancefs/instancefs_other.go b/internal/instancefs/instancefs_other.go
index 10901ac46..74aaf44ea 100644
--- a/internal/instancefs/instancefs_other.go
+++ b/internal/instancefs/instancefs_other.go
@@ -14,6 +14,20 @@ func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error)
return nil, errors.ErrUnsupported
}
+func OpenDirBeneath(dir, name string) (*os.File, error) {
+ if _, err := cleanRel(name); err != nil {
+ return nil, err
+ }
+ return nil, errors.ErrUnsupported
+}
+
+func OpenRootBeneath(dir, name string) (*os.Root, error) {
+ if _, err := cleanRel(name); err != nil {
+ return nil, err
+ }
+ return nil, errors.ErrUnsupported
+}
+
func WriteAtomic(dir, name string, data []byte, perm os.FileMode) error {
if _, err := cleanRel(name); err != nil {
return err
diff --git a/internal/instancefs/opendir_linux_test.go b/internal/instancefs/opendir_linux_test.go
new file mode 100644
index 000000000..3714f5576
--- /dev/null
+++ b/internal/instancefs/opendir_linux_test.go
@@ -0,0 +1,160 @@
+//go:build linux
+
+package instancefs
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+)
+
+// OpenDirBeneath and OpenRootBeneath open a directory as OpenBeneath opens
+// a file: no symlink at the name or anywhere in dir, never waiting on a
+// FIFO, and nothing but a directory accepted.
+func TestOpenDirBeneathRefusesWhatATenantCanPlant(t *testing.T) {
+ opens := map[string]func(dir, name string) (func(), error){
+ "OpenDirBeneath": func(dir, name string) (func(), error) {
+ f, err := OpenDirBeneath(dir, name)
+ if err != nil {
+ return nil, err
+ }
+ return func() { f.Close() }, nil
+ },
+ "OpenRootBeneath": func(dir, name string) (func(), error) {
+ r, err := OpenRootBeneath(dir, name)
+ if err != nil {
+ return nil, err
+ }
+ return func() { r.Close() }, nil
+ },
+ }
+ for fn, openIt := range opens {
+ for _, tc := range []struct {
+ name string
+ plant func(t *testing.T, parent string)
+ dir func(parent string) string
+ want error
+ }{
+ {"a symlink at the name", func(t *testing.T, parent string) {
+ if err := os.Symlink(t.TempDir(), filepath.Join(parent, "d")); err != nil {
+ t.Fatal(err)
+ }
+ }, nil, ErrSymlink},
+ {"a symlinked parent", func(t *testing.T, parent string) {
+ if err := os.Mkdir(filepath.Join(parent, "d"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(parent, parent+".link"); err != nil {
+ t.Fatal(err)
+ }
+ }, func(parent string) string { return parent + ".link" }, ErrSymlink},
+ {"a FIFO", func(t *testing.T, parent string) {
+ if err := syscall.Mkfifo(filepath.Join(parent, "d"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, nil, nil},
+ {"a regular file", func(t *testing.T, parent string) {
+ if err := os.WriteFile(filepath.Join(parent, "d"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, nil, nil},
+ } {
+ t.Run(fn+"/"+tc.name, func(t *testing.T) {
+ parent := filepath.Join(t.TempDir(), "inst")
+ if err := os.Mkdir(parent, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ tc.plant(t, parent)
+ dir := parent
+ if tc.dir != nil {
+ dir = tc.dir(parent)
+ }
+ done := make(chan error, 1)
+ go func() {
+ c, err := openIt(dir, "d")
+ if c != nil {
+ c()
+ }
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ // nil want is "not a directory": O_DIRECTORY answers
+ // ENOTDIR, and the fstat check ErrNotDir behind it.
+ notDir := errors.Is(err, syscall.ENOTDIR) || errors.Is(err, ErrNotDir)
+ if (tc.want == nil && !notDir) || (tc.want != nil && !errors.Is(err, tc.want)) {
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ case <-time.After(20 * time.Second):
+ t.Fatal("the open blocked on what was planted")
+ }
+ })
+ }
+ }
+}
+
+// What OpenRootBeneath hands back is the directory it vetted, even once
+// the path names something else.
+func TestOpenRootBeneathIsTheDirectoryItOpened(t *testing.T) {
+ parent := t.TempDir()
+ d := filepath.Join(parent, "d")
+ if err := os.Mkdir(d, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(d, "mine"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ r, err := OpenRootBeneath(parent, "d")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ if err := os.Rename(d, d+".moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(t.TempDir(), d); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.Stat("mine"); err != nil {
+ t.Fatalf("the root is not the directory that was opened: %v", err)
+ }
+ f, err := OpenDirBeneath(parent, ".")
+ if err != nil {
+ t.Fatalf("dir itself: %v", err)
+ }
+ f.Close()
+}
+
+// The root is the directory OpenDirBeneath vetted, never the path opened
+// again: a link swapped in between the two is not followed.
+func TestOpenRootBeneathNeverOpensThePathAgain(t *testing.T) {
+ parent := t.TempDir()
+ d := filepath.Join(parent, "d")
+ if err := os.Mkdir(d, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(d, "mine"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ prev := betweenOpenAndRoot
+ betweenOpenAndRoot = func() {
+ if err := os.Rename(d, d+".moved"); err != nil {
+ t.Error(err)
+ }
+ if err := os.Symlink(t.TempDir(), d); err != nil {
+ t.Error(err)
+ }
+ }
+ t.Cleanup(func() { betweenOpenAndRoot = prev })
+ r, err := OpenRootBeneath(parent, "d")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ if _, err := r.Stat("mine"); err != nil {
+ t.Fatalf("the root followed a link swapped in after the open: %v", err)
+ }
+}