diff --git a/internal/output/output.go b/internal/output/output.go index c56c5d8..c8c6695 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -110,6 +110,22 @@ func (o Options) Active() bool { return o.JSONSet || o.JQ != "" || o.Template != "" } +// MarkWebMutuallyExclusive declares the standard output flags +// (--json, --jq, --template) incompatible with --web on cmd. Call +// from any builder that has both — without this, cobra silently lets +// both groups coexist and runtime picks --web, discarding the +// machine-readable request. The C-audit (C5, C15) flagged exactly +// this footgun. +// +// Three independent pair-wise calls (rather than one quadruple call) +// so cobra still allows the existing legal combinations among +// --json / --jq / --template. +func MarkWebMutuallyExclusive(cmd *cobra.Command) { + cmd.MarkFlagsMutuallyExclusive("web", FlagJSON) + cmd.MarkFlagsMutuallyExclusive("web", FlagJQ) + cmd.MarkFlagsMutuallyExclusive("web", FlagTemplate) +} + // validate enforces mutual exclusion across --jq / --template against // each other. --json may combine with --jq or --template because it // drives the *projection*; jq/template format the projection. diff --git a/internal/output/output_test.go b/internal/output/output_test.go index d568cc8..4bde80d 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -7,6 +7,8 @@ import ( "encoding/json" "strings" "testing" + + "github.com/spf13/cobra" ) // fakeExporter projects the supplied data through json.Marshal-friendly @@ -204,3 +206,55 @@ func TestDefaultTemplateFuncs(t *testing.T) { t.Errorf("color stub: got %q", got) } } + +// TestMarkWebMutuallyExclusive: C-audit C5/C15. When a command has +// both --web and the standard output flags, supplying both must be +// rejected at parse time. Three pair-wise groups means --json + --jq +// stays legal (existing legal combination). +func TestMarkWebMutuallyExclusive(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + args []string + wantErr bool + }{ + {"web alone", []string{"--web"}, false}, + {"json alone", []string{"--json", "id"}, false}, + {"jq alone", []string{"--jq", ".id"}, false}, + {"template alone", []string{"--template", "{{.id}}"}, false}, + {"json+jq still legal", []string{"--json", "id", "--jq", ".id"}, false}, + {"web + json rejected", []string{"--web", "--json", "id"}, true}, + {"web + jq rejected", []string{"--web", "--jq", ".id"}, true}, + {"web + template rejected", []string{"--web", "--template", "{{.id}}"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := newTestCmdWithWebAndOutput() + cmd.SetArgs(tc.args) + err := cmd.Execute() + gotErr := err != nil + if gotErr != tc.wantErr { + t.Errorf("err: got %v, wantErr=%v", err, tc.wantErr) + } + // cobra's group-message form: "if any flags in the group + // [a b] are set none of the others can be; [...] were all set" + if tc.wantErr && err != nil && !strings.Contains(err.Error(), "none of the others can be") { + t.Errorf("error should be cobra's mutex form; got: %v", err) + } + }) + } +} + +// newTestCmdWithWebAndOutput builds a no-op cobra command that has +// both `--web` and the output flag set, with the mutex applied. +func newTestCmdWithWebAndOutput() *cobra.Command { + var web bool + opts := &Options{} + cmd := &cobra.Command{ + Use: "x", + RunE: func(*cobra.Command, []string) error { return nil }, + } + cmd.Flags().BoolVar(&web, "web", false, "") + AddFlags(cmd, opts) + MarkWebMutuallyExclusive(cmd) + return cmd +} diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 44a68c1..f9cb831 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -88,6 +88,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().IntVarP(&opts.Limit, "limit", "L", DefaultLimit, "maximum number of issues to fetch") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open the issues list in a browser") output.AddFlags(cmd, &opts.Exporter) + output.MarkWebMutuallyExclusive(cmd) return cmd } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 8496c25..b788421 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -65,6 +65,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open the issue in a browser") cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "include the comment thread inline") output.AddFlags(cmd, &opts.Exporter) + output.MarkWebMutuallyExclusive(cmd) return cmd } diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 1458e5b..b3bc350 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -89,6 +89,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().IntVarP(&opts.Limit, "limit", "L", DefaultLimit, "maximum number of PRs to fetch") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open the PR list in a browser") output.AddFlags(cmd, &opts.Exporter) + output.MarkWebMutuallyExclusive(cmd) return cmd } diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index ce89b0a..47ff5c4 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -69,6 +69,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open the PR in a browser") cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "include the conversation thread inline") output.AddFlags(cmd, &opts.Exporter) + output.MarkWebMutuallyExclusive(cmd) return cmd } diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index 5018bd0..088fb4b 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -74,6 +74,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open the repository in the browser") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "branch to view the README from (default: default branch)") output.AddFlags(cmd, &opts.Exporter) + output.MarkWebMutuallyExclusive(cmd) return cmd }