diff --git a/internal/sync/repository/git.go b/internal/sync/repository/git.go new file mode 100644 index 00000000..ccdc824f --- /dev/null +++ b/internal/sync/repository/git.go @@ -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) + } + + 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) +} diff --git a/internal/sync/repository/git_test.go b/internal/sync/repository/git_test.go new file mode 100644 index 00000000..ba8e7131 --- /dev/null +++ b/internal/sync/repository/git_test.go @@ -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) +} diff --git a/internal/sync/source.go b/internal/sync/source.go new file mode 100644 index 00000000..b3fd2f4e --- /dev/null +++ b/internal/sync/source.go @@ -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 +} diff --git a/internal/sync/source/resolver.go b/internal/sync/source/resolver.go new file mode 100644 index 00000000..769d1bf7 --- /dev/null +++ b/internal/sync/source/resolver.go @@ -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 +} diff --git a/internal/sync/source/resolver_test.go b/internal/sync/source/resolver_test.go new file mode 100644 index 00000000..9e4dd547 --- /dev/null +++ b/internal/sync/source/resolver_test.go @@ -0,0 +1,87 @@ +package source + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +func TestResolverUsesGitSourceWhenAvailable(t *testing.T) { + root := t.TempDir() + gitSource, err := syncdomain.NewSource( + syncdomain.SourceTypeGit, + "github.com/launchdarkly/ldcli", + ) + require.NoError(t, err) + + resolver := NewResolver() + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{Root: root, Source: gitSource}, true, nil + } + + workspace, err := resolver.Resolve(filepath.Join(root, "nested")) + + require.NoError(t, err) + assert.Equal(t, requireCanonicalPath(t, root), workspace.Root) + assert.Equal(t, gitSource, workspace.Source) +} + +func TestResolverRequiresGit(t *testing.T) { + resolver := NewResolver() + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, nil + } + + _, err := resolver.Resolve(t.TempDir()) + + require.ErrorIs(t, err, ErrGitRequired) + require.ErrorContains(t, err, "git is required to use sync") +} + +func TestResolverCanonicalizesSymlinkedWorkspace(t *testing.T) { + root := t.TempDir() + link := filepath.Join(t.TempDir(), "workspace") + require.NoError(t, os.Symlink(root, link)) + gitSource, err := syncdomain.NewSource( + syncdomain.SourceTypeGit, + "github.com/launchdarkly/ldcli", + ) + require.NoError(t, err) + resolver := NewResolver() + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{Root: link, Source: gitSource}, true, nil + } + + workspace, err := resolver.Resolve(link) + + require.NoError(t, err) + assert.Equal(t, requireCanonicalPath(t, root), workspace.Root) + assert.Equal(t, gitSource, workspace.Source) +} + +func TestResolverReturnsGitErrors(t *testing.T) { + resolver := NewResolver() + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, errors.New("invalid origin") + } + + _, err := resolver.Resolve(t.TempDir()) + + require.ErrorContains(t, err, "invalid origin") +} + +func requireCanonicalPath(t *testing.T, path string) string { + t.Helper() + + resolved, err := canonicalPath(path) + require.NoError(t, err) + + return resolved +} diff --git a/internal/sync/source_test.go b/internal/sync/source_test.go new file mode 100644 index 00000000..384396db --- /dev/null +++ b/internal/sync/source_test.go @@ -0,0 +1,44 @@ +package sync + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSource(t *testing.T) { + source, err := NewSource(SourceTypeGit, "source-identifier") + + require.NoError(t, err) + assert.Equal(t, SourceTypeGit, source.Type()) + assert.Equal(t, "source-identifier", source.Identifier()) +} + +func TestNewSourceRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + sourceType SourceType + identifier string + wantErr error + }{ + {"invalid type", "filesystem", "local/project", ErrInvalidSourceType}, + {"missing identifier", SourceTypeGit, "", ErrInvalidSourceIdentifier}, + {"blank identifier", SourceTypeGit, " ", ErrInvalidSourceIdentifier}, + {"surrounding whitespace", SourceTypeGit, " source ", ErrInvalidSourceIdentifier}, + { + "identifier too long", + SourceTypeGit, + strings.Repeat("a", MaxSourceIdentifierLength+1), + ErrInvalidSourceIdentifier, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewSource(test.sourceType, test.identifier) + assert.ErrorIs(t, err, test.wantErr) + }) + } +}