diff --git a/packages/cmd/export.go b/packages/cmd/export.go index 776074f2..382b5362 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -83,7 +83,7 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secretsPath, err := cmd.Flags().GetString("path") + secretsPaths, err := cmd.Flags().GetStringArray("path") if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -93,20 +93,16 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - request := models.GetAllSecretsParameters{ - Environment: environmentName, - TagSlugs: tagSlugs, - WorkspaceId: projectId, - SecretsPath: secretsPath, - IncludeImport: includeImports, - ExpandSecretReferences: shouldExpandSecrets, - IncludePersonalOverrides: secretOverriding, - } - - if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { - request.InfisicalToken = token.Token - } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { - request.UniversalAuthAccessToken = token.Token + // TagSlugs is intentionally left unset: filtering server side would drop untagged + // secrets from each path before the paths are merged, so a secret that is tagged on + // an earlier path could survive even though a later path overrides it with an + // untagged one. Tags are applied client side once the merge has happened. + request := models.GetMultiPathSecretsParameters{ + Environment: environmentName, + WorkspaceId: projectId, + SecretsPaths: secretsPaths, + IncludeImport: includeImports, + ExpandSecretReferences: shouldExpandSecrets, } if templatePath != "" { @@ -133,14 +129,13 @@ var exportCmd = &cobra.Command{ return } - secrets, err := util.GetAllEnvironmentVariables(request, "") + secrets, err := fetchSecrets(request, "", secretOverriding, token) if err != nil { util.HandleError(err, "Unable to fetch secrets") } var output string - secrets = util.FilterSecretsByTag(secrets, tagSlugs) - secrets = util.SortSecretsByKeys(secrets) + secrets = mergeAndFilterSecrets(secrets, tagSlugs) output, err = formatEnvs(secrets, format) if err != nil { @@ -169,6 +164,31 @@ var exportCmd = &cobra.Command{ }, } +// mergeAndFilterSecrets turns the secrets fetched from every path into the final set to +// export. The merge has to run before the tag filter so that a key present on several +// paths resolves to the last path first, and only that winning secret is then checked +// against the requested tags. +func mergeAndFilterSecrets(secrets []models.SingleEnvironmentVariable, tagSlugs string) []models.SingleEnvironmentVariable { + secrets = mergeSecretsByKey(secrets) + secrets = util.FilterSecretsByTag(secrets, tagSlugs) + + return util.SortSecretsByKeys(secrets) +} + +// mergeSecretsByKey collapses secrets that share the same key, which can happen when +// secrets are fetched from multiple paths. The last path a key was found in wins. +// The returned order is not stable, so callers are expected to sort the result. +func mergeSecretsByKey(secrets []models.SingleEnvironmentVariable) []models.SingleEnvironmentVariable { + secretsByKey := getSecretsByKeys(secrets) + + mergedSecrets := make([]models.SingleEnvironmentVariable, 0, len(secretsByKey)) + for _, secret := range secretsByKey { + mergedSecrets = append(mergedSecrets, secret) + } + + return mergedSecrets +} + // resolveOutputPath determines the final output path based on the provided path and format func resolveOutputPath(outputFile, format string) (string, error) { // Expand ~ to home directory if present @@ -272,7 +292,7 @@ func init() { exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") exportCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs") exportCmd.Flags().String("projectId", "", "manually set the projectId to export secrets from") - exportCmd.Flags().String("path", "/", "get secrets within a folder path") + exportCmd.Flags().StringArray("path", []string{"/"}, "get secrets within a folder path (can be specified multiple times)") exportCmd.Flags().String("template", "", "The path to the template file used to render secrets") exportCmd.Flags().StringP("output-file", "o", "", "The path to write the output file to. Can be a full file path, directory, or filename. If not specified, output will be printed to stdout") } diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 0e3921ec..a227add6 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v2" ) @@ -133,6 +135,126 @@ func TestFormatAsDotEnvEval(t *testing.T) { } } +func TestMergeSecretsByKey(t *testing.T) { + tests := []struct { + name string + input []models.SingleEnvironmentVariable + expected []models.SingleEnvironmentVariable + }{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + expected: []models.SingleEnvironmentVariable{}, + }, + { + name: "Distinct keys are all kept", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + expected: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + }, + { + name: "Duplicate keys across paths keep the last value", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "FROM_FIRST_PATH"}, + {Key: "KEY2", Value: "VALUE2"}, + {Key: "KEY1", Value: "FROM_SECOND_PATH"}, + }, + expected: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "FROM_SECOND_PATH"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := util.SortSecretsByKeys(mergeSecretsByKey(tt.input)) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMergeAndFilterSecrets(t *testing.T) { + teamTag := []models.Tag{{Slug: "team"}} + + tests := []struct { + name string + input []models.SingleEnvironmentVariable + tagSlugs string + expected []models.SingleEnvironmentVariable + }{ + { + name: "An untagged secret on a later path drops the tagged one from an earlier path", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "old", Tags: teamTag}, + {Key: "KEY2", Value: "kept", Tags: teamTag}, + {Key: "KEY1", Value: "new"}, + }, + tagSlugs: "team", + expected: []models.SingleEnvironmentVariable{ + {Key: "KEY2", Value: "kept", Tags: teamTag}, + }, + }, + { + name: "A tagged secret on a later path overrides an untagged one from an earlier path", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "old"}, + {Key: "KEY1", Value: "new", Tags: teamTag}, + }, + tagSlugs: "team", + expected: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "new", Tags: teamTag}, + }, + }, + { + name: "Without tags the merged result is returned as is", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY2", Value: "VALUE2"}, + {Key: "KEY1", Value: "old"}, + {Key: "KEY1", Value: "new"}, + }, + tagSlugs: "", + expected: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "new"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, mergeAndFilterSecrets(tt.input, tt.tagSlugs)) + }) + } +} + +func newExportTestCmd() *cobra.Command { + c := &cobra.Command{Use: "export"} + c.Flags().StringArray("path", []string{"/"}, "") + return c +} + +func TestExportPathFlagAcceptsMultipleValues(t *testing.T) { + // the export command must declare --path as a repeatable string array + paths, err := exportCmd.Flags().GetStringArray("path") + assert.NoError(t, err) + assert.Equal(t, []string{"/"}, paths) + + // repeated --path values accumulate instead of overwriting one another + cmd := newExportTestCmd() + assert.NoError(t, cmd.Flags().Set("path", "/first")) + assert.NoError(t, cmd.Flags().Set("path", "/second")) + + paths, err = cmd.Flags().GetStringArray("path") + assert.NoError(t, err) + assert.Equal(t, []string{"/first", "/second"}, paths) +} + func TestPosixShellQuote(t *testing.T) { tests := []struct { input string diff --git a/packages/util/log.go b/packages/util/log.go index 9a6c7c0c..69c770c1 100644 --- a/packages/util/log.go +++ b/packages/util/log.go @@ -2,6 +2,7 @@ package util import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -69,8 +70,10 @@ func HandleError(err error, messages ...string) { } func PrintErrorAndExit(exitCode int, err error, messages ...string) { - // Check if it's an API error for special formatting - if apiErr, ok := err.(*api.APIError); ok { + // Check if it's an API error for special formatting. errors.As is used so that + // API errors still get pretty printed when callers wrap them with additional context. + var apiErr *api.APIError + if errors.As(err, &apiErr) { if len(messages) > 0 { apiErr.ExtraMessages = messages }