diff --git a/internal/sync/manifest/model.go b/internal/sync/manifest/model.go new file mode 100644 index 00000000..f3db682c --- /dev/null +++ b/internal/sync/manifest/model.go @@ -0,0 +1,128 @@ +package manifest + +import ( + "fmt" + "regexp" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// FormatVersion is the current manifest schema version. +const FormatVersion = 1 + +var fingerprintPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// Manifest records the common resource state accepted by the last successful +// synchronization. +type Manifest struct { + FormatVersion int `yaml:"formatVersion"` + Resources []Resource `yaml:"resources"` +} + +// Resource identifies one tracked resource and its last synchronized +// fingerprint. +type Resource struct { + ResourceKind syncdomain.Kind `yaml:"resourceKind"` + ProjectKey string `yaml:"projectKey"` + LookupKey string `yaml:"lookupKey"` + Fingerprint string `yaml:"fingerprint"` +} + +// ID returns the common identity represented by this manifest entry. +func (resource Resource) ID() syncdomain.ResourceID { + return syncdomain.ResourceID{Kind: resource.ResourceKind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey} +} + +// New returns an empty current-version manifest. +func New() Manifest { + return Manifest{FormatVersion: FormatVersion, Resources: []Resource{}} +} + +// SetFingerprint records the last synchronized state for one resource. +func (manifest *Manifest) SetFingerprint(id syncdomain.ResourceID, fingerprint string) { + for index := range manifest.Resources { + if manifest.Resources[index].ID() == id { + manifest.Resources[index].Fingerprint = fingerprint + return + } + } + manifest.Resources = append(manifest.Resources, Resource{ + ResourceKind: id.Kind, + ProjectKey: id.ProjectKey, + LookupKey: id.LookupKey, + Fingerprint: fingerprint, + }) +} + +// Remove deletes one resource from the manifest. +func (manifest *Manifest) Remove(id syncdomain.ResourceID) { + for index, resource := range manifest.Resources { + if resource.ID() == id { + manifest.Resources = append(manifest.Resources[:index], manifest.Resources[index+1:]...) + return + } + } +} + +// Validate checks the manifest schema and resource identities. +func (manifest Manifest) Validate() error { + if manifest.FormatVersion != FormatVersion { + return fmt.Errorf("unsupported manifest formatVersion %d", manifest.FormatVersion) + } + + seen := make(map[syncdomain.ResourceID]struct{}, len(manifest.Resources)) + for _, resource := range manifest.Resources { + if err := validatePathSegment("resource kind", string(resource.ResourceKind)); err != nil { + return err + } + if err := validatePathSegment("project key", resource.ProjectKey); err != nil { + return err + } + if err := validateLookupKey(resource.LookupKey); err != nil { + return err + } + if !fingerprintPattern.MatchString(resource.Fingerprint) { + return fmt.Errorf("invalid fingerprint for %s/%s", resource.ProjectKey, resource.LookupKey) + } + + identity := resource.ID() + if _, exists := seen[identity]; exists { + return fmt.Errorf("duplicate manifest resource %s/%s", resource.ProjectKey, resource.LookupKey) + } + seen[identity] = struct{}{} + } + return nil +} + +// Sort orders resources deterministically for stable Git diffs. +func (manifest *Manifest) Sort() { + slices.SortFunc(manifest.Resources, func(left, right Resource) int { + if result := strings.Compare(string(left.ResourceKind), string(right.ResourceKind)); result != 0 { + return result + } + if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { + return result + } + return strings.Compare(left.LookupKey, right.LookupKey) + }) +} + +// validateLookupKey checks every slash-delimited resource identity segment. +func validateLookupKey(value string) error { + for _, segment := range strings.Split(value, "/") { + if err := validatePathSegment("lookup segment", segment); err != nil { + return fmt.Errorf("invalid lookup key %q: %w", value, err) + } + } + return nil +} + +// validatePathSegment rejects values that are empty, unsafe, or non-portable. +func validatePathSegment(name, value string) error { + if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\`) || strings.IndexByte(value, 0) >= 0 { + return fmt.Errorf("invalid %s %q", name, value) + } + return nil +} diff --git a/internal/sync/manifest/model_test.go b/internal/sync/manifest/model_test.go new file mode 100644 index 00000000..e1f9c3b3 --- /dev/null +++ b/internal/sync/manifest/model_test.go @@ -0,0 +1,27 @@ +package manifest + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestManifestSetFingerprintAndRemove(t *testing.T) { + first := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/first"} + second := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/second"} + manifest := New() + + manifest.SetFingerprint(first, "sha256:first") + manifest.SetFingerprint(first, "sha256:updated") + manifest.SetFingerprint(second, "sha256:second") + manifest.Remove(first) + + assert.Equal(t, []Resource{{ + ResourceKind: second.Kind, + ProjectKey: second.ProjectKey, + LookupKey: second.LookupKey, + Fingerprint: "sha256:second", + }}, manifest.Resources) +} diff --git a/internal/sync/manifest/store.go b/internal/sync/manifest/store.go new file mode 100644 index 00000000..8e1c97ee --- /dev/null +++ b/internal/sync/manifest/store.go @@ -0,0 +1,129 @@ +package manifest + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "gopkg.in/yaml.v3" +) + +// FileName is the repository-local sync manifest filename. +const FileName = "manifest.yaml" + +// Store reads and atomically writes the committed synchronization manifest. +type Store struct { + path string +} + +// NewStore creates a manifest store rooted at the Git repository. +func NewStore(repositoryRoot string) Store { + return Store{path: filepath.Join(repositoryRoot, syncdomain.RootDir, FileName)} +} + +// Load returns the manifest and whether it already exists. +func (store Store) Load() (Manifest, bool, error) { + data, err := os.ReadFile(store.path) + if errors.Is(err, os.ErrNotExist) { + return New(), false, nil + } + if err != nil { + return Manifest{}, false, fmt.Errorf("read sync manifest: %w", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + // A committed manifest is an API between CLI versions. Reject unknown + // fields instead of silently discarding data written by a newer schema. + decoder.KnownFields(true) + var manifest Manifest + if err := decoder.Decode(&manifest); err != nil { + return Manifest{}, false, fmt.Errorf("decode sync manifest: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple YAML documents are not supported") + } + return Manifest{}, false, fmt.Errorf("decode sync manifest: %w", err) + } + if err := manifest.Validate(); err != nil { + return Manifest{}, false, fmt.Errorf("validate sync manifest: %w", err) + } + manifest.Sort() + return manifest, true, nil +} + +// Write atomically replaces the manifest with deterministic YAML. +func (store Store) Write(manifest Manifest) error { + manifest.FormatVersion = FormatVersion + if manifest.Resources == nil { + manifest.Resources = []Resource{} + } + manifest.Sort() + if err := manifest.Validate(); err != nil { + return fmt.Errorf("validate sync manifest: %w", err) + } + + var data bytes.Buffer + encoder := yaml.NewEncoder(&data) + encoder.SetIndent(2) + if err := encoder.Encode(manifest); err != nil { + return fmt.Errorf("encode sync manifest: %w", err) + } + if err := encoder.Close(); err != nil { + return fmt.Errorf("encode sync manifest: %w", err) + } + + directory := filepath.Dir(store.path) + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("create sync directory: %w", err) + } + temporary, err := os.CreateTemp(directory, ".manifest-*.yaml") + if err != nil { + return fmt.Errorf("create temporary sync manifest: %w", err) + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + + // Flush and close the complete temporary file before the single rename + // commit point, so readers observe either the old or the new manifest. + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() + return fmt.Errorf("set sync manifest permissions: %w", err) + } + if _, err := temporary.Write(data.Bytes()); err != nil { + _ = temporary.Close() + return fmt.Errorf("write sync manifest: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync manifest contents: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close sync manifest: %w", err) + } + if err := os.Rename(temporaryPath, store.path); err != nil { + return fmt.Errorf("replace sync manifest: %w", err) + } + + // The rename above is the commit point. Directory syncing improves crash + // durability where the platform supports it, but must not turn a committed + // replacement into a reported failure. + if directoryHandle, err := os.Open(directory); err == nil { + _ = directoryHandle.Sync() + _ = directoryHandle.Close() + } + return nil +} + +// Remove deletes the manifest when rolling back creation of a new manifest. +func (store Store) Remove() error { + if err := os.Remove(store.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove sync manifest: %w", err) + } + return nil +} diff --git a/internal/sync/manifest/store_test.go b/internal/sync/manifest/store_test.go new file mode 100644 index 00000000..394c8ef0 --- /dev/null +++ b/internal/sync/manifest/store_test.go @@ -0,0 +1,141 @@ +package manifest + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestStoreRoundTripIsDeterministic(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + input := Manifest{ + Resources: []Resource{ + {ResourceKind: syncdomain.KindVariation, ProjectKey: "zeta", LookupKey: "config/b", Fingerprint: fingerprint("b")}, + {ResourceKind: syncdomain.KindVariation, ProjectKey: "alpha", LookupKey: "config/a", Fingerprint: fingerprint("a")}, + }, + } + + require.NoError(t, store.Write(input)) + data, err := os.ReadFile(filepath.Join(root, syncdomain.RootDir, FileName)) + require.NoError(t, err) + require.Equal(t, `formatVersion: 1 +resources: + - resourceKind: variation + projectKey: alpha + lookupKey: config/a + fingerprint: `+fingerprint("a")+` + - resourceKind: variation + projectKey: zeta + lookupKey: config/b + fingerprint: `+fingerprint("b")+` +`, string(data)) + + loaded, exists, err := store.Load() + require.NoError(t, err) + require.True(t, exists) + require.Equal(t, []Resource{ + {ResourceKind: syncdomain.KindVariation, ProjectKey: "alpha", LookupKey: "config/a", Fingerprint: fingerprint("a")}, + {ResourceKind: syncdomain.KindVariation, ProjectKey: "zeta", LookupKey: "config/b", Fingerprint: fingerprint("b")}, + }, loaded.Resources) +} + +func TestStoreLoadsMissingManifestAsEmpty(t *testing.T) { + loaded, exists, err := NewStore(t.TempDir()).Load() + + require.NoError(t, err) + require.False(t, exists) + require.Equal(t, New(), loaded) +} + +func TestStoreRejectsUnknownFields(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, syncdomain.RootDir, FileName) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("formatVersion: 1\nunknown: true\nresources: []\n"), 0o644)) + + _, _, err := NewStore(root).Load() + require.ErrorContains(t, err, "field unknown not found") +} + +func TestStoreRejectsMultipleYAMLDocuments(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, syncdomain.RootDir, FileName) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("formatVersion: 1\nresources: []\n---\nformatVersion: 1\nresources: []\n"), 0o644)) + + _, _, err := NewStore(root).Load() + require.ErrorContains(t, err, "multiple YAML documents") +} + +func TestManifestValidation(t *testing.T) { + valid := Resource{ + ResourceKind: syncdomain.KindVariation, + ProjectKey: "project", + LookupKey: "config/variation", + Fingerprint: fingerprint("a"), + } + + tests := map[string]struct { + mutate func(*Manifest) + error string + }{ + "format": { + mutate: func(manifest *Manifest) { manifest.FormatVersion = 2 }, + error: "unsupported manifest formatVersion", + }, + "kind": { + mutate: func(manifest *Manifest) { manifest.Resources[0].ResourceKind = "" }, + error: "invalid resource kind", + }, + "project traversal": { + mutate: func(manifest *Manifest) { manifest.Resources[0].ProjectKey = ".." }, + error: "invalid project key", + }, + "lookup traversal": { + mutate: func(manifest *Manifest) { manifest.Resources[0].LookupKey = "../variation" }, + error: "invalid lookup key", + }, + "empty lookup segment": { + mutate: func(manifest *Manifest) { manifest.Resources[0].LookupKey = "config//variation" }, + error: "invalid lookup key", + }, + "fingerprint": { + mutate: func(manifest *Manifest) { manifest.Resources[0].Fingerprint = "not-a-hash" }, + error: "invalid fingerprint", + }, + "duplicate": { + mutate: func(manifest *Manifest) { manifest.Resources = append(manifest.Resources, manifest.Resources[0]) }, + error: "duplicate manifest resource", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + manifest := Manifest{FormatVersion: FormatVersion, Resources: []Resource{valid}} + test.mutate(&manifest) + require.ErrorContains(t, manifest.Validate(), test.error) + }) + } +} + +func TestManifestSupportsDifferentResourceIdentities(t *testing.T) { + manifest := Manifest{ + FormatVersion: FormatVersion, + Resources: []Resource{ + {ResourceKind: "tool", ProjectKey: "project", LookupKey: "weather", Fingerprint: fingerprint("a")}, + {ResourceKind: "skill", ProjectKey: "project", LookupKey: "support/summarize/v2", Fingerprint: fingerprint("b")}, + }, + } + + require.NoError(t, manifest.Validate()) +} + +func fingerprint(value string) string { + return "sha256:" + strings.Repeat(value, 64) +}