carlos / platform Public

settings-1 task 2: manifest digests and GetManifestRaw

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b83486b7ddea0e9e121d4f3b1b6e108ee948cd59 parent 4f26f15
3 files changed, +129 −5
  • internal/release/digests.go +59 −0
  • internal/release/digests_test.go +57 −0
  • internal/release/release.go +13 −5
diff --git a/internal/release/digests.go b/internal/release/digests.go
new file mode 100644
index 000000000..81944efc9
--- /dev/null
+++ b/internal/release/digests.go
@@ -0,0 +1,59 @@
+package release
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+)
+
+// ManifestDigest is the sha256 of a manifest's canonical bytes: exactly what
+// the store holds at ManifestKey, never a re-marshal (#253, spec §4.6). The
+// manifest carries every artifact's sha256, so this one digest binds the
+// bytes a box will stage.
+func ManifestDigest(raw []byte) string { return etagOf(raw) }
+
+// ErrManifestDigest is a manifest whose bytes do not hash to the digest a
+// signed pointer (or, from plan 2, a promotion record) recorded for it: a
+// version name reused with other bytes, or a swapped object.
+var ErrManifestDigest = errors.New("release: manifest does not hash to the digest its signer recorded")
+
+// CheckManifestDigest reads version's canonical manifest and, when want is
+// set, refuses it unless its bytes hash to want. The manifest and bytes it
+// returns are the ones it checked: a caller must stage from these and never
+// re-read the manifest, or a swap between the check and the read would slip
+// through. want "" is today's read, unchecked.
+func CheckManifestDigest(ctx context.Context, s Store, acct, app, version, want string) (*Manifest, []byte, error) {
+ m, raw, err := GetManifestRaw(ctx, s, acct, app, version)
+ if err != nil {
+ return nil, nil, err
+ }
+ if want != "" {
+ if got := ManifestDigest(raw); got != want {
+ return nil, nil, fmt.Errorf("%w: %s/%s hashes to %s, the pointer signed %s",
+ ErrManifestDigest, app, version, digestShort(got), digestShort(want))
+ }
+ }
+ return m, raw, nil
+}
+
+// validDigest is the one shape a digest may take: 64 lowercase hex.
+func validDigest(d string) bool {
+ if len(d) != 2*sha256.Size {
+ return false
+ }
+ for _, c := range d {
+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+// digestShort is a digest as a person reads it in a log line.
+func digestShort(d string) string {
+ if len(d) > 12 {
+ return d[:12]
+ }
+ return d
+}
diff --git a/internal/release/digests_test.go b/internal/release/digests_test.go
new file mode 100644
index 000000000..959dc2241
--- /dev/null
+++ b/internal/release/digests_test.go
@@ -0,0 +1,57 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestManifestDigestIsTheStoredBytes(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("bin one"))
+ raw, _, err := s.Get(ctx, ManifestKey("bab", "km", "v1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ m, got, err := GetManifestRaw(ctx, s, "bab", "km", "v1")
+ if err != nil || m.Version != "v1" || !bytes.Equal(got, raw) {
+ t.Fatalf("GetManifestRaw = %+v, %q, %v; want the stored bytes", m, got, err)
+ }
+ if d := ManifestDigest(raw); d != etagOf(raw) || !validDigest(d) {
+ t.Fatalf("ManifestDigest = %q, want the hex sha256 of the stored bytes", d)
+ }
+}
+
+func TestCheckManifestDigest(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("bin one"))
+ raw, _, _ := s.Get(ctx, ManifestKey("bab", "km", "v1"))
+ want := ManifestDigest(raw)
+
+ if m, _, err := CheckManifestDigest(ctx, s, "bab", "km", "v1", want); err != nil || m == nil {
+ t.Fatalf("matching digest: %v", err)
+ }
+ if _, _, err := CheckManifestDigest(ctx, s, "bab", "km", "v1", ""); err != nil {
+ t.Fatalf("no digest asked for: %v", err)
+ }
+ other := ManifestDigest([]byte("some other manifest"))
+ if _, _, err := CheckManifestDigest(ctx, s, "bab", "km", "v1", other); !errors.Is(err, ErrManifestDigest) {
+ t.Fatalf("mismatched digest: %v, want ErrManifestDigest", err)
+ }
+}
+
+func TestValidDigest(t *testing.T) {
+ for d, ok := range map[string]bool{
+ ManifestDigest([]byte("x")): true,
+ "": false,
+ "ABCDEF" + ManifestDigest([]byte("x"))[6:]: false, // upper case is not canonical
+ ManifestDigest([]byte("x"))[:63]: false,
+ } {
+ if validDigest(d) != ok {
+ t.Errorf("validDigest(%q) = %v, want %v", d, !ok, ok)
+ }
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index 4f73d15e6..cf90d48ee 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -665,21 +665,29 @@ func putImmutable(ctx context.Context, s Store, key string, body []byte) error {
// that one. Not-found is os.ErrNotExist-wrapped, and names `accounts migrate
// -from legacy` for a deployment whose objects never moved.
func GetManifest(ctx context.Context, s Store, acct, app, version string) (*Manifest, error) {
+ m, _, err := GetManifestRaw(ctx, s, acct, app, version)
+ return m, err
+}
+
+// GetManifestRaw is GetManifest that also returns the exact bytes it read, so
+// a box can keep them (the staged sibling) rather than a re-marshal that
+// drops every field this binary does not know (spec §4.2).
+func GetManifestRaw(ctx context.Context, s Store, acct, app, version string) (*Manifest, []byte, error) {
raw, _, err := GetQualified(ctx, s, ManifestKey(acct, app, version))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return nil, fmt.Errorf("manifest %s/%s: %v", app, version, err)
+ return nil, nil, fmt.Errorf("manifest %s/%s: %v", app, version, err)
}
if err := validateManifest(&m); err != nil {
- return nil, fmt.Errorf("manifest %s/%s: %w", app, version, err)
+ return nil, nil, fmt.Errorf("manifest %s/%s: %w", app, version, err)
}
if m.App != app || m.Version != version {
- return nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(acct, app, version), m.App, m.Version)
+ return nil, nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(acct, app, version), m.App, m.Version)
}
- return &m, nil
+ return &m, raw, nil
}
// Promote points a channel at a shipped version with the deployment's root