diff --git a/go.mod b/go.mod index b7eb74200..2c9b32aa7 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 + github.com/cenkalti/backoff/v5 v5.0.2 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 github.com/charmbracelet/huh v0.7.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc @@ -48,6 +49,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 + github.com/theupdateframework/go-tuf/v2 v2.1.1 github.com/yuin/goldmark v1.7.12 github.com/zalando/go-keyring v0.2.5 golang.org/x/crypto v0.38.0 @@ -73,7 +75,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/bubbletea v1.3.4 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect @@ -167,7 +168,6 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect - github.com/theupdateframework/go-tuf/v2 v2.1.1 // indirect github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index b571eee01..9a2bb5d3f 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -77,13 +77,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command opts.Hostname, _ = ghauth.DefaultHost() } - err := auth.IsHostSupported(opts.Hostname) + if err := auth.IsHostSupported(opts.Hostname); err != nil { + return err + } + + hc, err := f.HttpClient() if err != nil { return err } config := verification.SigstoreConfig{ - Logger: opts.Logger, + HttpClient: hc, + Logger: opts.Logger, } if ghauth.IsTenancy(opts.Hostname) { diff --git a/pkg/cmd/attestation/inspect/inspect_integration_test.go b/pkg/cmd/attestation/inspect/inspect_integration_test.go new file mode 100644 index 000000000..6c56461af --- /dev/null +++ b/pkg/cmd/attestation/inspect/inspect_integration_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package inspect + +import ( + "bytes" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + return http.DefaultClient, nil + }, + } + + t.Run("Print output in JSON format", func(t *testing.T) { + var opts *Options + cmd := NewInspectCmd(f, func(o *Options) error { + opts = o + return nil + }) + + argv := strings.Split(fmt.Sprintf("%s --format json", bundlePath), " ") + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, bundlePath, opts.BundlePath) + assert.NotNil(t, opts.Logger) + assert.NotNil(t, opts.exporter) + }) +} diff --git a/pkg/cmd/attestation/inspect/inspect_test.go b/pkg/cmd/attestation/inspect/inspect_test.go index 1e0c1305e..c94e80ad2 100644 --- a/pkg/cmd/attestation/inspect/inspect_test.go +++ b/pkg/cmd/attestation/inspect/inspect_test.go @@ -1,11 +1,7 @@ package inspect import ( - "bytes" "encoding/json" - "fmt" - "net/http" - "strings" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" @@ -14,7 +10,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -26,66 +21,7 @@ const ( SigstoreSanRegex = "^https://github.com/sigstore/sigstore-js/" ) -var ( - bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") -) - -func TestNewInspectCmd(t *testing.T) { - testIO, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{ - IOStreams: testIO, - HttpClient: func() (*http.Client, error) { - reg := &httpmock.Registry{} - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil - }, - } - - testcases := []struct { - name string - cli string - wants Options - wantsErr bool - wantsExporter bool - }{ - { - name: "Prints output in JSON format", - cli: fmt.Sprintf("%s --format json", bundlePath), - wants: Options{ - BundlePath: bundlePath, - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - }, - wantsExporter: true, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - var opts *Options - cmd := NewInspectCmd(f, func(o *Options) error { - opts = o - return nil - }) - - argv := strings.Split(tc.cli, " ") - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - _, err := cmd.ExecuteC() - if tc.wantsErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - - assert.Equal(t, tc.wants.BundlePath, opts.BundlePath) - assert.NotNil(t, opts.Logger) - assert.Equal(t, tc.wantsExporter, opts.exporter != nil) - }) - } -} +var bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") func TestRunInspect(t *testing.T) { opts := Options{ diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index 4e55e27ab..a347b64b2 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "net/http" "os" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -68,6 +69,10 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return err } + hc, err := f.HttpClient() + if err != nil { + return err + } if ghauth.IsTenancy(opts.Hostname) { c, err := f.Config() if err != nil { @@ -77,11 +82,6 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com if !c.Authentication().HasActiveToken(opts.Hostname) { return fmt.Errorf("not authenticated with %s", opts.Hostname) } - - hc, err := f.HttpClient() - if err != nil { - return err - } logger := io.NewHandler(f.IOStreams) apiClient := api.NewLiveClient(hc, opts.Hostname, logger) td, err := apiClient.GetTrustDomain() @@ -95,7 +95,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return runF(opts) } - if err := getTrustedRoot(tuf.New, opts); err != nil { + if err := getTrustedRoot(tuf.New, opts, hc); err != nil { return fmt.Errorf("Failed to verify the TUF repository: %w", err) } @@ -118,11 +118,11 @@ type tufConfig struct { targets []string } -func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { +func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options, hc *http.Client) error { var tufOptions []tufConfig var defaultTR = "trusted_root.json" - tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string]()) + tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string](), hc) // Disable local caching, so we get up-to-date response from TUF repository tufOpt.CacheValidity = 0 @@ -151,7 +151,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { targets: []string{defaultTR}, }) - tufOpt = verification.GitHubTUFOptions(o.None[string]()) + tufOpt = verification.GitHubTUFOptions(o.None[string](), hc) tufOpt.CacheValidity = 0 tufOptions = append(tufOptions, tufConfig{ tufOptions: tufOpt, diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index c4a259436..0d67c4445 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -28,6 +28,12 @@ func TestNewTrustedRootCmd(t *testing.T) { Config: func() (gh.Config, error) { return &ghmock.ConfigMock{}, nil }, + HttpClient: func() (*http.Client, error) { + reg := &httpmock.Registry{} + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, } testcases := []struct { @@ -113,6 +119,7 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { }, }, nil }, + HttpClient: httpClientFunc, } cmd := NewTrustedRootCmd(f, func(_ *Options) error { @@ -171,15 +178,19 @@ func TestGetTrustedRoot(t *testing.T) { TufRootPath: root, } + reg := &httpmock.Registry{} + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + t.Run("failed to create TUF root", func(t *testing.T) { - err := getTrustedRoot(newTUFErrClient, opts) + err := getTrustedRoot(newTUFErrClient, opts, client) require.Error(t, err) require.ErrorContains(t, err, "failed to create TUF client") }) t.Run("fails because the root cannot be found", func(t *testing.T) { opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json") - err := getTrustedRoot(tuf.New, opts) + err := getTrustedRoot(tuf.New, opts, client) require.Error(t, err) require.ErrorContains(t, err, "failed to read root file") }) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index a8a76e234..95dc2fb9c 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "errors" "fmt" + "net/http" "os" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -33,6 +34,7 @@ type SigstoreConfig struct { TrustedRoot string Logger *io.Handler NoPublicGood bool + HttpClient *http.Client // If tenancy mode is not used, trust domain is empty TrustDomain string // TUFMetadataDir @@ -71,13 +73,13 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro return liveVerifier, nil } if !config.NoPublicGood { - publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir) + publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient) if err != nil { return nil, err } liveVerifier.PublicGood = publicGoodVerifier } - github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir) + github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient) if err != nil { return nil, err } @@ -314,10 +316,10 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) return gv, nil } -func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.Verifier, error) { +func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string], hc *http.Client) (*verify.Verifier, error) { var tr string - opts := GitHubTUFOptions(tufMetadataDir) + opts := GitHubTUFOptions(tufMetadataDir, hc) client, err := tuf.New(opts) if err != nil { return nil, fmt.Errorf("failed to create TUF client: %v", err) @@ -348,8 +350,8 @@ func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Ve return gv, nil } -func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.Verifier, error) { - opts := DefaultOptionsWithCacheSetting(tufMetadataDir) +func newPublicGoodVerifier(tufMetadataDir o.Option[string], hc *http.Client) (*verify.Verifier, error) { + opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc) client, err := tuf.New(opts) if err != nil { return nil, fmt.Errorf("failed to create TUF client: %v", err) diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index 2a2d3beea..d37b94fc8 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -3,6 +3,7 @@ package verification import ( + "net/http" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -51,6 +52,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -71,6 +73,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("with 2/3 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -89,6 +92,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("fail with 0/2 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -114,6 +118,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -128,6 +133,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), TUFMetadataDir: o.Some(t.TempDir()), diff --git a/pkg/cmd/attestation/verification/tuf.go b/pkg/cmd/attestation/verification/tuf.go index dcfdd0b32..b88b15547 100644 --- a/pkg/cmd/attestation/verification/tuf.go +++ b/pkg/cmd/attestation/verification/tuf.go @@ -2,12 +2,15 @@ package verification import ( _ "embed" + "net/http" "os" "path/filepath" + "github.com/cenkalti/backoff/v5" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/go-gh/v2/pkg/config" "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/theupdateframework/go-tuf/v2/metadata/fetcher" ) //go:embed embed/tuf-repo.github.com/root.json @@ -15,7 +18,7 @@ var githubRoot []byte const GitHubTUFMirror = "https://tuf-repo.github.com" -func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Options { +func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options { opts := tuf.DefaultOptions() // The CODESPACES environment variable will be set to true in a Codespaces workspace @@ -32,11 +35,18 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Option // Allow TUF cache for 1 day opts.CacheValidity = 1 + // configure fetcher timeout and retry + f := fetcher.NewDefaultFetcher() + f.SetHTTPClient(hc) + retryOptions := []backoff.RetryOption{backoff.WithMaxTries(3)} + f.SetRetryOptions(retryOptions...) + opts.WithFetcher(f) + return opts } -func GitHubTUFOptions(tufMetadataDir o.Option[string]) *tuf.Options { - opts := DefaultOptionsWithCacheSetting(tufMetadataDir) +func GitHubTUFOptions(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options { + opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc) opts.Root = githubRoot opts.RepositoryBaseURL = GitHubTUFMirror diff --git a/pkg/cmd/attestation/verification/tuf_test.go b/pkg/cmd/attestation/verification/tuf_test.go index e8b6ecf98..41f766ac9 100644 --- a/pkg/cmd/attestation/verification/tuf_test.go +++ b/pkg/cmd/attestation/verification/tuf_test.go @@ -12,7 +12,7 @@ import ( func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) { os.Setenv("CODESPACES", "true") - opts := GitHubTUFOptions(o.None[string]()) + opts := GitHubTUFOptions(o.None[string](), nil) require.Equal(t, GitHubTUFMirror, opts.RepositoryBaseURL) require.NotNil(t, opts.Root) @@ -21,6 +21,6 @@ func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) { } func TestGitHubTUFOptionsWithMetadataDir(t *testing.T) { - opts := GitHubTUFOptions(o.Some("anything")) + opts := GitHubTUFOptions(o.Some("anything"), nil) require.Equal(t, "anything", opts.CachePath) } diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go index 73452c425..ec3eb271c 100644 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -3,6 +3,7 @@ package verify import ( + "net/http" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -26,6 +27,7 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { func TestVerifyAttestations(t *testing.T) { sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index b8debc529..90cc5643c 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -186,9 +186,10 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) config := verification.SigstoreConfig{ - TrustedRoot: opts.TrustedRoot, + HttpClient: hc, Logger: opts.Logger, NoPublicGood: opts.NoPublicGood, + TrustedRoot: opts.TrustedRoot, } // Prepare for tenancy if detected diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 92864f78e..d77f21f70 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -3,6 +3,7 @@ package verify import ( + "net/http" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -20,6 +21,7 @@ func TestVerifyIntegration(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } @@ -136,6 +138,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } @@ -209,6 +212,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } @@ -301,6 +305,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), }