carlos / platform Public

host: the relocation install reads no env file and no wake state from a manifest that lacks them unless the pin evidences a host that never published files, and refuses before any write otherwise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 73bab739ec6300bd385e9113382b7e2b2bdd6eb2 parent 91ed7c2
6 files changed, +308 −79
  • internal/host/relocationbuild_test.go +10 −2
  • internal/host/relocationinstall.go +90 −14
  • internal/host/relocationinstall_linux_test.go +111 −31
  • internal/host/relocationinstall_test.go +35 −0
  • internal/hostfiles/present.go +25 −20
  • internal/hostfiles/present_test.go +37 −12
diff --git a/internal/host/relocationbuild_test.go b/internal/host/relocationbuild_test.go
index d6c6d9f36..4535f42c5 100644
--- a/internal/host/relocationbuild_test.go
+++ b/internal/host/relocationbuild_test.go
@@ -86,12 +86,15 @@ func (s *hookedVersions) PutIfAbsent(ctx context.Context, bucket, key string, bo
// buildOptions shape a buildFixture: a pin with or without a database, the
// data root (a fresh one with a hostguard directory when empty), and the
// pinned manifest's entries and platform items, each stored as an object
-// under the source generation's prefix.
+// under the source generation's prefix. NoFiles pins a host that never
+// published files instead (no digest, no retained copy: the pinner's
+// evidenced empty files set), and Files and Platform are ignored.
type buildOptions struct {
DB bool
Data string
Files map[string]string
Platform map[string]string
+ NoFiles bool
}
func newBuildFixture(t *testing.T, withDB bool) *buildFixture {
@@ -170,6 +173,11 @@ func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture {
t.Fatal(err)
}
pin.Files = hostrelocate.FilesPin{Prefix: srcPrefix, Seq: 5, SHA256: hex.EncodeToString(pinnedSum[:]), Origins: []string{srcPrefix}, Retained: retained}
+ filesManifest, filesSeq := retained, int64(5)
+ if o.NoFiles {
+ pin.Files = hostrelocate.FilesPin{Prefix: srcPrefix, Origins: []string{srcPrefix}}
+ filesManifest, filesSeq = hostfiles.ManifestKey(srcPrefix), 0
+ }
pinRaw, err := json.Marshal(pin)
if err != nil {
t.Fatal(err)
@@ -179,7 +187,7 @@ func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture {
t.Fatal(err)
}
pinSum := sha256.Sum256(pinRaw)
- ref := hostclaim.RecoveryPin{Key: pinKey, SHA256: hex.EncodeToString(pinSum[:]), Generation: "g1", FilesManifest: retained, FilesSeq: 5, PinnedAt: created, Release: "v1", Empty: pin.DB == nil}
+ ref := hostclaim.RecoveryPin{Key: pinKey, SHA256: hex.EncodeToString(pinSum[:]), Generation: "g1", FilesManifest: filesManifest, FilesSeq: filesSeq, PinnedAt: created, Release: "v1", Empty: pin.DB == nil}
if pin.DB != nil {
ref.MaxTXID = pin.DB.MaxTXID
}
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index 84299ceed..8afcb5355 100644
--- a/internal/host/relocationinstall.go
+++ b/internal/host/relocationinstall.go
@@ -228,6 +228,44 @@ func relocationRecord(ctx context.Context, dataDir string, claims release.Store,
return provision.ClaimRecord{Host: o.Host, Key: provision.RecordKey(record.Account, record.App, record.Host), Family: "instance", Account: record.Account, App: record.App, CreatedAt: record.CreatedAt, Value: record, Route: rt}, nil
}
+// relocationPin reads the pin the claim's op journals, through ReadPin.
+func relocationPin(ctx context.Context, claims release.Store, c *hostclaim.Claim) (hostrelocate.PinObject, error) {
+ var ref hostclaim.RecoveryPin
+ if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil {
+ return hostrelocate.PinObject{}, fmt.Errorf("%w: the op journals no readable pin: %w", hostrelocate.ErrRefused, err)
+ }
+ return hostrelocate.ReadPin(ctx, claims, ref)
+}
+
+// relocationPlatformItem reports whether P's manifest m carries the
+// platform item name (the env file, the wake state) for the install to
+// restore, or the host has none (ruling 68, amended in Task 8c: absence is
+// never evidence). A live item is the host's. A manifest without it is
+// "none" only when the pin evidences a host that never published files:
+// a files pin with neither a digest nor a retained copy, which the pinner
+// admits only for a holder no writer was ever admitted to or a release
+// flush that published nothing, and which ReadPin refuses half-made (H2).
+// No app ran there, so there is no env file and no timer. Any other
+// manifest without the item refuses: platform items have no tombstone
+// (StagePlatformDelete drops the item), so a source whose env file was
+// deleted reads the same as one whose env file was never published, and a
+// manifest no release flush wrote (a failover's) carries no wake state
+// however many timers the host had. A tombstone, which no code writes,
+// refuses too.
+func relocationPlatformItem(pin hostrelocate.PinObject, m hostfiles.Manifest, name string) (bool, error) {
+ e, ok := m.Platform[name]
+ switch {
+ case ok && !e.Deleted:
+ return true, nil
+ case ok:
+ return false, fmt.Errorf("%w: P's manifest holds a tombstone for %s, which no publisher writes", hostrelocate.ErrRefused, name)
+ case pin.Files.SHA256 == "" && pin.Files.Retained == "":
+ return false, nil
+ default:
+ return false, fmt.Errorf("%w: P's manifest has no %s, and the pin does not evidence a host that never published files", hostrelocate.ErrRefused, name)
+ }
+}
+
// relocationPolicy is region HA's relocating owner: it runs after the
// claim's own gate and only refuses. The captured installation must be the
// holder's, this box a participant and not lost.
@@ -329,17 +367,29 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
return hostclaim.LocalInstall{}, err
}
stamp, _ := relocationInstallStamp(c)
- var ref hostclaim.RecoveryPin
- if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil {
- return hostclaim.LocalInstall{}, err
- }
- pin, err := hostrelocate.ReadPin(ctx, a.Claims, ref)
+ pin, err := relocationPin(ctx, a.Claims, c)
if err != nil {
return hostclaim.LocalInstall{}, err
}
p := c.Op.Journal["prepared"]
prefix := c.Holder.Location + "/files/" + p + "/"
origins := append(append([]string(nil), pin.Files.Origins...), prefix)
+ // What P's manifest says about the env file and the wake state is
+ // judged before anything is written: a manifest missing either,
+ // under a pin that does not evidence its absence, refuses here with
+ // no row, file or claim write (absence is never evidence).
+ pm, found, err := hostfiles.Load(ctx, cfg.Data, prefix)
+ if err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ if !found {
+ return hostclaim.LocalInstall{}, fmt.Errorf("%w: P's files manifest %s is missing", hostrelocate.ErrRefused, prefix)
+ }
+ for _, item := range []string{hostfiles.EnvItem, hostfiles.WakeItem} {
+ if _, err := relocationPlatformItem(pin, pm, item); err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ }
rec, err := relocationRecord(ctx, cfg.DataDir, a.Claims, o)
if err != nil {
return hostclaim.LocalInstall{}, err
@@ -408,9 +458,15 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
}
journal.Steps["files"] = strconv.FormatInt(m.Seq, 10)
// O3: the env file is P's EnvItem byte for byte; nothing is
- // minted. A deleted EnvItem is the same as none (Astra round 3
- // finding 5): any env file here is not the host's, so it goes.
- if e, ok := m.Platform[hostfiles.EnvItem]; ok && !e.Deleted {
+ // minted. No env file only by the pin's evidence (ruling 68 as
+ // amended, relocationPlatformItem): then any env file here is
+ // not the host's, so it goes. A manifest without the item and
+ // without that evidence refuses.
+ wantEnv, err := relocationPlatformItem(pin, m, hostfiles.EnvItem)
+ if err != nil {
+ return err
+ }
+ if wantEnv {
env, err := hostfiles.ReadPlatform(ctx, cfg.Data, prefix, m, hostfiles.EnvItem, origins)
if err != nil {
return err
@@ -424,12 +480,19 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
} else {
journal.Steps["env"] = "none"
}
- ws, ok, err := hostfiles.LoadWake(ctx, cfg.Data, prefix, m, origins)
+ // The wake state likewise: no timers only by the pin's evidence.
+ // A manifest no release flush wrote (a failover's) has none,
+ // and that is not a host without timers.
+ wantWake, err := relocationPlatformItem(pin, m, hostfiles.WakeItem)
if err != nil {
return err
}
journal.Steps["wake"] = "none"
- if ok {
+ if wantWake {
+ ws, _, err := hostfiles.LoadWake(ctx, cfg.Data, prefix, m, origins)
+ if err != nil {
+ return err
+ }
if err := hostfiles.ImportWake(cfg.Registry, o.Host, ws); err != nil {
return err
}
@@ -462,9 +525,12 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
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 && !errors.Is(err, os.ErrNotExist) {
- return hostclaim.LocalInstall{}, err
+ if err != nil && (journal.Steps["env"] != "none" || !errors.Is(err, os.ErrNotExist)) {
+ return hostclaim.LocalInstall{}, errors.Join(hostrelocate.ErrRefused, err)
}
if err := instancekey.VerifyDelivered(configEnv, ownEnv, pin.InstanceKey.SHA256); err != nil {
return hostclaim.LocalInstall{}, errors.Join(hostrelocate.ErrRefused, err)
@@ -523,7 +589,8 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
// relocationVerifyLocal reports whether the receipt's install is whole on
// this box now: the journal at the receipt's digest, the guard record
// active at its stamp, the row held by the operation, and every manifest
-// entry and the env file as P's manifest says (hostfiles.Present).
+// entry and the env file as P's manifest says (hostfiles.Present), no env
+// file only by the pin's evidence (relocationPlatformItem).
func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) {
receipt := a.relocationReceipt(cfg)
return func(ctx context.Context, o hostclaim.Observed, l hostclaim.LocalInstall) (bool, error) {
@@ -560,8 +627,17 @@ func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func(
if err := json.Unmarshal(raw, &m); err != nil {
return false, err
}
+ // No env file only by the pin's evidence, as the install judged it.
+ pin, err := relocationPin(ctx, a.Claims, o.Claim)
+ if err != nil {
+ return false, err
+ }
+ wantEnv, err := relocationPlatformItem(pin, m, hostfiles.EnvItem)
+ if err != nil {
+ return false, err
+ }
paths := relocationPathsFor(cfg.DataDir, *row)
- return hostfiles.Present(paths.FilesDir, paths.EnvPath, m)
+ return hostfiles.Present(paths.FilesDir, paths.EnvPath, m, !wantEnv)
}
}
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go
index 94a1f41d6..d60b32868 100644
--- a/internal/host/relocationinstall_linux_test.go
+++ b/internal/host/relocationinstall_linux_test.go
@@ -51,21 +51,34 @@ type installFixture struct {
configs map[string]string // the rendered config's variables
}
+// installWake is the fixture's published wake state: two cron timers.
+func installWake() string {
+ ws, _ := json.Marshal(hostfiles.WakeState{V: 1, Host: buildHost, NextDue: 1700000000, Timers: []hostfiles.WakeTimer{
+ {Name: "nightly", Kind: "cron", Spec: "0 3 * * *", Path: "/jobs/nightly", NextAt: 1700000000, CreatedAt: 1690000000, Registration: "r1"},
+ {Name: "hourly", Kind: "cron", Spec: "0 * * * *", Path: "/jobs/hourly", NextAt: 1700000100, CreatedAt: 1690000000, Registration: "r2"},
+ }})
+ return string(ws)
+}
+
func newInstallFixture(t *testing.T, platform map[string]string) *installFixture {
+ t.Helper()
+ if platform == nil {
+ platform = map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: installWake()}
+ }
+ return newInstallFixtureWith(t, buildOptions{Platform: platform, Files: map[string]string{"public/index.html": "<h1>shop</h1>", "uploads/a/b.png": "png-bytes"}})
+}
+
+// newInstallFixtureWith is newInstallFixture over the build options opts
+// (opts.Data is the bootstrapped box's).
+func newInstallFixtureWith(t *testing.T, opts buildOptions) *installFixture {
t.Helper()
ctx := t.Context()
for _, k := range []string{"CARLOS_HOST_CLAIM", "CARLOS_MOVES_PHASE1", "CARLOS_REGION_HA"} {
t.Setenv(k, "1")
}
r := claimCensusBox(t)
- if platform == nil {
- ws, _ := json.Marshal(hostfiles.WakeState{V: 1, Host: buildHost, NextDue: 1700000000, Timers: []hostfiles.WakeTimer{
- {Name: "nightly", Kind: "cron", Spec: "0 3 * * *", Path: "/jobs/nightly", NextAt: 1700000000, CreatedAt: 1690000000, Registration: "r1"},
- {Name: "hourly", Kind: "cron", Spec: "0 * * * *", Path: "/jobs/hourly", NextAt: 1700000100, CreatedAt: 1690000000, Registration: "r2"},
- }})
- platform = map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: string(ws)}
- }
- b := newBuildFixtureWith(t, buildOptions{Data: r.DataDir, Platform: platform, Files: map[string]string{"public/index.html": "<h1>shop</h1>", "uploads/a/b.png": "png-bytes"}})
+ opts.Data = r.DataDir
+ b := newBuildFixtureWith(t, opts)
f := &installFixture{buildFixture: b, r: r, actor: hostbox.Actor{Box: "dst", Enrollment: strings.Repeat("a", 32), Region: "ie", Revision: 4}}
// The record the claim's holder names: a hibernating instance that
// derives its paths.
@@ -302,46 +315,113 @@ func TestRelocationInstallKeepsThePublishedEnvByteForByte(t *testing.T) {
}
}
-// Astra round 3 finding 5: a deleted EnvItem is no env file. A repeated
-// install (a crash before its journal) removes a stray env file at the
-// path and writes none.
-func TestRelocationInstallRemovesAStaleEnvForADeletedItem(t *testing.T) {
- f := newInstallFixture(t, map[string]string{})
- o := f.read()
- key := hostfiles.ManifestKey(o.Claim.Holder.Location + "/files/p1/")
- raw, etag, err := f.files.Get(t.Context(), key)
- if err != nil {
+// Astra round 3 finding 5 and ruling 68, amended in Task 8c (absence is
+// never evidence): no code writes a platform tombstone, so a source whose
+// env file was deleted publishes a manifest without the item, exactly as
+// one whose env file was never published does. Such a manifest, under a
+// pin of published files, refuses the install before anything is written:
+// no row, no files, no env file, no journal, and a stray env file at the
+// path is left as it was.
+func TestRelocationInstallRefusesAManifestWithoutTheEnvItem(t *testing.T) {
+ f := newInstallFixture(t, map[string]string{hostfiles.WakeItem: installWake()})
+ p := f.paths()
+ if err := os.MkdirAll(filepath.Dir(p.EnvPath), 0o700); err != nil {
t.Fatal(err)
}
- var m hostfiles.Manifest
- if err := json.Unmarshal(raw, &m); err != nil {
+ if err := os.WriteFile(p.EnvPath, []byte("STALE=1\n"), 0o600); err != nil {
t.Fatal(err)
}
- m.Platform[hostfiles.EnvItem] = hostfiles.Entry{Deleted: true, Seq: 4}
- raw, _ = json.Marshal(m)
- if _, err := f.files.Store.Put(t.Context(), key, raw, etag); err != nil {
- t.Fatal(err)
+ if _, err := f.install(); !errors.Is(err, hostrelocate.ErrRefused) {
+ t.Fatalf("a manifest without the env item: %v", err)
}
- if _, err := f.install(); err != nil {
+ if row, err := f.reg.Get(buildHost); err != nil || row != nil {
+ t.Fatalf("a row was minted: %+v %v", row, err)
+ }
+ if _, err := os.Stat(filepath.Join(p.FilesDir, "public", "index.html")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("files were restored: %v", err)
+ }
+ if env, err := os.ReadFile(p.EnvPath); err != nil || string(env) != "STALE=1\n" {
+ t.Fatalf("the env path was touched: %q %v", env, err)
+ }
+ if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok {
+ t.Fatal("a refused install left a receipt")
+ }
+}
+
+// The wake half of the same rule: a manifest without wake state (a
+// failover's, which no release flush wrote) is not a host without timers,
+// and refuses the install.
+func TestRelocationInstallRefusesAManifestWithoutWakeState(t *testing.T) {
+ f := newInstallFixture(t, map[string]string{hostfiles.EnvItem: installEnv})
+ if _, err := f.install(); !errors.Is(err, hostrelocate.ErrRefused) {
+ t.Fatalf("a manifest without wake state: %v", err)
+ }
+ if row, err := f.reg.Get(buildHost); err != nil || row != nil {
+ t.Fatalf("a row was minted: %+v %v", row, err)
+ }
+}
+
+// Ruling 68's positive evidence: a pin of a host that never published
+// files (no digest, no retained copy) is no env file and no timers. The
+// install writes no env file and imports no wake state, and a repeated
+// install (a crash before its journal) removes a stray env file at the
+// path. VerifyLocal reads the same evidence: whole with no env file, not
+// whole once one appears.
+func TestRelocationInstallRemovesAStaleEnvWhenThePinEvidencesNoFiles(t *testing.T) {
+ f := newInstallFixtureWith(t, buildOptions{NoFiles: true})
+ ctx := t.Context()
+ l, err := f.install()
+ if err != nil {
t.Fatal(err)
}
p := f.paths()
if _, err := os.Stat(p.EnvPath); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("an env file was written for a deleted item: %v", err)
+ t.Fatalf("an env file was written for a host with none: %v", err)
}
- // A crash before the journal, and an env file at the path meanwhile.
- journal, _ := relocationInstallPath(f.r.DataDir, "rel-1")
- if err := os.Remove(journal); err != nil {
- t.Fatal(err)
+ if timers, err := f.reg.TimersFor(buildHost); err != nil || len(timers) != 0 {
+ t.Fatalf("timers %+v %v", timers, err)
+ }
+ verify := f.a.relocationVerifyLocal(f.cfg)
+ if ok, err := verify(ctx, f.read(), l); err != nil || !ok {
+ t.Fatalf("verify with no env file: %v %v", ok, err)
}
if err := os.WriteFile(p.EnvPath, []byte("STALE=1\n"), 0o600); err != nil {
t.Fatal(err)
}
+ if ok, err := verify(ctx, f.read(), l); err != nil || ok {
+ t.Fatalf("verify with a stray env file: %v %v", ok, err)
+ }
+ // A crash before the journal, and the env file at the path meanwhile.
+ journal, _ := relocationInstallPath(f.r.DataDir, "rel-1")
+ if err := os.Remove(journal); err != nil {
+ t.Fatal(err)
+ }
if _, err := f.install(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p.EnvPath); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("a stale env survived a deleted item: %v", err)
+ t.Fatalf("a stale env survived: %v", err)
+ }
+}
+
+// The O3' check reads the env file the install wrote: one gone by the time
+// the key is checked is not an env with no key of its own.
+func TestRelocationInstallKeyCheckNeedsTheEnvItWrote(t *testing.T) {
+ const configured = "configured-key"
+ 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) {
+ if err := os.Remove(f.paths().EnvPath); err != nil {
+ return appconfig.HeldRender{}, err
+ }
+ return f.render(ctx, s, k, d, rt, op, box, work)
+ }
+ if _, err := f.install(); !errors.Is(err, hostrelocate.ErrRefused) || !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the key check without the env it wrote: %v", err)
+ }
+ if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok {
+ t.Fatal("a receipt without the env file")
}
}
@@ -467,7 +547,7 @@ func TestRelocationInstallRefusesAKeyChangedSinceThePin(t *testing.T) {
{"a key changed since the pin", "another-key", hostrelocate.ErrRefused},
} {
t.Run(tc.name, func(t *testing.T) {
- f := newInstallFixture(t, map[string]string{hostfiles.EnvItem: "CARLOS_ADMIN_TOKEN=t\n"})
+ f := newInstallFixture(t, map[string]string{hostfiles.EnvItem: "CARLOS_ADMIN_TOKEN=t\n", hostfiles.WakeItem: installWake()})
f.configs = map[string]string{"CARLOS_INSTANCE_KEY": tc.config}
repinWithKey(t, f.buildFixture, &hostrelocate.KeyPin{Source: instancekey.SourceConfig, SHA256: instancekey.Digest(configured)})
_, err := f.install()
diff --git a/internal/host/relocationinstall_test.go b/internal/host/relocationinstall_test.go
index 31bd60a61..c74178789 100644
--- a/internal/host/relocationinstall_test.go
+++ b/internal/host/relocationinstall_test.go
@@ -3,11 +3,14 @@ package host
import (
"context"
"encoding/json"
+ "errors"
"testing"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
"github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/hostwrite"
)
@@ -59,3 +62,35 @@ func TestRelocationInstallPolicyIsTyped(t *testing.T) {
t.Fatal("another op's hold admitted")
}
}
+
+// Ruling 68, amended in Task 8c (absence is never evidence): a live
+// platform item is installed; a missing one is "none" only under a pin of
+// a host that never published files (no digest, no retained copy); a
+// missing one under a pin of published files, and a tombstone (which no
+// publisher writes) under either pin, refuse.
+func TestRelocationPlatformItemNeedsEvidenceForNone(t *testing.T) {
+ published := hostrelocate.PinObject{Files: hostrelocate.FilesPin{Prefix: "loc/files/g1/", SHA256: "ab", Retained: "retained/ab"}}
+ never := hostrelocate.PinObject{Files: hostrelocate.FilesPin{Prefix: "loc/files/g1/"}}
+ live := hostfiles.Manifest{Platform: map[string]hostfiles.Entry{hostfiles.EnvItem: {Object: "o", SHA256: "cd", Size: 1, Seq: 1}}}
+ missing := hostfiles.Manifest{Platform: map[string]hostfiles.Entry{}}
+ tomb := hostfiles.Manifest{Platform: map[string]hostfiles.Entry{hostfiles.EnvItem: {Deleted: true, Seq: 2}}}
+ for _, tc := range []struct {
+ name string
+ pin hostrelocate.PinObject
+ m hostfiles.Manifest
+ want bool
+ err error
+ }{
+ {"a live item", published, live, true, nil},
+ {"a live item, evidence or not", never, live, true, nil},
+ {"missing under published files", published, missing, false, hostrelocate.ErrRefused},
+ {"missing under a host that never published", never, missing, false, nil},
+ {"a tombstone under published files", published, tomb, false, hostrelocate.ErrRefused},
+ {"a tombstone under a host that never published", never, tomb, false, hostrelocate.ErrRefused},
+ } {
+ got, err := relocationPlatformItem(tc.pin, tc.m, hostfiles.EnvItem)
+ if got != tc.want || !errors.Is(err, tc.err) || (tc.err == nil) != (err == nil) {
+ t.Errorf("%s: %v %v, want %v %v", tc.name, got, err, tc.want, tc.err)
+ }
+ }
+}
diff --git a/internal/hostfiles/present.go b/internal/hostfiles/present.go
index a178d7af2..29f529694 100644
--- a/internal/hostfiles/present.go
+++ b/internal/hostfiles/present.go
@@ -12,11 +12,18 @@ import (
// Present reports, read-only, whether dir and envPath hold exactly what m
// names (region HA ruling 52, VerifyLocal): every live, non-skipped,
// non-torn entry present as a regular file at its size and sha256, and
-// the env file a regular file at its platform entry's size and sha256, or
-// absent when m carries none or a deleted one. Extra files are not
-// checked: the app may write after P's opening, and Present is called
-// only before it.
-func Present(dir, envPath string, m Manifest) (bool, error) {
+// the env file a regular file at its platform entry's size and sha256.
+// Extra files are not checked: the app may write after P's opening, and
+// Present is called only before it.
+//
+// A manifest without a live env entry is not evidence that the host has
+// no env file (platform items have no tombstone: StagePlatformDelete drops
+// the item, so a deleted env reads as one never published). envAbsent is
+// the caller's own positive evidence that it has none; only then is an
+// absent env file present, and any file at envPath is not. Without it, a
+// manifest with no env entry, or a tombstone (which no publisher writes),
+// is never present.
+func Present(dir, envPath string, m Manifest, envAbsent bool) (bool, error) {
for rel, e := range m.Entries {
if e.Deleted || e.Torn || e.Skipped != "" {
continue
@@ -27,23 +34,21 @@ func Present(dir, envPath string, m Manifest) (bool, error) {
}
}
// The env file is the platform entry's exact bytes (Astra round 2
- // finding 9): a regular file at its size and digest; a deleted or
- // absent entry means no env file at all.
- e, wantEnv := m.Platform[EnvItem]
- if wantEnv && e.Deleted {
- wantEnv = false
- }
- if !wantEnv {
- _, err := os.Lstat(envPath)
- if errors.Is(err, os.ErrNotExist) {
- return true, nil
- }
- return false, err
- }
- if e.Torn || e.Skipped != "" || e.SHA256 == "" {
+ // finding 9): a regular file at its size and digest.
+ 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)
+ case !envAbsent:
+ return false, nil
+ }
+ _, err := os.Lstat(envPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return true, nil
}
- return presentFile(envPath, e.Size, e.SHA256)
+ return false, err
}
func presentFile(path string, size int64, sum string) (bool, error) {
diff --git a/internal/hostfiles/present_test.go b/internal/hostfiles/present_test.go
index ba287596f..f94d2407b 100644
--- a/internal/hostfiles/present_test.go
+++ b/internal/hostfiles/present_test.go
@@ -9,8 +9,9 @@ import (
)
// Astra round 2 finding 9: after a good install, a truncated env, another
-// key, a directory or a symlink in its place is not present; a deleted
-// entry wants no env file.
+// key, a directory or a symlink in its place is not present. A manifest
+// without the env entry wants no env file only by the caller's evidence
+// (Task 8c: absence is never evidence).
func TestPresentReadsTheEnvBack(t *testing.T) {
env := []byte("CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=k1\n")
sum := sha256.Sum256(env)
@@ -42,23 +43,47 @@ func TestPresentReadsTheEnvBack(t *testing.T) {
if err := tc.make(path); err != nil {
t.Fatal(err)
}
- if ok, err := Present(t.TempDir(), path, m); err != nil || ok != tc.want {
+ if ok, err := Present(t.TempDir(), path, m, false); err != nil || ok != tc.want {
t.Fatalf("Present = %v, %v; want %v", ok, err, tc.want)
}
})
}
- deleted := m
- deleted.Platform = map[string]Entry{EnvItem: {Deleted: true, Seq: 2}}
+ // Production never writes a platform tombstone: StagePlatformDelete
+ // drops the item. A manifest without the env entry is what a deleted
+ // env file and one never published both publish.
+ none := m
+ none.Platform = map[string]Entry{}
dir := t.TempDir()
path := filepath.Join(dir, "shop.example.env")
- if ok, err := Present(dir, path, deleted); err != nil || !ok {
- t.Fatalf("a deleted entry with no env file: %v %v", ok, err)
+ if ok, err := Present(dir, path, none, false); err != nil || ok {
+ t.Fatalf("no env entry and no evidence read as no env file: %v %v", ok, err)
+ }
+ if ok, err := Present(dir, path, none, true); err != nil || !ok {
+ t.Fatalf("evidenced no env file, none there: %v %v", ok, err)
}
if err := os.WriteFile(path, env, 0o600); err != nil {
t.Fatal(err)
}
- if ok, _ := Present(dir, path, deleted); ok {
- t.Fatal("a deleted entry accepted an env file")
+ if ok, _ := Present(dir, path, none, true); ok {
+ t.Fatal("evidenced no env file accepted an env file")
+ }
+ // The evidence never overrides a live entry, and a tombstone (which no
+ // publisher writes) is never present.
+ if ok, err := Present(dir, path, m, true); err != nil || !ok {
+ t.Fatalf("a live entry with its bytes: %v %v", ok, err)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if ok, _ := Present(dir, path, m, true); ok {
+ t.Fatal("the evidence excused a live entry's missing env file")
+ }
+ tomb := m
+ tomb.Platform = map[string]Entry{EnvItem: {Deleted: true, Seq: 2}}
+ for _, evidenced := range []bool{false, true} {
+ if ok, err := Present(dir, path, tomb, evidenced); err != nil || ok {
+ t.Fatalf("a tombstone read as no env file (evidence %v): %v %v", evidenced, ok, err)
+ }
}
}
@@ -89,7 +114,7 @@ func TestPresentReadsTheManifestBack(t *testing.T) {
}
}
}
- env := filepath.Join(t.TempDir(), "shop.example.env") // no EnvItem: absence wanted
+ env := filepath.Join(t.TempDir(), "shop.example.env") // no EnvItem: absence evidenced by the caller
for name, tc := range map[string]struct {
files map[string]string
mutate func(t *testing.T, dir string)
@@ -112,7 +137,7 @@ func TestPresentReadsTheManifestBack(t *testing.T) {
if tc.mutate != nil {
tc.mutate(t, dir)
}
- if ok, err := Present(dir, env, m); err != nil || ok != tc.want {
+ if ok, err := Present(dir, env, m, true); err != nil || ok != tc.want {
t.Fatalf("Present = %v, %v; want %v", ok, err, tc.want)
}
})
@@ -122,7 +147,7 @@ func TestPresentReadsTheManifestBack(t *testing.T) {
if err := os.WriteFile(env, []byte("X=1\n"), 0o600); err != nil {
t.Fatal(err)
}
- if ok, err := Present(dir, env, m); err != nil || ok {
+ if ok, err := Present(dir, env, m, true); err != nil || ok {
t.Fatalf("an env file with no EnvItem was accepted: %v %v", ok, err)
}
}