diff --git a/.golangci.yml b/.golangci.yml index e8a0311936..b19eaa91c6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -224,6 +224,11 @@ linters: # We verify manually so that we can emit verbose output while doing so. path: private/buf/bufcurl/tls.go text: "G402:" + - linters: + - gosec + # InsecureSkipVerify mirrors the value of the --insecure flag chosen by the user. + path: cmd/buf/internal/command/curl/curl.go + text: "G402:" - linters: - paralleltest # This test shouldn't run in parallel as it needs osext.Getwd. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9845b4f1f7..6bfdc66be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add support for `--rbs_out` as a `protoc_builtin` plugin (requires protoc v34.0+). - Add relevant links from CEL LSP hover documentation to either or +- Add shell completions for `buf curl`: `--protocol` and `--reflect-protocol` flag values, and URL + path completion (service and method names) via server reflection, `--schema`, or the local buf module. ## [v1.66.1] - 2026-03-09 diff --git a/cmd/buf/internal/command/curl/curl.go b/cmd/buf/internal/command/curl/curl.go index 633a520378..4438f25c26 100644 --- a/cmd/buf/internal/command/curl/curl.go +++ b/cmd/buf/internal/command/curl/curl.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "net/http" "net/url" @@ -43,6 +44,7 @@ import ( "github.com/bufbuild/buf/private/pkg/verbose" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" + "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -150,26 +152,26 @@ Examples: Issue a unary RPC to a plain-text (i.e. "h2c") gRPC server, where the schema for the service is in a Buf module in the current directory, using an empty request message: - $ buf curl --schema . --protocol grpc --http2-prior-knowledge \ + $ buf curl --schema . --protocol grpc --http2-prior-knowledge \ http://localhost:20202/foo.bar.v1.FooService/DoSomething Issue an RPC to a Connect server, where the schema comes from the Buf Schema Registry, using a request that is defined as a command-line argument: - $ buf curl --schema buf.build/connectrpc/eliza \ - --data '{"name": "Bob Loblaw"}' \ + $ buf curl --schema buf.build/connectrpc/eliza \ + --data '{"name": "Bob Loblaw"}' \ https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Introduce Issue a unary RPC to a server that supports reflection, with verbose output: - $ buf curl --data '{"sentence": "I am not feeling well."}' -v \ - https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say + $ buf curl --data '{"sentence": "I am not feeling well."}' -v \ + https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say Issue a client-streaming RPC to a gRPC-web server that supports reflection, where custom headers and request data are both in a heredoc: - $ buf curl --data @- --header @- --protocol grpcweb \ - https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Converse \ + $ buf curl --data @- --header @- --protocol grpcweb \ + https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Converse \ < positional argument. +// It completes service and method name path components using the first source +// that succeeds, tried in order: +// +// 1. --schema flag (explicit schemas provided by the user) +// 2. Live server reflection against the URL being completed +// 3. The buf module found by walking up from the current working directory +// +// Sources 2 and 3 are tried in order so that a running server takes precedence, +// but local development against a service without a live server still works. +func completeURL(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if toComplete == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + parsed, err := url.Parse(toComplete) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + baseURL := parsed.Scheme + "://" + parsed.Host + // rawPath is everything after the leading slash, e.g.: + // "" → completing package prefix + // "acme." → completing next package segment + // "acme.foo.v1.FooService" → completing up to trailing slash + // "acme.foo.v1.FooService/" → completing method name + // "acme.foo.v1.FooService/Get" → completing method name (partial) + rawPath := strings.TrimPrefix(parsed.Path, "/") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1. Explicit --schema flag. + schemas, _ := cmd.Flags().GetStringSlice(schemaFlagName) + if len(schemas) > 0 { + return completeURLFromSchema(ctx, schemas, baseURL, rawPath, "--schema") + } + + // 2. Live server reflection. + isSecure := parsed.Scheme == "https" + if httpClient, ok := makeCompletionHTTPClient(cmd, isSecure); ok { + if completions, directive, ok := completeURLFromReflection(ctx, httpClient, baseURL, rawPath); ok { + return completions, directive + } + } + + // 3. Buf module in the current working directory (walks up to find buf.yaml / + // buf.work.yaml). This covers the common local-dev case: the user is working + // inside a buf workspace and hasn't started the server yet, or the server + // doesn't expose reflection. + return completeURLFromSchema(ctx, []string{"."}, baseURL, rawPath, "local module") +} + +// completeURLFromReflection attempts server reflection against baseURL and +// returns completions if the server is reachable and supports reflection. +// The third return value is false if reflection was unavailable (connection +// refused, server unreachable, reflection not implemented), in which case the +// caller should try an alternative source. It is true even if the service list +// was empty or no completions matched — that is a valid reflection result. +// +// Reflection for completion always uses gRPC regardless of --protocol or +// --reflect-protocol, and does not forward --reflect-header, --cert, --key, +// --cacert, or --servername. Servers that require auth on the reflection +// endpoint will silently produce no completions. +func completeURLFromReflection(ctx context.Context, httpClient connect.HTTPClient, baseURL, rawPath string) ([]string, cobra.ShellCompDirective, bool) { + reflectionResolver, closeResolver := bufcurl.NewServerReflectionResolver( + ctx, + httpClient, + []connect.ClientOption{connect.WithGRPC()}, + baseURL, + bufcurl.ReflectProtocolUnknown, + http.Header{}, + verbose.NopPrinter, + ) + defer closeResolver() + + serviceNames, err := reflectionResolver.ListServices() + if err != nil { + // Reflection unavailable or server unreachable; let the caller try another source. + return nil, cobra.ShellCompDirectiveNoFileComp, false + } + completions, directive := completePathFromServices( + baseURL, + serviceNames, + rawPath, + func(svcName string) (protoreflect.ServiceDescriptor, error) { + return bufcurl.ResolveServiceDescriptor(reflectionResolver, svcName) + }, + "reflection", + ) + return completions, directive, true +} + +// completeURLFromSchema builds a resolver from the given schemas using the full +// schema-loading stack (BSR auth, caching, etc.) and uses it to complete +// service and method name path components. +func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPath, source string) ([]string, cobra.ShellCompDirective) { + // Only surface errors when the user explicitly provided a source (e.g. + // --schema). The CWD fallback ("local module") is silent because there may + // simply be no buf.yaml in the current directory. + reportError := func(format string, args ...any) { + if source != "local module" { + cobra.CompErrorln(fmt.Sprintf("buf curl completion: "+format, args...)) + } + } + + baseContainer, err := app.NewContainerForOS() + if err != nil { + reportError("%v", err) + return nil, cobra.ShellCompDirectiveNoFileComp + } + nameContainer, err := appext.NewNameContainer(baseContainer, "buf") + if err != nil { + reportError("%v", err) + return nil, cobra.ShellCompDirectiveNoFileComp + } + // Discard log output during shell completion. + container := appext.NewContainer(nameContainer, slog.New(slog.NewTextHandler(io.Discard, nil))) + controller, err := bufcli.NewController(container) + if err != nil { + reportError("%v", err) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + resolvers := make([]bufcurl.Resolver, 0, len(schemas)) + for _, schema := range schemas { + image, err := controller.GetImage(ctx, schema) + if err != nil { + reportError("failed to load schema %q: %v", schema, err) + return nil, cobra.ShellCompDirectiveNoFileComp + } + resolvers = append(resolvers, bufcurl.ResolverForImage(image)) + } + resolver := bufcurl.CombineResolvers(resolvers...) + + serviceNames, err := resolver.ListServices() + if err != nil { + reportError("%v", err) + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completePathFromServices( + baseURL, + serviceNames, + rawPath, + func(svcName string) (protoreflect.ServiceDescriptor, error) { + return bufcurl.ResolveServiceDescriptor(resolver, svcName) + }, + source, + ) +} + +// completePathFromServices computes URL completions given a list of known +// service names and the raw path typed so far (everything after the host). +// +// Service-level completions advance through unambiguous dot-segments automatically +// so a single tab press reaches the first real fork in the name hierarchy. For +// example, given services +// +// acme.foo.v1.FooService +// acme.bar.v1.BarService +// +// the progression is: +// +// "" → "https://host/acme.foo." and "https://host/acme.bar." (skips unambiguous "acme.") +// "acme.foo." → "https://host/acme.foo.v1.FooService/" (skips unambiguous "v1.") +// "acme.foo.v1.FooService/" → method names +func completePathFromServices( + baseURL string, + serviceNames []protoreflect.FullName, + rawPath string, + getServiceDescriptor func(serviceName string) (protoreflect.ServiceDescriptor, error), + source string, +) ([]string, cobra.ShellCompDirective) { + serviceName, methodPrefix, hasSlash := strings.Cut(rawPath, "/") + if hasSlash { + // Method completion. + desc, err := getServiceDescriptor(serviceName) + if err != nil { + // The service name was already listed, so failing to fetch its + // descriptor is unexpected — surface it regardless of source. + if source != "" { + cobra.CompErrorln(fmt.Sprintf("buf curl completion: failed to resolve service %q: %v", serviceName, err)) + } + return nil, cobra.ShellCompDirectiveNoFileComp + } + methods := desc.Methods() + completions := make([]string, 0, methods.Len()) + for i := range methods.Len() { + name := string(methods.Get(i).Name()) + if strings.HasPrefix(name, methodPrefix) { + item := baseURL + "/" + serviceName + "/" + name + if source != "" { + item += "\t" + source + } + completions = append(completions, item) + } + } + slices.Sort(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } + + // Service/package name completion. We loop, advancing the prefix through + // dot-segments until we reach a fork (multiple candidates) or a terminal + // service name (ends with "/"). This means a single tab press skips over + // any unambiguous prefix segments. + prefix := rawPath + for { + seen := make(map[string]struct{}) + for _, svc := range serviceNames { + svcStr := string(svc) + if !strings.HasPrefix(svcStr, prefix) { + continue + } + remainder := svcStr[len(prefix):] + if idx := strings.Index(remainder, "."); idx >= 0 { + // More package components remain: offer only the next segment. + seen[prefix+remainder[:idx+1]] = struct{}{} + } else { + // No more dots: full service name; add trailing slash. + seen[svcStr+"/"] = struct{}{} + } + } + // If there is exactly one candidate and it is not yet a terminal service + // name (i.e. it ends with "." not "/"), advance and loop so the next + // segment is also consumed without requiring another tab press. + if len(seen) == 1 { + var only string + for k := range seen { + only = k + } + if !strings.HasSuffix(only, "/") { + prefix = only + continue + } + } + completions := make([]string, 0, len(seen)) + for p := range seen { + item := baseURL + "/" + p + // Add source description only to terminal service names (ending with "/"), + // not to intermediate package segments (ending with "."). + if source != "" && strings.HasSuffix(p, "/") { + item += "\t" + source + } + completions = append(completions, item) + } + slices.Sort(completions) + // NoSpace so the shell does not insert a space after a trailing dot or + // slash, letting the user continue typing the next segment immediately. + return completions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } +} + +// makeCompletionHTTPClient builds a minimal HTTP client for use during shell +// completion. Returns (client, true) on success, or (nil, false) when +// reflection is not possible (e.g. plain HTTP without HTTP/2 prior knowledge). +func makeCompletionHTTPClient(cmd *cobra.Command, isSecure bool) (connect.HTTPClient, bool) { + protocols := new(http.Protocols) + if isSecure { + insecure, _ := cmd.Flags().GetBool(insecureFlagName) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + ForceAttemptHTTP2: true, + Protocols: protocols, + }, + }, true + } + // Plain HTTP: server reflection requires HTTP/2, which needs prior knowledge + // over a cleartext connection. Skip completion if the flag is not set. + http2PriorKnowledge, _ := cmd.Flags().GetBool(http2PriorKnowledgeFlagName) + if !http2PriorKnowledge { + return nil, false + } + protocols.SetUnencryptedHTTP2(true) + return &http.Client{ + Transport: &http.Transport{ + Protocols: protocols, + }, + }, true +} diff --git a/cmd/buf/internal/command/curl/curl_completion_test.go b/cmd/buf/internal/command/curl/curl_completion_test.go new file mode 100644 index 0000000000..5946903a44 --- /dev/null +++ b/cmd/buf/internal/command/curl/curl_completion_test.go @@ -0,0 +1,434 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package curl + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "connectrpc.com/connect" + "connectrpc.com/grpcreflect" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" +) + +// testServices defines the two services used across all completion tests: +// +// acme.foo.v1.FooService – methods: GetFoo, ListFoos +// acme.bar.v1.BarService – methods: CreateBar +// +// The names are chosen to give a multi-level package hierarchy (acme → foo/bar +// → v1 → ServiceName) that exercises the hierarchical completion logic. +var testServices = []protoreflect.FullName{ + "acme.foo.v1.FooService", + "acme.bar.v1.BarService", +} + +// newTestDescriptorResolver builds a protodesc.Resolver containing the two +// test services. Both use google.protobuf.Empty as input/output so they need +// no additional dependencies beyond the well-known types already in the global +// registry. +func newTestDescriptorResolver(t *testing.T) protodesc.Resolver { + t.Helper() + // Build one file per service so their proto packages match their names. + fooFDP := &descriptorpb.FileDescriptorProto{ + Name: proto.String("acme/foo/v1/foo.proto"), + Syntax: proto.String("proto3"), + Package: proto.String("acme.foo.v1"), + Dependency: []string{"google/protobuf/empty.proto"}, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("FooService"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("GetFoo"), InputType: proto.String(".google.protobuf.Empty"), OutputType: proto.String(".google.protobuf.Empty")}, + {Name: proto.String("ListFoos"), InputType: proto.String(".google.protobuf.Empty"), OutputType: proto.String(".google.protobuf.Empty")}, + }, + }, + }, + } + barFDP := &descriptorpb.FileDescriptorProto{ + Name: proto.String("acme/bar/v1/bar.proto"), + Syntax: proto.String("proto3"), + Package: proto.String("acme.bar.v1"), + Dependency: []string{"google/protobuf/empty.proto"}, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("BarService"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("CreateBar"), InputType: proto.String(".google.protobuf.Empty"), OutputType: proto.String(".google.protobuf.Empty")}, + }, + }, + }, + } + fooFD, err := protodesc.NewFile(fooFDP, protoregistry.GlobalFiles) + require.NoError(t, err) + barFD, err := protodesc.NewFile(barFDP, protoregistry.GlobalFiles) + require.NoError(t, err) + + files := new(protoregistry.Files) + require.NoError(t, files.RegisterFile(fooFD)) + require.NoError(t, files.RegisterFile(barFD)) + return files +} + +// newTestReflectionServer starts an in-process TLS Connect server that serves +// gRPC reflection (v1 and v1alpha) for the given service names, using resolver +// to look up their descriptors. The server is automatically closed when t +// completes. +func newTestReflectionServer(t *testing.T, resolver protodesc.Resolver, serviceNames ...string) *httptest.Server { + t.Helper() + reflector := grpcreflect.NewReflector( + grpcreflect.NamerFunc(func() []string { return serviceNames }), + grpcreflect.WithDescriptorResolver(resolver), + ) + mux := http.NewServeMux() + mux.Handle(grpcreflect.NewHandlerV1(reflector)) + mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector)) + + server := httptest.NewUnstartedServer(mux) + server.EnableHTTP2 = true + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +// newCompletionCmd returns a minimal cobra.Command that has the flags accessed +// by completeURL (schema, insecure, http2-prior-knowledge). +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{} + flags := cmd.Flags() + flags.StringSlice(schemaFlagName, nil, "") + flags.Bool(insecureFlagName, false, "") + flags.Bool(http2PriorKnowledgeFlagName, false, "") + return cmd +} + +// TestFlagCompletions verifies that --protocol and --reflect-protocol return +// the expected static lists with no file completions. +func TestFlagCompletions(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{} + // completeCurlCommand requires the flags to already be registered. + newFlags().Bind(cmd.Flags()) + require.NoError(t, completeCurlCommand(cmd)) + + protocolFn, _ := cmd.GetFlagCompletionFunc(protocolFlagName) + require.NotNil(t, protocolFn) + protocols, directive := protocolFn(cmd, nil, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.ElementsMatch(t, []string{connect.ProtocolConnect, connect.ProtocolGRPC, connect.ProtocolGRPCWeb}, protocols) + + reflectFn, _ := cmd.GetFlagCompletionFunc(reflectProtocolFlagName) + require.NotNil(t, reflectFn) + reflectProtocols, directive := reflectFn(cmd, nil, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.ElementsMatch(t, []string{"grpc-v1", "grpc-v1alpha"}, reflectProtocols) +} + +// TestCompletePathFromServices_ServiceHierarchy exercises the hierarchical +// package-segment completion for the service-name portion of the URL. +func TestCompletePathFromServices_ServiceHierarchy(t *testing.T) { + t.Parallel() + const base = "https://api.example.com" + + // getDesc is unused for service-level tests but must not panic. + noDesc := func(string) (protoreflect.ServiceDescriptor, error) { + t.Fatal("getServiceDescriptor should not be called during service-level completion") + return nil, nil + } + + t.Run("empty prefix skips to first fork", func(t *testing.T) { + t.Parallel() + // Both services share the unambiguous prefix "acme.", so the completer + // advances past it automatically and returns the two diverging branches. + completions, directive := completePathFromServices(base, testServices, "", noDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, completions) + }) + + t.Run("acme. prefix shows same fork", func(t *testing.T) { + t.Parallel() + // Explicitly typing "acme." produces the same result as the empty prefix. + completions, directive := completePathFromServices(base, testServices, "acme.", noDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, completions) + }) + + t.Run("unambiguous branch jumps to service name", func(t *testing.T) { + t.Parallel() + // "acme.foo." has only one service beneath it, so the completer skips + // the intermediate "v1." segment and lands on the full service name. + completions, directive := completePathFromServices(base, testServices, "acme.foo.", noDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("full service name adds trailing slash", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.", noDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("exact service name without trailing slash adds slash", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService", noDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/"}, completions) + }) + + t.Run("no match returns empty list", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "notexist.", noDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Empty(t, completions) + }) + + t.Run("source appended to terminal service names only", func(t *testing.T) { + t.Parallel() + // Intermediate package segments get no description; only the terminal + // service name (ending with "/") gets the source description. + forkCompletions, _ := completePathFromServices(base, testServices, "acme.", noDesc, "test-source") + assert.Equal(t, []string{base + "/acme.bar.", base + "/acme.foo."}, forkCompletions, + "intermediate package segments should have no description") + + terminalCompletions, _ := completePathFromServices(base, testServices, "acme.foo.", noDesc, "test-source") + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/\ttest-source"}, terminalCompletions, + "terminal service name should have description") + }) +} + +// TestCompletePathFromServices_Methods exercises method completion once a +// slash is present in the raw path. +func TestCompletePathFromServices_Methods(t *testing.T) { + t.Parallel() + const base = "https://api.example.com" + + resolver := newTestDescriptorResolver(t) + getDesc := func(svcName string) (protoreflect.ServiceDescriptor, error) { + desc, err := resolver.FindDescriptorByName(protoreflect.FullName(svcName)) + if err != nil { + return nil, err + } + svcDesc, ok := desc.(protoreflect.ServiceDescriptor) + if !ok { + return nil, fmt.Errorf("%s is not a service", svcName) + } + return svcDesc, nil + } + + t.Run("all methods listed after trailing slash", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", getDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{ + base + "/acme.foo.v1.FooService/GetFoo", + base + "/acme.foo.v1.FooService/ListFoos", + }, completions) + }) + + t.Run("method prefix filters results", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/List", getDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{base + "/acme.foo.v1.FooService/ListFoos"}, completions) + }) + + t.Run("non-matching method prefix returns empty", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/Delete", getDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Empty(t, completions) + }) + + t.Run("descriptor lookup error returns no completions", func(t *testing.T) { + t.Parallel() + errDesc := func(string) (protoreflect.ServiceDescriptor, error) { + return nil, fmt.Errorf("not found") + } + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + }) + + t.Run("source appended to method completions", func(t *testing.T) { + t.Parallel() + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", getDesc, "test-source") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{ + base + "/acme.foo.v1.FooService/GetFoo\ttest-source", + base + "/acme.foo.v1.FooService/ListFoos\ttest-source", + }, completions) + }) +} + +// TestCompleteURL_ReflectionServer verifies end-to-end completion via a real +// in-process Connect server with gRPC reflection enabled. +func TestCompleteURL_ReflectionServer(t *testing.T) { + t.Parallel() + resolver := newTestDescriptorResolver(t) + server := newTestReflectionServer(t, resolver, "acme.foo.v1.FooService", "acme.bar.v1.BarService") + + cmd := newCompletionCmd() + require.NoError(t, cmd.Flags().Set(insecureFlagName, "true")) + + t.Run("root skips to first fork", func(t *testing.T) { + t.Parallel() + // Unambiguous "acme." prefix is skipped automatically. + completions, directive := completeURL(cmd, nil, server.URL+"/") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{server.URL + "/acme.bar.", server.URL + "/acme.foo."}, completions) + }) + + t.Run("unambiguous branch jumps to service name", func(t *testing.T) { + t.Parallel() + // "acme.foo." has only one service; intermediate "v1." is skipped. + completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.") + assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{server.URL + "/acme.foo.v1.FooService/\treflection"}, completions) + }) + + t.Run("lists methods for a service", func(t *testing.T) { + t.Parallel() + completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.v1.FooService/") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Equal(t, []string{ + server.URL + "/acme.foo.v1.FooService/GetFoo\treflection", + server.URL + "/acme.foo.v1.FooService/ListFoos\treflection", + }, completions) + }) + + t.Run("empty toComplete returns no completions", func(t *testing.T) { + t.Parallel() + completions, directive := completeURL(cmd, nil, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + }) + + t.Run("non-URL toComplete returns no completions", func(t *testing.T) { + t.Parallel() + completions, directive := completeURL(cmd, nil, "not-a-url") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + }) +} + +// TestCompleteURLFromReflection_Unavailable verifies that when a server does not +// support reflection, completeURLFromReflection returns ok=false so the caller +// can try an alternative source. +func TestCompleteURLFromReflection_Unavailable(t *testing.T) { + t.Parallel() + // A plain HTTPS server with no reflection handlers; every request returns 404. + server := httptest.NewUnstartedServer(http.NotFoundHandler()) + server.EnableHTTP2 = true + server.StartTLS() + t.Cleanup(server.Close) + + transport, ok := server.Client().Transport.(*http.Transport) + require.True(t, ok) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: transport.TLSClientConfig, + ForceAttemptHTTP2: true, + Protocols: protocols, + }, + } + + ctx := t.Context() + completions, directive, ok := completeURLFromReflection(ctx, httpClient, server.URL, "") + assert.False(t, ok, "expected ok=false when server does not support reflection") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) +} + +// TestMakeCompletionHTTPClient verifies the two code paths in makeCompletionHTTPClient. +func TestMakeCompletionHTTPClient(t *testing.T) { + t.Parallel() + + t.Run("https returns client", func(t *testing.T) { + t.Parallel() + cmd := newCompletionCmd() + client, ok := makeCompletionHTTPClient(cmd, true) + assert.True(t, ok) + assert.NotNil(t, client) + }) + + t.Run("http without prior knowledge returns nothing", func(t *testing.T) { + t.Parallel() + cmd := newCompletionCmd() + client, ok := makeCompletionHTTPClient(cmd, false) + assert.False(t, ok) + assert.Nil(t, client) + }) + + t.Run("http with prior knowledge returns client", func(t *testing.T) { + t.Parallel() + cmd := newCompletionCmd() + require.NoError(t, cmd.Flags().Set(http2PriorKnowledgeFlagName, "true")) + client, ok := makeCompletionHTTPClient(cmd, false) + assert.True(t, ok) + assert.NotNil(t, client) + }) +} + +// TestCompletePathFromServices_ErrorReporting verifies that descriptor lookup +// errors are written to BASH_COMP_DEBUG_FILE when a source is set, and are +// silent when source is empty. These tests modify an environment variable so +// they cannot be run in parallel. +func TestCompletePathFromServices_ErrorReporting(t *testing.T) { + const base = "https://api.example.com" + errDesc := func(string) (protoreflect.ServiceDescriptor, error) { + return nil, fmt.Errorf("lookup failed") + } + + t.Run("error logged when source is set", func(t *testing.T) { + debugFile := filepath.Join(t.TempDir(), "comp_debug.log") + t.Setenv("BASH_COMP_DEBUG_FILE", debugFile) + + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc, "reflection") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + + contents, err := os.ReadFile(debugFile) + require.NoError(t, err, "expected error to be written to debug file") + assert.Contains(t, string(contents), "acme.foo.v1.FooService") + assert.Contains(t, string(contents), "lookup failed") + }) + + t.Run("error silent when source is empty", func(t *testing.T) { + debugFile := filepath.Join(t.TempDir(), "comp_debug.log") + t.Setenv("BASH_COMP_DEBUG_FILE", debugFile) + + completions, directive := completePathFromServices(base, testServices, "acme.foo.v1.FooService/", errDesc, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) + + _, err := os.ReadFile(debugFile) + assert.True(t, os.IsNotExist(err), "debug file should not be created when source is empty") + }) +} diff --git a/go.mod b/go.mod index 42708bb663..6b094e9f32 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 - buf.build/go/app v0.2.0 + buf.build/go/app v0.2.1-0.20260319172822-1a2734dfaf62 buf.build/go/bufplugin v0.9.0 buf.build/go/bufprivateusage v0.1.0 buf.build/go/protovalidate v1.1.3 @@ -15,6 +15,7 @@ require ( buf.build/go/spdx v0.2.0 buf.build/go/standard v0.1.0 connectrpc.com/connect v1.19.1 + connectrpc.com/grpcreflect v1.3.0 connectrpc.com/otelconnect v0.9.0 github.com/bufbuild/protocompile v0.14.2-0.20260313233150-4b57e9e2a3ff github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 diff --git a/go.sum b/go.sum index 3bb9875e30..08627cbc41 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-81 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 h1:iGPvEJltOXUMANWf0zajcRcbiOXLD90ZwPUFvbcuv6Q= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= -buf.build/go/app v0.2.0 h1:NYaH13A+RzPb7M5vO8uZYZ2maBZI5+MS9A9tQm66fy8= -buf.build/go/app v0.2.0/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= +buf.build/go/app v0.2.1-0.20260319172822-1a2734dfaf62 h1:mB0tocMF2//OKFGlZ91/TZnDm5865Dgnc7TdCj3PRiI= +buf.build/go/app v0.2.1-0.20260319172822-1a2734dfaf62/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= buf.build/go/bufplugin v0.9.0 h1:ktZJNP3If7ldcWVqh46XKeiYJVPxHQxCfjzVQDzZ/lo= buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= @@ -30,6 +30,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=