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>
6 files changed,
+308
−79
internal/host/relocationbuild_test.go+10 −2internal/host/relocationinstall.go+90 −14internal/host/relocationinstall_linux_test.go+111 −31internal/host/relocationinstall_test.go+35 −0internal/hostfiles/present.go+25 −20internal/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) |
| } |
| } |