carlos / platform Public

moves: the recovery relabels .files-gen only on content evidence (Task 3 fix 1, ruling 3)

Before its first start the recovery journals the tree it placed
(rc_files_placed in the rollback's or the revert's journal: the files
directory's and <host>.env's identities and the plain digest; a dirty
return's must be the tree its install placed). PublishFenced verifies the
placement identity inside the fence, records the tree's digest for the
published generation, and immediately before relabelling requires the
same identity and digest, as the landing does. A tree changed or replaced
during the publication is refused and keeps its label
(TestRecoveryPublicationVerifiesThePlacedTreeBeforeRelabelling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b23cd3c3a0ff5449c8bcf3ab18875f7c8650de92 parent e68d4ba
2 files changed, +280 −6
  • internal/host/movesrecoveryowners.go +177 −6
  • internal/host/movesrecoveryowners_test.go +103 −0
diff --git a/internal/host/movesrecoveryowners.go b/internal/host/movesrecoveryowners.go
index 6b287b0b1..fa6e582e9 100644
--- a/internal/host/movesrecoveryowners.go
+++ b/internal/host/movesrecoveryowners.go
@@ -3,7 +3,9 @@ package host
import (
"context"
"encoding/json"
+ "errors"
"fmt"
+ "io/fs"
"path/filepath"
"reflect"
"strconv"
@@ -486,6 +488,10 @@ func (r movesRecoveryOwners) Materialize(ctx context.Context, o hostclaim.Observ
return recoveryInputsChanged("launch environment")
}
m.recordFence(o.Host, h.Session, work)
+ // The tree the recovery restored, as placed, before anything runs on it.
+ if err := r.recordPlacement(ctx, o.Host, v, work, in.Identity); err != nil {
+ return err
+ }
if v.dirty {
receipt, err := m.preparedMarker(ctx, o.Host, v.prepared(), fence)
if err != nil {
@@ -561,18 +567,48 @@ func (r movesRecoveryOwners) PublishFenced(ctx context.Context, o hostclaim.Obse
if work == nil {
return ClaimPublicationReceipt{}, fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session)
}
- return m.publishFresh(ctx, o.Host, h, pub, func() error { return r.refreshFilesGen(ctx, o.Host, h, work, in.Identity) })
+ // The files on disk must be the tree the recovery placed before anything
+ // is published, and their content is recorded as this generation's (the
+ // landing's fix round 3 rule): the digest the relabel compares.
+ placed, ok, err := r.placement(o.Host, v)
+ if err != nil {
+ return ClaimPublicationReceipt{}, err
+ }
+ if !ok {
+ return ClaimPublicationReceipt{}, fmt.Errorf("%w: the recovery recorded no placed tree", hostbox.ErrChanged)
+ }
+ var digest string
+ if err := work.With(ctx, in.Identity, func() error {
+ if err := r.verifyPlaced(o.Host, placed); err != nil {
+ return err
+ }
+ var err error
+ digest, err = m.filesDigest(o.Host)
+ return err
+ }); err != nil {
+ return ClaimPublicationReceipt{}, err
+ }
+ if err := m.recordFilesDigest(o.Host, v.journalOp(), h.Generation, digest); err != nil {
+ return ClaimPublicationReceipt{}, err
+ }
+ return m.publishFresh(ctx, o.Host, h, pub, func() error { return r.refreshFilesGen(ctx, o.Host, h, placed, digest, work, in.Identity) })
}
// refreshFilesGen points .files-gen at the published generation, inside the
-// admitted writer's fence, when it names that generation's cold predecessor
-// for this incarnation: the files the start check accepted for this
-// session, which the session has served since. Absent, or anything else, is
-// left alone for the start check to decide.
-func (r movesRecoveryOwners) refreshFilesGen(ctx context.Context, host string, h *hostclaim.Holder, work hostwrite.Work, id hostwrite.Identity) error {
+// admitted writer's fence, after the files on disk are proved the tree the
+// recovery placed (its identity) with the content just recorded for the
+// generation (digest): never by identity alone, and a mismatch fails the
+// publication and relabels nothing. It relabels only a .files-gen naming the
+// generation's cold predecessor for this incarnation (the files the start
+// check accepted for this session); absent, or anything else, is left for
+// the start check to decide.
+func (r movesRecoveryOwners) refreshFilesGen(ctx context.Context, host string, h *hostclaim.Holder, placed recoveryPlacement, digest string, work hostwrite.Work, id hostwrite.Identity) error {
m := r.m
dir, _ := m.FilesDir(host)
return work.With(ctx, id, func() error {
+ if err := r.verifyContent(host, placed, digest); err != nil {
+ return err
+ }
files, _, err := m.localMarkers(host, filepath.Join(dir, ".none"))
if err != nil || files == nil || files.Incarnation != h.Incarnation || files.Op == h.Generation {
return err
@@ -605,3 +641,138 @@ func (r movesRecoveryOwners) PublicationEvidence(ctx context.Context, o hostclai
}
return r.m.publicationEvidence(ctx, o.Host, v.holder, prepared, v.manifest)
}
+
+// rcStepFilesPlaced is the recovery journal's record of the tree it placed
+// (JSON recoveryPlacement): the files directory's and <host>.env's
+// identities and the tree's plain digest before the recovery's first start.
+// The recovery journal is the rollback's (a return) or the move's (a
+// revert).
+const rcStepFilesPlaced = "rc_files_placed"
+
+// recoveryPlacement is what rcStepFilesPlaced records.
+type recoveryPlacement struct {
+ Files string `json:"files"`
+ Env string `json:"env,omitempty"`
+ Plain string `json:"plain"`
+}
+
+// journalOp is the recovery's journal: the rollback's for a return, the
+// move's for a revert.
+func (v recoveryProbe) journalOp() string {
+ if v.kind == hostclaim.KindRestoring {
+ return v.rb
+ }
+ return v.op
+}
+
+// placement reads the recorded placed tree; false when none is recorded.
+func (r movesRecoveryOwners) placement(host string, v recoveryProbe) (recoveryPlacement, bool, error) {
+ j, err := moves.ReadJournal(r.m.DataDir, host, v.journalOp())
+ if err != nil {
+ return recoveryPlacement{}, false, err
+ }
+ raw, ok := j.Steps[rcStepFilesPlaced]
+ if !ok {
+ return recoveryPlacement{}, false, nil
+ }
+ var p recoveryPlacement
+ if err := json.Unmarshal([]byte(raw), &p); err != nil || p.Files == "" || p.Plain == "" {
+ return recoveryPlacement{}, false, fmt.Errorf("%w: the recorded placed tree does not read (%v)", hostbox.ErrChanged, err)
+ }
+ return p, true, nil
+}
+
+// recordPlacement journals the tree the recovery restored, as placed, under
+// the start's work, before the recovery's first start: the files directory's
+// and <host>.env's identities and the plain digest. A dirty return's tree is
+// the one its install placed (landStepFilesPlaced), and must still be; a
+// clean return's and a revert's is the source's own tree the recovery
+// verified. Once recorded it is never rewritten.
+func (r movesRecoveryOwners) recordPlacement(ctx context.Context, host string, v recoveryProbe, work hostwrite.Work, id hostwrite.Identity) error {
+ m := r.m
+ if _, ok, err := r.placement(host, v); err != nil || ok {
+ return err
+ }
+ dir, env := m.FilesDir(host)
+ var p recoveryPlacement
+ if err := work.With(ctx, id, func() error {
+ var err error
+ if p.Files, err = pathIdentity(dir); err != nil {
+ return err
+ }
+ switch envID, err := pathIdentity(env); {
+ case err == nil:
+ p.Env = envID
+ case !errors.Is(err, fs.ErrNotExist):
+ return err
+ }
+ if v.dirty {
+ j, err := moves.ReadJournal(m.DataDir, host, v.rb)
+ if err != nil {
+ return err
+ }
+ installed, err := journalledStaged(j, landStepFilesPlaced)
+ if err != nil {
+ return fmt.Errorf("%w: %v", hostbox.ErrChanged, err)
+ }
+ if installed.Files != p.Files || installed.Env != p.Env {
+ return fmt.Errorf("%w: the files are not the tree the return installed", hostbox.ErrChanged)
+ }
+ }
+ p.Plain, err = m.filesDigest(host)
+ return err
+ }); err != nil {
+ return err
+ }
+ body, err := json.Marshal(p)
+ if err != nil {
+ return err
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ err = moves.UpdateJournal(m.DataDir, host, v.journalOp(), func(j *moves.Journal) error {
+ if _, ok := j.Steps[rcStepFilesPlaced]; ok {
+ return errJournalUnchanged
+ }
+ j.Mark(rcStepFilesPlaced, string(body))
+ return nil
+ })
+ if errors.Is(err, errJournalUnchanged) {
+ return nil
+ }
+ return err
+}
+
+// verifyPlaced requires the files directory and <host>.env on disk to be
+// the ones the recovery placed.
+func (r movesRecoveryOwners) verifyPlaced(host string, p recoveryPlacement) error {
+ dir, env := r.m.FilesDir(host)
+ if ok, err := sameIdentity(dir, p.Files); err != nil || !ok {
+ return fmt.Errorf("%w: %s is not the files directory the recovery placed (%v)", hostbox.ErrChanged, dir, err)
+ }
+ if p.Env != "" {
+ if ok, err := sameIdentity(env, p.Env); err != nil || !ok {
+ return fmt.Errorf("%w: %s is not the environment file the recovery placed (%v)", hostbox.ErrChanged, env, err)
+ }
+ }
+ return nil
+}
+
+// verifyContent requires both the placement (identity) and the recorded
+// content (the plain digest): never identity alone.
+func (r movesRecoveryOwners) verifyContent(host string, p recoveryPlacement, want string) error {
+ if want == "" {
+ return fmt.Errorf("%w: no recorded files digest", hostbox.ErrChanged)
+ }
+ if err := r.verifyPlaced(host, p); err != nil {
+ return err
+ }
+ got, err := r.m.filesDigest(host)
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return fmt.Errorf("%w: the files on disk are not the recorded ones", hostbox.ErrChanged)
+ }
+ return nil
+}
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go
index 78a1fe785..88a9caec7 100644
--- a/internal/host/movesrecoveryowners_test.go
+++ b/internal/host/movesrecoveryowners_test.go
@@ -113,6 +113,17 @@ func newRCFixture(t *testing.T, kind string) *rcFixture {
}
body, _ := json.Marshal(f.manifest)
j.Mark(landStepInstall, string(body))
+ // The install receipt: the return's tree at the live paths.
+ filesID, err := pathIdentity(f.files)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envID, err := pathIdentity(filepath.Join(f.dataDir, poHost+".env"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ placed, _ := json.Marshal(stagedFiles{Files: filesID, Env: envID})
+ j.Mark(landStepFilesPlaced, string(placed))
if err := j.Commit(f.dataDir); err != nil {
t.Fatal(err)
}
@@ -649,3 +660,95 @@ func rcInOrder(t *testing.T, got []string, want ...string) {
t.Fatalf("order:\n got %q\nwant, in order, %q (missing from %q)", got, want, want[i])
}
}
+
+// Fix round 1, ruling (3): a recovery's fenced publication relabels
+// .files-gen only with content evidence, as the landing's does. The
+// recovery journals the placed tree (its identity and plain digest) before
+// its first start; PublishFenced verifies the placement identity, records
+// the tree's digest for the published generation, and inside the fence,
+// immediately before relabelling, requires the same identity and digest. A
+// tree changed or replaced during the publication is refused and keeps its
+// label.
+func TestRecoveryPublicationVerifiesThePlacedTreeBeforeRelabelling(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ during func(t *testing.T, f *rcFixture)
+ ok bool
+ }{
+ {"the tree as placed", nil, true},
+ {"the tree changes during the publication", func(t *testing.T, f *rcFixture) {
+ if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("changed under the publication"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }, false},
+ {"the tree is replaced during the publication", func(t *testing.T, f *rcFixture) {
+ label, err := os.ReadFile(filepath.Join(f.files, archive.MarkerName))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(f.files, f.files+"-replaced"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(f.files, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string][]byte{"a.txt": []byte("the landing's file"), archive.MarkerName: label} {
+ if err := os.WriteFile(filepath.Join(f.files, name), body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newRCFixture(t, rcDirty)
+ ctx := t.Context()
+ work := poWork{action: "spawn", f: f.poFixture}
+ o := f.read()
+ in := f.selectAdmission(o)
+ admitted := f.admit(o, in)
+ if err := f.owners.Materialize(ctx, admitted, in, work); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.owners.Publish(ctx, admitted, in); err != nil {
+ t.Fatal(err)
+ }
+ // A restart over the published return generation.
+ again, err := f.owners.Select(ctx, f.route, admitted, f.policy())
+ if err != nil {
+ t.Fatal(err)
+ }
+ restarted := f.admit(admitted, again)
+ if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", "fresh1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.owners.Materialize(ctx, restarted, again, work); err != nil {
+ t.Fatal(err)
+ }
+ f.bucket.Put("data", "instances/"+poHost+"/g-fresh1/0009/"+ltx.FormatFilename(1, 51), f.ltxHeader(1, 51))
+ pub := ClaimPublication{Check: func(context.Context) error { return nil }, Sync: func(context.Context) error {
+ if tc.during != nil {
+ tc.during(t, f)
+ }
+ return nil
+ }}
+ _, err = f.owners.PublishFenced(ctx, restarted, again, pub)
+ raw, rerr := os.ReadFile(filepath.Join(f.files, archive.MarkerName))
+ if rerr != nil {
+ t.Fatal(rerr)
+ }
+ var label moves.FilesGen
+ if err := json.Unmarshal(raw, &label); err != nil {
+ t.Fatal(err)
+ }
+ if tc.ok {
+ if err != nil || label.Op != "fresh1" {
+ t.Fatalf("publication %v, label %+v", err, label)
+ }
+ return
+ }
+ if !errors.Is(err, hostbox.ErrChanged) || label.Op != rpRB {
+ t.Fatalf("publication %v, label %+v: want refused and still %s", err, label, rpRB)
+ }
+ })
+ }
+}