Skip to content
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Config struct {
DevStreamURI string `json:"dev-stream-uri,omitempty" yaml:"dev-stream-uri,omitempty"`
Environment string `json:"environment,omitempty" yaml:"environment,omitempty"`
Flag string `json:"flag,omitempty" yaml:"flag,omitempty"`
InstallationID string `json:"-" yaml:"installation-id,omitempty"`
Output string `json:"output,omitempty" yaml:"output,omitempty"`
Project string `json:"project,omitempty" yaml:"project,omitempty"`
}
Expand Down
120 changes: 120 additions & 0 deletions internal/config/installation_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package config

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

"github.com/google/uuid"
"gopkg.in/yaml.v3"
)

const installationIDKey = "installation-id"

func EnsureInstallationID(filename string) (string, error) {
if strings.TrimSpace(filename) == "" {
return "", errors.New("config filename is required")
}

values, mode, err := readConfigValues(filename)
if err != nil {
return "", err
}

if value, ok := values[installationIDKey]; ok {
installationID, ok := value.(string)
if !ok || uuid.Validate(installationID) != nil {
return "", errors.New("ldcli installation ID in config is invalid")
}

return installationID, nil
}

installationID := uuid.NewString()
values[installationIDKey] = installationID

if err := writeConfigValues(filename, values, mode); err != nil {
return "", err
}

return installationID, nil
}

func readConfigValues(filename string) (map[string]any, os.FileMode, error) {
data, err := os.ReadFile(filename)
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, 0o600, nil
}
if err != nil {
return nil, 0, fmt.Errorf("read ldcli config: %w", err)
}

values := map[string]any{}
if err := yaml.Unmarshal(data, &values); err != nil {
return nil, 0, fmt.Errorf("parse ldcli config: %w", err)
}

info, err := os.Stat(filename)
if err != nil {
return nil, 0, fmt.Errorf("stat ldcli config: %w", err)
}

mode := info.Mode().Perm()
if mode == 0 {
mode = 0o600
}

return values, mode, nil
}

func writeConfigValues(
filename string,
values map[string]any,
mode os.FileMode,
) error {
data, err := yaml.Marshal(values)
if err != nil {
return fmt.Errorf("marshal ldcli config: %w", err)
}

dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create ldcli config directory: %w", err)
}

file, err := os.CreateTemp(dir, ".config-*.tmp")
if err != nil {
return fmt.Errorf("create temporary ldcli config: %w", err)
}

tempName := file.Name()
defer func() {
_ = os.Remove(tempName)
}()

if err := file.Chmod(mode); err != nil {
_ = file.Close()

return fmt.Errorf("set ldcli config permissions: %w", err)
}
if _, err := file.Write(data); err != nil {
_ = file.Close()

return fmt.Errorf("write ldcli config: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()

return fmt.Errorf("sync ldcli config: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close ldcli config: %w", err)
}
if err := os.Rename(tempName, filename); err != nil {
return fmt.Errorf("replace ldcli config: %w", err)
}

return nil
}
89 changes: 89 additions & 0 deletions internal/config/installation_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package config

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func TestEnsureInstallationIDCreatesConfig(t *testing.T) {
filename := filepath.Join(t.TempDir(), "ldcli", "config.yml")

installationID, err := EnsureInstallationID(filename)

require.NoError(t, err)
require.NoError(t, uuid.Validate(installationID))

loaded, err := New(filename, os.ReadFile)
require.NoError(t, err)
assert.Equal(t, installationID, loaded.InstallationID)

info, err := os.Stat(filename)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}

func TestEnsureInstallationIDIsStableAndPreservesConfig(t *testing.T) {
filename := filepath.Join(t.TempDir(), "config.yml")
require.NoError(t, os.WriteFile(
filename,
[]byte("access-token: token\nfuture-setting: value\n"),
0o640,
))

first, err := EnsureInstallationID(filename)
require.NoError(t, err)
second, err := EnsureInstallationID(filename)
require.NoError(t, err)
assert.Equal(t, first, second)

data, err := os.ReadFile(filename)
require.NoError(t, err)

var values map[string]any
require.NoError(t, yaml.Unmarshal(data, &values))
assert.Equal(t, "token", values["access-token"])
assert.Equal(t, "value", values["future-setting"])
assert.Equal(t, first, values[installationIDKey])

info, err := os.Stat(filename)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o640), info.Mode().Perm())
}

func TestEnsureInstallationIDUsesExistingValueWithoutRewriting(t *testing.T) {
filename := filepath.Join(t.TempDir(), "config.yml")
const existing = "installation-id: 45cb6eca-6c83-4db6-b171-174fd2fed588\n"
require.NoError(t, os.WriteFile(filename, []byte(existing), 0o600))

installationID, err := EnsureInstallationID(filename)

require.NoError(t, err)
assert.Equal(t, "45cb6eca-6c83-4db6-b171-174fd2fed588", installationID)

data, err := os.ReadFile(filename)
require.NoError(t, err)
assert.Equal(t, existing, string(data))
}

func TestEnsureInstallationIDRejectsInvalidValue(t *testing.T) {
filename := filepath.Join(t.TempDir(), "config.yml")
require.NoError(t, os.WriteFile(filename, []byte("installation-id: invalid\n"), 0o600))

_, err := EnsureInstallationID(filename)

require.ErrorContains(t, err, "installation ID")
}

func TestConfigJSONDoesNotExposeInstallationID(t *testing.T) {
data, err := json.Marshal(Config{InstallationID: uuid.NewString()})

require.NoError(t, err)
assert.JSONEq(t, `{}`, string(data))
}
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)
}

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)
}
Loading
Loading