Repository: bufbuild/buf (go)

Issue:
Fix Language server import path handling cross-platform
Uri's are currently being manually constructed and are non-compliant on windows. This results in the LSP incorrectly converting `file://` URIs to a valid file path.

Example: https://github.com/bufbuild/buf/blob/37b5a2a496a2279aa03507be64cba00193644a1e/private/buf/buflsp/file.go#L521.

go.lsp.dev/uri already has cross-platform handling of URIs - #3698 proposes to leverage this to resolve the issue.

Pull request description:
Fix path handling on windows
Ensure go.lsp.dev/uri is used for path handling to correctly handle paths cross-platform e.g. windows.

Closes #3699.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d22590999..0e7040f7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
 
 - Fix `buf convert` to allow for zero length for `binpb`, `txtpb`, and `yaml` formats.
 - Fix use of deprecated flag `--include-types` for `buf generate`.
+- Add `--against-registry` flag to `buf breaking` that runs breaking checks against the latest
+  commit on the default branch of the corresponding module in the registry.
 
 ## [v1.50.1] - 2025-03-10
 
diff --git a/go.mod b/go.mod
index 3aca0bc81..54918b8bf 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/bufbuild/protocompile v0.14.1
 	github.com/bufbuild/protoplugin v0.0.0-20250106231243-3a819552c9d9
 	github.com/bufbuild/protovalidate-go v0.9.3-0.20250317160558-38a17488914d
-	github.com/docker/docker v28.0.1+incompatible
+	github.com/docker/docker v28.0.2+incompatible
 	github.com/go-chi/chi/v5 v5.2.1
 	github.com/gofrs/flock v0.12.1
 	github.com/google/cel-go v0.24.1
@@ -30,7 +30,7 @@ require (
 	github.com/klauspost/pgzip v1.2.6
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
 	github.com/pkg/profile v1.7.0
-	github.com/quic-go/quic-go v0.50.0
+	github.com/quic-go/quic-go v0.50.1
 	github.com/rs/cors v1.11.1
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/pflag v1.0.6
@@ -38,6 +38,7 @@ require (
 	github.com/tetratelabs/wazero v1.9.0
 	go.lsp.dev/jsonrpc2 v0.10.0
 	go.lsp.dev/protocol v0.12.0
+	go.lsp.dev/uri v0.3.0
 	go.uber.org/zap v1.27.0
 	go.uber.org/zap/exp v0.3.0
 	golang.org/x/crypto v0.36.0
@@ -105,7 +106,6 @@ require (
 	github.com/stoewer/go-strcase v1.3.0 // indirect
 	github.com/vbatts/tar-split v0.12.1 // indirect
 	go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
-	go.lsp.dev/uri v0.3.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
 	go.opentelemetry.io/otel v1.34.0 // indirect
diff --git a/go.sum b/go.sum
index 25b4b0400..5c58629f9 100644
--- a/go.sum
+++ b/go.sum
@@ -64,8 +64,8 @@ github.com/docker/cli v27.5.1+incompatible h1:JB9cieUT9YNiMITtIsguaN55PLOHhBSz3L
 github.com/docker/cli v27.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0=
-github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v28.0.2+incompatible h1:9BILleFwug5FSSqWBgVevgL3ewDJfWWWyZVqlDMttE8=
+github.com/docker/docker v28.0.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
 github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -178,8 +178,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
 github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
-github.com/quic-go/quic-go v0.50.0 h1:3H/ld1pa3CYhkcc20TPIyG1bNsdhn9qZBGN3b9/UyUo=
-github.com/quic-go/quic-go v0.50.0/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E=
+github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q=
+github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E=
 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
 github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
diff --git a/private/buf/bufctl/controller.go b/private/buf/bufctl/controller.go
index a9baacbf9..ed5521b43 100644
--- a/private/buf/bufctl/controller.go
+++ b/private/buf/bufctl/controller.go
@@ -54,10 +54,13 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
-// ImageWithConfig pairs an Image with lint and breaking configuration.
+// ImageWithConfig pairs an Image with its corresponding [bufmodule.Module] full name
+// (which may be nil), [bufmodule.Module] opaque ID, and lint and breaking configurations.
 type ImageWithConfig interface {
 	bufimage.Image
 
+	ModuleFullName() bufparse.FullName
+	ModuleOpaqueID() string
 	LintConfig() bufconfig.LintConfig
 	BreakingConfig() bufconfig.BreakingConfig
 	PluginConfigs() []bufconfig.PluginConfig
@@ -476,6 +479,8 @@ func (c *controller) GetTargetImageWithConfigsAndCheckClient(
 		imageWithConfigs := []ImageWithConfig{
 			newImageWithConfig(
 				image,
+				nil, // No module name for a single message ref
+				"",  // No module opaque ID for a single message ref
 				lintConfig,
 				breakingConfig,
 				pluginConfigs,
@@ -1170,6 +1175,8 @@ func (c *controller) buildTargetImageWithConfigs(
 			imageWithConfigs,
 			newImageWithConfig(
 				image,
+				module.FullName(),
+				module.OpaqueID(),
 				workspace.GetLintConfigForOpaqueID(module.OpaqueID()),
 				workspace.GetBreakingConfigForOpaqueID(module.OpaqueID()),
 				workspace.PluginConfigs(),
diff --git a/private/buf/bufctl/image_with_config.go b/private/buf/bufctl/image_with_config.go
index 62668180e..cdba3d54b 100644
--- a/private/buf/bufctl/image_with_config.go
+++ b/private/buf/bufctl/image_with_config.go
@@ -17,11 +17,14 @@ package bufctl
 import (
 	"github.com/bufbuild/buf/private/bufpkg/bufconfig"
 	"github.com/bufbuild/buf/private/bufpkg/bufimage"
+	"github.com/bufbuild/buf/private/bufpkg/bufparse"
 )
 
 type imageWithConfig struct {
 	bufimage.Image
 
+	moduleFullName bufparse.FullName
+	moduleOpaqueID string
 	lintConfig     bufconfig.LintConfig
 	breakingConfig bufconfig.BreakingConfig
 	pluginConfigs  []bufconfig.PluginConfig
@@ -29,18 +32,30 @@ type imageWithConfig struct {
 
 func newImageWithConfig(
 	image bufimage.Image,
+	moduleFullName bufparse.FullName,
+	moduleOpaqueID string,
 	lintConfig bufconfig.LintConfig,
 	breakingConfig bufconfig.BreakingConfig,
 	pluginConfigs []bufconfig.PluginConfig,
 ) *imageWithConfig {
 	return &imageWithConfig{
 		Image:          image,
+		moduleFullName: moduleFullName,
+		moduleOpaqueID: moduleOpaqueID,
 		lintConfig:     lintConfig,
 		breakingConfig: breakingConfig,
 		pluginConfigs:  pluginConfigs,
 	}
 }
 
+func (i *imageWithConfig) ModuleFullName() bufparse.FullName {
+	return i.moduleFullName
+}
+
+func (i *imageWithConfig) ModuleOpaqueID() string {
+	return i.moduleOpaqueID
+}
+
 func (i *imageWithConfig) LintConfig() bufconfig.LintConfig {
 	return i.lintConfig
 }
diff --git a/private/buf/buflsp/file.go b/private/buf/buflsp/file.go
index 4d835474e..c990f5af3 100644
--- a/private/buf/buflsp/file.go
+++ b/private/buf/buflsp/file.go
@@ -42,6 +42,7 @@ import (
 	"github.com/bufbuild/protocompile/parser"
 	"github.com/bufbuild/protocompile/reporter"
 	"go.lsp.dev/protocol"
+	"go.lsp.dev/uri"
 )
 
 const (
@@ -518,7 +519,7 @@ func (f *file) IndexImports(ctx context.Context) {
 		if fileInfo.LocalPath() == f.uri.Filename() {
 			imported = f
 		} else {
-			imported = f.Manager().Open(ctx, protocol.URI("file://"+fileInfo.LocalPath()))
+			imported = f.Manager().Open(ctx, uri.File(fileInfo.LocalPath()))
 		}
 
 		imported.objectInfo = fileInfo
@@ -528,7 +529,7 @@ func (f *file) IndexImports(ctx context.Context) {
 	// descriptor.proto is always implicitly imported.
 	if _, ok := f.importToFile[descriptorPath]; !ok {
 		descriptorFile := importable[descriptorPath]
-		descriptorURI := protocol.URI("file://" + descriptorFile.LocalPath())
+		descriptorURI := uri.File(descriptorFile.LocalPath())
 		if f.uri == descriptorURI {
 			f.importToFile[descriptorPath] = f
 		} else {
@@ -568,15 +569,15 @@ func (f *file) newFileOpener() fileOpener {
 	}
 
 	return func(path string) (io.ReadCloser, error) {
-		var uri protocol.URI
+		var newURI protocol.URI
 		fileInfo, ok := f.importablePathToObject[path]
 		if ok {
-			uri = protocol.URI("file://" + fileInfo.LocalPath())
+			newURI = uri.File(fileInfo.LocalPath())
 		} else {
-			uri = protocol.URI("file://" + path)
+			newURI = uri.File(path)
 		}
 
-		if file := f.Manager().Get(uri); file != nil {
+		if file := f.Manager().Get(newURI); file != nil {
 			return ioext.CompositeReadCloser(strings.NewReader(file.text), ioext.NopCloser), nil
 		} else if !ok {
 			return nil, os.ErrNotExist
@@ -744,7 +745,7 @@ func (f *file) appendLintErrors(source string, err error) bool {
 				},
 			},
 			Code:     annotation.Type(),
-			Severity: protocol.DiagnosticSeverityError,
+			Severity: protocol.DiagnosticSeverityWarning,
 			Source:   source,
 			Message:  annotation.Message(),
 		})
diff --git a/private/buf/cmd/buf/command/breaking/breaking.go b/private/buf/cmd/buf/command/breaking/breaking.go
index 3be479a7f..15268fbe5 100644
--- a/private/buf/cmd/buf/command/breaking/breaking.go
+++ b/private/buf/cmd/buf/command/breaking/breaking.go
@@ -30,6 +30,7 @@ import (
 	"github.com/bufbuild/buf/private/pkg/app/appext"
 	"github.com/bufbuild/buf/private/pkg/slicesext"
 	"github.com/bufbuild/buf/private/pkg/stringutil"
+	"github.com/bufbuild/buf/private/pkg/syserror"
 	"github.com/bufbuild/buf/private/pkg/wasm"
 	"github.com/spf13/pflag"
 )
@@ -42,6 +43,7 @@ const (
 	configFlagName            = "config"
 	againstFlagName           = "against"
 	againstConfigFlagName     = "against-config"
+	againstRegistryFlagName   = "against-registry"
 	excludePathsFlagName      = "exclude-path"
 	disableSymlinksFlagName   = "disable-symlinks"
 )
@@ -77,6 +79,7 @@ type flags struct {
 	Config            string
 	Against           string
 	AgainstConfig     string
+	AgainstRegistry   bool
 	ExcludePaths      []string
 	DisableSymlinks   bool
 	// special
@@ -129,7 +132,8 @@ Overrides --%s`,
 		againstFlagName,
 		"",
 		fmt.Sprintf(
-			`Required. The source, module, or image to check against. Must be one of format %s`,
+			`Required, except if --%s is set. The source, module, or image to check against. Must be one of format %s`,
+			againstRegistryFlagName,
 			buffetch.AllFormatsString,
 		),
 	)
@@ -139,6 +143,16 @@ Overrides --%s`,
 		"",
 		`The buf.yaml file or data to use to configure the against source, module, or image`,
 	)
+	flagSet.BoolVar(
+		&f.AgainstRegistry,
+		againstRegistryFlagName,
+		false,
+		fmt.Sprintf(
+			`Run breaking checks against the latest commit on the default branch in the registry. All modules in the input must have a name configured, otherwise this will fail.
+If a remote module is not found with the configured name, then this will fail. This cannot be set with --%s.`,
+			againstFlagName,
+		),
+	)
 }
 
 func run(
@@ -146,7 +160,7 @@ func run(
 	container appext.Container,
 	flags *flags,
 ) (retErr error) {
-	if err := bufcli.ValidateRequiredFlag(againstFlagName, flags.Against); err != nil {
+	if err := validateFlags(flags); err != nil {
 		return err
 	}
 	input, err := bufcli.GetInputValue(container, flags.InputHashtag, ".")
@@ -194,19 +208,64 @@ func run(
 			return err
 		}
 	}
-	// Do not exclude imports here. bufcheck's Client requires all imports.
-	// Use bufcheck's BreakingWithExcludeImports.
-	againstImageWithConfigs, _, err := controller.GetTargetImageWithConfigsAndCheckClient(
-		ctx,
-		flags.Against,
-		wasm.UnimplementedRuntime,
-		bufctl.WithTargetPaths(externalPaths, flags.ExcludePaths),
-		bufctl.WithConfigOverride(flags.AgainstConfig),
-	)
-	if err != nil {
-		return err
+	var againstImages []bufimage.Image
+	if flags.Against != "" {
+		// Do not exclude imports here. bufcheck's Client requires all imports.
+		// Use bufcheck's BreakingWithExcludeImports.
+		againstImagesWithConfigs, _, err := controller.GetTargetImageWithConfigsAndCheckClient(
+			ctx,
+			flags.Against,
+			wasm.UnimplementedRuntime,
+			bufctl.WithTargetPaths(externalPaths, flags.ExcludePaths),
+			bufctl.WithConfigOverride(flags.AgainstConfig),
+		)
+		if err != nil {
+			return err
+		}
+		// We do not require the check configs from the against target once built, so they can
+		// be dropped here.
+		againstImages, err = slicesext.MapError(
+			againstImagesWithConfigs,
+			func(imageWithConfig bufctl.ImageWithConfig) (bufimage.Image, error) {
+				againstImage, ok := imageWithConfig.(bufimage.Image)
+				if !ok {
+					return nil, syserror.New("imageWithConfig could not be converted to Image")
+				}
+				return againstImage, nil
+			},
+		)
+		if err != nil {
+			return err
+		}
 	}
-	if len(imageWithConfigs) != len(againstImageWithConfigs) {
+	if flags.AgainstRegistry {
+		for _, imageWithConfig := range imageWithConfigs {
+			if imageWithConfig.ModuleFullName() == nil {
+				if imageWithConfig.ModuleOpaqueID() == "" {
+					// This can occur in the case of a [buffetch.MessageRef], where we resolve the message
+					// ref directly from the bucket without building the [bufmodule.Module]. In that case,
+					// we are unnable to use --against-registry.
+					return fmt.Errorf("cannot use --%s with unnamed module", againstRegistryFlagName)
+				}
+				return fmt.Errorf(
+					"cannot use --%s with unnamed module, %s",
+					againstRegistryFlagName,
+					imageWithConfig.ModuleOpaqueID(),
+				)
+			}
+			againstImage, err := controller.GetImage(
+				ctx,
+				imageWithConfig.ModuleFullName().String(),
+				bufctl.WithTargetPaths(externalPaths, flags.ExcludePaths),
+				bufctl.WithConfigOverride(flags.AgainstConfig),
+			)
+			if err != nil {
+				return err
+			}
+			againstImages = append(againstImages, againstImage)
+		}
+	}
+	if len(imageWithConfigs) != len(againstImages) {
 		// If workspaces are being used as input, the number
 		// of images MUST match. Otherwise the results will
 		// be meaningless and yield false positives.
@@ -216,7 +275,7 @@ func run(
 		return fmt.Errorf(
 			"input contained %d images, whereas against contained %d images",
 			len(imageWithConfigs),
-			len(againstImageWithConfigs),
+			len(againstImages),
 		)
 	}
 	// We add all check configs (both lint and breaking) as related configs to check if plugins
@@ -240,7 +299,7 @@ func run(
 			ctx,
 			imageWithConfig.BreakingConfig(),
 			imageWithConfig,
-			againstImageWithConfigs[i],
+			againstImages[i],
 			breakingOptions...,
 		); err != nil {
 			var fileAnnotationSet bufanalysis.FileAnnotationSet
@@ -274,3 +333,13 @@ func getExternalPathsForImages[I bufimage.Image, S ~[]I](images S) ([]string, er
 	}
 	return slicesext.MapKeysToSlice(externalPaths), nil
 }
+
+func validateFlags(flags *flags) error {
+	if flags.Against == "" && !flags.AgainstRegistry {
+		return fmt.Errorf("Must set --%s or --%s", againstFlagName, againstRegistryFlagName)
+	}
+	if flags.Against != "" && flags.AgainstRegistry {
+		return fmt.Errorf("Cannot set both --%s and --%s", againstFlagName, againstRegistryFlagName)
+	}
+	return nil
+}
diff --git a/private/pkg/git/cloner.go b/private/pkg/git/cloner.go
index 8d651ef4b..6d6a44a9e 100644
--- a/private/pkg/git/cloner.go
+++ b/private/pkg/git/cloner.go
@@ -181,7 +181,7 @@ func (c *cloner) CloneToBucket(
 		if err := execext.Run(
 			ctx,
 			"git",
-			execext.WithArgs("sparse-checkout", "set", options.SubDir),
+			execext.WithArgs(append(gitConfigAuthArgs, "sparse-checkout", "set", options.SubDir)...),
 			execext.WithEnv(app.Environ(envContainer)),
 			execext.WithStderr(buffer),
 			execext.WithDir(baseDir.Path()),
@@ -196,7 +196,7 @@ func (c *cloner) CloneToBucket(
 	if err := execext.Run(
 		ctx,
 		"git",
-		execext.WithArgs("checkout", "--force", "FETCH_HEAD"),
+		execext.WithArgs(append(gitConfigAuthArgs, "checkout", "--force", "FETCH_HEAD")...),
 		execext.WithEnv(app.Environ(envContainer)),
 		execext.WithStderr(buffer),
 		execext.WithDir(baseDir.Path()),
@@ -210,7 +210,7 @@ func (c *cloner) CloneToBucket(
 		if err := execext.Run(
 			ctx,
 			"git",
-			execext.WithArgs("checkout", "--force", checkoutRef),
+			execext.WithArgs(append(gitConfigAuthArgs, "checkout", "--force", checkoutRef)...),
 			execext.WithEnv(app.Environ(envContainer)),
 			execext.WithStderr(buffer),
 			execext.WithDir(baseDir.Path()),


Test patch:
diff --git a/private/buf/cmd/buf/buf_test.go b/private/buf/cmd/buf/buf_test.go
index 600b15943..872885892 100644
--- a/private/buf/cmd/buf/buf_test.go
+++ b/private/buf/cmd/buf/buf_test.go
@@ -3558,6 +3558,30 @@ testdata/check_plugins/current/proto/common/v1/breaking.proto:18:1:Enum "common.
 	)
 }
 
+func TestBreakingAgainstRegistryFlag(t *testing.T) {
+	t.Parallel()
+	testRunStderr(
+		t,
+		nil,
+		1,
+		"Failure: Cannot set both --against and --against-registry",
+		"breaking",
+		filepath.Join("testdata", "check_plugins", "current", "proto"),
+		"--against",
+		filepath.Join("testdata", "check_plugins", "previous", "proto"),
+		"--against-registry",
+	)
+	testRunStderr(
+		t,
+		nil,
+		1,
+		"Failure: cannot use --against-registry with unnamed module, testdata/success",
+		"breaking",
+		filepath.Join("testdata", "success"),
+		"--against-registry",
+	)
+}
+
 func TestVersion(t *testing.T) {
 	t.Parallel()
 	testRunStdout(t, nil, 0, bufcli.Version, "--version")