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 4 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
52 changes: 49 additions & 3 deletions private/bufpkg/bufcheck/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,62 @@
package bufcheck

import (
"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,
protoencoding.NewLazyResolver(slicesext.Map(descriptors, func(fileDescriptor *descriptorv1.FileDescriptor) *descriptorpb.FileDescriptorProto {
return fileDescriptor.FileDescriptorProto
})...),
)
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 := &descriptorpb.FileDescriptorProto{}
proto.Merge(fileDescriptorProto, descriptor.FileDescriptorProto)
Copy link
Member

Choose a reason for hiding this comment

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

If you were to keep the defensive copy, why not just clone it?

Suggested change
fileDescriptorProto := &descriptorpb.FileDescriptorProto{}
proto.Merge(fileDescriptorProto, descriptor.FileDescriptorProto)
fileDescriptorProto := proto.Clone(descriptor.FileDescriptorProto).(*descriptorpb.FileDescriptorProto)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The linter complains about type assertion so used merge to defer the proto package to handle it.

Copy link
Member

Choose a reason for hiding this comment

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

I guess //nolint isn't accepted in this repo? 😞

Copy link
Contributor Author

@emcfarlane emcfarlane Jan 14, 2025

Choose a reason for hiding this comment

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

We use proto.Clone elsewhere with the two var destructor and error check. Now do the same here.

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)
}
32 changes: 32 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,34 @@ 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 protoregistryFiles protoregistry.Files
if err := protoregistryFiles.RegisterFile(
gofeaturespb.File_google_protobuf_go_features_proto,
); err != nil {
return nil, err
}

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

return &goFeaturesResolver{
Files: protoregistryFiles,
Types: protoregistryTypes,
}, nil
Copy link
Member

Choose a reason for hiding this comment

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

This is fine and safe right now. But this seems a little suspicious to copy these types by value. I think it might be less scary looking to instead define the var at the top:

var resolver goFeaturesResolver

And then use resolver.Files and resolver.Types to register everything and finally:

return &goFeaturesResolver

Not a blocking comment. Just my two cents... 🤷

}
Loading