hostfiles, host: the admitted origin set travels in the manifest (Task 6 fix round 2, finding 3, platform-1e's ruling)
The location rule is syntax, not provenance: an admitted manifest naming an object under files/g-never-admitted/ passed and was copied forward. The manifest now carries its admitted origin set (Manifest.Origins, additive and omitted when empty, so an old manifest reads with none and is judged by the admitted chain alone): - a prepared manifest seeds it with its own prefix (Prepared.Commit); - a copy carries the base's set (NewFromCopy) and the establishment sets the new manifest's to the previous set plus the generations admitted in the then-current chain plus its own prefix (Publisher.SetOrigins, sorted and deduplicated; the same set is not a change); - movesFilesAllowed is that set for the manifest an admission establishes from: every origin the manifest's entries name must lie in it (ErrOrigin otherwise), after the location rule and ValidateManifest; a pruned ancestor stays proven by the carried set, and a generation never admitted cannot enter it; - the prepared path takes the prepared manifest's set plus the chain plus P, and a prepared replay requires the current manifest's carried set to be exactly that; - a legacy adopted location (no incarnation component) takes the same path and is seeded at its first establishment. Regressions: the pruned ancestor (g1..g10, an object first written in g1, admissions g3..g10) passes and g11 carries g1..g11; a never-admitted origin under the location is refused with no carried set and with a set that omits it, and the new set is exactly the chain plus the new generation; a legacy location's old manifest is seeded and a never-admitted origin under it is refused; the format tests in hostfiles.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+282
−19
internal/host/movesfiles.go+64 −16internal/host/movesfiles_test.go+103 −0internal/hostfiles/manifest.go+11 −0internal/hostfiles/prepared.go+5 −3internal/hostfiles/prepared_test.go+75 −0internal/hostfiles/publisher.go+24 −0
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index b51137324..a5cb86ef2 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -13,6 +13,7 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| + "sort" |
| "strings" |
| "github.com/carlosframework/platform/internal/hostbox" |
| @@ -391,18 +392,51 @@ func (f *movesFiles) newestManifest(ctx context.Context, location string, chain |
| if !found { |
| return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s is not readable", key, version) |
| } |
| - origins, err := movesFilesOrigins(location, prefix, m) |
| + allowed, err := movesFilesAllowed(location, chain, m) |
| if err != nil { |
| return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s: %w", key, version, err) |
| } |
| - if err := hostfiles.ValidateManifest(m, origins); err != nil { |
| - return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s: %w", key, version, err) |
| - } |
| - return m, origins, true, nil |
| + return m, allowed, true, nil |
| } |
| return hostfiles.Manifest{}, nil, false, nil |
| } |
| +// movesFilesAllowed is the origin set an admission allows for m, the |
| +// manifest it establishes from (fix round 2, finding 3; platform-1e's |
| +// ruling): the set m carries plus the generations admitted in the |
| +// current chain. An old manifest with no carried set is judged by the |
| +// chain alone. Every origin m's entries name must lie in it (a generation |
| +// never admitted and never carried is ErrOrigin, whatever its name), after |
| +// the location rule (movesFilesOrigins) and ValidateManifest. The new |
| +// manifest's set is this plus its own prefix (SetOrigins). |
| +func movesFilesAllowed(location string, chain []string, m hostfiles.Manifest) ([]string, error) { |
| + set := map[string]bool{} |
| + for _, g := range chain { |
| + set[movesFilesPrefix(location, g)] = true |
| + } |
| + for _, o := range m.Origins { |
| + set[o] = true |
| + } |
| + named, err := movesFilesOrigins(location, "", m) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, o := range named { |
| + if !set[o] { |
| + return nil, fmt.Errorf("%w: %s is neither admitted in the chain nor carried by the manifest", hostfiles.ErrOrigin, o) |
| + } |
| + } |
| + allowed := make([]string, 0, len(set)) |
| + for o := range set { |
| + allowed = append(allowed, o) |
| + } |
| + sort.Strings(allowed) |
| + if err := hostfiles.ValidateManifest(m, allowed); err != nil { |
| + return nil, err |
| + } |
| + return allowed, nil |
| +} |
| + |
| // movesFilesOrigins is the set of origins a manifest's objects name, each |
| // held to the claim's provenance rule (P6-D3): an immutable object within |
| // the authorized incarnation location in this region's bucket, under a |
| @@ -435,7 +469,7 @@ func movesFilesOrigins(location, prefix string, m hostfiles.Manifest) ([]string, |
| } |
| } |
| } |
| - if len(out) == 0 { |
| + if len(out) == 0 && prefix != "" { |
| out = []string{prefix} |
| } |
| return out, nil |
| @@ -485,11 +519,14 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho |
| if err != nil || !found { |
| return nil, fmt.Errorf("host: the prepared files manifest %s@%s: %v", key, pm.Version, err) |
| } |
| - origins, err := movesFilesOrigins(h.Location, prefix, prepared) |
| - if err != nil { |
| - return nil, fmt.Errorf("host: the prepared files manifest %s@%s: %w", key, pm.Version, err) |
| + // The prepared manifest's allowed set: what it carries (its own prefix, |
| + // seeded by the build; an old one carries none) plus the admitted |
| + // chain, plus P itself. |
| + if len(prepared.Origins) == 0 { |
| + prepared.Origins = []string{prefix} |
| } |
| - if err := hostfiles.ValidateManifest(prepared, origins); err != nil { |
| + allowed, err := movesFilesAllowed(h.Location, append(movesFilesChain(h, f.HomeRegion, f.Region), gen), prepared) |
| + if err != nil { |
| return nil, fmt.Errorf("host: the prepared files manifest %s@%s: %w", key, pm.Version, err) |
| } |
| if prepared.Seq != 0 { |
| @@ -508,11 +545,16 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho |
| if err != nil || !found { |
| return nil, fmt.Errorf("host: the files manifest %s: %v", key, err) |
| } |
| - if err := establishedOver(h.Location, prefix, current, prepared, env); err != nil { |
| + if err := establishedOver(h.Location, prefix, allowed, current, prepared, env); err != nil { |
| return nil, fmt.Errorf("%w: the files manifest %s is neither the prepared manifest %s nor this box's establishment of it: %v", hostbox.ErrChanged, key, pm.Version, err) |
| } |
| } |
| - return hostfiles.OpenAt(files, prefix, cur, token) |
| + p, err := hostfiles.OpenAt(files, prefix, cur, token) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.SetOrigins(allowed) |
| + return p, nil |
| } |
| // establishedOver reports whether cur is an establishment of prepared by |
| @@ -520,6 +562,8 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho |
| // entries and the same platform items apart from the env file and the |
| // adoption item; the adoption item gone; cur's origins holding the |
| // location rule and every entry validating against them; and the env |
| +// adoption item gone; the carried origin set exactly the admitted one; cur's origins holding the |
| +// location rule and every entry validating against them; and the env |
| // entry with the complete shape of the object this box's establishment |
| // wrote, not merely its digest: the object key under P at the entry's own |
| // sequence and digest (what Publisher.put writes), a sequence after the |
| @@ -527,10 +571,13 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho |
| // manifest whose env entry names an object elsewhere, or a different |
| // object under P, is not this box's establishment, however its digest |
| // reads. |
| -func establishedOver(location, prefix string, cur, prepared hostfiles.Manifest, env []byte) error { |
| +func establishedOver(location, prefix string, allowed []string, cur, prepared hostfiles.Manifest, env []byte) error { |
| if cur.Seq <= prepared.Seq { |
| return fmt.Errorf("sequence %d is not after the prepared %d", cur.Seq, prepared.Seq) |
| } |
| + if !reflect.DeepEqual(cur.Origins, allowed) { |
| + return fmt.Errorf("the carried origin set %v is not the admitted one %v", cur.Origins, allowed) |
| + } |
| if !reflect.DeepEqual(cur.Entries, prepared.Entries) { |
| return errors.New("the entries are not the prepared manifest's") |
| } |
| @@ -549,11 +596,10 @@ func establishedOver(location, prefix string, cur, prepared hostfiles.Manifest, |
| if !reflect.DeepEqual(rest(cur), rest(prepared)) { |
| return errors.New("the platform items are not the prepared manifest's") |
| } |
| - origins, err := movesFilesOrigins(location, prefix, cur) |
| - if err != nil { |
| + if _, err := movesFilesOrigins(location, prefix, cur); err != nil { |
| return err |
| } |
| - if err := hostfiles.ValidateManifest(cur, origins); err != nil { |
| + if err := hostfiles.ValidateManifest(cur, allowed); err != nil { |
| return err |
| } |
| e, ok := cur.Platform[hostfiles.EnvItem] |
| @@ -689,6 +735,7 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| if err != nil { |
| return err |
| } |
| + p.SetOrigins([]string{prefix}) |
| default: |
| base, origins, found, err := f.newestManifest(ctx, h.Location, movesFilesChain(h, f.HomeRegion, f.Region)) |
| if err != nil { |
| @@ -712,6 +759,7 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| if err != nil { |
| return err |
| } |
| + p.SetOrigins(append(origins, prefix)) |
| } |
| if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil { |
| return err |
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go| index 1877753fd..eb28b2c39 100644 |
| --- a/internal/host/movesfiles_test.go |
| +++ b/internal/host/movesfiles_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| + "sort" |
| "strings" |
| "sync" |
| "testing" |
| @@ -517,6 +518,9 @@ func TestEstablishKeepsOriginsPastAdmissionPruning(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + // Each copy carries its set as an establishment would: the previous |
| + // set, the chain then (g1 and the previous generation) and itself. |
| + p.SetOrigins(append(append([]string(nil), prev.Origins...), movesFilesPrefix(filesLoc, "g1"), movesFilesPrefix(filesLoc, gens[len(gens)-1]), movesFilesPrefix(filesLoc, gen))) |
| if _, err := p.Commit(ctx); err != nil { |
| t.Fatal(err) |
| } |
| @@ -539,6 +543,17 @@ func TestEstablishKeepsOriginsPastAdmissionPruning(t *testing.T) { |
| if !found || m.Entries["a.txt"].Object != movesFilesPrefix(filesLoc, "g1")+"objects/0000000000000001-"+sha([]byte("A")) { |
| t.Fatalf("g11: %+v %v", m, found) |
| } |
| + // The carried set (fix round 2, finding 3): the previous manifest's |
| + // set, the then-admitted chain and the new generation, so the pruned |
| + // ancestor stays proven by it. |
| + want := []string{} |
| + for _, g := range append(gens, "g11") { |
| + want = append(want, movesFilesPrefix(filesLoc, g)) |
| + } |
| + sort.Strings(want) |
| + if !reflect.DeepEqual(m.Origins, want) { |
| + t.Fatalf("g11 origins %v, want %v", m.Origins, want) |
| + } |
| // Under the location but not a generation's files prefix: refused. |
| for name, object := range map[string]string{ |
| "another location": "instances/other/files/g-g1/objects/0000000000000001-" + sha([]byte("A")), |
| @@ -759,6 +774,94 @@ func TestEstablishPublishesOutsideTheStartsWork(t *testing.T) { |
| } |
| } |
| +// A never-admitted origin under the location is refused (fix round 2, |
| +// finding 3; platform-1e's ruling): the location rule is syntax, not |
| +// provenance. A manifest in an admitted generation naming an object |
| +// under files/g-never/, which no admission and no carried set names, is |
| +// refused with ErrOrigin, whether the manifest carries no set (the old |
| +// format, judged by the chain alone) or a set that omits it; and a |
| +// carried set is never widened by anything but the admitted chain. |
| +func TestEstablishRefusesANeverAdmittedOrigin(t *testing.T) { |
| + x := newFilesFixture(t) |
| + ctx := context.Background() |
| + x.writeEnv("K=1\n") |
| + g1 := x.publish("g1", func(p *hostfiles.Publisher) { |
| + if err := p.Stage(ctx, "a.txt", []byte("A"), 0o600, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + o := servingObserved("g1", "g1:uk:@root:uk") |
| + never := movesFilesPrefix(filesLoc, "never") + "objects/0000000000000001-" + sha([]byte("N")) |
| + x.bucket.Put("data", never, []byte("N")) |
| + for name, origins := range map[string][]string{"no carried set": nil, "a carried set without it": {movesFilesPrefix(filesLoc, "g1")}} { |
| + bad := g1 |
| + bad.Entries = map[string]hostfiles.Entry{"a.txt": g1.Entries["a.txt"], "n.txt": {Object: never, SHA256: sha([]byte("N")), Size: 1, Seq: 1}} |
| + bad.Origins = origins |
| + x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "g1")), mustJSON(t, bad)) |
| + if err := x.f.Establish(ctx, o, launchInputs("g2"), x); !errors.Is(err, hostfiles.ErrOrigin) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if _, found := x.manifest("g2"); found { |
| + t.Fatalf("%s: a manifest was written over a never-admitted origin", name) |
| + } |
| + } |
| + // The genuine manifest establishes, and the new set is exactly the |
| + // chain and the new generation. |
| + x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "g1")), mustJSON(t, g1)) |
| + if err := x.f.Establish(ctx, o, launchInputs("g2"), x); err != nil { |
| + t.Fatal(err) |
| + } |
| + m, _ := x.manifest("g2") |
| + if want := []string{movesFilesPrefix(filesLoc, "g1"), movesFilesPrefix(filesLoc, "g2")}; !reflect.DeepEqual(m.Origins, want) { |
| + t.Fatalf("g2 origins %v, want %v", m.Origins, want) |
| + } |
| +} |
| + |
| +// A legacy adopted location, with no incarnation component, gets the same |
| +// treatment and is seeded at its first establishment: its old manifest |
| +// carries no set and is judged by the admitted chain alone; the new |
| +// manifest carries the chain and the new generation; a never-admitted |
| +// origin under it is refused as anywhere else. |
| +func TestEstablishSeedsALegacyLocationsOriginSet(t *testing.T) { |
| + x := newFilesFixture(t) |
| + ctx := context.Background() |
| + x.writeEnv("K=1\n") |
| + legacy := "instances/" + filesHost |
| + loc := func(gen string) string { return movesFilesPrefix(legacy, gen) } |
| + p, err := hostfiles.Open(ctx, x.files, loc("g1")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Stage(ctx, "a.txt", []byte("A"), 0o600, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := p.Commit(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + old, _, _ := hostfiles.Load(ctx, x.files, loc("g1")) |
| + if old.Origins != nil { |
| + t.Fatalf("the legacy manifest carries a set: %v", old.Origins) |
| + } |
| + o := servingObserved("g1", "g1:uk:@root:uk") |
| + o.Claim.Holder.Location = legacy |
| + if err := x.f.Establish(ctx, o, launchInputs("g2"), x); err != nil { |
| + t.Fatal(err) |
| + } |
| + m, found, err := hostfiles.Load(ctx, x.files, loc("g2")) |
| + if err != nil || !found { |
| + t.Fatal(err, found) |
| + } |
| + if want := []string{loc("g1"), loc("g2")}; !reflect.DeepEqual(m.Origins, want) || m.Entries["a.txt"].Object != old.Entries["a.txt"].Object { |
| + t.Fatalf("seeded: %+v", m) |
| + } |
| + bad := old |
| + bad.Entries = map[string]hostfiles.Entry{"n.txt": {Object: loc("never") + "objects/0000000000000001-" + sha([]byte("N")), SHA256: sha([]byte("N")), Size: 1, Seq: 1}} |
| + x.bucket.Put("data", hostfiles.ManifestKey(loc("g1")), mustJSON(t, bad)) |
| + if err := x.f.Establish(ctx, o, launchInputs("g3"), x); !errors.Is(err, hostfiles.ErrOrigin) { |
| + t.Fatalf("never admitted under a legacy location: %v", err) |
| + } |
| +} |
| + |
| // The instance key rule (O1'): the manifest Establish leaves carries the |
| // env file with exactly the bytes on disk when it runs, staged and |
| // committed before it returns, and no adoption item; the local adoption |
diff --git a/internal/hostfiles/manifest.go b/internal/hostfiles/manifest.go| index 7a15abfef..f451efe49 100644 |
| --- a/internal/hostfiles/manifest.go |
| +++ b/internal/hostfiles/manifest.go |
| @@ -59,6 +59,17 @@ type Manifest struct { |
| Seq int64 `json:"seq"` |
| Entries map[string]Entry `json:"entries"` |
| Platform map[string]Entry `json:"platform"` |
| + // Origins is the admitted origin set the manifest carries forward |
| + // (moves Task 6 fix round 2, platform-1e's ruling): the generation |
| + // files prefixes whose objects its entries may name, sorted. A |
| + // prepared manifest seeds it with its own prefix; each admission's |
| + // establishment sets the new manifest's to the previous manifest's set |
| + // plus the generations admitted in the then-current chain plus its |
| + // own prefix, so a pruned ancestor stays proven by it and a generation |
| + // never admitted cannot enter it. A manifest without the field (the |
| + // format before this) carries none, and a reader falls back to the |
| + // admitted chain alone. Additive: every other field is as before. |
| + Origins []string `json:"origins,omitempty"` |
| } |
| func ManifestKey(prefix string) string { return prefix + "manifest.json" } |
diff --git a/internal/hostfiles/prepared.go b/internal/hostfiles/prepared.go| index 8a2ab7723..9c27e3c06 100644 |
| --- a/internal/hostfiles/prepared.go |
| +++ b/internal/hostfiles/prepared.go |
| @@ -187,6 +187,7 @@ func (p *Prepared) Commit(ctx context.Context) (Manifest, []byte, string, error) |
| return Manifest{}, nil, "", errors.New("hostfiles: prepared manifest already committed") |
| } |
| p.man.Seq = 0 |
| + p.man.Origins = []string{p.prefix} |
| body, err := json.Marshal(p.man) |
| if err != nil { |
| return Manifest{}, nil, "", fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| @@ -212,8 +213,9 @@ func (p *Prepared) Commit(ctx context.Context) (Manifest, []byte, string, error) |
| // seeded with base: a validated manifest of an admitted predecessor, read |
| // by the caller at its exact version, or an empty one for the claim's own |
| // empty bootstrap or a host the pin evidences never published files. Every |
| -// entry keeps its origin-qualified object (nothing is uploaded again); new |
| -// objects go under prefix from base.Seq+1; and the first Commit is |
| +// entry keeps its origin-qualified object (nothing is uploaded again) and |
| +// the copy carries base's origin set (the caller sets the new one with |
| +// SetOrigins); new objects go under prefix from base.Seq+1; and the first Commit is |
| // create-only, so a repeat reads its own bytes back and another manifest |
| // at the prefix is ErrSuperseded, never replaced. It is CopyManifest with |
| // the caller's own changes in the same revision: a cold admission's |
| @@ -239,7 +241,7 @@ func NewFromCopy(s release.Store, prefix string, base Manifest, origins []string |
| if err := checkManifest(base, origins); err != nil { |
| return nil, err |
| } |
| - man := Manifest{V: ManifestVersion, Seq: base.Seq, Entries: map[string]Entry{}, Platform: map[string]Entry{}} |
| + man := Manifest{V: ManifestVersion, Seq: base.Seq, Entries: map[string]Entry{}, Platform: map[string]Entry{}, Origins: append([]string(nil), base.Origins...)} |
| for k, e := range base.Entries { |
| man.Entries[k] = e |
| } |
diff --git a/internal/hostfiles/prepared_test.go b/internal/hostfiles/prepared_test.go| index 6ef352aae..f3cecfa71 100644 |
| --- a/internal/hostfiles/prepared_test.go |
| +++ b/internal/hostfiles/prepared_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "reflect" |
| "strings" |
| "testing" |
| @@ -244,6 +245,80 @@ func TestOpenAtPublishesOnlyOverTheBytesRead(t *testing.T) { |
| } |
| } |
| +// The admitted origin set travels in the manifest (Task 6 fix round 2, |
| +// finding 3): a prepared manifest carries its own prefix; a copy carries |
| +// the set its caller validated (SetOrigins, sorted and deduplicated, its |
| +// own prefix included), and a commit keeps it; an old manifest without |
| +// the field reads with none, so a reader falls back to the admitted |
| +// chain alone. |
| +func TestManifestCarriesItsOriginSet(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := prepared(t, s, pfx) |
| + if err := p.Stage(ctx, "a.txt", []byte("A"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + m, _, _, err := p.Commit(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(m.Origins, []string{pfx}) { |
| + t.Fatalf("prepared origins %v", m.Origins) |
| + } |
| + to := "instances/shop.test/abc/files/g2/" |
| + q, err := NewFromCopy(s, to, m, []string{pfx}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + q.SetOrigins([]string{"instances/shop.test/abc/files/g1b/", pfx, to, pfx}) |
| + if _, err := q.Commit(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, found, err := Load(ctx, s, to) |
| + if err != nil || !found { |
| + t.Fatal(err, found) |
| + } |
| + if !reflect.DeepEqual(got.Origins, []string{pfx, "instances/shop.test/abc/files/g1b/", to}) { // sorted, deduplicated |
| + t.Fatalf("copied origins %v", got.Origins) |
| + } |
| + // A later revision by the ordinary publisher keeps the set. |
| + r := open(t, s) |
| + stage(t, r, "b.txt", "B") |
| + commit(t, r) |
| + again, _, _ := Load(ctx, s, pfx) |
| + if !reflect.DeepEqual(again.Origins, []string{pfx}) { |
| + t.Fatalf("a commit dropped the set: %v", again.Origins) |
| + } |
| + // SetOrigins with the same set is not a change. |
| + same, _ := OpenAt(s, to, mustBytes(t, s, to), mustETag(t, s, to)) |
| + same.SetOrigins([]string{to, pfx, "instances/shop.test/abc/files/g1b/"}) |
| + if same.Dirty() { |
| + t.Fatal("the same set marked the publisher dirty") |
| + } |
| + // An old manifest without the field. |
| + var old Manifest |
| + if err := json.Unmarshal([]byte(`{"v":2,"seq":1,"entries":{},"platform":{}}`), &old); err != nil || old.Origins != nil { |
| + t.Fatalf("old manifest: %v %v", old.Origins, err) |
| + } |
| +} |
| + |
| +func mustBytes(t *testing.T, s release.Store, prefix string) []byte { |
| + t.Helper() |
| + b, _, err := s.Get(context.Background(), ManifestKey(prefix)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +func mustETag(t *testing.T, s release.Store, prefix string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(context.Background(), ManifestKey(prefix)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return etag |
| +} |
| + |
| // 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 0abf0b136..272dba96f 100644 |
| --- a/internal/hostfiles/publisher.go |
| +++ b/internal/hostfiles/publisher.go |
| @@ -8,6 +8,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "slices" |
| "sort" |
| "strings" |
| "sync" |
| @@ -223,6 +224,29 @@ func (p *Publisher) Commit(ctx context.Context) (int64, error) { |
| return out.Seq, nil |
| } |
| +// SetOrigins sets the manifest's carried origin set (Manifest.Origins), |
| +// sorted and deduplicated. The same set is not a change; a different one |
| +// goes out with the next Commit. |
| +func (p *Publisher) SetOrigins(origins []string) { |
| + set := map[string]bool{} |
| + for _, o := range origins { |
| + if o != "" { |
| + set[o] = true |
| + } |
| + } |
| + out := make([]string, 0, len(set)) |
| + for o := range set { |
| + out = append(out, o) |
| + } |
| + sort.Strings(out) |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if slices.Equal(out, p.man.Origins) { |
| + return |
| + } |
| + p.man.Origins, p.dirty = out, true |
| +} |
| + |
| func (p *Publisher) Dirty() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.dirty } |
| func (p *Publisher) Seq() int64 { p.mu.Lock(); defer p.mu.Unlock(); return p.man.Seq } |