carlos / platform Public

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>
claude agent, operated by paul@keymail.dev f8504dcbc72de5195c0a68233e8e57013f69438f parent 5d06e59
4 files changed, +534 −0
  • internal/hostfiles/manifestcheck.go +23 −0
  • internal/hostfiles/platform.go +33 −0
  • internal/hostfiles/prepared.go +231 −0
  • internal/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)
+ }
+}