carlos / platform Public

fix(release): a trashed app cannot be flagged production

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 55cae7b6ec54824bf6cbc5b65c2bc0cd3c0061f2 parent 3b928e1
2 files changed, +78 −0
  • internal/release/production.go +14 −0
  • internal/release/production_test.go +64 −0
diff --git a/internal/release/production.go b/internal/release/production.go
index 29383b989..b8a58cca8 100644
--- a/internal/release/production.go
+++ b/internal/release/production.go
@@ -68,6 +68,20 @@ func SetProduction(ctx context.Context, s Store, priv ed25519.PrivateKey, acct,
if !ValidApp(app) {
return fmt.Errorf("set production: invalid app %q", app)
}
+ // A trashed app cannot be declared production, in EITHER direction: §3
+ // already refuses ship/promote to one, and a production flag appearing
+ // on an app in the trash would contradict the flag's own promise
+ // (production = never deletable). There is nothing legitimate to do to
+ // a trashed app's flag; restore first. Unreadable refuses too — the
+ // usual direction: a caller about to declare production must never act
+ // as though a marker it cannot verify simply isn't there.
+ tomb, err := ReadAppTombstone(ctx, s, acct, app)
+ if err != nil {
+ return fmt.Errorf("set production: cannot check the trash marker for %s/%s: %w", acct, app, err)
+ }
+ if tomb != nil {
+ return fmt.Errorf("set production: %s/%s is in the trash — restore it before flagging it production", acct, app)
+ }
key := ProductionKey(acct, app)
raw, etag, err := s.Get(ctx, key)
if errors.Is(err, os.ErrNotExist) {
diff --git a/internal/release/production_test.go b/internal/release/production_test.go
index 2ae0866fc..e1a0959f6 100644
--- a/internal/release/production_test.go
+++ b/internal/release/production_test.go
@@ -13,6 +13,8 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
+ "errors"
+ "os"
"strings"
"testing"
"time"
@@ -202,6 +204,68 @@ func TestProductionFlagRefusesTamperedPayload(t *testing.T) {
}
}
+// A trashed app cannot be flagged production in EITHER direction: restore
+// first. This holds even though the flag would refuse to WRITE anything
+// destructive itself — the point is that a production flag appearing on a
+// trashed app contradicts the flag's own promise (production = never
+// deletable), so the door closes before the CAS write, not after.
+func TestSetProductionRefusesTrashedApp(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetProduction(ctx, s, priv, "bab", "derp", true, "paul@keymail.dev"); err == nil {
+ t.Fatal("SetProduction(true) on a trashed app succeeded, want a refusal")
+ }
+ // The OFF direction is refused too — there is nothing legitimate to do to
+ // a trashed app's flag; restore first.
+ if err := SetProduction(ctx, s, priv, "bab", "derp", false, "paul@keymail.dev"); err == nil {
+ t.Fatal("SetProduction(false) on a trashed app succeeded, want a refusal")
+ }
+ // No flag object should have been written.
+ if _, _, err := s.Get(ctx, ProductionKey("bab", "derp")); err == nil {
+ t.Fatal("a refused SetProduction on a trashed app wrote the flag object anyway")
+ }
+}
+
+// An unreadable trash marker refuses too — the usual direction: a caller
+// about to declare production must never act as though a marker it cannot
+// verify simply isn't there. Uses a store that fails ONLY the tombstone
+// key (everything else, including the eventual production.json write,
+// would otherwise succeed) so this test actually pins the new trash-marker
+// check rather than any generic "the store is broken" refusal.
+func TestSetProductionRefusesUnreadableTrashMarker(t *testing.T) {
+ priv, _ := testKeys(t)
+ real := NewDirStore(t.TempDir())
+ s := &tombstoneReadFailStore{Store: real, key: AppTombstoneKey("bab", "derp"), err: errors.New("bucket on fire")}
+ if err := SetProduction(context.Background(), s, priv, "bab", "derp", true, "paul@keymail.dev"); err == nil {
+ t.Fatal("SetProduction with an unreadable trash marker succeeded, want a refusal")
+ }
+ // Confirm nothing else about the store is broken — the refusal really is
+ // the trash-marker check, not a store that happens to fail everything.
+ if _, _, err := real.Get(context.Background(), ProductionKey("bab", "derp")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("production flag written despite an unreadable trash marker: err=%v", err)
+ }
+}
+
+// tombstoneReadFailStore fails Get for exactly one key and delegates
+// everything else to the wrapped Store.
+type tombstoneReadFailStore struct {
+ Store
+ key string
+ err error
+}
+
+func (t *tombstoneReadFailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == t.key {
+ return nil, "", t.err
+ }
+ return t.Store.Get(ctx, key)
+}
+
// timeNowTestHelper is a stamped, truncated now — what signProductionFlag
// itself writes — for tests that hand-build payloads.
func timeNowTestHelper() time.Time { return time.Now().UTC().Truncate(time.Second) }