hostfiles: the create-only prepared manifest, a copy-seeded publisher, and the shared never-published-files rule (Task 6, C8)
platform.go and manifestcheck.go are region HA's stage B files (region-ha-1b-next-build 73bab739), carried verbatim: EnvItem, StagePlatformDelete and ValidateManifest. prepared.go adds what a cold admission and an installation share with stage B: - PinnedFiles and PlatformItem carry stage B's relocationPlatformItem rule into one place: a missing platform item is "none" only when the pin evidences a host that never published files (no digest, no retained copy); otherwise ErrNoEvidence; a tombstone is ErrTombstone. Absence is never evidence. - Prepared builds an installation's prepared manifest: create-only content objects at sequence 0 keyed by digest, one create-only manifest at sequence 0, never replaced; a replay reads its own bytes back. - NewFromCopy seeds a publisher for a generation without a manifest from a validated predecessor manifest, so an establishment copies, stages the env file, drops the adoption item and commits in one create-only write.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+534
−0
internal/hostfiles/manifestcheck.go+23 −0internal/hostfiles/platform.go+33 −0internal/hostfiles/prepared.go+231 −0internal/hostfiles/prepared_test.go+247 −0
diff --git a/internal/hostfiles/manifestcheck.go b/internal/hostfiles/manifestcheck.go| new file mode 100644 |
| index 000000000..fbdd29d79 |
| --- /dev/null |
| +++ b/internal/hostfiles/manifestcheck.go |
| @@ -0,0 +1,23 @@ |
| +package hostfiles |
| + |
| +import "fmt" |
| + |
| +// ValidateManifest checks every entry and platform item of m against the |
| +// admitted origins, as Restore and CopyManifest do before they touch |
| +// anything (contracts note §1). A caller holding a manifest's exact bytes |
| +// from somewhere other than its prefix (a relocation's retained copy) |
| +// validates them with it before writing them under a new generation. |
| +func ValidateManifest(m Manifest, origins []string) error { |
| + if m.V != ManifestVersion { |
| + return fmt.Errorf("hostfiles: manifest is version %d; this binary reads %d", m.V, ManifestVersion) |
| + } |
| + if len(origins) == 0 { |
| + return ErrOrigin |
| + } |
| + for _, o := range origins { |
| + if err := checkPrefix(o); err != nil { |
| + return err |
| + } |
| + } |
| + return checkManifest(m, origins) |
| +} |
diff --git a/internal/hostfiles/platform.go b/internal/hostfiles/platform.go| new file mode 100644 |
| index 000000000..18dbbe48e |
| --- /dev/null |
| +++ b/internal/hostfiles/platform.go |
| @@ -0,0 +1,33 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// EnvItem is the host's env file (<host>.env) among the manifest's |
| +// platform items. A release flush stages it beside the wake state so a |
| +// relocation's prepared manifest carries it (region HA spec 1 §5.6: |
| +// always continuous, whatever the setting); the local install restores it |
| +// with ReadPlatform. |
| +const EnvItem = "host.env" |
| + |
| +// StagePlatformDelete records that a platform item is gone: the next |
| +// Commit publishes a revision without it, at a new sequence, so a reader |
| +// of that revision gets os.ErrNotExist from ReadPlatform instead of the |
| +// last bytes it published. Deleting an item the manifest does not hold |
| +// is a no-op, like StageDelete. |
| +func (p *Publisher) StagePlatformDelete(name string) error { |
| + if name == "" || strings.ContainsAny(name, "/\x00") { |
| + return fmt.Errorf("hostfiles: platform item %q is not a plain name", name) |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if _, ok := p.man.Platform[name]; !ok { |
| + return nil |
| + } |
| + p.next++ |
| + delete(p.man.Platform, name) |
| + p.dirty = true |
| + return nil |
| +} |
diff --git a/internal/hostfiles/prepared.go b/internal/hostfiles/prepared.go| new file mode 100644 |
| index 000000000..ad4aac0e8 |
| --- /dev/null |
| +++ b/internal/hostfiles/prepared.go |
| @@ -0,0 +1,231 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// This file holds what a cold admission and an installation share with |
| +// region HA's relocation (stage B, region-ha-1b-next-build 73bab739): the |
| +// one rule for a host that never published files, and the create-only |
| +// prepared manifest an installation writes before any session exists |
| +// (claim rev 6.30 R18-1, moves spec §7.6). Stage B rebases |
| +// relocationPlatformItem onto PlatformItem; the values it reads from its |
| +// files pin are PinnedFiles. |
| + |
| +// PinnedFiles is what a pin says about a host's published files: the |
| +// digest of the pinned manifest and the pinner's retained copy of it. The |
| +// pinner writes both for a host that published files and neither for one |
| +// that never did (a holder no writer was ever admitted to, or a release |
| +// flush that published nothing); a pin with one and not the other is |
| +// half-made and refused before it reaches here (ReadPin, H2). |
| +type PinnedFiles struct { |
| + SHA256 string |
| + Retained string |
| +} |
| + |
| +// NeverPublished reports the pinner's positive evidence that the host |
| +// never published files: neither a digest nor a retained copy. It is the |
| +// only evidence this package accepts for an empty files state; a missing |
| +// manifest, a missing pin or a missing item is never evidence (ruling 68 |
| +// as amended in Task 8c: absence is never evidence). |
| +func (p PinnedFiles) NeverPublished() bool { return p.SHA256 == "" && p.Retained == "" } |
| + |
| +// ErrNoEvidence marks a manifest without a platform item under a pin that |
| +// does not evidence a host that never published files. Platform items have |
| +// no tombstone (StagePlatformDelete drops the item), so a host 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. Only the pin can tell |
| +// them apart. |
| +var ErrNoEvidence = errors.New("hostfiles: the manifest has no such platform item, and the pin does not evidence a host that never published files") |
| + |
| +// ErrTombstone marks a platform item recorded as deleted, which no |
| +// publisher writes. |
| +var ErrTombstone = errors.New("hostfiles: the manifest holds a tombstone for a platform item, which no publisher writes") |
| + |
| +// PlatformItem reports whether m carries the platform item name (the env |
| +// file, the wake state) for a reader to restore, or the host has none. A |
| +// live item is the host's. A manifest without it is "none" only when pin |
| +// evidences a host that never published files (NeverPublished). Any other |
| +// manifest without the item is ErrNoEvidence; a tombstone is ErrTombstone |
| +// under either pin. |
| +func PlatformItem(pin PinnedFiles, m 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: %s", ErrTombstone, name) |
| + case pin.NeverPublished(): |
| + return false, nil |
| + default: |
| + return false, fmt.Errorf("%w: %s", ErrNoEvidence, name) |
| + } |
| +} |
| + |
| +// Prepared builds the prepared manifest of a generation no session has |
| +// opened: an installation's initial files state, written under the |
| +// authority of its operation and installation stamp, not a session. Every |
| +// content object is create-only at sequence 0 under the prepared prefix, |
| +// keyed by its digest, and Commit writes one create-only manifest at |
| +// sequence 0. Nothing is ever replaced: a replay finds each object by its |
| +// key (the same digest) and the manifest by its key, reads it back, and |
| +// succeeds only when the bytes are the ones it would have written. The |
| +// session-bound publisher later opens the prefix, takes this manifest as |
| +// its baseline and continues from sequence 1. |
| +// |
| +// The binding to {location, generation, op id, installation stamp} is the |
| +// caller's: the location and generation are the prefix, and the op and |
| +// stamp are journalled beside the manifest's digest by the installation |
| +// that wrote it. The manifest itself carries no such fields: a reader that |
| +// copies it must not reinterpret it, and the pinned bytes of a relocation |
| +// compare equal to the prepared copy entry for entry (stage B). |
| +type Prepared struct { |
| + s release.Store |
| + prefix string |
| + man Manifest |
| + spent bool |
| +} |
| + |
| +// NewPrepared returns a builder for prefix. It reads nothing: a manifest |
| +// already at the prefix is found by Commit. |
| +func NewPrepared(s release.Store, prefix string) (*Prepared, error) { |
| + if s == nil { |
| + return nil, errors.New("hostfiles: prepared manifest: no store") |
| + } |
| + if err := checkPrefix(prefix); err != nil { |
| + return nil, err |
| + } |
| + return &Prepared{s: s, prefix: prefix, man: Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{}}}, nil |
| +} |
| + |
| +// put writes body as a create-only object at sequence 0 under the prepared |
| +// prefix. An object already at that key is the same bytes: the key is the |
| +// digest. |
| +func (p *Prepared) put(ctx context.Context, body []byte, mode uint32) (Entry, error) { |
| + if p.spent { |
| + return Entry{}, errors.New("hostfiles: prepared manifest already committed") |
| + } |
| + if int64(len(body)) > MaxFileBytes { |
| + return Entry{}, fmt.Errorf("hostfiles: %d bytes is over the %d-byte limit", len(body), MaxFileBytes) |
| + } |
| + sha := sum(body) |
| + key := p.prefix + objectKey(0, sha) |
| + if _, err := p.s.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + return Entry{}, err |
| + } |
| + return Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode & 0o777, Seq: 0}, nil |
| +} |
| + |
| +// Stage records path's content. A file over MaxFileBytes is recorded as |
| +// skipped, as SyncDir records it, so the manifest still names every file. |
| +func (p *Prepared) Stage(ctx context.Context, path string, body []byte, mode uint32) error { |
| + clean, err := CleanPath(path) |
| + if err != nil { |
| + return err |
| + } |
| + if p.spent { |
| + return errors.New("hostfiles: prepared manifest already committed") |
| + } |
| + if int64(len(body)) > MaxFileBytes { |
| + p.man.Entries[clean] = Entry{Seq: 0, Skipped: fmt.Sprintf("%d bytes is over the %d-byte limit", len(body), MaxFileBytes)} |
| + return nil |
| + } |
| + e, err := p.put(ctx, body, mode) |
| + if err != nil { |
| + return err |
| + } |
| + p.man.Entries[clean] = e |
| + return nil |
| +} |
| + |
| +// StagePlatform records a platform-managed item (the env file, the wake |
| +// state). |
| +func (p *Prepared) StagePlatform(ctx context.Context, name string, body []byte) error { |
| + if name == "" || strings.ContainsAny(name, "/\x00") { |
| + return fmt.Errorf("hostfiles: platform item %q is not a plain name", name) |
| + } |
| + e, err := p.put(ctx, body, 0o600) |
| + if err != nil { |
| + return err |
| + } |
| + p.man.Platform[name] = e |
| + return nil |
| +} |
| + |
| +// Commit writes the one prepared manifest, create-only, and returns it |
| +// with its exact bytes (the caller journals their digest). A manifest |
| +// already at the key is read back: the same bytes are a replay and |
| +// success; any other manifest is ErrManifestExists and nothing is written. |
| +// After Commit the builder is spent. |
| +func (p *Prepared) Commit(ctx context.Context) (Manifest, []byte, error) { |
| + if p.spent { |
| + return Manifest{}, nil, errors.New("hostfiles: prepared manifest already committed") |
| + } |
| + p.man.Seq = 0 |
| + body, err := json.Marshal(p.man) |
| + if err != nil { |
| + return Manifest{}, nil, fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| + } |
| + if _, err := p.s.Put(ctx, ManifestKey(p.prefix), body, ""); errors.Is(err, release.ErrCASConflict) { |
| + cur, _, gerr := p.s.Get(ctx, ManifestKey(p.prefix)) |
| + if gerr != nil { |
| + return Manifest{}, nil, gerr |
| + } |
| + if !bytes.Equal(cur, body) { |
| + return Manifest{}, nil, fmt.Errorf("%w: %s", ErrManifestExists, p.prefix) |
| + } |
| + } else if err != nil { |
| + return Manifest{}, nil, err |
| + } |
| + p.spent = true |
| + return p.man, body, nil |
| +} |
| + |
| +// NewFromCopy is a publisher for a generation that has no manifest yet, |
| +// 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 |
| +// 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 |
| +// establishment stages the env file and drops the adoption item before it |
| +// commits (claim §6.2a P6-D3; instance key O1'). |
| +// |
| +// origins are the prefixes base's entries may reference (nil or empty: |
| +// prefix alone). base is validated before anything is written. |
| +func NewFromCopy(s release.Store, prefix string, base Manifest, origins []string) (*Publisher, error) { |
| + if s == nil { |
| + return nil, errors.New("hostfiles: copy: no store") |
| + } |
| + if err := checkPrefix(prefix); err != nil { |
| + return nil, err |
| + } |
| + origins, err := admitted(prefix, origins) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if base.V != ManifestVersion { |
| + return nil, fmt.Errorf("hostfiles: manifest is version %d; this binary reads %d", base.V, ManifestVersion) |
| + } |
| + 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{}} |
| + for k, e := range base.Entries { |
| + man.Entries[k] = e |
| + } |
| + for k, e := range base.Platform { |
| + man.Platform[k] = e |
| + } |
| + return &Publisher{s: s, prefix: prefix, man: man, next: base.Seq, dirty: true}, nil |
| +} |
diff --git a/internal/hostfiles/prepared_test.go b/internal/hostfiles/prepared_test.go| new file mode 100644 |
| index 000000000..9249b7b36 |
| --- /dev/null |
| +++ b/internal/hostfiles/prepared_test.go |
| @@ -0,0 +1,247 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Region HA stage B's rule (73bab739, relocationPlatformItem), carried |
| +// here so both sides share it: a live platform item is the host's; 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 TestPlatformItemNeedsEvidenceForNone(t *testing.T) { |
| + published := PinnedFiles{SHA256: "ab", Retained: "retained/ab"} |
| + never := PinnedFiles{} |
| + live := Manifest{Platform: map[string]Entry{EnvItem: {Object: "o", SHA256: "cd", Size: 1, Seq: 1}}} |
| + missing := Manifest{Platform: map[string]Entry{}} |
| + tomb := Manifest{Platform: map[string]Entry{EnvItem: {Deleted: true, Seq: 2}}} |
| + for _, tc := range []struct { |
| + name string |
| + pin PinnedFiles |
| + m 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, ErrNoEvidence}, |
| + {"missing under a host that never published", never, missing, false, nil}, |
| + {"a tombstone under published files", published, tomb, false, ErrTombstone}, |
| + {"a tombstone under a host that never published", never, tomb, false, ErrTombstone}, |
| + } { |
| + got, err := PlatformItem(tc.pin, tc.m, 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) |
| + } |
| + } |
| + // Half-made evidence is not a host that never published files: the |
| + // pinner writes both or neither, and ReadPin refuses the rest (H2). |
| + for _, half := range []PinnedFiles{{SHA256: "ab"}, {Retained: "retained/ab"}} { |
| + if half.NeverPublished() { |
| + t.Errorf("%+v reads as never published", half) |
| + } |
| + } |
| +} |
| + |
| +func prepared(t *testing.T, s release.Store, prefix string) *Prepared { |
| + t.Helper() |
| + p, err := NewPrepared(s, prefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +// The prepared manifest (claim rev 6.30 R18-1, moves §7.6): one create-only |
| +// manifest at sequence 0 over create-only content objects, every object |
| +// keyed by its digest at sequence 0 under the prepared prefix. A second, |
| +// different build of the same prefix is refused at its commit (its objects |
| +// are referenced by no manifest, spec §7.6); the manifest the first build |
| +// wrote is still there, byte for byte. |
| +func TestPreparedIsCreateOnlyAtSequenceZero(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) |
| + } |
| + if err := p.StagePlatform(ctx, EnvItem, []byte("K=1\n")); err != nil { |
| + t.Fatal(err) |
| + } |
| + m, body, err := p.Commit(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Seq != 0 || m.V != ManifestVersion { |
| + t.Fatalf("manifest seq %d v %d", m.Seq, m.V) |
| + } |
| + for name, e := range map[string]Entry{"a.txt": m.Entries["a.txt"], EnvItem: m.Platform[EnvItem]} { |
| + if e.Seq != 0 || !strings.HasPrefix(e.Object, pfx+"objects/0000000000000000-") || e.SHA256 != sum([]byte(map[string]string{"a.txt": "A", EnvItem: "K=1\n"}[name])) { |
| + t.Fatalf("%s: %+v", name, e) |
| + } |
| + if got, _, err := s.Get(ctx, e.Object); err != nil || sum(got) != e.SHA256 { |
| + t.Fatalf("%s object: %v %q", name, err, got) |
| + } |
| + } |
| + if err := ValidateManifest(m, []string{pfx}); err != nil { |
| + t.Fatal(err) |
| + } |
| + stored, _, err := s.Get(ctx, ManifestKey(pfx)) |
| + if err != nil || string(stored) != string(body) { |
| + t.Fatalf("stored manifest: %v %s", err, stored) |
| + } |
| + // A different build of the same prefix is refused and writes nothing. |
| + q := prepared(t, s, pfx) |
| + if err := q.Stage(ctx, "a.txt", []byte("B"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := q.Commit(ctx); !errors.Is(err, ErrManifestExists) { |
| + t.Fatalf("second build: %v", err) |
| + } |
| + if again, _, err := s.Get(ctx, ManifestKey(pfx)); err != nil || string(again) != string(body) { |
| + t.Fatalf("the manifest was replaced: %s", again) |
| + } |
| + // Once committed, the builder is spent. |
| + if err := p.Stage(ctx, "b.txt", []byte("B"), 0o644); err == nil { |
| + t.Fatal("staged after commit") |
| + } |
| +} |
| + |
| +// A replay of the same build finds every object by its digest and the |
| +// manifest by its key (create-only, so the late second write fails and is |
| +// read back): success, the same bytes, and no new object. |
| +func TestPreparedReplayReusesObjects(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + build := func() ([]byte, error) { |
| + p := prepared(t, s, pfx) |
| + if err := p.Stage(ctx, "a.txt", []byte("A"), 0o644); err != nil { |
| + return nil, err |
| + } |
| + if err := p.Stage(ctx, "big", make([]byte, MaxFileBytes+1), 0o644); err != nil { |
| + return nil, err |
| + } |
| + if err := p.StagePlatform(ctx, WakeItem, []byte(`{"v":1}`)); err != nil { |
| + return nil, err |
| + } |
| + _, body, err := p.Commit(ctx) |
| + return body, err |
| + } |
| + first, err := build() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, err := s.List(ctx, pfx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := build() |
| + if err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if string(first) != string(second) { |
| + t.Fatalf("replay bytes differ:\n%s\n%s", first, second) |
| + } |
| + again, err := s.List(ctx, pfx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(again) != len(keys) || len(keys) != 3 { // two objects and the manifest |
| + t.Fatalf("objects after replay: %v, before %v", again, keys) |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(first, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if e := m.Entries["big"]; e.Skipped == "" || e.Object != "" { |
| + t.Fatalf("the oversize file is not recorded as skipped: %+v", e) |
| + } |
| +} |
| + |
| +// 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 |
| +// first Commit is create-only. A repeat with the same result reads its own |
| +// bytes back; another manifest at the target refuses. |
| +func TestNewFromCopyContinuesTheSequenceCreateOnly(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + src := open(t, s) |
| + stage(t, src, "a.txt", "A") |
| + if err := src.StagePlatform(ctx, EnvItem, []byte("E")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := src.StagePlatform(ctx, "instance-key-adoption.json", []byte("{}")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if seq := commit(t, src); seq != 3 { |
| + t.Fatalf("source seq %d", seq) |
| + } |
| + base, found, err := Load(ctx, s, pfx) |
| + if err != nil || !found { |
| + t.Fatal(err, found) |
| + } |
| + to := "instances/shop.test/abc/files/g2/" |
| + establish := func() (int64, error) { |
| + p, err := NewFromCopy(s, to, base, []string{pfx}) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if err := p.StagePlatform(ctx, EnvItem, []byte("E2")); err != nil { |
| + return 0, err |
| + } |
| + if err := p.StagePlatformDelete("instance-key-adoption.json"); err != nil { |
| + return 0, err |
| + } |
| + return p.Commit(ctx) |
| + } |
| + seq, err := establish() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if seq != 5 { |
| + t.Fatalf("seq %d, want 5 (3 copied, the env, the delete)", seq) |
| + } |
| + m, found, err := Load(ctx, s, to) |
| + if err != nil || !found { |
| + t.Fatal(err, found) |
| + } |
| + if m.Entries["a.txt"].Object != base.Entries["a.txt"].Object { |
| + t.Fatalf("the copy renamed an object: %+v", m.Entries["a.txt"]) |
| + } |
| + if e := m.Platform[EnvItem]; e.SHA256 != sum([]byte("E2")) || !strings.HasPrefix(e.Object, to+"objects/") { |
| + t.Fatalf("env: %+v", e) |
| + } |
| + if _, ok := m.Platform["instance-key-adoption.json"]; ok { |
| + t.Fatal("the adoption item was carried") |
| + } |
| + if again, err := establish(); err != nil || again != 5 { |
| + t.Fatalf("repeat: %d %v", again, err) |
| + } |
| + // Another manifest at the target: the copy refuses, nothing replaced. |
| + other := "instances/shop.test/abc/files/g3/" |
| + if _, err := s.Put(ctx, ManifestKey(other), []byte(`{"v":2,"seq":9,"entries":{},"platform":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := NewFromCopy(s, other, base, []string{pfx}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := p.Commit(ctx); !errors.Is(err, ErrSuperseded) { |
| + t.Fatalf("over another manifest: %v", err) |
| + } |
| + // An empty base (the claim's own empty bootstrap, or a never-published |
| + // host) is allowed; a base that fails validation is not. |
| + if _, err := NewFromCopy(s, "instances/shop.test/abc/files/g4/", Manifest{V: ManifestVersion}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + bad := base |
| + bad.Entries = map[string]Entry{"x": {Object: "elsewhere/objects/0000000000000001-" + sum(nil), SHA256: sum(nil), Seq: 1}} |
| + if _, err := NewFromCopy(s, "instances/shop.test/abc/files/g4/", bad, []string{pfx}); !errors.Is(err, ErrOrigin) { |
| + t.Fatalf("foreign origin: %v", err) |
| + } |
| +} |