diff --git a/packages/cmd/export.go b/packages/cmd/export.go index a27c5a0c..ceb7dfa0 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -92,6 +92,11 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + toFiles, err := cmd.Flags().GetBool("to-files") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + request := models.GetAllSecretsParameters{ Environment: environmentName, TagSlugs: tagSlugs, @@ -131,6 +136,16 @@ var exportCmd = &cobra.Command{ return } + if toFiles { + if outputFile != "" { + util.PrintErrorMessageAndExit("--to-files cannot be combined with --output-file") + } + if err := runExportToFiles(request, format, tagSlugs, secretOverriding); err != nil { + util.HandleError(err, "Failed to export secrets to files") + } + return + } + secrets, err := util.GetAllEnvironmentVariables(request, "") if err != nil { util.HandleError(err, "Unable to fetch secrets") @@ -275,6 +290,7 @@ func init() { exportCmd.Flags().String("path", "/", "get secrets within a folder path") 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") + exportCmd.Flags().Bool("to-files", false, "Write one file per logical path, mirroring the folder path into the filesystem (e.g. apps/cli/.env). Use with --path=\"apps/*\" to target the immediate subfolders of apps, or omit --path to export every folder. Cannot be combined with --output-file") } // Format according to the format flag diff --git a/packages/cmd/export_to_files.go b/packages/cmd/export_to_files.go new file mode 100644 index 00000000..6e70a920 --- /dev/null +++ b/packages/cmd/export_to_files.go @@ -0,0 +1,167 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" +) + +func normalizeSecretPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if len(path) > 1 { + path = strings.TrimRight(path, "/") + } + return path +} + +// isSafeFolderSegment guards against a server returning a folder name that would +// escape the target directory once mirrored into the filesystem (e.g. ".." or a +// name containing a path separator). +func isSafeFolderSegment(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + return !strings.ContainsAny(name, "/\\") +} + +// expandToFilePaths resolves the export --path pattern into the concrete secret +// paths a .env file should be written for: +// - a pattern ending in "/*" expands to the immediate child folders of its prefix +// (e.g. "apps/*" -> "/apps/cli", "/apps/api") +// - the root path ("/" or empty) expands to every folder in the tree, recursively +// - any other pattern is treated as a single concrete path +// +// listChildren returns the immediate child folder paths of the given parent path. +func expandToFilePaths(pattern string, listChildren func(parent string) ([]string, error)) ([]string, error) { + if strings.HasSuffix(strings.TrimSpace(pattern), "/*") { + parent := normalizeSecretPath(strings.TrimSuffix(strings.TrimSpace(pattern), "/*")) + return listChildren(parent) + } + + path := normalizeSecretPath(pattern) + if path == "/" { + return walkFolderTree("/", listChildren) + } + return []string{path}, nil +} + +func walkFolderTree(root string, listChildren func(parent string) ([]string, error)) ([]string, error) { + return walkFolderTreeVisited(root, listChildren, map[string]bool{}) +} + +func walkFolderTreeVisited( + root string, + listChildren func(parent string) ([]string, error), + visited map[string]bool, +) ([]string, error) { + if visited[root] { + return nil, nil + } + visited[root] = true + + paths := []string{root} + children, err := listChildren(root) + if err != nil { + return nil, err + } + for _, child := range children { + descendants, err := walkFolderTreeVisited(child, listChildren, visited) + if err != nil { + return nil, err + } + paths = append(paths, descendants...) + } + return paths, nil +} + +// mapPathToFile maps a secret path to the relative file path its output is written +// to, mirroring the logical folder path into the filesystem (e.g. "/apps/cli" with +// dotenv format -> "apps/cli/.env"). The root path maps to the default filename in +// the current directory. +func mapPathToFile(secretPath, format string) string { + clean := strings.Trim(secretPath, "/") + filename := getDefaultFilename(format) + if clean == "" { + return filename + } + return filepath.Join(clean, filename) +} + +func runExportToFiles(request models.GetAllSecretsParameters, format string, tagSlugs string, secretOverriding bool) error { + listChildren := func(parent string) ([]string, error) { + folders, err := util.GetAllFolders(models.GetAllFoldersParameters{ + WorkspaceId: request.WorkspaceId, + Environment: request.Environment, + FoldersPath: parent, + InfisicalToken: request.InfisicalToken, + UniversalAuthAccessToken: request.UniversalAuthAccessToken, + }) + if err != nil { + return nil, err + } + base := strings.TrimRight(parent, "/") + paths := make([]string, 0, len(folders)) + for _, folder := range folders { + if !isSafeFolderSegment(folder.Name) { + return nil, fmt.Errorf( + "refusing to export: server returned unsafe folder name %q", folder.Name, + ) + } + paths = append(paths, base+"/"+folder.Name) + } + return paths, nil + } + + paths, err := expandToFilePaths(request.SecretsPath, listChildren) + if err != nil { + return err + } + if len(paths) == 0 { + util.PrintfStderr("No folders matched path %q; nothing to export\n", request.SecretsPath) + return nil + } + + for _, path := range paths { + pathRequest := request + pathRequest.SecretsPath = path + + secrets, err := util.GetAllEnvironmentVariables(pathRequest, "") + if err != nil { + return fmt.Errorf("unable to fetch secrets for path %s: %w", path, err) + } + + if secretOverriding { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL) + } else { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED) + } + secrets = util.FilterSecretsByTag(secrets, tagSlugs) + secrets = util.SortSecretsByKeys(secrets) + + output, err := formatEnvs(secrets, format) + if err != nil { + return err + } + + outputFile := mapPathToFile(path, format) + if err := writeToFile(outputFile, output, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", outputFile, err) + } + + util.PrintfStderr("Exported %d secrets from %s to %s\n", len(secrets), path, outputFile) + } + + return nil +} diff --git a/packages/cmd/export_to_files_test.go b/packages/cmd/export_to_files_test.go new file mode 100644 index 00000000..0d66c83d --- /dev/null +++ b/packages/cmd/export_to_files_test.go @@ -0,0 +1,136 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// fakeFolderTree returns a listChildren func backed by a static folder tree, so the +// path-resolution logic can be unit-tested without a live Infisical backend. +func fakeFolderTree() func(string) ([]string, error) { + tree := map[string][]string{ + "/": {"/apps", "/packages"}, + "/apps": {"/apps/cli", "/apps/api", "/apps/web"}, + "/packages": {"/packages/eslint-config"}, + } + return func(parent string) ([]string, error) { + return tree[parent], nil + } +} + +func TestExpandToFilePaths(t *testing.T) { + listChildren := fakeFolderTree() + + tests := []struct { + name string + pattern string + expected []string + }{ + { + name: "glob expands to immediate children only", + pattern: "apps/*", + expected: []string{"/apps/cli", "/apps/api", "/apps/web"}, + }, + { + name: "glob normalizes a leading-slash prefix", + pattern: "/apps/*", + expected: []string{"/apps/cli", "/apps/api", "/apps/web"}, + }, + { + name: "root glob lists top-level folders, not recursively", + pattern: "/*", + expected: []string{"/apps", "/packages"}, + }, + { + name: "concrete path resolves to itself", + pattern: "/apps/cli", + expected: []string{"/apps/cli"}, + }, + { + name: "concrete path without leading slash is normalized", + pattern: "apps/cli", + expected: []string{"/apps/cli"}, + }, + { + name: "root path walks the whole tree recursively", + pattern: "/", + expected: []string{"/", "/apps", "/apps/cli", "/apps/api", "/apps/web", "/packages", "/packages/eslint-config"}, + }, + { + name: "empty path is treated as root", + pattern: "", + expected: []string{"/", "/apps", "/apps/cli", "/apps/api", "/apps/web", "/packages", "/packages/eslint-config"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := expandToFilePaths(tt.pattern, listChildren) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMapPathToFile(t *testing.T) { + tests := []struct { + name string + secretPath string + format string + expected string + }{ + {name: "dotenv mirrors the logical path", secretPath: "/apps/cli", format: "dotenv", expected: "apps/cli/.env"}, + {name: "root maps to default filename in cwd", secretPath: "/", format: "dotenv", expected: ".env"}, + {name: "json uses the json default filename", secretPath: "/apps/api", format: "json", expected: "apps/api/secrets.json"}, + {name: "yaml uses the yaml default filename", secretPath: "/packages/eslint-config", format: "yaml", expected: "packages/eslint-config/secrets.yaml"}, + {name: "trailing and leading slashes are trimmed", secretPath: "/apps/web/", format: "dotenv", expected: "apps/web/.env"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, mapPathToFile(tt.secretPath, tt.format)) + }) + } +} + +func TestExpandToFilePathsTerminatesOnCycle(t *testing.T) { + // A buggy/adversarial server could return a folder graph with a cycle. + tree := map[string][]string{ + "/": {"/a"}, + "/a": {"/a/b"}, + "/a/b": {"/a"}, // cycle back to an ancestor + } + listChildren := func(parent string) ([]string, error) { + return tree[parent], nil + } + + result, err := expandToFilePaths("/", listChildren) + assert.NoError(t, err) + assert.Equal(t, []string{"/", "/a", "/a/b"}, result) +} + +func TestIsSafeFolderSegment(t *testing.T) { + tests := []struct { + name string + safe bool + }{ + {name: "apps", safe: true}, + {name: "eslint-config", safe: true}, + {name: "", safe: false}, + {name: ".", safe: false}, + {name: "..", safe: false}, + {name: "../../.ssh", safe: false}, + {name: "a/b", safe: false}, + {name: "a\\b", safe: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.safe, isSafeFolderSegment(tt.name)) + }) + } +}