diff --git a/internal/config/config.go b/internal/config/config.go index b6f72004..ec957412 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } diff --git a/internal/config/installation_id.go b/internal/config/installation_id.go new file mode 100644 index 00000000..7f20e613 --- /dev/null +++ b/internal/config/installation_id.go @@ -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 +} diff --git a/internal/config/installation_id_test.go b/internal/config/installation_id_test.go new file mode 100644 index 00000000..6f1aea57 --- /dev/null +++ b/internal/config/installation_id_test.go @@ -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)) +}