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>
9 files changed,
+774
−33
internal/activator/instancekey.go+3 −2internal/activator/keyadoption.go+1 −1internal/host/relocationflush.go+56 −12internal/host/relocationflush_saferead_linux_test.go+324 −0internal/host/relocationinstall.go+17 −4internal/host/relocationinstall_linux_test.go+71 −0internal/hostfiles/present.go+65 −14internal/hostfiles/present_saferead_linux_test.go+224 −0internal/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 |