Skip to content
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
149 changes: 149 additions & 0 deletions internal/sync/repository/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package repository

import (
"fmt"
"net"
"net/url"
"os/exec"
"strings"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

type GitRepository struct {
Root string
Source syncdomain.Source
}

type gitRunner interface {
lookPath(name string) (string, error)
output(dir string, args ...string) (string, error)
}

type execGit struct{}

func (execGit) lookPath(name string) (string, error) {
return exec.LookPath(name)
}

func (execGit) output(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}

return strings.TrimSpace(string(out)), nil
}

func FindGitSource(dir string) (GitRepository, bool, error) {
return findGitSource(execGit{}, dir)
}

func findGitSource(git gitRunner, dir string) (GitRepository, bool, error) {
if _, err := git.lookPath("git"); err != nil {
return GitRepository{}, false, nil
}

root, err := git.output(dir, "rev-parse", "--show-toplevel")
if err != nil {
return GitRepository{}, false, nil
}

origin, err := git.output(root, "config", "--local", "--get", "remote.origin.url")
if err != nil || origin == "" {
return GitRepository{}, false, nil
}

identifier, err := gitSourceIdentifier(origin)
if err != nil {
return GitRepository{}, false, err
}

source, err := syncdomain.NewSource(syncdomain.SourceTypeGit, identifier)
if err != nil {
return GitRepository{}, false, err
}

return GitRepository{Root: root, Source: source}, true, nil
}

func gitSourceIdentifier(remote string) (string, error) {
remote = strings.TrimSpace(remote)

var host, repoPath string

if strings.Contains(remote, "://") {
parsed, err := url.Parse(remote)
if err != nil || parsed.Host == "" {
return "", invalidGitRemote(remote)
}

host = normalizedURLHost(parsed)
repoPath = parsed.Path
} else {
remoteHost, remotePath, ok := strings.Cut(remote, ":")
if !ok {
return "", invalidGitRemote(remote)
}

if _, value, ok := strings.Cut(remoteHost, "@"); ok {
remoteHost = value
}

host = strings.ToLower(strings.TrimSpace(remoteHost))
repoPath = remotePath
}

repoPath = strings.TrimSuffix(strings.Trim(repoPath, "/"), ".git")
if host == "" || !validRepositoryPath(repoPath) {
return "", invalidGitRemote(remote)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Local remotes become source identities

Medium Severity

gitSourceIdentifier never checks the URL scheme and treats any colon-separated string as an SCP remote. file:// remotes are rejected only when the host is empty, so forms with a host still produce an identifier, and Windows drive-letter remotes are parsed as SCP hosts. Those values become machine-specific source identities instead of failing closed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e884316. Configure here.


return host + "/" + repoPath, nil
}

func normalizedURLHost(remote *url.URL) string {
host := strings.ToLower(remote.Hostname())
port := remote.Port()
if port == "" || isDefaultPort(remote.Scheme, port) {
return host
}

return net.JoinHostPort(host, port)
}

func isDefaultPort(scheme, port string) bool {
switch strings.ToLower(scheme) {
case "http":
return port == "80"
case "https":
return port == "443"
case "ssh":
return port == "22"
case "git":
return port == "9418"
default:
return false
}
}

func validRepositoryPath(repoPath string) bool {
parts := strings.Split(repoPath, "/")
if len(parts) < 2 {
return false
}

for _, part := range parts {
if part == "" || part == "." || part == ".." {
return false
}
}

return true
}

func invalidGitRemote(remote string) error {
return fmt.Errorf("cannot derive source identifier from Git origin %q", remote)
}
135 changes: 135 additions & 0 deletions internal/sync/repository/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package repository

import (
"errors"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

func TestGitSourceIdentifier(t *testing.T) {
tests := map[string]string{
"git@github.com:launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli",
"https://github.com/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli",
"https://github.com:443/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli",
"ssh://git@github.com/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli",
"ssh://git@git.example.com:2222/platform/team/service": "git.example.com:2222/platform/team/service",
}

for remote, expected := range tests {
t.Run(remote, func(t *testing.T) {
actual, err := gitSourceIdentifier(remote)
require.NoError(t, err)
assert.Equal(t, expected, actual)
})
}
}

func TestGitSourceIdentifierRejectsInvalidRemote(t *testing.T) {
for _, remote := range []string{
"",
"ldcli",
"file:///workspace/launchdarkly/ldcli",
"https://github.com/ldcli",
"https://github.com/org/../repo",
} {
t.Run(remote, func(t *testing.T) {
_, err := gitSourceIdentifier(remote)
require.Error(t, err)
})
}
}

func TestFindGitSource(t *testing.T) {
repository, found, err := findGitSource(stubGit{
commands: map[string]string{
"rev-parse --show-toplevel": "/workspace",
"config --local --get remote.origin.url": "git@github.com:Acme/Widgets.git",
},
}, "/workspace/service")

require.NoError(t, err)
require.True(t, found)
assert.Equal(t, "/workspace", repository.Root)
assert.Equal(t, syncdomain.SourceTypeGit, repository.Source.Type())
assert.Equal(t, "github.com/Acme/Widgets", repository.Source.Identifier())
}

func TestFindGitSourceFallsBackWhenGitIdentityIsUnavailable(t *testing.T) {
tests := []struct {
name string
git stubGit
}{
{
name: "git is not installed",
git: stubGit{pathErr: errors.New("not found")},
},
{
name: "workspace is not a repository",
git: stubGit{
errors: map[string]error{"rev-parse --show-toplevel": errors.New("not a repository")},
},
},
{
name: "repository has no origin",
git: stubGit{
commands: map[string]string{"rev-parse --show-toplevel": "/workspace"},
errors: map[string]error{
"config --local --get remote.origin.url": errors.New("missing"),
},
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository, found, err := findGitSource(test.git, "/workspace")

require.NoError(t, err)
assert.False(t, found)
assert.Empty(t, repository)
})
}
}

func TestFindGitSourceRejectsInvalidOrigin(t *testing.T) {
_, found, err := findGitSource(stubGit{
commands: map[string]string{
"rev-parse --show-toplevel": "/workspace",
"config --local --get remote.origin.url": "invalid",
},
}, "/workspace")

require.Error(t, err)
assert.False(t, found)
}

type stubGit struct {
pathErr error
commands map[string]string
errors map[string]error
}

func (s stubGit) lookPath(string) (string, error) {
if s.pathErr != nil {
return "", s.pathErr
}

return "/usr/bin/git", nil
}

func (s stubGit) output(_ string, args ...string) (string, error) {
key := strings.Join(args, " ")
if err, ok := s.errors[key]; ok {
return "", err
}
if output, ok := s.commands[key]; ok {
return output, nil
}

return "", errors.New("unexpected git " + key)
}
46 changes: 46 additions & 0 deletions internal/sync/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package sync

import (
"errors"
"strings"
)

const MaxSourceIdentifierLength = 512

type SourceType string

const SourceTypeGit SourceType = "git"

var (
ErrInvalidSourceType = errors.New("invalid source type")
ErrInvalidSourceIdentifier = errors.New("invalid source identifier")
)

type Source struct {
sourceType SourceType
identifier string
}

func NewSource(sourceType SourceType, identifier string) (Source, error) {
if sourceType != SourceTypeGit {
return Source{}, ErrInvalidSourceType
}
if identifier == "" ||
identifier != strings.TrimSpace(identifier) ||
len(identifier) > MaxSourceIdentifierLength {
return Source{}, ErrInvalidSourceIdentifier
}

return Source{
sourceType: sourceType,
identifier: identifier,
}, nil
}

func (source Source) Type() SourceType {
return source.sourceType
}

func (source Source) Identifier() string {
return source.identifier
}
60 changes: 60 additions & 0 deletions internal/sync/source/resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package source

import (
"errors"
"fmt"
"path/filepath"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
"github.com/launchdarkly/ldcli/internal/sync/repository"
)

var ErrGitRequired = errors.New(
"git is required to use sync; initialize a Git repository and configure an origin remote",
)

type Workspace struct {
Root string
Source syncdomain.Source
}

type Resolver struct {
findGitSource func(string) (repository.GitRepository, bool, error)
}

func NewResolver() Resolver {
return Resolver{
findGitSource: repository.FindGitSource,
}
}

func (resolver Resolver) Resolve(dir string) (Workspace, error) {
gitRepository, found, err := resolver.findGitSource(dir)
if err != nil {
return Workspace{}, err
}
if !found {
return Workspace{}, ErrGitRequired
}

root, err := canonicalPath(gitRepository.Root)
if err != nil {
return Workspace{}, err
}

return Workspace{Root: root, Source: gitRepository.Source}, nil
}

func canonicalPath(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve absolute workspace path: %w", err)
}

resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return "", fmt.Errorf("resolve workspace symlinks: %w", err)
}

return filepath.Clean(resolved), nil
}
Loading
Loading