Skip to content
Draft
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
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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