carlos / platform Public

host: the probe's publisher is built from the bytes it checked and their token (Task 6 fix round 2, finding 2)

openPrepared read and checked P's current manifest, then hostfiles.Open
read it again for the publisher: a replacement between the two reads
became the baseline and supplied the compare-and-swap token, so a
publication could succeed over bytes never compared with the journalled
version. hostfiles.OpenAt builds a publisher from bytes already read and
the token answered for them; openPrepared reads the key once, decodes
those bytes for its checks and opens the publisher over exactly them. A
replacement after that read fails the commit (ErrSuperseded).

Regressions: a store that replaces the manifest right after the first
read of it makes the establishment fail and leaves the replacement as
it was; OpenAt refuses a missing token and another manifest version,
fails over a replacement, and commits as usual over the same bytes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e74fa48f5b7cc16c59e74b15fcb53d4514889d50 parent afdc321
5 files changed, +159 −6
  • internal/host/movesfiles.go +7 −3
  • internal/host/movesfiles_test.go +70 −0
  • internal/hostfiles/manifest.go +12 −3
  • internal/hostfiles/prepared_test.go +47 −0
  • internal/hostfiles/publisher.go +23 −0
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go
index f0eb656dd..827a11924 100644
--- a/internal/host/movesfiles.go
+++ b/internal/host/movesfiles.go
@@ -495,12 +495,16 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho
if prepared.Seq != 0 {
return nil, fmt.Errorf("%w: the prepared files manifest %s@%s is at sequence %d, not 0", hostbox.ErrChanged, key, pm.Version, prepared.Seq)
}
- cur, _, err := files.Get(ctx, key)
+ // One read of the key: the bytes and the token the publisher is built
+ // from are the ones checked here, never a second read (fix round 2,
+ // finding 2). A replacement after this read fails the commit's
+ // compare-and-swap.
+ cur, token, err := files.Get(ctx, key)
if err != nil {
return nil, err
}
if !bytes.Equal(cur, exact) {
- current, found, err := hostfiles.Load(ctx, files, prefix)
+ current, found, err := hostfiles.Load(ctx, exactBytesStore{key: key, body: cur, version: token}, prefix)
if err != nil || !found {
return nil, fmt.Errorf("host: the files manifest %s: %v", key, err)
}
@@ -508,7 +512,7 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho
return nil, fmt.Errorf("%w: the files manifest %s is neither the prepared manifest %s nor this box's establishment of it", hostbox.ErrChanged, key, pm.Version)
}
}
- return hostfiles.Open(ctx, files, prefix)
+ return hostfiles.OpenAt(files, prefix, cur, token)
}
// establishedOver reports whether cur is an establishment of prepared by
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go
index c6181792a..187eb44ce 100644
--- a/internal/host/movesfiles_test.go
+++ b/internal/host/movesfiles_test.go
@@ -557,6 +557,76 @@ func TestEstablishKeepsOriginsPastAdmissionPruning(t *testing.T) {
}
}
+// replacingStore replaces one key's object right after the first Get of
+// it returns: another writer landing between a reader's check and its
+// use.
+type replacingStore struct {
+ release.Store
+ key string
+ replace func()
+ once sync.Once
+}
+
+func (s *replacingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ body, etag, err := s.Store.Get(ctx, key)
+ if key == s.key && err == nil {
+ s.once.Do(s.replace)
+ }
+ return body, etag, err
+}
+
+// preparedFixture builds a prepared manifest for mv1 with its binding
+// journalled, the env file carrying the minted key, and the probe's
+// observation and inputs.
+func preparedFixture(t *testing.T) (*filesFixture, preparedManifest, hostclaim.Observed, ClaimLaunchInputs) {
+ t.Helper()
+ x := newFilesFixture(t)
+ ctx := context.Background()
+ dir := filepath.Join(x.dir, filesHost)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("A"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ x.writeEnv("K=1\n")
+ pm, err := x.f.BuildPrepared(ctx, filesLoc, "mv1", "mv1", 7, dir, x.envPath(), true, hostfiles.WakeState{V: 1, Host: filesHost, Timers: []hostfiles.WakeTimer{}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ x.journal("mv1", pm)
+ x.writeEnv("K=1\nCARLOS_INSTANCE_KEY=abc\n")
+ o := servingObserved("cur", "cur:ie:@root:ie")
+ o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box"}
+ in := launchInputs("mv1")
+ in.Prepared = true
+ return x, pm, o, in
+}
+
+// The probe's publisher is built from the bytes the establishment checked
+// and the token captured with them, never from a second read (fix round
+// 2, finding 2). Another writer replacing P's manifest between the check
+// and the publication makes the compare-and-swap fail: nothing is
+// published over bytes that were never compared with the journalled
+// version, and the replacement stands as it was.
+func TestEstablishPublishesOnlyOverTheBytesItChecked(t *testing.T) {
+ x, pm, o, in := preparedFixture(t)
+ ctx := context.Background()
+ other := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Entries: map[string]hostfiles.Entry{"b.txt": {Object: movesFilesPrefix(filesLoc, "mv1") + "objects/0000000000000005-" + sha([]byte("B")), SHA256: sha([]byte("B")), Size: 1, Seq: 5}}, Platform: map[string]hostfiles.Entry{}}
+ replacement := mustJSON(t, other)
+ x.f.Files = &replacingStore{Store: x.files, key: pm.Key, replace: func() { x.bucket.Put("data", pm.Key, replacement) }}
+ if err := x.f.Establish(ctx, o, in, x); err == nil {
+ t.Fatal("published over a manifest replaced after the check")
+ }
+ cur, _ := x.bucket.Current("data", pm.Key)
+ if string(cur) != string(replacement) {
+ t.Fatalf("the replacement was published over: %s", cur)
+ }
+ if x.workRan != 0 && x.workRan > 2 {
+ t.Fatalf("the local effect ran after a refused publication (%d)", x.workRan)
+ }
+}
+
// stallingStore holds one key's write until released, signalling that it
// is stalled: a slow S3 request.
type stallingStore struct {
diff --git a/internal/hostfiles/manifest.go b/internal/hostfiles/manifest.go
index bf441538a..7a15abfef 100644
--- a/internal/hostfiles/manifest.go
+++ b/internal/hostfiles/manifest.go
@@ -126,12 +126,21 @@ func load(ctx context.Context, s release.Store, prefix string) (Manifest, string
if err != nil {
return Manifest{}, "", false, err
}
+ m, err := decode(prefix, raw)
+ if err != nil {
+ return Manifest{}, "", false, err
+ }
+ return m, etag, true, nil
+}
+
+// decode is a manifest from its exact bytes.
+func decode(prefix string, raw []byte) (Manifest, error) {
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s: %w", prefix, err)
+ return Manifest{}, fmt.Errorf("hostfiles: manifest %s: %w", prefix, err)
}
if m.V != ManifestVersion {
- return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s is version %d; this binary reads %d", prefix, m.V, ManifestVersion)
+ return Manifest{}, fmt.Errorf("hostfiles: manifest %s is version %d; this binary reads %d", prefix, m.V, ManifestVersion)
}
if m.Entries == nil {
m.Entries = map[string]Entry{}
@@ -139,5 +148,5 @@ func load(ctx context.Context, s release.Store, prefix string) (Manifest, string
if m.Platform == nil {
m.Platform = map[string]Entry{}
}
- return m, etag, true, nil
+ return m, nil
}
diff --git a/internal/hostfiles/prepared_test.go b/internal/hostfiles/prepared_test.go
index dbcd5f833..6ef352aae 100644
--- a/internal/hostfiles/prepared_test.go
+++ b/internal/hostfiles/prepared_test.go
@@ -197,6 +197,53 @@ func TestPreparedStageSkippedNamesNoObject(t *testing.T) {
}
}
+// OpenAt publishes over the bytes its caller checked and the token read
+// with them: a replacement at the key after that read fails the commit
+// (ErrSuperseded) and the replacement stands; the same bytes still at the
+// key commit as usual.
+func TestOpenAtPublishesOnlyOverTheBytesRead(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ src := open(t, s)
+ stage(t, src, "a.txt", "A")
+ commit(t, src)
+ body, etag, err := s.Get(ctx, ManifestKey(pfx))
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := OpenAt(s, pfx, body, etag)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := OpenAt(s, pfx, body, ""); err == nil {
+ t.Fatal("opened without a token")
+ }
+ if _, err := OpenAt(s, pfx, []byte(`{"v":1}`), etag); err == nil {
+ t.Fatal("opened a manifest of another version")
+ }
+ // Another writer replaces the key after the read.
+ other := open(t, s)
+ stage(t, other, "b.txt", "B")
+ commit(t, other)
+ replaced, _, _ := s.Get(ctx, ManifestKey(pfx))
+ stage(t, p, "c.txt", "C")
+ if _, err := p.Commit(ctx); !errors.Is(err, ErrSuperseded) {
+ t.Fatalf("commit over a replacement: %v", err)
+ }
+ if again, _, _ := s.Get(ctx, ManifestKey(pfx)); string(again) != string(replaced) {
+ t.Fatal("the replacement was published over")
+ }
+ // The same bytes still at the key: the commit goes through.
+ body, etag, _ = s.Get(ctx, ManifestKey(pfx))
+ q, err := OpenAt(s, pfx, body, etag)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stage(t, q, "c.txt", "C")
+ if seq := commit(t, q); seq != 3 {
+ t.Fatalf("seq %d", seq)
+ }
+}
+
// NewFromCopy seeds a publisher for a generation that has no manifest yet
// with a predecessor's validated manifest: every entry keeps its
// origin-qualified object, the sequence continues from the copy, and the
diff --git a/internal/hostfiles/publisher.go b/internal/hostfiles/publisher.go
index a55621888..0abf0b136 100644
--- a/internal/hostfiles/publisher.go
+++ b/internal/hostfiles/publisher.go
@@ -49,6 +49,29 @@ func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, erro
return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil
}
+// OpenAt is a publisher over the manifest bytes a caller already read and
+// checked, with the token the store answered for them: the publisher's
+// baseline and its compare-and-swap token are exactly those, never a
+// second read (Task 6 fix round 2, finding 2). A manifest replaced at the
+// key after that read makes the first Commit fail (ErrSuperseded) rather
+// than publish over bytes nobody checked.
+func OpenAt(s release.Store, prefix string, body []byte, etag string) (*Publisher, error) {
+ if s == nil {
+ return nil, errors.New("hostfiles: open: no store")
+ }
+ if err := checkPrefix(prefix); err != nil {
+ return nil, err
+ }
+ if etag == "" {
+ return nil, fmt.Errorf("hostfiles: open %s at its read bytes: no token", prefix)
+ }
+ m, err := decode(prefix, body)
+ if err != nil {
+ return nil, err
+ }
+ return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil
+}
+
func sum(body []byte) string {
h := sha256.Sum256(body)
return hex.EncodeToString(h[:])