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>
17 files changed,
+784
−107
internal/host/relocationeffects_test.go+0 −89internal/host/relocationflush.go+28 −5internal/host/relocationflush_adoption_test.go+2 −0internal/host/relocationflush_saferead_linux_test.go+29 −0internal/host/relocationflush_wake_linux_test.go+106 −0internal/host/relocationinstall.go+34 −7internal/host/relocationinstall_tenantwrite_linux_test.go+182 −0internal/hostfiles/dir_other.go+6 −2internal/hostfiles/dir_unix.go+16 −0internal/hostfiles/present_test.go+2 −0internal/hostfiles/restore.go+29 −3internal/hostfiles/sync.go+16 −0internal/hostfiles/variants_linux_test.go+108 −0internal/instancefs/instancefs.go+4 −0internal/instancefs/instancefs_linux.go+48 −1internal/instancefs/instancefs_other.go+14 −0internal/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) |
| + } |
| +} |