-
Notifications
You must be signed in to change notification settings - Fork 38
feat(export): add --to-files to write per-path .env files #258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
Comment on lines
+60
to
+87
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the Infisical API returns a folder structure where a child path equals or prefixes an ancestor (e.g. |
||
|
|
||
| // 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) | ||
| } | ||
|
Comment on lines
+115
to
+123
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider validating that |
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
*anywhere but the end are silently treated as concrete pathsOnly the suffix
/*is detected as a glob. A user supplyingapps/*/webor*/cliwill not get an error — the pattern is passed throughnormalizeSecretPathand treated as a literal Infisical path, almost certainly returning zero secrets with no diagnostic message. Consider returning an error for patterns that contain*in a position other than the trailing/*.