carlos / platform Public

host: the release flush, the install's key check and Present read the tenant's instance directory only through instancefs, so a symlink, FIFO or oversized file there is refused and absence counts only when the directory itself opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev dca370c20d260c30cd491e4af10ce0f2ce72b861 parent cfb4314
9 files changed, +774 −33
  • internal/activator/instancekey.go +3 −2
  • internal/activator/keyadoption.go +1 −1
  • internal/host/relocationflush.go +56 −12
  • internal/host/relocationflush_saferead_linux_test.go +324 −0
  • internal/host/relocationinstall.go +17 −4
  • internal/host/relocationinstall_linux_test.go +71 −0
  • internal/hostfiles/present.go +65 −14
  • internal/hostfiles/present_saferead_linux_test.go +224 −0
  • internal/instancekey/instancekey.go +13 −0
diff --git a/internal/activator/instancekey.go b/internal/activator/instancekey.go
index b683e65c3..ad10821ec 100644
--- a/internal/activator/instancekey.go
+++ b/internal/activator/instancekey.go
@@ -321,8 +321,9 @@ var errOwnEnvNotRegular = errors.New("activator: the instance env file is not a
// ownEnvReadLimit caps a flat read of an own env file or its rendered
// configuration, as instancefs.ReadBeneathLimit caps the nested one: no
-// read under adminTokenMu is unbounded.
-const ownEnvReadLimit = 1 << 20
+// read under adminTokenMu is unbounded. It is instancekey's one cap, the
+// one relocation's reads of the same files use.
+const ownEnvReadLimit = instancekey.EnvReadLimit
// errOwnEnvTooLarge is a flat env file over ownEnvReadLimit.
var errOwnEnvTooLarge = errors.New("activator: the instance env file is larger than the limit, refused")
diff --git a/internal/activator/keyadoption.go b/internal/activator/keyadoption.go
index cd0e2314c..662ebcbd8 100644
--- a/internal/activator/keyadoption.go
+++ b/internal/activator/keyadoption.go
@@ -151,7 +151,7 @@ func writeAdoptionRecord(l AdoptionLaunch, host string, record []byte) error {
var cur []byte
var err error
if l.Beneath {
- cur, err = instancefs.ReadBeneathLimit(l.Dir, name, 1<<16)
+ cur, err = instancefs.ReadBeneathLimit(l.Dir, name, instancekey.AdoptionReadLimit)
} else {
cur, err = os.ReadFile(filepath.Join(l.Dir, name))
}
diff --git a/internal/host/relocationflush.go b/internal/host/relocationflush.go
index dc27ee063..83a94567f 100644
--- a/internal/host/relocationflush.go
+++ b/internal/host/relocationflush.go
@@ -12,6 +12,7 @@ import (
"github.com/carlosframework/platform/internal/controlname"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/instancefs"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -37,7 +38,9 @@ type ReleaseFlushFiles struct {
// Flush publishes and returns the committed manifest sequence. The env
// file is staged when it exists; when it does not, its deletion is staged
// (StagePlatformDelete), so a host whose env file was removed after an
-// earlier flush publishes none rather than the old bytes. A
+// earlier flush publishes none rather than the old bytes. "Does not
+// exist" is positive evidence only (readInstanceFile): a file that cannot
+// be read safely, or a directory that cannot be opened, refuses. A
// manifest revision is committed even when nothing changed (Commit
// returns the current sequence), so the release's manifest_seq is always
// the sequence that holds this box's final state.
@@ -66,20 +69,24 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
if err := hostfiles.StageWake(ctx, p, ws); err != nil {
return 0, err
}
- env, err := os.ReadFile(envPath)
+ // The env file and the adoption record live in the instance directory,
+ // which the tenant can write: both are read through main's one safe
+ // read for that ground (readInstanceFile), never os.ReadFile.
+ envDir, envName := filepath.Dir(envPath), filepath.Base(envPath)
+ env, absent, err := readInstanceFile(envDir, envName, instancekey.EnvReadLimit)
switch {
- case err == nil:
- if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil {
- return 0, err
- }
- case errors.Is(err, os.ErrNotExist):
+ case err != nil:
+ return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err)
+ case absent:
// The env file is gone: the release's final state has none, so a
// previously published one is deleted, never carried forward.
if err := p.StagePlatformDelete(hostfiles.EnvItem); err != nil {
return 0, err
}
default:
- return 0, err
+ if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil {
+ return 0, err
+ }
}
// O5 (instance-key plan rev 3): the adoption record beside the env
// file is staged in the same commit when it binds exactly the
@@ -89,14 +96,14 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
// parse refuses the flush: it may be this writer's, and absence is
// never evidence (fix round 1, M9).
writer := instancekey.Binding{Host: obs.Host, Incarnation: h.Incarnation, Session: h.Session, Generation: h.Generation}
- rec, err := os.ReadFile(filepath.Join(filepath.Dir(envPath), obs.Host+instancekey.AdoptionSuffix))
+ rec, absent, err := readInstanceFile(envDir, obs.Host+instancekey.AdoptionSuffix, instancekey.AdoptionReadLimit)
switch {
- case errors.Is(err, os.ErrNotExist):
+ case err != nil:
+ return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err)
+ case absent:
if err := p.StagePlatformDelete(instancekey.AdoptionItem); err != nil {
return 0, err
}
- case err != nil:
- return 0, err
default:
a, err := instancekey.ParseAdoption(rec)
if err != nil {
@@ -111,6 +118,16 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
return 0, err
}
}
+ // SyncDir opens its root by path and follows a symlink there (the
+ // 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 {
+ return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err)
+ }
st, err := hostfiles.SyncDir(ctx, p, filesDir, f.Skip)
if err != nil {
return 0, err
@@ -124,6 +141,33 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
return p.Commit(ctx)
}
+// 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
+// code and appconfig's nested render use for these very files. It refuses
+// a symlink at any component of dir or name (openat2 RESOLVE_NO_SYMLINKS),
+// opens O_NONBLOCK so a FIFO cannot hang the agent, accepts only a regular
+// file, and never reads more than max bytes. Any of those refusals, and
+// any other failure to read, is an error.
+//
+// absent is true only on positive evidence: dir itself opened as a real
+// directory with no symlink anywhere in its path (instancefs.StatDir), and
+// the name was then not in it. A missing directory is not that evidence:
+// it is an error, never "no file".
+func readInstanceFile(dir, name string, max int64) (raw []byte, absent bool, err error) {
+ if _, err := instancefs.StatDir(dir); err != nil {
+ return nil, false, err
+ }
+ raw, err = instancefs.ReadBeneathLimit(dir, name, max)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, true, nil
+ }
+ if err != nil {
+ return nil, false, err
+ }
+ return raw, false, nil
+}
+
// releasePosition is one recorded release on disk: a versioned
// ReleasePosition.
type releasePosition struct {
diff --git a/internal/host/relocationflush_saferead_linux_test.go b/internal/host/relocationflush_saferead_linux_test.go
new file mode 100644
index 000000000..0882bc43c
--- /dev/null
+++ b/internal/host/relocationflush_saferead_linux_test.go
@@ -0,0 +1,324 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// What a tenant can leave at a name in its own instance directory, and the
+// error main's safe read (instancefs) answers it with.
+type tenantPlant struct {
+ name string
+ want error
+}
+
+var tenantPlants = []tenantPlant{
+ {"a symlink to the right bytes", instancefs.ErrSymlink},
+ {"a FIFO", instancefs.ErrNotRegular},
+ {"an oversized file", instancefs.ErrTooLarge},
+ {"a symlinked parent directory", instancefs.ErrSymlink},
+}
+
+// plantAt replaces the file at path with what p names. body is the file's
+// legitimate bytes: the symlink points at a copy of them, so a read that
+// followed it would see nothing wrong; the oversized file is body padded
+// past limit with what a reader of the file would ignore (pad), so a read
+// that took it whole would see nothing wrong either.
+func plantAt(t *testing.T, p tenantPlant, path string, body []byte, limit int64, pad byte) {
+ t.Helper()
+ switch p.name {
+ case "a symlink to the right bytes":
+ real := filepath.Join(t.TempDir(), "real")
+ if err := os.WriteFile(real, body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(real, path); err != nil {
+ t.Fatal(err)
+ }
+ case "a FIFO":
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if err := syscall.Mkfifo(path, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ case "an oversized file":
+ big := append(append([]byte(nil), body...), bytes.Repeat([]byte{pad}, int(limit)+1-len(body))...)
+ if err := os.WriteFile(path, big, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ case "a symlinked parent directory":
+ dir := filepath.Dir(path)
+ moved := dir + ".moved"
+ if err := os.Rename(dir, moved); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(moved, dir); err != nil {
+ t.Fatal(err)
+ }
+ default:
+ t.Fatalf("unknown plant %q", p.name)
+ }
+}
+
+// bounded runs fn and fails the test if it does not return in time: a read
+// that opened a planted FIFO without O_NONBLOCK would block for ever.
+func bounded(t *testing.T, fn func() error) error {
+ t.Helper()
+ done := make(chan error, 1)
+ go func() { done <- fn() }()
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(20 * time.Second):
+ t.Fatal("the read did not return: it blocked on what the tenant planted")
+ return nil
+ }
+}
+
+// readInstanceFile is main's safe read for tenant-writable ground, and
+// absence only on positive evidence.
+func TestReadInstanceFileRefusesWhatTheTenantCanPlant(t *testing.T) {
+ const max = 64
+ body := []byte("KEY=v\n")
+ for _, p := range tenantPlants {
+ t.Run(p.name, func(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "inst")
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "shop.example.env")
+ if err := os.WriteFile(path, body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ plantAt(t, p, path, body, max, '\n')
+ var raw []byte
+ var absent bool
+ err := bounded(t, func() (err error) {
+ raw, absent, err = readInstanceFile(dir, "shop.example.env", max)
+ return err
+ })
+ if !errors.Is(err, p.want) || absent || raw != nil {
+ t.Fatalf("got %q absent=%v %v; want %v", raw, absent, err, p.want)
+ }
+ })
+ }
+ t.Run("the cap is inclusive", func(t *testing.T) {
+ dir := t.TempDir()
+ full := bytes.Repeat([]byte("x"), max)
+ if err := os.WriteFile(filepath.Join(dir, "f"), full, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ raw, absent, err := readInstanceFile(dir, "f", max)
+ if err != nil || absent || !bytes.Equal(raw, full) {
+ t.Fatalf("a file at the cap: %d bytes absent=%v %v", len(raw), absent, err)
+ }
+ })
+ t.Run("absent in a directory that opened", func(t *testing.T) {
+ raw, absent, err := readInstanceFile(t.TempDir(), "f", max)
+ if err != nil || !absent || raw != nil {
+ t.Fatalf("got %q absent=%v %v", raw, absent, err)
+ }
+ })
+ t.Run("a missing directory is not evidence", func(t *testing.T) {
+ raw, absent, err := readInstanceFile(filepath.Join(t.TempDir(), "gone"), "f", max)
+ if err == nil || absent || raw != nil {
+ t.Fatalf("got %q absent=%v %v", raw, absent, err)
+ }
+ })
+}
+
+// flushFixture is one host's release flush over an env file and the
+// releasing writer's adoption record in envDir, and files in filesDir.
+type flushFixture struct {
+ data release.Store
+ f ReleaseFlushFiles
+ obs hostclaim.Observed
+ envDir, filesDir string
+ env, record []byte
+ envPath, recPath string
+ prefix string
+ filesRoot, secret string
+}
+
+func newFlushFixture(t *testing.T) *flushFixture {
+ t.Helper()
+ const host = "shop.example"
+ 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() })
+ 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)
+ }
+ inst := filepath.Join(t.TempDir(), "inst")
+ files := filepath.Join(inst, host)
+ if err := os.MkdirAll(files, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(files, "a.txt"), []byte("hello"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 6, Generation: "g6"}
+ x := &flushFixture{
+ data: data, envDir: inst, filesDir: files,
+ env: []byte("CARLOS_INSTANCE_KEY=k1\n"),
+ record: instancekey.AdoptionRecord(writer, instancekey.SourcePlatform, instancekey.Digest("k1")),
+ envPath: filepath.Join(inst, host+".env"), recPath: filepath.Join(inst, host+instancekey.AdoptionSuffix),
+ prefix: "instances/" + host + "/inc/files/g6/",
+ }
+ if err := os.WriteFile(x.envPath, x.env, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(x.recPath, x.record, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ // A directory the tenant must never get published as its files: the
+ // box's own data, say.
+ x.filesRoot = t.TempDir()
+ x.secret = "bucket-credentials"
+ if err := os.WriteFile(filepath.Join(x.filesRoot, "litestream.env"), []byte(x.secret), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ x.f = ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return x.filesDir }, EnvPath: func(string) string { return x.envPath }}
+ x.obs = hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Incarnation: "inc-1", Session: 6, Generation: "g6"}}}
+ return x
+}
+
+func (x *flushFixture) flush(t *testing.T) (int64, error) {
+ t.Helper()
+ var seq int64
+ err := bounded(t, func() (err error) {
+ seq, err = x.f.Flush(context.Background(), x.obs)
+ return err
+ })
+ return seq, err
+}
+
+func (x *flushFixture) refused(t *testing.T, err, want error) {
+ t.Helper()
+ if !errors.Is(err, ErrClaimStopUnavailable) || !errors.Is(err, want) {
+ t.Fatalf("flush: %v; want %v", err, want)
+ }
+ if _, ok, err := hostfiles.Load(context.Background(), x.data, x.prefix); ok || err != nil {
+ t.Fatalf("a refused flush committed a manifest: %v %v", ok, err)
+ }
+}
+
+// The release flush reads <host>.env and the adoption record through main's
+// safe read: a symlink (at the name or a parent), a FIFO or an oversized
+// file planted at either refuses the flush, nothing is committed, and the
+// flush never blocks.
+func TestReleaseFlushRefusesWhatTheTenantPlantsAtTheEnvAndAdoptionRecord(t *testing.T) {
+ for _, target := range []string{"env", "adoption"} {
+ for _, p := range tenantPlants {
+ if target == "adoption" && p.name == "a symlinked parent directory" {
+ continue // the env file's directory: the env case covers it
+ }
+ t.Run(target+"/"+p.name, func(t *testing.T) {
+ x := newFlushFixture(t)
+ path, body, limit, pad := x.envPath, x.env, int64(instancekey.EnvReadLimit), byte('\n')
+ if target == "adoption" {
+ path, body, limit, pad = x.recPath, x.record, instancekey.AdoptionReadLimit, ' '
+ }
+ plantAt(t, p, path, body, limit, pad)
+ _, err := x.flush(t)
+ x.refused(t, err, p.want)
+ })
+ }
+ }
+}
+
+// SyncDir follows a symlink at its root, so the flush opens the files
+// directory (the tenant's to rename) as a real directory with no link at
+// any component first: a link there would publish the directory it names.
+func TestReleaseFlushRefusesALinkedFilesDirectory(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ plant func(t *testing.T, x *flushFixture)
+ want error
+ }{
+ {"a symlink at the files directory", func(t *testing.T, x *flushFixture) {
+ if err := os.RemoveAll(x.filesDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(x.filesRoot, x.filesDir); err != nil {
+ t.Fatal(err)
+ }
+ }, instancefs.ErrSymlink},
+ {"a symlinked parent directory", func(t *testing.T, x *flushFixture) {
+ // The files directory lives under a linked parent of its own; the
+ // env directory stays real, so only the files root is at issue.
+ parent := filepath.Join(t.TempDir(), "linked")
+ if err := os.Symlink(filepath.Dir(x.filesRoot), parent); err != nil {
+ t.Fatal(err)
+ }
+ x.filesDir = filepath.Join(parent, filepath.Base(x.filesRoot))
+ }, instancefs.ErrSymlink},
+ {"a FIFO", func(t *testing.T, x *flushFixture) {
+ if err := os.RemoveAll(x.filesDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := syscall.Mkfifo(x.filesDir, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, syscall.ENOTDIR},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ x := newFlushFixture(t)
+ tc.plant(t, x)
+ _, err := x.flush(t)
+ x.refused(t, err, tc.want)
+ })
+ }
+}
+
+// Absence stays absence only on positive evidence: a missing env file or
+// record in a directory that opened stages their deletion as before; a
+// missing directory refuses.
+func TestReleaseFlushReadsAbsenceOnlyFromADirectoryThatOpened(t *testing.T) {
+ x := newFlushFixture(t)
+ for _, p := range []string{x.envPath, x.recPath} {
+ if err := os.Remove(p); 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)
+ }
+ for _, item := range []string{hostfiles.EnvItem, instancekey.AdoptionItem} {
+ if _, ok := m.Platform[item]; ok {
+ t.Fatalf("%s published for a file that is not there", item)
+ }
+ }
+
+ y := newFlushFixture(t)
+ y.envPath = filepath.Join(t.TempDir(), "gone", "shop.example.env")
+ _, err = y.flush(t)
+ y.refused(t, err, os.ErrNotExist)
+}
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index b4d950617..f635bcbde 100644
--- a/internal/host/relocationinstall.go
+++ b/internal/host/relocationinstall.go
@@ -546,15 +546,28 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
// the pin refuses here, before anything launches. The adoption item
// itself is never restored: the target's sessions are new writers.
if pin.InstanceKey != nil {
- configEnv, err := os.ReadFile(rendered.Path)
+ // Both files are in the instance directory, which the tenant can
+ // write (an install repeated after the app has run there reads
+ // what it left), so both are read through main's one safe read
+ // for that ground (readInstanceFile), never os.ReadFile. The render
+ // always writes its file: a missing one is an error, as any
+ // other failure to read it is.
+ configEnv, absent, err := readInstanceFile(filepath.Dir(rendered.Path), filepath.Base(rendered.Path), instancekey.EnvReadLimit)
+ if err == nil && absent {
+ err = fmt.Errorf("%s: %w", rendered.Path, os.ErrNotExist)
+ }
if err != nil {
return hostclaim.LocalInstall{}, err
}
// The env file the install wrote must still be there: a missing
// one is not an env with no key of its own. Only an install
- // that evidenced no env file reads its absence as none.
- ownEnv, err := os.ReadFile(paths.EnvPath)
- if err != nil && (journal.Steps["env"] != "none" || !errors.Is(err, os.ErrNotExist)) {
+ // that evidenced no env file reads its absence as none, and
+ // only when the directory itself opened (positive evidence).
+ ownEnv, absent, err := readInstanceFile(filepath.Dir(paths.EnvPath), filepath.Base(paths.EnvPath), instancekey.EnvReadLimit)
+ if err == nil && absent && journal.Steps["env"] != "none" {
+ err = fmt.Errorf("%s: %w", paths.EnvPath, os.ErrNotExist)
+ }
+ if err != nil {
return hostclaim.LocalInstall{}, errors.Join(hostrelocate.ErrRefused, err)
}
if err := instancekey.VerifyDelivered(configEnv, ownEnv, pin.InstanceKey.SHA256); err != nil {
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go
index 0bd92f496..4396bc67a 100644
--- a/internal/host/relocationinstall_linux_test.go
+++ b/internal/host/relocationinstall_linux_test.go
@@ -1105,3 +1105,74 @@ func TestRelocationVerifyLocalNeedsTheStampAndTheHold(t *testing.T) {
}
})
}
+
+// O3' reads the held config and the env file it wrote from the instance
+// directory, the tenant's to write, through main's safe read: a symlink
+// (at the name or the directory), a FIFO or an oversized file at either
+// refuses the install, leaves no receipt, and never blocks it. Each plant
+// keeps the bytes a following or unbounded read would accept.
+func TestRelocationInstallKeyCheckRefusesWhatTheTenantPlants(t *testing.T) {
+ const configured = "configured-key"
+ for _, target := range []string{"config", "env"} {
+ for _, p := range tenantPlants {
+ if target == "env" && p.name == "a symlinked parent directory" {
+ continue // the config's directory too: the config case covers it
+ }
+ t.Run(target+"/"+p.name, func(t *testing.T) {
+ f := newInstallFixture(t, map[string]string{hostfiles.EnvItem: "CARLOS_ADMIN_TOKEN=t\n", hostfiles.WakeItem: installWake()})
+ f.configs = map[string]string{"CARLOS_INSTANCE_KEY": configured}
+ repinWithKey(t, f.buildFixture, &hostrelocate.KeyPin{Source: instancekey.SourceConfig, SHA256: instancekey.Digest(configured)})
+ f.cfg.render = func(ctx context.Context, s release.Store, k *ecdh.PrivateKey, d string, rt registry.Route, op, box string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) {
+ r, err := f.render(ctx, s, k, d, rt, op, box, work)
+ if err != nil {
+ return r, err
+ }
+ path := r.Path
+ if target == "env" {
+ path = f.paths().EnvPath
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return r, err
+ }
+ plantAt(t, p, path, body, instancekey.EnvReadLimit, '\n')
+ return r, nil
+ }
+ var err error
+ if e := bounded(t, func() error { _, err = f.install(); return nil }); e != nil {
+ t.Fatal(e)
+ }
+ if !errors.Is(err, p.want) {
+ t.Fatalf("install: %v; want %v", err, p.want)
+ }
+ if target == "env" && !errors.Is(err, hostrelocate.ErrRefused) {
+ t.Fatalf("an unreadable env file is not a refusal: %v", err)
+ }
+ if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok {
+ t.Fatal("a refused install left a receipt")
+ }
+ })
+ }
+ }
+}
+
+// The held render always writes its file, so a config missing at the key
+// check is an error, never a configuration without a key.
+func TestRelocationInstallKeyCheckNeedsTheConfigItRendered(t *testing.T) {
+ const configured = "configured-key"
+ f := newInstallFixture(t, map[string]string{hostfiles.EnvItem: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=" + configured + "\n", hostfiles.WakeItem: installWake()})
+ repinWithKey(t, f.buildFixture, &hostrelocate.KeyPin{Source: instancekey.SourcePlatform, SHA256: instancekey.Digest(configured)})
+ f.cfg.render = func(ctx context.Context, s release.Store, k *ecdh.PrivateKey, d string, rt registry.Route, op, box string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) {
+ r, err := f.render(ctx, s, k, d, rt, op, box, work)
+ if err != nil {
+ return r, err
+ }
+ return r, os.Remove(r.Path)
+ }
+ if _, err := f.install(); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the key check without the config it rendered: %v", err)
+ }
+ if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok {
+ t.Fatal("a receipt without the config")
+ }
+}
diff --git a/internal/hostfiles/present.go b/internal/hostfiles/present.go
index 45a51f03e..90db56b8b 100644
--- a/internal/hostfiles/present.go
+++ b/internal/hostfiles/present.go
@@ -7,6 +7,8 @@ import (
"io"
"os"
"path/filepath"
+
+ "github.com/carlosframework/platform/internal/instancefs"
)
// Present reports, read-only, whether dir and envPath hold exactly what m
@@ -32,48 +34,97 @@ func Present(dir, envPath string, m Manifest, envAbsent bool) (bool, error) {
if e.Deleted || e.Torn || e.Skipped != "" || loser[rel] {
continue
}
- ok, err := presentFile(filepath.Join(dir, filepath.FromSlash(rel)), e.Size, e.SHA256)
+ ok, err := presentFile(dir, filepath.FromSlash(rel), e.Size, e.SHA256)
if err != nil || !ok {
return false, err
}
}
// The env file is the platform entry's exact bytes (Astra round 2
// finding 9): a regular file at its size and digest.
+ envDir, envName := filepath.Dir(envPath), filepath.Base(envPath)
e, ok := m.Platform[EnvItem]
switch {
case ok && (e.Deleted || e.Torn || e.Skipped != "" || e.SHA256 == ""):
return false, nil
case ok:
- return presentFile(envPath, e.Size, e.SHA256)
+ return presentFile(envDir, envName, e.Size, e.SHA256)
case !envAbsent:
return false, nil
}
- _, err := os.Lstat(envPath)
+ // No env file is present only on positive evidence: the directory
+ // opened with no symlink at any component, and the name is not in it.
+ // A missing directory is not that evidence.
+ if err := presentRoot(envDir); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ return false, err
+ }
+ f, err := instancefs.OpenBeneath(envDir, envName, os.O_RDONLY, 0)
+ if err == nil {
+ f.Close()
+ return false, nil
+ }
if errors.Is(err, os.ErrNotExist) {
return true, nil
}
+ if notThere(err) {
+ return false, nil // something that is not a regular file: not "none"
+ }
return false, err
}
-func presentFile(path string, size int64, sum string) (bool, error) {
- info, err := os.Lstat(path)
- if errors.Is(err, os.ErrNotExist) {
- return false, nil
- }
- if err != nil {
+// presentRoot is dir opened as a real directory with no symlink at any
+// component. dir is an instance directory or the files directory in it,
+// both the tenant's to write, so a link there is refused as an error,
+// never read through and never read as "not present".
+func presentRoot(dir string) error {
+ _, err := instancefs.StatDir(dir)
+ return err
+}
+
+// notThere is what instancefs.OpenBeneath answers for a name that holds
+// something other than a regular file reached without a link: a symlink
+// at the name or beneath dir, a directory, a FIFO or a device. None of
+// those is the file a restore wrote, so the file is not present; none of
+// them is read.
+func notThere(err error) bool {
+ return errors.Is(err, instancefs.ErrSymlink) || errors.Is(err, instancefs.ErrNotRegular)
+}
+
+// presentFile reports whether rel beneath dir is a regular file of size
+// bytes whose sha256 is sum. Both dir and everything beneath it are the
+// tenant's to write, so it opens through main's one safe open for that
+// ground (instancefs.OpenBeneath: openat2 with no symlink at any
+// component, O_NONBLOCK, a regular file only) and reads no more than size
+// bytes plus one, so a file grown or swapped after the look cannot make it
+// read without bound.
+func presentFile(dir, rel string, size int64, sum string) (bool, error) {
+ if err := presentRoot(dir); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil // nothing restored yet
+ }
return false, err
}
- if !info.Mode().IsRegular() || info.Size() != size {
+ f, err := instancefs.OpenBeneath(dir, rel, os.O_RDONLY, 0)
+ switch {
+ case errors.Is(err, os.ErrNotExist), notThere(err):
return false, nil
+ case err != nil:
+ return false, err
}
- f, err := os.Open(path)
+ defer f.Close()
+ info, err := f.Stat()
if err != nil {
return false, err
}
- defer f.Close()
+ if info.Size() != size {
+ return false, nil
+ }
h := sha256.New()
- if _, err := io.Copy(h, f); err != nil {
+ n, err := io.Copy(h, io.LimitReader(f, size+1))
+ if err != nil {
return false, err
}
- return hex.EncodeToString(h.Sum(nil)) == sum, nil
+ return n == size && hex.EncodeToString(h.Sum(nil)) == sum, nil
}
diff --git a/internal/hostfiles/present_saferead_linux_test.go b/internal/hostfiles/present_saferead_linux_test.go
new file mode 100644
index 000000000..fcafeada9
--- /dev/null
+++ b/internal/hostfiles/present_saferead_linux_test.go
@@ -0,0 +1,224 @@
+//go:build linux
+
+package hostfiles
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancefs"
+)
+
+// presentBounded runs Present and fails the test if it does not return: a
+// read that opened a planted FIFO without O_NONBLOCK would block for ever.
+func presentBounded(t *testing.T, dir, envPath string, m Manifest, envAbsent bool) (bool, error) {
+ t.Helper()
+ type res struct {
+ ok bool
+ err error
+ }
+ done := make(chan res, 1)
+ go func() { ok, err := Present(dir, envPath, m, envAbsent); done <- res{ok, err} }()
+ select {
+ case r := <-done:
+ return r.ok, r.err
+ case <-time.After(20 * time.Second):
+ t.Fatal("Present did not return: it blocked on what the tenant planted")
+ return false, nil
+ }
+}
+
+func presentEntry(body []byte) Entry {
+ sum := sha256.Sum256(body)
+ return Entry{Object: "o", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(body)), Seq: 1}
+}
+
+// Present reads the files directory and the env file, both in the
+// tenant's instance directory, through main's safe open (instancefs): a
+// symlink at a name or beneath the root, a FIFO, or a file larger than
+// its entry is not present and is never read through or waited on; a
+// symlink at the root itself, or in the path to it, is an error.
+func TestPresentReadsTenantGroundThroughTheSafeOpen(t *testing.T) {
+ body := []byte("delta")
+ env := []byte("CARLOS_ADMIN_TOKEN=t\n")
+ m := Manifest{V: ManifestVersion, Entries: map[string]Entry{"b/c/d.bin": presentEntry(body)}, Platform: map[string]Entry{EnvItem: presentEntry(env)}}
+ // setup lays out a good install: inst/files/b/c/d.bin and inst/shop.example.env.
+ setup := func(t *testing.T) (files, envPath string) {
+ t.Helper()
+ inst := filepath.Join(t.TempDir(), "inst")
+ files = filepath.Join(inst, "files")
+ if err := os.MkdirAll(filepath.Join(files, "b", "c"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(files, "b", "c", "d.bin"), body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ envPath = filepath.Join(inst, "shop.example.env")
+ if err := os.WriteFile(envPath, env, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := Present(files, envPath, m, false); err != nil || !ok {
+ t.Fatalf("a good install: %v %v", ok, err)
+ }
+ return files, envPath
+ }
+ // linkTo replaces path with a symlink to a copy of the same bytes or
+ // directory elsewhere, so a read that followed it would be satisfied.
+ linkTo := func(t *testing.T, path string) {
+ t.Helper()
+ moved := filepath.Join(t.TempDir(), "moved")
+ if err := os.Rename(path, moved); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(moved, path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fifo := func(t *testing.T, path string) {
+ t.Helper()
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := syscall.Mkfifo(path, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ grow := func(t *testing.T, path string, keep []byte) {
+ t.Helper()
+ if err := os.WriteFile(path, append(append([]byte(nil), keep...), "more"...), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, tc := range []struct {
+ name string
+ plant func(t *testing.T, files, envPath string)
+ wantErr error // nil: not present, no error
+ }{
+ {"a symlink at a file", func(t *testing.T, files, _ string) { linkTo(t, filepath.Join(files, "b", "c", "d.bin")) }, nil},
+ {"a symlinked directory beneath the root", func(t *testing.T, files, _ string) { linkTo(t, filepath.Join(files, "b")) }, nil},
+ {"a FIFO at a file", func(t *testing.T, files, _ string) { fifo(t, filepath.Join(files, "b", "c", "d.bin")) }, nil},
+ {"a file larger than its entry", func(t *testing.T, files, _ string) { grow(t, filepath.Join(files, "b", "c", "d.bin"), body) }, nil},
+ {"a symlink at the files root", func(t *testing.T, files, _ string) { linkTo(t, files) }, instancefs.ErrSymlink},
+ {"a symlink at the env file", func(t *testing.T, _, envPath string) { linkTo(t, envPath) }, nil},
+ {"a FIFO at the env file", func(t *testing.T, _, envPath string) { fifo(t, envPath) }, nil},
+ {"an env file larger than its entry", func(t *testing.T, _, envPath string) { grow(t, envPath, env) }, nil},
+ {"a symlinked instance directory", func(t *testing.T, _, envPath string) { linkTo(t, filepath.Dir(envPath)) }, instancefs.ErrSymlink},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ files, envPath := setup(t)
+ tc.plant(t, files, envPath)
+ ok, err := presentBounded(t, files, envPath, m, false)
+ if ok || !errors.Is(err, tc.wantErr) || (tc.wantErr == nil && err != nil) {
+ t.Fatalf("Present = %v, %v; want false, %v", ok, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+// With the caller's evidence of no env file, an env file is "none" only
+// when its directory opened with no symlink anywhere and the name is not
+// in it: a FIFO or a link there is something, not none, and is never
+// opened blocking; a linked directory is an error; a missing one is not
+// evidence.
+func TestPresentReadsNoEnvFileOnlyFromADirectoryThatOpened(t *testing.T) {
+ none := Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{}}
+ for _, tc := range []struct {
+ name string
+ plant func(t *testing.T, envPath string)
+ want bool
+ wantErr error
+ }{
+ {"none there", func(*testing.T, string) {}, true, nil},
+ {"a FIFO", func(t *testing.T, envPath string) {
+ if err := syscall.Mkfifo(envPath, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, false, nil},
+ {"a symlink to nothing", func(t *testing.T, envPath string) {
+ if err := os.Symlink(filepath.Join(t.TempDir(), "nothing"), envPath); err != nil {
+ t.Fatal(err)
+ }
+ }, false, nil},
+ {"a symlinked directory", func(t *testing.T, envPath string) {
+ dir := filepath.Dir(envPath)
+ moved := filepath.Join(t.TempDir(), "moved")
+ if err := os.Rename(dir, moved); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(moved, dir); err != nil {
+ t.Fatal(err)
+ }
+ }, false, instancefs.ErrSymlink},
+ {"a missing directory", func(t *testing.T, envPath string) {
+ if err := os.Remove(filepath.Dir(envPath)); err != nil {
+ t.Fatal(err)
+ }
+ }, false, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "inst")
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ envPath := filepath.Join(dir, "shop.example.env")
+ tc.plant(t, envPath)
+ ok, err := presentBounded(t, t.TempDir(), envPath, none, true)
+ if ok != tc.want || !errors.Is(err, tc.wantErr) || (tc.wantErr == nil && err != nil) {
+ t.Fatalf("Present = %v, %v; want %v, %v", ok, err, tc.want, tc.wantErr)
+ }
+ })
+ }
+}
+
+// A file far larger than its entry is answered from its size, never read
+// whole: a tenant's sparse terabyte costs nothing to make.
+func TestPresentNeverReadsPastAnEntrysSize(t *testing.T) {
+ body := []byte("delta")
+ m := Manifest{V: ManifestVersion, Entries: map[string]Entry{"big": presentEntry(body)}, Platform: map[string]Entry{}}
+ dir := t.TempDir()
+ f, err := os.Create(filepath.Join(dir, "big"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.Write(body); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Truncate(1 << 40); err != nil {
+ f.Close()
+ t.Skipf("no sparse terabyte here: %v", err)
+ }
+ f.Close()
+ if ok, err := presentBounded(t, dir, filepath.Join(t.TempDir(), "shop.example.env"), m, true); ok || err != nil {
+ t.Fatalf("Present = %v, %v; want false", ok, err)
+ }
+}
+
+// An empty entry's size and digest are what a FIFO with no writer reads
+// as (zero bytes, then EOF), so only the regular-file check tells the two
+// apart: a FIFO at an empty file's name is not present.
+func TestPresentNeverTakesAFIFOForAnEmptyFile(t *testing.T) {
+ m := Manifest{V: ManifestVersion, Entries: map[string]Entry{"empty": presentEntry(nil)}, Platform: map[string]Entry{}}
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "empty"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ env := filepath.Join(t.TempDir(), "shop.example.env")
+ if ok, err := Present(dir, env, m, true); err != nil || !ok {
+ t.Fatalf("an empty file: %v %v", ok, err)
+ }
+ if err := os.Remove(filepath.Join(dir, "empty")); err != nil {
+ t.Fatal(err)
+ }
+ if err := syscall.Mkfifo(filepath.Join(dir, "empty"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := presentBounded(t, dir, env, m, true); ok || err != nil {
+ t.Fatalf("a FIFO at an empty file's name: %v, %v; want false", ok, err)
+ }
+}
diff --git a/internal/instancekey/instancekey.go b/internal/instancekey/instancekey.go
index ae87ec793..d62f10b42 100644
--- a/internal/instancekey/instancekey.go
+++ b/internal/instancekey/instancekey.go
@@ -34,6 +34,19 @@ const AdoptionSuffix = ".instance-key-adoption.json"
// platform items, beside hostfiles' EnvItem ("host.env").
const AdoptionItem = "instance-key-adoption.json"
+// EnvReadLimit caps every read of an own env file (<host>.env) or its
+// rendered configuration (<host>.config.env). Both live in a directory a
+// nested row's tenant can write, and the agent reads them outside the
+// tenant's memory wall, so no read of them is unbounded. The activator's
+// reads and relocation's release flush and install read under this one
+// cap, so a file one accepts the other never refuses as too large.
+const EnvReadLimit = 1 << 20
+
+// AdoptionReadLimit caps every read of the adoption record, as
+// EnvReadLimit caps the env file beside it: the adoption verb's writer and
+// relocation's release flush read the record under this one cap.
+const AdoptionReadLimit = 1 << 16
+
// ParseEnv parses an EnvironmentFile: KEY=VALUE lines, blank lines and #
// comments skipped, the key and the value trimmed of surrounding
// whitespace, the last of two lines for one key winning. It is exactly