Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Go feature set handling in editions for checks #3590

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions private/bufpkg/bufcheck/breaking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,14 @@ func TestRunBreakingWithCustomPlugins(t *testing.T) {
)
}

func TestRunBreakingWithEditionsGoFeatures(t *testing.T) {
t.Parallel()
testBreaking(
t,
"breaking_editions_go_features",
)
}

func testBreaking(
t *testing.T,
relDirPath string,
Expand Down
21 changes: 18 additions & 3 deletions private/bufpkg/bufcheck/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ func (c *client) Lint(
return err
}
logRulesConfig(c.logger, config.rulesConfig)
files, err := descriptor.FileDescriptorsForProtoFileDescriptors(imageToProtoFileDescriptors(image))
protoFileDescriptors, err := imageToProtoFileDescriptors(image)
if err != nil {
// If a validated Image results in an error, this is a system error.
return syserror.Wrap(err)
}
files, err := descriptor.FileDescriptorsForProtoFileDescriptors(protoFileDescriptors)
if err != nil {
// If a validated Image results in an error, this is a system error.
return syserror.Wrap(err)
Expand Down Expand Up @@ -174,12 +179,22 @@ func (c *client) Breaking(
return err
}
logRulesConfig(c.logger, config.rulesConfig)
fileDescriptors, err := descriptor.FileDescriptorsForProtoFileDescriptors(imageToProtoFileDescriptors(image))
protoFileDescriptors, err := imageToProtoFileDescriptors(image)
if err != nil {
// If a validated Image results in an error, this is a system error.
return syserror.Wrap(err)
}
fileDescriptors, err := descriptor.FileDescriptorsForProtoFileDescriptors(protoFileDescriptors)
if err != nil {
// If a validated Image results in an error, this is a system error.
return syserror.Wrap(err)
}
againstProtoFileDescriptors, err := imageToProtoFileDescriptors(againstImage)
if err != nil {
// If a validated Image results in an error, this is a system error.
return syserror.Wrap(err)
}
againstFileDescriptors, err := descriptor.FileDescriptorsForProtoFileDescriptors(imageToProtoFileDescriptors(againstImage))
againstFileDescriptors, err := descriptor.FileDescriptorsForProtoFileDescriptors(againstProtoFileDescriptors)
if err != nil {
// If a validated Image results in an error, this is a system error.
return syserror.Wrap(err)
Expand Down
9 changes: 9 additions & 0 deletions private/bufpkg/bufcheck/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,15 @@ func TestRunLintCustomWasmPlugins(t *testing.T) {
)
}

func TestRunLintEditionsGoFeatures(t *testing.T) {
t.Parallel()
testLint(
t,
"editions_go_features",
bufanalysistesting.NewFileAnnotationNoLocation(t, "a.proto", "PACKAGE_DEFINED"),
)
}

func testLint(
t *testing.T,
relDirPath string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
edition = "2023";

import "google/protobuf/go_features.proto";

option features.(pb.go).api_level = API_OPAQUE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: v1
lint:
use:
- PACKAGE_DEFINED
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
edition = "2023";

import "google/protobuf/go_features.proto";

option features.(pb.go).api_level = API_HYBRID;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: v1
lint:
use:
- PACKAGE_DEFINED
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
edition = "2023";

import "google/protobuf/go_features.proto";

option features.(pb.go).api_level = API_OPAQUE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: v1
lint:
use:
- PACKAGE_DEFINED
53 changes: 50 additions & 3 deletions private/bufpkg/bufcheck/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,63 @@
package bufcheck

import (
"fmt"
"slices"

descriptorv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/descriptor/v1"
"github.com/bufbuild/buf/private/bufpkg/bufimage"
"github.com/bufbuild/buf/private/pkg/protoencoding"
"github.com/bufbuild/buf/private/pkg/slicesext"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
)

func imageToProtoFileDescriptors(image bufimage.Image) []*descriptorv1.FileDescriptor {
func imageToProtoFileDescriptors(image bufimage.Image) ([]*descriptorv1.FileDescriptor, error) {
if image == nil {
return nil
return nil, nil
}
descriptors := slicesext.Map(image.Files(), imageToProtoFileDescriptor)
// We need to ensure that if a FileDescriptorProto includes a Go
// feature set extension, that it matches the library version in
// gofeaturespb. The library will use the gofeaturespb.E_Go extension
// type to determine how to parse the file. This must match the expected
// go type to avoid panics on getting the extension when using
// proto.GetExtension or protodesc.NewFiles. We therefore reparse all
// extensions if it may contain any Go feature set extensions, with
// a resolver that includes the Go feature set resolver.
// See the issue: https://github.com/golang/protobuf/issues/1669
const goFeaturesImportPath = "google/protobuf/go_features.proto"
var reparseDescriptors []*descriptorv1.FileDescriptor
for _, descriptor := range descriptors {
fileDescriptorProto := descriptor.FileDescriptorProto
// Trigger reparsing on any file that includes the gofeatures import.
if slices.Contains(fileDescriptorProto.Dependency, goFeaturesImportPath) {
reparseDescriptors = append(reparseDescriptors, descriptor)
}
}
if len(reparseDescriptors) == 0 {
return descriptors, nil
}
goFeaturesResolver, err := protoencoding.NewGoFeaturesResolver()
if err != nil {
return nil, err
}
resolver := protoencoding.CombineResolvers(
goFeaturesResolver,
image.Resolver(),
)
for _, descriptor := range reparseDescriptors {
// We clone the FileDescriptorProto to avoid modifying the original.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this matter? This is an unexported method, not an exported helper. So do the call sites really re-use the original? If not, seems like a waste to generate an unnecessary copy.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will modify the image so anything using that would see it. I don't think it matters though this was just done to be defensive. For the lint/breaking checks the images aren't used elsewhere.

fileDescriptorProto, ok := proto.Clone(descriptor.FileDescriptorProto).(*descriptorpb.FileDescriptorProto)
if !ok {
return nil, fmt.Errorf("could not clone descriptor.FileDescriptorProto")
}
if err := protoencoding.ReparseExtensions(resolver, fileDescriptorProto.ProtoReflect()); err != nil {
return nil, err
}
descriptor.FileDescriptorProto = fileDescriptorProto
}
return slicesext.Map(image.Files(), imageToProtoFileDescriptor)
return descriptors, nil
}

func imageToProtoFileDescriptor(imageFile bufimage.ImageFile) *descriptorv1.FileDescriptor {
Expand Down
18 changes: 18 additions & 0 deletions private/pkg/protoencoding/protoencoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package protoencoding

import (
"sync"

"buf.build/go/protoyaml"
"github.com/bufbuild/buf/private/pkg/protodescriptor"
"google.golang.org/protobuf/proto"
Expand All @@ -26,6 +28,13 @@ import (
// EmptyResolver is a resolver that never resolves any descriptors. All methods will return (nil, NotFound).
var EmptyResolver Resolver = emptyResolver{}

var (
// goFeaturesOnce is used to lazily create goFeaturesValue in NewGoFeaturesResolver.
goFeaturesOnce sync.Once
goFeaturesValue *goFeaturesResolver
goFeaturesErr error
)

// Resolver can resolve files, messages, enums, and extensions.
type Resolver interface {
protodesc.Resolver
Expand Down Expand Up @@ -54,6 +63,15 @@ func NewLazyResolver[F protodescriptor.FileDescriptor](fileDescriptors ...F) Res
}}
}

// NewGoFeaturesResolver returns a new Resolver that resolves Go features to
// the gofeaturespb package.
func NewGoFeaturesResolver() (Resolver, error) {
goFeaturesOnce.Do(func() {
goFeaturesValue, goFeaturesErr = newGoFeaturesResolver()
})
return goFeaturesValue, goFeaturesErr
}

// CombineResolvers returns a resolver that uses all of the given resolvers. It
// will use the first resolver, and if it returns an error, the second will be
// tried, and so on.
Expand Down
65 changes: 65 additions & 0 deletions private/pkg/protoencoding/reparse_extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import (
"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/testing/protocmp"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/dynamicpb"
"google.golang.org/protobuf/types/gofeaturespb"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
Expand Down Expand Up @@ -126,3 +128,66 @@ func TestReparseExtensions(t *testing.T) {
})
assert.Equal(t, 2, found)
}

func TestReparseExtensionsGoFeatures(t *testing.T) {
t.Parallel()

goFeaturesMessageDesc := gofeaturespb.File_google_protobuf_go_features_proto.Messages().ByName("GoFeatures")
dynamicGoFeatures := dynamicpb.NewMessage(goFeaturesMessageDesc)
dynamicGoFeatures.Set(
goFeaturesMessageDesc.Fields().ByName("api_level"),
protoreflect.ValueOfEnum(gofeaturespb.GoFeatures_API_OPAQUE.Number()),
)
assert.True(t, dynamicGoFeatures.IsValid())
dynamicExt := dynamicpb.NewExtensionType(gofeaturespb.E_Go.TypeDescriptor().Descriptor())

featureSet := &descriptorpb.FeatureSet{}
featureSetReflect := featureSet.ProtoReflect()
featureSetReflect.Set(
dynamicExt.TypeDescriptor(),
protoreflect.ValueOfMessage(dynamicGoFeatures),
)

// Validates the error conditions that cause this panic.
// See issue https://github.com/golang/protobuf/issues/1669
assert.Panics(t, func() {
proto.GetExtension(featureSet, gofeaturespb.E_Go)
})
descFileDesc, err := protoregistry.GlobalFiles.FindFileByPath("google/protobuf/descriptor.proto")
assert.NoError(t, err)
goFeaturesFileDesc, err := protoregistry.GlobalFiles.FindFileByPath("google/protobuf/go_features.proto")
assert.NoError(t, err)
fileDesc := &descriptorpb.FileDescriptorProto{
Name: proto.String("a.proto"),
Dependency: []string{
"google/protobuf/go_features.proto",
},
Edition: descriptorpb.Edition_EDITION_2023.Enum(),
Syntax: proto.String("editions"),
Options: &descriptorpb.FileOptions{
Features: featureSet,
},
}
fileSet := &descriptorpb.FileDescriptorSet{
File: []*descriptorpb.FileDescriptorProto{
protodesc.ToFileDescriptorProto(descFileDesc),
protodesc.ToFileDescriptorProto(goFeaturesFileDesc),
fileDesc,
},
}
assert.Panics(t, func() {
// TODO: if this no longer panics, we can remove the code handling
// this workaround in bufcheck.imageToProtoFileDescriptors.
_, err := protodesc.NewFiles(fileSet)
assert.NoError(t, err)
})
Comment on lines +178 to +183
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


// Run the resvoler to convert the extension.
goFeaturesResolver, err := newGoFeaturesResolver()
require.NoError(t, err)
err = ReparseExtensions(goFeaturesResolver, featureSetReflect)
require.NoError(t, err)
goFeatures, ok := proto.GetExtension(featureSet, gofeaturespb.E_Go).(*gofeaturespb.GoFeatures)
require.True(t, ok)
assert.Equal(t, goFeatures.GetApiLevel(), gofeaturespb.GoFeatures_API_OPAQUE)
}
27 changes: 27 additions & 0 deletions private/pkg/protoencoding/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/dynamicpb"
"google.golang.org/protobuf/types/gofeaturespb"
)

const maxTagNumber = 536870911 // 2^29 - 1
Expand Down Expand Up @@ -248,3 +249,29 @@ func (emptyResolver) FindMessageByName(protoreflect.FullName) (protoreflect.Mess
func (emptyResolver) FindMessageByURL(string) (protoreflect.MessageType, error) {
return nil, protoregistry.NotFound
}

type goFeaturesResolver struct {
protoregistry.Files
protoregistry.Types
}

func newGoFeaturesResolver() (*goFeaturesResolver, error) {
var resolver goFeaturesResolver
if err := resolver.Files.RegisterFile(
gofeaturespb.File_google_protobuf_go_features_proto,
); err != nil {
return nil, err
}

if err := resolver.Types.RegisterExtension(
gofeaturespb.E_Go.TypeDescriptor().Type(),
); err != nil {
return nil, err
}
if err := resolver.Types.RegisterMessage(
(&gofeaturespb.GoFeatures{}).ProtoReflect().Type(),
); err != nil {
return nil, err
}
return &resolver, nil
}
Loading