diff --git a/cmd/commands/config.go b/cmd/commands/config.go index d414a594a..35a903933 100644 --- a/cmd/commands/config.go +++ b/cmd/commands/config.go @@ -40,7 +40,9 @@ Secret keys are any key whose name contains: SECRET, PASSWORD, KEY, TOKEN.`, var configGetCmd = &cobra.Command{ Use: "get ", Short: "Get a single configuration value", - Long: `Return the raw value of KEY from .env (or .env.{env} with --env). + Long: `Return the value of KEY from the same .env cascade used by build. +Use --env to select the cascade environment; otherwise ENV selects it. +Later layers override earlier ones, with .env.local winning by default. Output is the plain value only, making it safe for scripting. Exits non-zero if the key is not found.`, diff --git a/cmd/commands/config_get_cascade_test.go b/cmd/commands/config_get_cascade_test.go new file mode 100644 index 000000000..95984c7f2 --- /dev/null +++ b/cmd/commands/config_get_cascade_test.go @@ -0,0 +1,43 @@ +package commands + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// Purpose: config get must resolve the same cascade as a local build (D-0919). +// Inputs: fake dotenv layers and environment selectors. Outputs: winning value. +// Constraints: no config.Load, process exports, network, or real secrets. +func TestConfigGetCascade(t *testing.T) { + for _, tc := range []struct { + name, base, processEnv, flagEnv, legacy, want string + layers map[string]string + }{ + {"local override", "HASURA_JWT_KEY=fake-base\n", "", "", "", "fake-local", map[string]string{".env.local": "HASURA_JWT_KEY=fake-local\n"}}, + {"base environment selector", "ENV=prod\nHASURA_JWT_KEY=fake-base\n", "", "", "", "fake-prod", map[string]string{".env.prod": "HASURA_JWT_KEY=fake-prod\n"}}, + {"process environment selector", "ENV=prod\nHASURA_JWT_KEY=fake-base\n", "staging", "", "", "fake-staging", map[string]string{".env.staging": "HASURA_JWT_KEY=fake-staging\n"}}, + {"explicit environment selector", "HASURA_JWT_KEY=fake-base\n", "prod", "qa", "", "fake-qa", map[string]string{".env.qa": "HASURA_JWT_KEY=fake-qa\n"}}, + {"secrets override environment", "HASURA_JWT_KEY=fake-base\n", "", "", "", "fake-secret", map[string]string{".env.dev": "HASURA_JWT_KEY=fake-dev\n", ".env.secrets": "HASURA_JWT_KEY=fake-secret\n"}}, + {"legacy base wins", "HASURA_JWT_KEY=fake-base\n", "", "", "1", "fake-base", map[string]string{".env.local": "HASURA_JWT_KEY=fake-local\n"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("ENV", tc.processEnv) + t.Setenv("NSELF_LEGACY_ENV_ORDER", tc.legacy) + dir := makeNSelfProject(t, tc.base) + for name, body := range tc.layers { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + root := newConfigCmd() + root.SetErr(new(bytes.Buffer)) + root.SetArgs([]string{"config", "get", "HASURA_JWT_KEY", "--reveal", "--env", tc.flagEnv}) + out, err := captureStdout(t, root.Execute) + if err != nil || out != tc.want+"\n" { + t.Fatalf("output=%q err=%v, want %q", out, err, tc.want+"\n") + } + }) + } +} diff --git a/cmd/commands/config_show_get.go b/cmd/commands/config_show_get.go index 3a8923ea1..8ecb2064d 100644 --- a/cmd/commands/config_show_get.go +++ b/cmd/commands/config_show_get.go @@ -8,7 +8,6 @@ package commands import ( "fmt" "os" - "path/filepath" "sort" "strings" @@ -93,16 +92,14 @@ func runConfigGet(cmd *cobra.Command, args []string) error { return err } - envFile := envFileName(projectDir, envFlag) - pairs, err := config.ReadEnvFile(envFile) + if envFlag == "" { + envFlag, _ = config.ResolveEnv(projectDir) + } + explanation, err := config.ExplainKey(projectDir, envFlag, key) if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("env file not found: %s", envFile) - } - return config.EnvReadError(envFile, err) + return err } - - val, ok := pairs[key] + envFile, val, ok := explanation.Winner() if !ok { return fmt.Errorf("key not found: %s", key) } @@ -111,7 +108,7 @@ func runConfigGet(cmd *cobra.Command, args []string) error { // compat.V15(P7-REG-09): --json ignored -> envelope if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut && compat.V15() { return output.EmitData(pilotWriter(), "config get", configGetData{ - Key: key, Value: shown, Masked: shown != val, File: filepath.Base(envFile), + Key: key, Value: shown, Masked: shown != val, File: envFile, }) } diff --git a/cmd/commands/license_grace.go b/cmd/commands/license_grace.go index a9d166bf7..8ca9a1df5 100644 --- a/cmd/commands/license_grace.go +++ b/cmd/commands/license_grace.go @@ -147,7 +147,7 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o masked := license.MaskKey(key) if err != nil { doc.Keys = append(doc.Keys, licenseRefreshKey{Key: masked, Error: "refresh failed: " + licenseScrub(err.Error())}) - fmt.Fprintf(os.Stderr, "%s: refresh failed: %v\n", masked, err) + fmt.Fprintf(os.Stderr, "%s: refresh failed: %v\n", masked, licenseScrub(err.Error(), key)) continue } if !result.Valid { @@ -155,7 +155,7 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o fmt.Fprintf(os.Stderr, "%s: warning: could not record the licence rejection: %v\n", masked, err) } doc.Keys = append(doc.Keys, licenseRefreshKey{Key: masked, Error: "invalid: " + licenseScrub(result.Message)}) - fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, result.Message) + fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, licenseScrub(result.Message, key)) continue } if err := entitlement.RecordAccepted(key); err != nil { // a stale rejection only fails closed @@ -167,7 +167,7 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o } doc.Keys = append(doc.Keys, row) fmt.Printf("%s: refreshed (tier: %s, expires: %s)\n", - masked, result.Tier, result.ExpiresAt.Format("2006-01-02")) + masked, row.Tier, result.ExpiresAt.Format("2006-01-02")) refreshed = true } setData(cmd, doc) diff --git a/cmd/commands/license_human_scrub_test.go b/cmd/commands/license_human_scrub_test.go new file mode 100644 index 000000000..5277bd19e --- /dev/null +++ b/cmd/commands/license_human_scrub_test.go @@ -0,0 +1,90 @@ +package commands + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestLicenseHumanDiagnosticsScrubbed(t *testing.T) { + for _, tc := range []struct { + name string + cmd *cobra.Command + body map[string]any + link bool + }{ + {"validate reason", licenseValidateCmd, map[string]any{"valid": false}, false}, + {"refresh reason", licenseRefreshCmd, map[string]any{"valid": false}, false}, + {"revalidate reason", licenseRevalidateCmd, map[string]any{"valid": false}, false}, + {"migration error", licenseMigrateCmd, map[string]any{"error": "hostile"}, false}, + {"migration status", licenseMigrateCmd, map[string]any{"migration_status": "hostile"}, false}, + {"migration link error", licenseMigrateCmd, map[string]any{"error": "hostile"}, true}, + {"migration linked", licenseMigrateCmd, map[string]any{"already_migrated": true, "account_id": "hostile", "tier": "hostile"}, true}, + {"migration completed", licenseMigrateCmd, map[string]any{"migrated": true, "account_id": "hostile", "tier": "hostile"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + fx := newLicenseFixture(t) + t.Setenv("NSELF_LICENSE_KEY_1", fixtureLicenseKey) + hostile := fixtureLicenseKey + " " + fx.jwt + " " + fixtureAccessToken + for k, value := range tc.body { + if value == "hostile" { + tc.body[k] = hostile + } + } + tc.body["reason"] = hostile + fx.reply(t, tc.body) + flags := map[string]string{} + if tc.link { + flags["account-id"] = "00000000-0000-0000-0000-000000000001" + } + var diagnostics bytes.Buffer + tc.cmd.SetErr(&diagnostics) + t.Cleanup(func() { tc.cmd.SetErr(nil) }) + var out string + var runErr error + stderr := captureStderr(t, func() { + out, runErr = runLicenseJSON(t, tc.cmd, flags) + }) + all := out + stderr + diagnostics.String() + if runErr != nil { + all += runErr.Error() + } + for _, secret := range []string{fixtureLicenseKey, fx.jwt, fixtureAccessToken} { + if strings.Contains(all, secret) { + t.Error("human diagnostic or JSON contains an unredacted synthetic secret") + } + } + if !strings.Contains(all, "[REDACTED]") { + t.Error("expected hostile reply to reach the scrubbed diagnostic or document") + } + }) + } +} + +func TestMigrationStatusUsesHeader(t *testing.T) { + for _, key := range []string{fixtureLicenseKey, "synthetic-key-with+reserved&characters"} { + t.Run(key, func(t *testing.T) { + requests := make(chan *http.Request, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r + _, _ = w.Write([]byte(`{"migration_status":"legacy"}`)) + })) + defer srv.Close() + if _, err := fetchMigrationStatus(context.Background(), key, srv.URL); err != nil { + t.Fatal(err) + } + r := <-requests + if r.Method != http.MethodGet || r.URL.Path != "/license/migration-status" || r.URL.RawQuery != "" { + t.Error("migration status must use GET without a query string") + } + if r.Header.Get("X-License-Key") != key { + t.Error("migration status must send the key in X-License-Key") + } + }) + } +} diff --git a/cmd/commands/license_migrate_actions.go b/cmd/commands/license_migrate_actions.go index 3303ade63..c605e863f 100644 --- a/cmd/commands/license_migrate_actions.go +++ b/cmd/commands/license_migrate_actions.go @@ -71,13 +71,13 @@ func runMigrateCheck(ctx context.Context, pingURL string, doc *licenseMigrateDat status, err := fetchMigrationStatus(ctx, key, pingURL) if err != nil { doc.Keys = append(doc.Keys, licenseMigrateKey{Key: masked, Product: pp.DisplayName, Remaining: "?", Status: licenseScrub(fmt.Sprintf("Error: %v", err), migrateKeyFlag)}) - tbl.AddRow(masked, pp.DisplayName, "?", fmt.Sprintf("Error: %v", err)) + tbl.AddRow(masked, pp.DisplayName, "?", licenseScrub(fmt.Sprintf("Error: %v", err), key)) continue } if status.Error != "" { doc.Keys = append(doc.Keys, licenseMigrateKey{Key: masked, Product: pp.DisplayName, Remaining: "?", Status: licenseScrub(fmt.Sprintf("Error: %s", status.Error), migrateKeyFlag)}) - tbl.AddRow(masked, pp.DisplayName, "?", fmt.Sprintf("Error: %s", status.Error)) + tbl.AddRow(masked, pp.DisplayName, "?", licenseScrub(fmt.Sprintf("Error: %s", status.Error), key)) continue } @@ -92,7 +92,7 @@ func runMigrateCheck(ctx context.Context, pingURL string, doc *licenseMigrateDat } doc.Keys = append(doc.Keys, licenseMigrateKey{Key: masked, Product: pp.DisplayName, Remaining: daysStr, Status: licenseScrub(migStatus, migrateKeyFlag)}) - tbl.AddRow(masked, pp.DisplayName, daysStr, migStatus) + tbl.AddRow(masked, pp.DisplayName, daysStr, licenseScrub(migStatus, key)) } tbl.Render() @@ -134,29 +134,29 @@ func runMigrateLink(ctx context.Context, pingURL string, doc *licenseMigrateData } masked := license.MaskKey(keyToMigrate) - fmt.Printf("Linking key %s to account %s ...\n", masked, migrateAccountID) + fmt.Printf("Linking key %s to account %s ...\n", masked, licenseScrub(migrateAccountID, keyToMigrate)) resp, err := sendMigrateRequest(ctx, keyToMigrate, migrateAccountID, pingURL) if err != nil { - return fmt.Errorf("migration request failed: %w", err) + return scrubErr(fmt.Errorf("migration request failed: %w", err)) } if resp.Error != "" { - return fmt.Errorf("server error: %s", resp.Error) + return fmt.Errorf("server error: %s", licenseScrub(resp.Error, keyToMigrate)) } doc.Link = &licenseMigrateLink{Key: masked, AccountID: licenseScrub(resp.AccountID, keyToMigrate), Tier: licenseScrub(resp.Tier, keyToMigrate), AlreadyLinked: resp.AlreadyMigrated} if resp.AlreadyMigrated { fmt.Printf("Key %s is already linked to account %s (tier: %s).\n", - masked, resp.AccountID, resp.Tier) + masked, doc.Link.AccountID, doc.Link.Tier) return nil } if resp.Migrated { fmt.Printf("Migration complete.\n") fmt.Printf(" Key: %s\n", masked) - fmt.Printf(" Account: %s\n", resp.AccountID) - fmt.Printf(" Tier: %s\n", resp.Tier) + fmt.Printf(" Account: %s\n", doc.Link.AccountID) + fmt.Printf(" Tier: %s\n", doc.Link.Tier) fmt.Println() fmt.Println("Your key is now linked to your nSelf account.") fmt.Println("Run 'nself license status' to verify.") diff --git a/cmd/commands/license_migrate_api.go b/cmd/commands/license_migrate_api.go index e5b7776f4..f999f381b 100644 --- a/cmd/commands/license_migrate_api.go +++ b/cmd/commands/license_migrate_api.go @@ -66,16 +66,17 @@ func sendMigrateRequest(ctx context.Context, licenseKey, accountID, pingURL stri return &result, nil } -// fetchMigrationStatus — GET /license/migration-status?key=. +// fetchMigrationStatus uses the server-supported X-License-Key header to keep keys out of URLs. func fetchMigrationStatus(ctx context.Context, key string, pingURL string) (*migrationStatusResponse, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - url := fmt.Sprintf("%s/license/migration-status?key=%s", pingURL, key) + url := pingURL + "/license/migration-status" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } + req.Header.Set("X-License-Key", key) resp, err := httptimeout.License.Do(req) if err != nil { diff --git a/cmd/commands/license_scrub.go b/cmd/commands/license_scrub.go index 576a91a0f..5aedaf879 100644 --- a/cmd/commands/license_scrub.go +++ b/cmd/commands/license_scrub.go @@ -7,8 +7,8 @@ package commands // Inputs: a document value (any struct, slice, map or pointer tree) or an // error. Outputs: a copy of the same type with every string leaf scrubbed, or // an error with scrubbed text and the same classification (code, exit status). -// Constraints: human and v1.4 output are never touched (callers gate on -// jsonEnvelopeOn); a masked key, a UUID and a date pass unchanged; nil slices +// Constraints: document callers gate on jsonEnvelopeOn; human licence +// diagnostics also scrub server text. A masked key, UUID and date pass unchanged; nil slices // stay nil and empty ones stay empty. SPORT: P7-SURF-16, // contract:cli.json-envelope diff --git a/cmd/commands/license_validate.go b/cmd/commands/license_validate.go index 0f2655c69..e1b09111b 100644 --- a/cmd/commands/license_validate.go +++ b/cmd/commands/license_validate.go @@ -49,7 +49,7 @@ var licenseValidateCmd = &cobra.Command{ masked := license.MaskKey(key) if err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: validation failed: %v\n", masked, err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: validation failed: %v\n", masked, licenseScrub(err.Error(), key)) allValid = false } else if valid { validKeys = append(validKeys, masked) @@ -244,10 +244,10 @@ Requires network connectivity.`, result, err := license.RefreshCache(ctx, keys[0]) if err != nil { - return fmt.Errorf("revalidation failed: %w", err) + return scrubErr(fmt.Errorf("revalidation failed: %w", err)) } if !result.Valid { - return fmt.Errorf("license is invalid: %s", result.Message) + return fmt.Errorf("license is invalid: %s", licenseScrub(result.Message)) } rev := licenseRevalidateData{Tier: licenseScrub(result.Tier)} @@ -255,7 +255,7 @@ Requires network connectivity.`, rev.ExpiresAt = result.ExpiresAt.Format("2006-01-02") } setData(cmd, rev) - fmt.Printf("License revalidated. Tier: %s\n", result.Tier) + fmt.Printf("License revalidated. Tier: %s\n", rev.Tier) if !result.ExpiresAt.IsZero() { fmt.Printf("Expires: %s\n", result.ExpiresAt.Format("2006-01-02")) } diff --git a/internal/deploy/remote_backup_confd_test.go b/internal/deploy/remote_backup_confd_test.go index c6f9dcc42..806d1a157 100644 --- a/internal/deploy/remote_backup_confd_test.go +++ b/internal/deploy/remote_backup_confd_test.go @@ -126,6 +126,10 @@ func TestKeptHostConfDDefaultServerCollision(t *testing.T) { "sites file": {"nginx/sites/other.conf", "server {\n listen 80 default_server;\n}\n", "", "nginx/sites/other.conf", true}, "conf.d-env file": {"nginx/conf.d-prod/x.conf", "server { listen 443 ssl default_server; }\n", "", "nginx/conf.d-prod/x.conf", true}, "conf.d sibling": {"nginx/conf.d/other.conf", "server {\n listen [::]:80 default_server;\n}\n", "", "nginx/conf.d/other.conf", true}, + "conf.d backup": {"nginx/conf.d/default.conf.bak", "server { listen 80 default_server; }\n", "", "", false}, + "env conf.d backup": {"nginx/conf.d-prod/default.conf.bak", "server { listen 80 default_server; }\n", "", "", false}, + "disabled site": {"nginx/sites/default.conf.disabled", "server { listen 80 default_server; }\n", "", "", false}, + "site text file": {"nginx/sites/notes.txt", "server { listen 80 default_server; }\n", "", "", false}, "commented out": {"nginx/sites/other.conf", "# listen 80 default_server;\nserver { listen 80; }\n", "", "", false}, "no default_server": {"nginx/sites/other.conf", "server { listen 80; }\n", "", "", false}, "backup file": {"nginx/conf.d/default.conf.bak", "server { listen 80 default_server; }\n", "", "", false}, diff --git a/internal/deploy/remote_layout.go b/internal/deploy/remote_layout.go index 74406c10c..2da4d9acd 100644 --- a/internal/deploy/remote_layout.go +++ b/internal/deploy/remote_layout.go @@ -189,8 +189,6 @@ type KeptConfD struct { // deploy must not ship (D-0424): the path exists as a regular file with no line // in its first 5 starting with BuildNginxMarker, or is a symlink or anything that // is not a regular file. Also, when nginx/conf.d/default.conf is absent on the -// host and any other regular *.conf file (the only names nginx.conf.tmpl includes, -// so default.conf.bak does not count) under nginx/conf.d*/ or nginx/sites/ has a // listen line with default_server, our default.conf is kept out (reason // DefaultServerIn), since two default servers fail nginx -t. The previous // deploy's record and the host's shipped list are ignored for conf.d: the host diff --git a/scripts/gen-signed-since.sh b/scripts/gen-signed-since.sh index d5be55b31..02ebec9b7 100755 --- a/scripts/gen-signed-since.sh +++ b/scripts/gen-signed-since.sh @@ -103,7 +103,7 @@ tier_from_gh() { rm -rf "$tmp" } -# build: prints the whole document. Tiers not selected keep the committed value. +# build: prints the whole document. Tiers not selected keep their committed value. build() { local free_floor licensed_floor free_refs='[]' licensed_refs='[]' free_pre licensed_pre both free_floor="$(jq -c '.tiers.free // {}' "$OUT" 2>/dev/null || echo '{}')" diff --git a/scripts/testdata/signed-since/expected.json b/scripts/testdata/signed-since/expected.json index cbc993d64..7ee93385f 100644 --- a/scripts/testdata/signed-since/expected.json +++ b/scripts/testdata/signed-since/expected.json @@ -1,4 +1,18 @@ { - "free": {"ci": "1.2.7", "minio": "v1.2.9-rc.1", "rcmix": "1.4.0-1", "rcnum": "1.3.0-rc.2", "tokens": "1.2.9"}, - "licensed": {"ai": "1.1.2", "claw": "2.0.0+build7"} + "free": { + "ci": "1.2.7", + "hyphen-prerelease": "1.2.3-alpha-beta.2", + "minio": "v1.2.9-rc.1", + "numeric-before-text": "1.2.3-11", + "numeric-prerelease": "1.2.3-rc.2", + "rcmix": "1.4.0-1", + "rcnum": "1.3.0-rc.2", + "release-after-prerelease": "1.2.3-rc.2+build10", + "shorter-prerelease": "1.2.3-alpha", + "tokens": "1.2.9" + }, + "licensed": { + "ai": "1.1.2", + "claw": "2.0.0+build7" + } } diff --git a/scripts/testdata/signed-since/free-1.json b/scripts/testdata/signed-since/free-1.json index bf6a96340..93ffbf536 100644 --- a/scripts/testdata/signed-since/free-1.json +++ b/scripts/testdata/signed-since/free-1.json @@ -38,7 +38,12 @@ "alg": "ed25519", "sig": "AAAA" } - } + }, + "numeric-prerelease": {"version": "1.2.3-rc.10", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "numeric-before-text": {"version": "1.2.3-11", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "shorter-prerelease": {"version": "1.2.3-alpha.1", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "hyphen-prerelease": {"version": "1.2.3-alpha-beta.10", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "release-after-prerelease": {"version": "1.2.3", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}} }, "history": [ { diff --git a/scripts/testdata/signed-since/free-2.json b/scripts/testdata/signed-since/free-2.json index e564a090f..6c199ee6f 100644 --- a/scripts/testdata/signed-since/free-2.json +++ b/scripts/testdata/signed-since/free-2.json @@ -4,5 +4,10 @@ "tokens": {"version": "1.2.9", "release_signature": {"key_id": "plugins-0123456789abcdef", "alg": "ed25519", "sig": "AAAA"}}, "rcnum": {"version": "1.3.0-rc.2", "release_signature": {"key_id": "plugins-0123456789abcdef", "alg": "ed25519", "sig": "AAAA"}}, "rcmix": {"version": "1.4.0-1", "release_signature": {"key_id": "plugins-0123456789abcdef", "alg": "ed25519", "sig": "AAAA"}}, - "unsigned-only": {"version": "1.2.9", "release_signature": null} -}} + "unsigned-only": {"version": "1.2.9", "release_signature": null}, + "numeric-prerelease": {"version": "1.2.3-rc.2", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "numeric-before-text": {"version": "1.2.3-2a", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "shorter-prerelease": {"version": "1.2.3-alpha", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "hyphen-prerelease": {"version": "1.2.3-alpha-beta.2", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}}, + "release-after-prerelease": {"version": "1.2.3-rc.2+build10", "release_signature": {"key_id": "test-key", "alg": "ed25519", "sig": "AAAA"}} + }}