diff --git a/cmd/sops/codes/codes.go b/cmd/sops/codes/codes.go index d62605bd05..d49666dc16 100644 --- a/cmd/sops/codes/codes.go +++ b/cmd/sops/codes/codes.go @@ -19,6 +19,7 @@ const ( MacMismatch int = 51 MacNotFound int = 52 ConfigFileNotFound int = 61 + NoRulesMatched int = 62 KeyboardInterrupt int = 85 InvalidTreePathFormat int = 91 NeedAtLeastOneDocument int = 92 diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 330c3bc8eb..6a9024411d 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -26,6 +26,7 @@ import ( "github.com/getsops/sops/v3/azkv" "github.com/getsops/sops/v3/cmd/sops/codes" "github.com/getsops/sops/v3/cmd/sops/common" + configcmd "github.com/getsops/sops/v3/cmd/sops/subcommand/config" "github.com/getsops/sops/v3/cmd/sops/subcommand/exec" filestatuscmd "github.com/getsops/sops/v3/cmd/sops/subcommand/filestatus" "github.com/getsops/sops/v3/cmd/sops/subcommand/groups" @@ -550,6 +551,73 @@ func main() { return nil }, }, + { + Name: "config", + Usage: "print the .sops.yaml rules (creation + destination) that apply to a file path", + ArgsUsage: `file`, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "require-match", + Usage: "exit non-zero if no creation_rule or destination_rule applies to the given file path", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return common.NewExitError("Error: no file specified", codes.NoFileSpecified) + } + if c.NArg() > 1 { + return common.NewExitError("Error: too many arguments", codes.ErrorGeneric) + } + + inputPath := c.Args()[0] + + absFilePath, err := filepath.Abs(inputPath) + if err != nil { + return common.NewExitError(fmt.Sprintf("Error: cannot resolve file path: %v", err), codes.ErrorGeneric) + } + + // Resolve config path. --config (top-level flag) wins; otherwise auto-discover. + configPath := c.GlobalString("config") + if configPath == "" { + // FindConfigFile takes a file path; it walks up from the file's directory + // looking for .sops.yaml (LookupConfigFile internally calls path.Dir). + configPath, err = config.FindConfigFile(absFilePath) + if err != nil { + return common.NewExitError(fmt.Sprintf("Error: %v", err), codes.ConfigFileNotFound) + } + } + absConfPath, err := filepath.Abs(configPath) + if err != nil { + return common.NewExitError(fmt.Sprintf("Error: cannot resolve config path: %v", err), codes.ErrorGeneric) + } + + opts := configcmd.Opts{ + ConfigPath: absConfPath, + FilePath: absFilePath, + RequireMatch: c.Bool("require-match"), + } + + output, exitCode, runErr := configcmd.Run(opts) + + // Always print the JSON if we have an Output (even when runErr is set + // for --require-match — consumers may still want the structured data). + if output != nil { + b, mErr := configcmd.Marshal(output) + if mErr != nil { + return common.NewExitError(fmt.Sprintf("Error: cannot marshal output: %v", mErr), codes.ErrorGeneric) + } + fmt.Println(string(b)) + } + + if runErr != nil { + return common.NewExitError(fmt.Sprintf("Error: %v", runErr), exitCode) + } + if exitCode != 0 { + return common.NewExitError("", exitCode) + } + return nil + }, + }, { Name: "groups", Usage: "modify the groups on a SOPS file", diff --git a/cmd/sops/subcommand/config/config.go b/cmd/sops/subcommand/config/config.go new file mode 100644 index 0000000000..5fa5173172 --- /dev/null +++ b/cmd/sops/subcommand/config/config.go @@ -0,0 +1,362 @@ +// Package config implements the `sops config ` subcommand, which +// prints the .sops.yaml rules that would apply to a given file path. +package config + +import ( + "encoding/json" + "fmt" + "regexp" + + "github.com/getsops/sops/v3/cmd/sops/codes" + "github.com/getsops/sops/v3/config" +) + +// SchemaVersion is the version of the JSON schema emitted by this command. +// See docs/superpowers/specs/2026-05-22-sops-config-subcommand-design.md +// for the schema evolution policy. +const SchemaVersion = 1 + +// Output is the top-level JSON shape printed by `sops config `. +// CreationRules and DestinationRules are guaranteed non-nil — even when +// empty they marshal to "[]", never "null". Implementation MUST use +// make([]T, 0) to construct them. +type Output struct { + SchemaVersion int `json:"schema_version"` + ConfigPath string `json:"config_path"` + FilePath string `json:"file_path"` + CreationRules []CreationRuleView `json:"creation_rules"` + DestinationRules []DestinationRuleView `json:"destination_rules"` +} + +// CreationRuleView is the public JSON representation of a matched creation_rule. +// +// Recipient fields (KMS, GCPKMS, AzureKeyVault, HCKms, HCVaultTransitURI, Age, +// PGP, KeyGroups) use omitempty because sops's parser uses either KeyGroups OR +// the flat recipient fields, never both (see config/config.go:371-381). Echoing +// only what was written in .sops.yaml keeps the output unambiguous. +// +// Non-recipient fields are always emitted for schema stability. +type CreationRuleView struct { + RuleIndex int `json:"rule_index"` + PathRegex string `json:"path_regex"` + + KMS []KmsKeyView `json:"kms,omitempty"` + GCPKMS []GcpKmsKeyView `json:"gcp_kms,omitempty"` + AzureKeyVault []AzureKVKeyView `json:"azure_keyvault,omitempty"` + HCKms []HCKmsKeyView `json:"hckms,omitempty"` + HCVaultTransitURI []string `json:"hc_vault_transit_uri,omitempty"` + Age []string `json:"age,omitempty"` + PGP []string `json:"pgp,omitempty"` + KeyGroups []KeyGroupView `json:"key_groups,omitempty"` + + ShamirThreshold int `json:"shamir_threshold"` + UnencryptedSuffix string `json:"unencrypted_suffix"` + EncryptedSuffix string `json:"encrypted_suffix"` + UnencryptedRegex string `json:"unencrypted_regex"` + EncryptedRegex string `json:"encrypted_regex"` + UnencryptedCommentRegex string `json:"unencrypted_comment_regex"` + EncryptedCommentRegex string `json:"encrypted_comment_regex"` + MACOnlyEncrypted bool `json:"mac_only_encrypted"` +} + +// DestinationRuleView is the public JSON representation of a matched destination_rule. +// +// Destination and RecreationRule are pointer types with omitempty because Go's +// encoding/json cannot treat value-typed structs as empty regardless of their +// field values — pointer + omitempty is the only way to get true optionality. +// +// RecreationRule reuses CreationRuleView. In the nested recreation_rule +// context, the inherited RuleIndex and PathRegex fields have no meaning and +// will marshal as their zero values ("rule_index": 0, "path_regex": ""); +// consumers should ignore those two fields when reading recreation_rule. +type DestinationRuleView struct { + RuleIndex int `json:"rule_index"` + PathRegex string `json:"path_regex"` + Destination *DestinationView `json:"destination,omitempty"` + OmitExtensions bool `json:"omit_extensions"` + RecreationRule *CreationRuleView `json:"recreation_rule,omitempty"` +} + +// DestinationView is the discriminated-union JSON form of a publish target. +// Type is one of "s3", "gcs", "vault"; other fields are populated according +// to Type. +type DestinationView struct { + Type string `json:"type"` + Bucket string `json:"bucket,omitempty"` + Prefix string `json:"prefix,omitempty"` + Address string `json:"address,omitempty"` + Path string `json:"path,omitempty"` + KVMountName string `json:"kv_mount_name,omitempty"` + KVVersion int `json:"kv_version,omitempty"` +} + +// KmsKeyView mirrors the internal config kmsKey struct with JSON tags. +type KmsKeyView struct { + Arn string `json:"arn"` + Role string `json:"role,omitempty"` + Context map[string]*string `json:"context,omitempty"` + AwsProfile string `json:"aws_profile,omitempty"` +} + +// GcpKmsKeyView mirrors the internal config gcpKmsKey struct. +type GcpKmsKeyView struct { + ResourceID string `json:"resource_id"` +} + +// AzureKVKeyView mirrors the internal config azureKVKey struct. +type AzureKVKeyView struct { + VaultURL string `json:"vaultUrl"` + Key string `json:"key"` + Version string `json:"version,omitempty"` +} + +// HCKmsKeyView mirrors the internal config hckmsKey struct. +type HCKmsKeyView struct { + KeyID string `json:"key_id"` +} + +// KeyGroupView mirrors the internal config keyGroup struct for use inside +// CreationRuleView.KeyGroups. +type KeyGroupView struct { + Merge []KeyGroupView `json:"merge,omitempty"` + KMS []KmsKeyView `json:"kms,omitempty"` + GCPKMS []GcpKmsKeyView `json:"gcp_kms,omitempty"` + HCKms []HCKmsKeyView `json:"hckms,omitempty"` + AzureKeyVault []AzureKVKeyView `json:"azure_keyvault,omitempty"` + HCVaultTransit []string `json:"hc_vault,omitempty"` + Age []string `json:"age,omitempty"` + PGP []string `json:"pgp,omitempty"` +} + +// buildCreationRuleView converts a config.CreationRuleMatch into the +// public JSON view. All recipient-list errors propagate (e.g., malformed +// interface{} types). +// +// Sops's parser uses key_groups XOR flat recipient fields (never both). +// When key_groups is populated the flat fields are silently ignored by the +// encryption pipeline, so the view only populates the branch that is active. +func buildCreationRuleView(m *config.CreationRuleMatch) (*CreationRuleView, error) { + view := &CreationRuleView{ + RuleIndex: m.RuleIndex, + PathRegex: m.PathRegex(), + ShamirThreshold: m.ShamirThreshold(), + UnencryptedSuffix: m.UnencryptedSuffix(), + EncryptedSuffix: m.EncryptedSuffix(), + UnencryptedRegex: m.UnencryptedRegex(), + EncryptedRegex: m.EncryptedRegex(), + UnencryptedCommentRegex: m.UnencryptedCommentRegex(), + EncryptedCommentRegex: m.EncryptedCommentRegex(), + MACOnlyEncrypted: m.MACOnlyEncrypted(), + } + + if len(m.KeyGroups()) > 0 { + // key_groups is active; flat fields are dead. Only populate KeyGroups. + for _, g := range m.KeyGroups() { + view.KeyGroups = append(view.KeyGroups, convertKeyGroupView(g)) + } + } else { + kmsEntries, err := m.KMSEntries() + if err != nil { + return nil, fmt.Errorf("invalid kms: %w", err) + } + for _, e := range kmsEntries { + view.KMS = append(view.KMS, KmsKeyView{ + Arn: e.Arn, + Role: e.Role, + Context: e.Context, + AwsProfile: e.AwsProfile, + }) + } + + age, err := m.AgeRecipients() + if err != nil { + return nil, fmt.Errorf("invalid age: %w", err) + } + view.Age = age + + pgp, err := m.PGPFingerprints() + if err != nil { + return nil, fmt.Errorf("invalid pgp: %w", err) + } + view.PGP = pgp + + gcp, err := m.GCPKMSResourceIDs() + if err != nil { + return nil, fmt.Errorf("invalid gcp_kms: %w", err) + } + for _, id := range gcp { + view.GCPKMS = append(view.GCPKMS, GcpKmsKeyView{ResourceID: id}) + } + + azkv, err := m.AzureKeyVaults() + if err != nil { + return nil, fmt.Errorf("invalid azure_keyvault: %w", err) + } + for _, u := range azkv { + view.AzureKeyVault = append(view.AzureKeyVault, parseAzureKeyVaultURL(u)) + } + + vaults, err := m.HCVaultTransitURIs() + if err != nil { + return nil, fmt.Errorf("invalid hc_vault_transit_uri: %w", err) + } + view.HCVaultTransitURI = vaults + + for _, kid := range m.HCKmsKeyIDs() { + view.HCKms = append(view.HCKms, HCKmsKeyView{KeyID: kid}) + } + } + + return view, nil +} + +// buildDestinationRuleView converts a config.DestinationRuleMatch into the +// public JSON view. The destination type is determined by which set of +// destinationRule fields is populated (S3, GCS, or Vault). The nested +// recreation_rule is converted via buildCreationRuleView. +func buildDestinationRuleView(m *config.DestinationRuleMatch) (*DestinationRuleView, error) { + view := &DestinationRuleView{ + RuleIndex: m.RuleIndex, + PathRegex: m.PathRegex(), + OmitExtensions: m.OmitExtensions(), + } + + if bucket, prefix, ok := m.S3(); ok { + view.Destination = &DestinationView{Type: "s3", Bucket: bucket, Prefix: prefix} + } else if bucket, prefix, ok := m.GCS(); ok { + view.Destination = &DestinationView{Type: "gcs", Bucket: bucket, Prefix: prefix} + } else if addr, path, kvMount, kvVer, ok := m.Vault(); ok { + view.Destination = &DestinationView{ + Type: "vault", + Address: addr, + Path: path, + KVMountName: kvMount, + KVVersion: kvVer, + } + } + + if rr := m.RecreationRule(); rr != nil { + recView, err := buildCreationRuleView(rr) + if err != nil { + return nil, fmt.Errorf("invalid recreation_rule: %w", err) + } + view.RecreationRule = recView + } + + return view, nil +} + +// Opts describes the inputs to Run. +type Opts struct { + ConfigPath string // absolute path to .sops.yaml + FilePath string // absolute path to the query file + RequireMatch bool // if true, exit non-zero when no rule matches +} + +// Run executes the `sops config ` subcommand and returns the Output +// to serialize, the exit code, and any error. +// +// Output is non-nil in exactly two cases: +// - Success (err == nil, exitCode == 0). +// - --require-match with no matches (err != nil, exitCode == codes.NoRulesMatched). +// +// In the require-match case the Output is still valid JSON and SHOULD be +// printed to stdout — consumers may want the structured data even when the +// overall run is treated as a failure. +// +// Output is nil on all other errors (config load failure, view-conversion +// failure, etc.). The caller should NOT print anything in those cases. +func Run(opts Opts) (*Output, int, error) { + mr, err := config.MatchRulesForFile(opts.ConfigPath, opts.FilePath) + if err != nil { + return nil, codes.ErrorReadingConfig, err + } + + output := &Output{ + SchemaVersion: SchemaVersion, + ConfigPath: mr.ConfigPath, + FilePath: mr.FilePath, + // make([]T, 0) so the slices marshal to "[]" rather than "null". + CreationRules: make([]CreationRuleView, 0), + DestinationRules: make([]DestinationRuleView, 0), + } + + if mr.CreationRule != nil { + view, err := buildCreationRuleView(mr.CreationRule) + if err != nil { + return nil, codes.ErrorReadingConfig, fmt.Errorf("convert creation_rule: %w", err) + } + output.CreationRules = append(output.CreationRules, *view) + } + + if mr.DestinationRule != nil { + view, err := buildDestinationRuleView(mr.DestinationRule) + if err != nil { + return nil, codes.ErrorReadingConfig, fmt.Errorf("convert destination_rule: %w", err) + } + output.DestinationRules = append(output.DestinationRules, *view) + } + + if opts.RequireMatch && len(output.CreationRules) == 0 && len(output.DestinationRules) == 0 { + return output, codes.NoRulesMatched, fmt.Errorf("no matching rules found for %q in %s", opts.FilePath, opts.ConfigPath) + } + + return output, 0, nil +} + +// Marshal returns the JSON representation of the Output suitable for stdout. +func Marshal(output *Output) ([]byte, error) { + return json.MarshalIndent(output, "", " ") +} + +// parseAzureKeyVaultURL splits an Azure Key Vault key URL into its +// constituent parts. Mirrors the parser at azkv/keysource.go:96. If the +// URL doesn't match the expected shape, the whole URL is returned in +// VaultURL with Key and Version empty — preserves the input for debugging. +func parseAzureKeyVaultURL(url string) AzureKVKeyView { + re := regexp.MustCompile(`^(https://[^/]+)/keys/([^/]+)(/[^/]*)?$`) + parts := re.FindStringSubmatch(url) + if len(parts) < 3 { + return AzureKVKeyView{VaultURL: url} + } + view := AzureKVKeyView{VaultURL: parts[1], Key: parts[2]} + if len(parts[3]) > 1 { + view.Version = parts[3][1:] // strip the leading "/" + } + return view +} + +// convertKeyGroupView translates a config.KeyGroupEntry to its JSON view form. +// Nested merge groups recurse. +func convertKeyGroupView(g config.KeyGroupEntry) KeyGroupView { + out := KeyGroupView{ + HCVaultTransit: g.HCVaultTransit, + Age: g.Age, + PGP: g.PGP, + } + for _, k := range g.KMS { + out.KMS = append(out.KMS, KmsKeyView{ + Arn: k.Arn, + Role: k.Role, + Context: k.Context, + AwsProfile: k.AwsProfile, + }) + } + for _, id := range g.GCPKMS { + out.GCPKMS = append(out.GCPKMS, GcpKmsKeyView{ResourceID: id}) + } + for _, id := range g.HCKms { + out.HCKms = append(out.HCKms, HCKmsKeyView{KeyID: id}) + } + for _, kv := range g.AzureKeyVault { + out.AzureKeyVault = append(out.AzureKeyVault, AzureKVKeyView{ + VaultURL: kv.VaultURL, + Key: kv.Key, + Version: kv.Version, + }) + } + for _, sub := range g.Merge { + out.Merge = append(out.Merge, convertKeyGroupView(sub)) + } + return out +} diff --git a/cmd/sops/subcommand/config/config_integration_test.go b/cmd/sops/subcommand/config/config_integration_test.go new file mode 100644 index 0000000000..4230d65975 --- /dev/null +++ b/cmd/sops/subcommand/config/config_integration_test.go @@ -0,0 +1,102 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/getsops/sops/v3/cmd/sops/codes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRun_PathNormalization exercises the path-normalization codepaths +// end-to-end through Run. Run takes absolute paths only, so the CLI layer +// (filepath.Abs, cwd lookup) is mocked out by always passing absolute +// paths anchored at t.TempDir(). +func TestRun_PathNormalization(t *testing.T) { + t.Parallel() + + root := t.TempDir() + confPath := filepath.Join(root, ".sops.yaml") + require.NoError(t, os.WriteFile(confPath, []byte(` +creation_rules: + - path_regex: '^secrets/.*\.yaml$' + kms: 'arn:secrets' + - kms: 'arn:fallback' +`), 0644)) + + cases := []struct { + name string + filePath string + wantIndex int + wantCatchAll bool + }{ + { + name: "absolute path inside the tree → matches relative regex", + filePath: filepath.Join(root, "secrets", "db.yaml"), + wantIndex: 0, + }, + { + name: "absolute path in a nested subdir → matches relative regex", + filePath: filepath.Join(root, "secrets", "team", "team.yaml"), + wantIndex: 0, + }, + { + name: "absolute path outside the tree → falls back, hits catch-all", + filePath: filepath.Join(os.TempDir(), "sops-not-in-tree", "x.yaml"), + wantIndex: 1, + wantCatchAll: true, + }, + { + name: "non-existent file in tree → matches as if it existed", + filePath: filepath.Join(root, "secrets", "future.yaml"), + wantIndex: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := Opts{ + ConfigPath: confPath, + FilePath: tc.filePath, + } + output, exitCode, err := Run(opts) + require.NoError(t, err) + assert.Equal(t, 0, exitCode) + require.Len(t, output.CreationRules, 1) + assert.Equal(t, tc.wantIndex, output.CreationRules[0].RuleIndex) + }) + } +} + +func TestRun_ConfigOverride(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + primaryConf := filepath.Join(dir, ".sops.yaml") + overrideConf := filepath.Join(dir, "override.yaml") + + require.NoError(t, os.WriteFile(primaryConf, []byte(`creation_rules: + - kms: 'arn:primary' +`), 0644)) + require.NoError(t, os.WriteFile(overrideConf, []byte(`creation_rules: + - kms: 'arn:override' +`), 0644)) + + opts := Opts{ + ConfigPath: overrideConf, + FilePath: filepath.Join(dir, "x.yaml"), + } + output, _, err := Run(opts) + require.NoError(t, err) + require.Len(t, output.CreationRules, 1) + require.Len(t, output.CreationRules[0].KMS, 1) + assert.Equal(t, "arn:override", output.CreationRules[0].KMS[0].Arn) +} + +// Confirm the NoRulesMatched code is what we actually use. +func TestRun_NoRulesMatchedCodeIs62(t *testing.T) { + assert.Equal(t, 62, codes.NoRulesMatched, "spec promises code 62") +} diff --git a/cmd/sops/subcommand/config/config_internal_test.go b/cmd/sops/subcommand/config/config_internal_test.go new file mode 100644 index 0000000000..cdfdf8518f --- /dev/null +++ b/cmd/sops/subcommand/config/config_internal_test.go @@ -0,0 +1,264 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/getsops/sops/v3/cmd/sops/codes" + "github.com/getsops/sops/v3/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// matchFromBytes writes the given .sops.yaml contents to a temp dir and +// returns the MatchResult for an absolute file path. The file path can be +// outside the temp dir — in that case the matcher falls back to absolute- +// path matching (which is fine for catch-all creation rules with no +// path_regex). Tests that exercise path normalization should anchor the +// file path inside the temp dir. +func matchFromBytes(t *testing.T, confBytes []byte, absFilePath string) *config.MatchResult { + t.Helper() + dir := t.TempDir() + confPath := filepath.Join(dir, ".sops.yaml") + require.NoError(t, os.WriteFile(confPath, confBytes, 0644)) + mr, err := config.MatchRulesForFile(confPath, absFilePath) + require.NoError(t, err) + return mr +} + +func TestBuildCreationRuleView_StringKMS(t *testing.T) { + confBytes := []byte(` +creation_rules: + - kms: 'arn:aws:kms:us-east-1:1:key/short' +`) + mr := matchFromBytes(t, confBytes, "/conf/x.yaml") + require.NotNil(t, mr.CreationRule) + + view, err := buildCreationRuleView(mr.CreationRule) + require.NoError(t, err) + assert.Equal(t, 0, view.RuleIndex) + require.Len(t, view.KMS, 1) + assert.Equal(t, "arn:aws:kms:us-east-1:1:key/short", view.KMS[0].Arn) + // Other recipient fields stay empty/omitted. + assert.Empty(t, view.Age) + assert.Empty(t, view.PGP) +} + +func TestBuildCreationRuleView_KeyGroups(t *testing.T) { + confBytes := []byte(` +creation_rules: + - shamir_threshold: 2 + key_groups: + - kms: + - arn: 'arn:group1' + role: 'arn:role/r1' + pgp: + - 'FP1' + - age: + - 'age1g2' +`) + mr := matchFromBytes(t, confBytes, "/conf/x.yaml") + require.NotNil(t, mr.CreationRule) + + view, err := buildCreationRuleView(mr.CreationRule) + require.NoError(t, err) + assert.Equal(t, 2, view.ShamirThreshold) + require.Len(t, view.KeyGroups, 2) + require.Len(t, view.KeyGroups[0].KMS, 1) + assert.Equal(t, "arn:group1", view.KeyGroups[0].KMS[0].Arn) + assert.Equal(t, "arn:role/r1", view.KeyGroups[0].KMS[0].Role) + assert.Equal(t, []string{"FP1"}, view.KeyGroups[0].PGP) + require.Len(t, view.KeyGroups[1].Age, 1) + // Flat recipient fields should be omitted (omitempty) when key_groups populated. + assert.Empty(t, view.KMS) + assert.Empty(t, view.Age) + assert.Empty(t, view.PGP) +} + +func TestBuildCreationRuleView_KMSObjectForm(t *testing.T) { + confBytes := []byte(` +creation_rules: + - kms: + - arn: 'arn:rich' + role: 'arn:r' + aws_profile: 'prod' + context: + team: 'payments' +`) + mr := matchFromBytes(t, confBytes, "/conf/x.yaml") + view, err := buildCreationRuleView(mr.CreationRule) + require.NoError(t, err) + require.Len(t, view.KMS, 1) + assert.Equal(t, "arn:rich", view.KMS[0].Arn) + assert.Equal(t, "arn:r", view.KMS[0].Role) + assert.Equal(t, "prod", view.KMS[0].AwsProfile) + require.NotNil(t, view.KMS[0].Context) + assert.Equal(t, "payments", *view.KMS[0].Context["team"]) +} + +func TestBuildDestinationRuleView_S3(t *testing.T) { + confBytes := []byte(` +destination_rules: + - path_regex: 'publish/.*\.json$' + s3_bucket: 'org-secrets' + s3_prefix: 'sops/' + omit_extensions: true + recreation_rule: + kms: 'arn:recreation' +`) + mr := matchFromBytes(t, confBytes, "/conf/publish/app.json") + require.NotNil(t, mr.DestinationRule) + + view, err := buildDestinationRuleView(mr.DestinationRule) + require.NoError(t, err) + assert.Equal(t, 0, view.RuleIndex) + assert.Equal(t, `publish/.*\.json$`, view.PathRegex) + assert.True(t, view.OmitExtensions) + require.NotNil(t, view.Destination) + assert.Equal(t, "s3", view.Destination.Type) + assert.Equal(t, "org-secrets", view.Destination.Bucket) + assert.Equal(t, "sops/", view.Destination.Prefix) + require.NotNil(t, view.RecreationRule) + require.Len(t, view.RecreationRule.KMS, 1) + assert.Equal(t, "arn:recreation", view.RecreationRule.KMS[0].Arn) +} + +func TestBuildDestinationRuleView_VaultNoRecreation(t *testing.T) { + confBytes := []byte(` +destination_rules: + - path_regex: 'publish/.*' + vault_path: 'secret/data/app' + vault_address: 'https://v.example.com' +`) + mr := matchFromBytes(t, confBytes, "/conf/publish/app.yaml") + require.NotNil(t, mr.DestinationRule) + + view, err := buildDestinationRuleView(mr.DestinationRule) + require.NoError(t, err) + require.NotNil(t, view.Destination) + assert.Equal(t, "vault", view.Destination.Type) + assert.Equal(t, "https://v.example.com", view.Destination.Address) + assert.Equal(t, "secret/data/app", view.Destination.Path) + // No recreation_rule in source → nil. + assert.Nil(t, view.RecreationRule) +} + +func TestRun_NoMatchEmptyArrays(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, ".sops.yaml") + require.NoError(t, os.WriteFile(confPath, []byte(`creation_rules: []`), 0644)) + + opts := Opts{ + ConfigPath: confPath, + FilePath: filepath.Join(dir, "anything.yaml"), + } + out, exitCode, err := Run(opts) + require.NoError(t, err) + assert.Equal(t, 0, exitCode) + require.NotNil(t, out) + assert.Equal(t, 1, out.SchemaVersion) + // Critical: not nil, not "null" in JSON. + assert.NotNil(t, out.CreationRules) + assert.NotNil(t, out.DestinationRules) + assert.Empty(t, out.CreationRules) + assert.Empty(t, out.DestinationRules) + + // Marshal and verify the JSON shape directly. + b, err := json.Marshal(out) + require.NoError(t, err) + assert.Contains(t, string(b), `"creation_rules":[]`) + assert.Contains(t, string(b), `"destination_rules":[]`) + assert.NotContains(t, string(b), `"creation_rules":null`) +} + +func TestRun_RequireMatchExitsNonZero(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, ".sops.yaml") + require.NoError(t, os.WriteFile(confPath, []byte(`creation_rules: []`), 0644)) + + opts := Opts{ + ConfigPath: confPath, + FilePath: filepath.Join(dir, "anything.yaml"), + RequireMatch: true, + } + out, exitCode, err := Run(opts) + // JSON is still produced even on the error path. + require.NotNil(t, out) + assert.Equal(t, codes.NoRulesMatched, exitCode) + require.Error(t, err) +} + +func TestBuildCreationRuleView_SkipsFlatWhenKeyGroupsPresent(t *testing.T) { + // sops uses key_groups XOR flat fields. When both are written, the flat + // fields are ignored by the encryption pipeline. The view must reflect + // that to avoid misleading users about "which keys will encrypt this". + confBytes := []byte(` +creation_rules: + - kms: 'arn:flat-IGNORED-BY-SOPS' + pgp: 'IGNORED-PGP' + age: 'age1IGNORED' + key_groups: + - kms: [{ arn: 'arn:effective' }] + pgp: ['REALPGP'] +`) + mr := matchFromBytes(t, confBytes, "/conf/x.yaml") + view, err := buildCreationRuleView(mr.CreationRule) + require.NoError(t, err) + require.Len(t, view.KeyGroups, 1) + assert.Equal(t, "arn:effective", view.KeyGroups[0].KMS[0].Arn) + // Flat fields are dead per sops's parser; the view should not emit them. + assert.Empty(t, view.KMS) + assert.Empty(t, view.PGP) + assert.Empty(t, view.Age) +} + +func TestBuildCreationRuleView_AzureFlatURLParsed(t *testing.T) { + // Flat azure_keyvault URLs should split into vaultUrl/key/version, + // matching how azkv.NewMasterKeyFromURL parses them at runtime. + confBytes := []byte(` +creation_rules: + - azure_keyvault: 'https://akv1.vault.azure.net/keys/k1/v123' +`) + mr := matchFromBytes(t, confBytes, "/conf/x.yaml") + view, err := buildCreationRuleView(mr.CreationRule) + require.NoError(t, err) + require.Len(t, view.AzureKeyVault, 1) + assert.Equal(t, "https://akv1.vault.azure.net", view.AzureKeyVault[0].VaultURL) + assert.Equal(t, "k1", view.AzureKeyVault[0].Key) + assert.Equal(t, "v123", view.AzureKeyVault[0].Version) +} + +func TestBuildCreationRuleView_AzureFlatURLWithoutVersion(t *testing.T) { + confBytes := []byte(` +creation_rules: + - azure_keyvault: 'https://akv1.vault.azure.net/keys/k1' +`) + mr := matchFromBytes(t, confBytes, "/conf/x.yaml") + view, err := buildCreationRuleView(mr.CreationRule) + require.NoError(t, err) + require.Len(t, view.AzureKeyVault, 1) + assert.Equal(t, "https://akv1.vault.azure.net", view.AzureKeyVault[0].VaultURL) + assert.Equal(t, "k1", view.AzureKeyVault[0].Key) + assert.Equal(t, "", view.AzureKeyVault[0].Version) +} + +func TestRun_RequireMatchSucceedsOnMatch(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, ".sops.yaml") + require.NoError(t, os.WriteFile(confPath, []byte(` +creation_rules: + - kms: 'arn:catch' +`), 0644)) + + opts := Opts{ + ConfigPath: confPath, + FilePath: filepath.Join(dir, "x.yaml"), + RequireMatch: true, + } + out, exitCode, err := Run(opts) + require.NoError(t, err) + assert.Equal(t, 0, exitCode) + require.Len(t, out.CreationRules, 1) +} diff --git a/config/config.go b/config/config.go index 511df1bc15..921d50667e 100644 --- a/config/config.go +++ b/config/config.go @@ -4,10 +4,12 @@ Package config provides a way to find and load SOPS configuration files package config //import "github.com/getsops/sops/v3/config" import ( + "errors" "fmt" "os" "path" "path/filepath" + "reflect" "regexp" "strings" @@ -290,6 +292,248 @@ type Config struct { OmitExtensions bool } +// ErrPathNotAbsolute is returned by MatchRulesForFile when either of its path +// arguments is empty or relative. +var ErrPathNotAbsolute = errors.New("path must be absolute") + +// MatchResult holds the rules from .sops.yaml that match a given file path. +// At most one creation_rule and at most one destination_rule will be matched +// (first-match-wins, preserving today's semantics in parseCreationRuleForFile +// and parseDestinationRuleForFile). +// +// API stability: The JSON output of the `sops config` subcommand is the +// public, versioned contract (see "schema_version"). The Go types in this +// package — MatchResult, CreationRuleMatch, DestinationRuleMatch, and the +// associated accessor methods — are internal-stable-only and may change +// without a major version bump. External consumers should depend on the +// JSON output, not the Go API. +type MatchResult struct { + ConfigPath string // absolute, as received + FilePath string // absolute, as received + CreationRule *CreationRuleMatch // nil if no creation_rule matched + DestinationRule *DestinationRuleMatch // nil if no destination_rule matched +} + +// CreationRuleMatch describes which creation rule from .sops.yaml matched the +// queried file path. Rule is the raw internal struct; cmd-side packages +// translate it to a public JSON view. +type CreationRuleMatch struct { + RuleIndex int + Rule creationRule +} + +// DestinationRuleMatch describes which destination rule matched. +type DestinationRuleMatch struct { + RuleIndex int + Rule destinationRule +} + +// PathRegex returns the rule's path_regex value (or "" for the catch-all rule). +func (m *CreationRuleMatch) PathRegex() string { return m.Rule.PathRegex } + +// KMSEntry is a serializable view of a KMS key entry from .sops.yaml. +// Entries without role/context/profile come from the short string-form syntax. +type KMSEntry struct { + Arn string + Role string + Context map[string]*string + AwsProfile string +} + +// KMSEntries returns the rule's KMS entries normalized to a slice. Handles +// both the short string form ("arn1,arn2") and the object form (yaml map with +// arn/role/context/aws_profile fields). +func (m *CreationRuleMatch) KMSEntries() ([]KMSEntry, error) { + switch v := m.Rule.KMS.(type) { + case nil: + return nil, nil + case string: + out := []KMSEntry{} + for _, k := range splitKMSString(v) { + out = append(out, KMSEntry{Arn: k}) + } + return out, nil + case []interface{}: + out := []KMSEntry{} + for _, item := range v { + switch x := item.(type) { + case string: + out = append(out, KMSEntry{Arn: x}) + case map[string]interface{}: + entry := KMSEntry{} + if s, ok := x["arn"].(string); ok { + entry.Arn = s + } + if s, ok := x["role"].(string); ok { + entry.Role = s + } + if s, ok := x["aws_profile"].(string); ok { + entry.AwsProfile = s + } + if ctx, ok := x["context"].(map[string]interface{}); ok { + entry.Context = make(map[string]*string, len(ctx)) + for k, v := range ctx { + if s, ok := v.(string); ok { + s := s + entry.Context[k] = &s + } + } + } + out = append(out, entry) + default: + return nil, fmt.Errorf("unsupported kms entry type %T", item) + } + } + return out, nil + default: + return nil, fmt.Errorf("unsupported kms field type %T", v) + } +} + +// splitKMSString splits a comma-separated string form like "arn1,arn2" into +// a slice. Mirrors how parseKeyField handles the short form. +func splitKMSString(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func (m *CreationRuleMatch) AgeRecipients() ([]string, error) { return m.Rule.GetAgeKeys() } +func (m *CreationRuleMatch) PGPFingerprints() ([]string, error) { return m.Rule.GetPGPKeys() } +func (m *CreationRuleMatch) GCPKMSResourceIDs() ([]string, error) { return m.Rule.GetGCPKMSKeys() } +func (m *CreationRuleMatch) AzureKeyVaults() ([]string, error) { return m.Rule.GetAzureKeyVaultKeys() } +func (m *CreationRuleMatch) HCVaultTransitURIs() ([]string, error) { return m.Rule.GetVaultURIs() } + +// HCKmsKeyIDs returns the rule's HC KMS key_id values (the rule struct field +// is already a typed []string, no interface{} unwrap needed). +func (m *CreationRuleMatch) HCKmsKeyIDs() []string { + out := make([]string, 0, len(m.Rule.HCKms)) + out = append(out, m.Rule.HCKms...) + return out +} + +func (m *CreationRuleMatch) ShamirThreshold() int { return m.Rule.ShamirThreshold } +func (m *CreationRuleMatch) UnencryptedSuffix() string { return m.Rule.UnencryptedSuffix } +func (m *CreationRuleMatch) EncryptedSuffix() string { return m.Rule.EncryptedSuffix } +func (m *CreationRuleMatch) UnencryptedRegex() string { return m.Rule.UnencryptedRegex } +func (m *CreationRuleMatch) EncryptedRegex() string { return m.Rule.EncryptedRegex } +func (m *CreationRuleMatch) UnencryptedCommentRegex() string { return m.Rule.UnencryptedCommentRegex } +func (m *CreationRuleMatch) EncryptedCommentRegex() string { return m.Rule.EncryptedCommentRegex } +func (m *CreationRuleMatch) MACOnlyEncrypted() bool { return m.Rule.MACOnlyEncrypted } + +// KeyGroupEntry is a serializable view of a key_group entry from .sops.yaml. +// Recipient lists are normalized; nested merge groups recurse. +type KeyGroupEntry struct { + Merge []KeyGroupEntry + KMS []KMSEntry + GCPKMS []string // resource IDs + HCKms []string // key IDs + AzureKeyVault []AzureKeyVaultEntry + HCVaultTransit []string // URIs + Age []string + PGP []string +} + +// AzureKeyVaultEntry is the serializable form of an Azure Key Vault key from +// .sops.yaml (used inside KeyGroupEntry). +type AzureKeyVaultEntry struct { + VaultURL string + Key string + Version string +} + +// KeyGroups returns the rule's key_groups normalized to a serializable view. +// Nested merge directives recurse. +func (m *CreationRuleMatch) KeyGroups() []KeyGroupEntry { + return convertKeyGroups(m.Rule.KeyGroups) +} + +func (m *DestinationRuleMatch) PathRegex() string { return m.Rule.PathRegex } +func (m *DestinationRuleMatch) OmitExtensions() bool { return m.Rule.OmitExtensions } + +// S3 returns ("", "", false) if this rule does not target S3. +func (m *DestinationRuleMatch) S3() (bucket, prefix string, ok bool) { + if m.Rule.S3Bucket == "" { + return "", "", false + } + return m.Rule.S3Bucket, m.Rule.S3Prefix, true +} + +// GCS returns ("", "", false) if this rule does not target GCS. +func (m *DestinationRuleMatch) GCS() (bucket, prefix string, ok bool) { + if m.Rule.GCSBucket == "" { + return "", "", false + } + return m.Rule.GCSBucket, m.Rule.GCSPrefix, true +} + +// Vault returns address, path, kvMountName, kvVersion and ok=false if no +// Vault destination is configured for this rule. +func (m *DestinationRuleMatch) Vault() (address, path, kvMountName string, kvVersion int, ok bool) { + if m.Rule.VaultPath == "" { + return "", "", "", 0, false + } + return m.Rule.VaultAddress, m.Rule.VaultPath, m.Rule.VaultKVMountName, m.Rule.VaultKVVersion, true +} + +// RecreationRule converts the nested creation_rule inside this destination +// rule. Returns nil if the user did not specify a recreation_rule. The +// returned match has RuleIndex == 0 because the nested rule has no top-level +// position; the view layer treats this field as meaningless for nested +// recreation rules. +func (m *DestinationRuleMatch) RecreationRule() *CreationRuleMatch { + if reflect.DeepEqual(m.Rule.RecreationRule, creationRule{}) { + return nil + } + return &CreationRuleMatch{RuleIndex: 0, Rule: m.Rule.RecreationRule} +} + +func convertKeyGroups(in []keyGroup) []KeyGroupEntry { + out := make([]KeyGroupEntry, 0, len(in)) + for _, g := range in { + // Defensive copies on the string slices: callers must not be able to + // mutate the internal keyGroup via the returned view. + entry := KeyGroupEntry{ + Merge: convertKeyGroups(g.Merge), + HCVaultTransit: append([]string{}, g.Vault...), + Age: append([]string{}, g.Age...), + PGP: append([]string{}, g.PGP...), + } + for _, k := range g.KMS { + entry.KMS = append(entry.KMS, KMSEntry{ + Arn: k.Arn, + Role: k.Role, + Context: k.Context, + AwsProfile: k.AwsProfile, + }) + } + for _, k := range g.GCPKMS { + entry.GCPKMS = append(entry.GCPKMS, k.ResourceID) + } + for _, k := range g.HCKms { + entry.HCKms = append(entry.HCKms, k.KeyID) + } + for _, k := range g.AzureKV { + entry.AzureKeyVault = append(entry.AzureKeyVault, AzureKeyVaultEntry{ + VaultURL: k.VaultURL, + Key: k.Key, + Version: k.Version, + }) + } + out = append(out, entry) + } + return out +} + func deduplicateKeygroup(group sops.KeyGroup) sops.KeyGroup { var deduplicatedKeygroup sops.KeyGroup @@ -610,6 +854,101 @@ func parseCreationRuleForFile(conf *configFile, confPath, filePath string, kmsEn return config, nil } +// matchRulesForConfig matches a file path against an already-parsed config. +// Both absConfPath and absFilePath MUST be absolute (caller's responsibility). +// This is the file-IO-free core that production callers reach via MatchRulesForFile +// and that tests can exercise directly with parseConfigFile-built fixtures. +func matchRulesForConfig(conf *configFile, absConfPath, absFilePath string) (*MatchResult, error) { + result := &MatchResult{ + ConfigPath: absConfPath, + FilePath: absFilePath, + } + + matchPath := normalizeMatchPath(absConfPath, absFilePath) + + for i, r := range conf.CreationRules { + if r.PathRegex == "" { + rule := r + result.CreationRule = &CreationRuleMatch{RuleIndex: i, Rule: rule} + break + } + reg, err := regexp.Compile(r.PathRegex) + if err != nil { + return nil, fmt.Errorf("can not compile regexp: %w", err) + } + if reg.MatchString(matchPath) { + rule := r + result.CreationRule = &CreationRuleMatch{RuleIndex: i, Rule: rule} + break + } + } + + for i, r := range conf.DestinationRules { + if r.PathRegex == "" { + rule := r + result.DestinationRule = &DestinationRuleMatch{RuleIndex: i, Rule: rule} + break + } + reg, err := regexp.Compile(r.PathRegex) + if err != nil { + return nil, fmt.Errorf("can not compile regexp: %w", err) + } + if reg.MatchString(matchPath) { + rule := r + result.DestinationRule = &DestinationRuleMatch{RuleIndex: i, Rule: rule} + break + } + } + + return result, nil +} + +// normalizeMatchPath returns the path that should be matched against rule +// path_regex values. When absFilePath is inside the config file's directory +// tree, the config-dir prefix is stripped so users' path_regex values can be +// written as repo-relative. When the file is outside the tree (or on a +// different Windows drive), the absolute path is used as-is. +// +// Uses filepath.Rel for platform-aware path arithmetic; correctly handles +// trailing separators, mixed separators on Windows, and case-insensitive +// drive letters. +func normalizeMatchPath(absConfPath, absFilePath string) string { + configDir := filepath.Dir(absConfPath) + rel, err := filepath.Rel(configDir, absFilePath) + if err != nil { + // Different Windows drives, or otherwise unrelatable. Use absolute. + return absFilePath + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + // File is above the config dir. Use absolute. + return absFilePath + } + return rel +} + +// MatchRulesForFile loads the config at absConfPath and returns the +// creation_rule and destination_rule (each at most one) that apply to +// absFilePath. Both arguments MUST be absolute paths — MatchRulesForFile +// does NOT call filepath.Abs (which would implicitly depend on os.Getwd()) +// and does NOT perform .sops.yaml auto-discovery. The CLI layer is +// responsible for FindConfigFile, the --config flag, and resolving any +// relative input to absolute before calling this function. +// +// File existence on absFilePath is NOT checked; only path matching occurs. +func MatchRulesForFile(absConfPath, absFilePath string) (*MatchResult, error) { + if absConfPath == "" || !filepath.IsAbs(absConfPath) { + return nil, ErrPathNotAbsolute + } + if absFilePath == "" || !filepath.IsAbs(absFilePath) { + return nil, ErrPathNotAbsolute + } + conf, err := loadConfigFile(absConfPath) + if err != nil { + return nil, err + } + return matchRulesForConfig(conf, absConfPath, absFilePath) +} + // LoadCreationRuleForFile load the configuration for a given SOPS file from the config file at confPath. A kmsEncryptionContext // should be provided for configurations that do not contain key groups, as there's no way to specify context inside // a SOPS config file outside of key groups. diff --git a/config/config_test.go b/config/config_test.go index 04bed7f564..17d72c2547 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -8,6 +8,7 @@ import ( "github.com/getsops/sops/v3/keys" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type mockFS struct { @@ -919,3 +920,190 @@ creation_rules: // Format: ARN|context where context is "AppName:myapp" assert.Equal(t, "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012|AppName:myapp", conf.KeyGroups[0][0].ToString()) } + +var sampleConfigCatchAll = []byte(` +creation_rules: + - kms: 'arn:aws:kms:us-east-1:1:key/catch' + pgp: 'AAAA1111' +`) + +func TestMatchRulesForConfigCatchAll(t *testing.T) { + conf := parseConfigFile(sampleConfigCatchAll, t) + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/anything.yaml") + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Equal(t, "/conf/.sops.yaml", result.ConfigPath) + assert.Equal(t, "/conf/anything.yaml", result.FilePath) + assert.NotNil(t, result.CreationRule) + assert.Equal(t, 0, result.CreationRule.RuleIndex) + assert.Equal(t, "", result.CreationRule.Rule.PathRegex) + assert.Nil(t, result.DestinationRule) +} + +var sampleConfigPathRegex = []byte(` +creation_rules: + - path_regex: 'staging/.*\.yaml$' + age: 'age1staging' + - path_regex: 'prod/.*\.yaml$' + kms: 'arn:prod' + - kms: 'arn:fallback' +`) + +func TestMatchRulesForConfigPathRegex(t *testing.T) { + conf := parseConfigFile(sampleConfigPathRegex, t) + cases := []struct { + name string + filePath string + wantIndex int + wantRegex string + }{ + {"first rule matches", "/conf/staging/db.yaml", 0, `staging/.*\.yaml$`}, + {"second rule matches", "/conf/prod/api.yaml", 1, `prod/.*\.yaml$`}, + {"fallback (catch-all) matches", "/conf/notes.md", 2, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", tc.filePath) + assert.Nil(t, err) + assert.NotNil(t, result.CreationRule) + assert.Equal(t, tc.wantIndex, result.CreationRule.RuleIndex) + assert.Equal(t, tc.wantRegex, result.CreationRule.Rule.PathRegex) + }) + } +} + +var sampleConfigAmbiguousFirstMatchWins = []byte(` +creation_rules: + - path_regex: 'shared/.*' + kms: 'arn:rule0' + - path_regex: 'shared/secret\.yaml' + kms: 'arn:rule1' +`) + +func TestMatchRulesForConfigFirstMatchWins(t *testing.T) { + conf := parseConfigFile(sampleConfigAmbiguousFirstMatchWins, t) + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/shared/secret.yaml") + assert.Nil(t, err) + assert.NotNil(t, result.CreationRule) + assert.Equal(t, 0, result.CreationRule.RuleIndex, "first matching rule wins, not most-specific") +} + +var sampleConfigDestinationOnly = []byte(` +destination_rules: + - path_regex: 'publish/.*\.json$' + s3_bucket: 'org-secrets' + s3_prefix: 'sops/' + recreation_rule: + kms: 'arn:publish' +`) + +func TestMatchRulesForConfigDestinationOnly(t *testing.T) { + conf := parseConfigFile(sampleConfigDestinationOnly, t) + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/publish/app.json") + assert.Nil(t, err) + assert.Nil(t, result.CreationRule) + assert.NotNil(t, result.DestinationRule) + assert.Equal(t, 0, result.DestinationRule.RuleIndex) + assert.Equal(t, "org-secrets", result.DestinationRule.Rule.S3Bucket) +} + +var sampleConfigBothRuleTypes = []byte(` +creation_rules: + - kms: 'arn:creation' +destination_rules: + - path_regex: 'publish/.*' + s3_bucket: 'b' + recreation_rule: + kms: 'arn:recreation' +`) + +func TestMatchRulesForConfigBothRuleTypes(t *testing.T) { + conf := parseConfigFile(sampleConfigBothRuleTypes, t) + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/publish/app.yaml") + assert.Nil(t, err) + assert.NotNil(t, result.CreationRule) + assert.NotNil(t, result.DestinationRule) +} + +var sampleConfigForNormalization = []byte(` +creation_rules: + - path_regex: '^secrets/.*\.yaml$' + kms: 'arn:secrets' +`) + +func TestMatchRulesForConfigPathInsideTree(t *testing.T) { + conf := parseConfigFile(sampleConfigForNormalization, t) + // Absolute file path under config dir → prefix-stripped to "secrets/db.yaml" + // and matched against "^secrets/.*\.yaml$". + result, err := matchRulesForConfig(conf, "/repo/.sops.yaml", "/repo/secrets/db.yaml") + assert.Nil(t, err) + assert.NotNil(t, result.CreationRule) + assert.Equal(t, 0, result.CreationRule.RuleIndex) +} + +func TestMatchRulesForConfigPathOutsideTree(t *testing.T) { + conf := parseConfigFile(sampleConfigForNormalization, t) + // Absolute file path NOT under config dir → matched as absolute. + // "^secrets/.*\.yaml$" does NOT match "/tmp/secrets/x.yaml" because of the ^ anchor. + result, err := matchRulesForConfig(conf, "/repo/.sops.yaml", "/tmp/secrets/x.yaml") + assert.Nil(t, err) + assert.Nil(t, result.CreationRule) +} + +var sampleConfigEmpty = []byte(``) + +var sampleConfigEmptyCreationRules = []byte(` +creation_rules: [] +`) + +var sampleConfigInvalidRegex = []byte(` +creation_rules: + - path_regex: '[invalid(' + kms: 'arn:x' +`) + +func TestMatchRulesForConfigEmpty(t *testing.T) { + conf := parseConfigFile(sampleConfigEmpty, t) + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/x.yaml") + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Nil(t, result.CreationRule) + assert.Nil(t, result.DestinationRule) +} + +func TestMatchRulesForConfigEmptyCreationRules(t *testing.T) { + conf := parseConfigFile(sampleConfigEmptyCreationRules, t) + result, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/x.yaml") + assert.Nil(t, err) + assert.Nil(t, result.CreationRule) +} + +func TestMatchRulesForConfigInvalidRegex(t *testing.T) { + conf := parseConfigFile(sampleConfigInvalidRegex, t) + _, err := matchRulesForConfig(conf, "/conf/.sops.yaml", "/conf/x.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "can not compile regexp") +} + +func TestMatchRulesForFileRejectsRelativePaths(t *testing.T) { + _, err := MatchRulesForFile("relative/path.yaml", "/abs/file.yaml") + assert.ErrorIs(t, err, ErrPathNotAbsolute) + + _, err = MatchRulesForFile("/abs/.sops.yaml", "relative/file.yaml") + assert.ErrorIs(t, err, ErrPathNotAbsolute) +} + +func TestMatchRulesForFileRejectsEmptyPaths(t *testing.T) { + _, err := MatchRulesForFile("", "/abs/file.yaml") + assert.ErrorIs(t, err, ErrPathNotAbsolute) + + _, err = MatchRulesForFile("/abs/.sops.yaml", "") + assert.ErrorIs(t, err, ErrPathNotAbsolute) +} + +func TestMatchRulesForFileMissingConfigFile(t *testing.T) { + // Pointing at a path that does not exist — bubbles up loadConfigFile's error. + _, err := MatchRulesForFile("/definitely/not/a/real/path/.sops.yaml", "/abs/x.yaml") + require.Error(t, err) + // Don't assert on the exact wrap; just that we get an error from the loader. +}