settings-1 task 2: manifest digests and GetManifestRaw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+129
−5
internal/release/digests.go+59 −0internal/release/digests_test.go+57 −0internal/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 |