From f5647cce4a14531226f4f0d88ec8eda27785f957 Mon Sep 17 00:00:00 2001 From: Deepak kudi Date: Sat, 30 May 2026 23:30:55 +0530 Subject: [PATCH 1/2] feat(export): support dotenv quote styles --- packages/cmd/export.go | 72 ++++++++++++++++++++++++++++++------- packages/cmd/export_test.go | 60 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/packages/cmd/export.go b/packages/cmd/export.go index a27c5a0c..cdc4a4c1 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -24,6 +24,10 @@ const ( FormatCSV string = "csv" FormatYaml string = "yaml" FormatDotEnvExport string = "dotenv-export" + + DotEnvQuoteStyleSingle string = "single" + DotEnvQuoteStyleDouble string = "double" + DotEnvQuoteStyleNone string = "none" ) // exportCmd represents the export command @@ -31,7 +35,7 @@ var exportCmd = &cobra.Command{ Use: "export", Short: "Used to export environment variables to a file", DisableFlagsInUseLine: true, - Example: "infisical export --env=prod --format=json > secrets.json\ninfisical export --env=prod --format=json --output-file=secrets.json", + Example: "infisical export --env=prod --format=json > secrets.json\ninfisical export --env=prod --format=json --output-file=secrets.json\ninfisical export --env=prod --format=dotenv --quote=double > .env", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { environmentName, _ := cmd.Flags().GetString("env") @@ -67,6 +71,11 @@ var exportCmd = &cobra.Command{ util.HandleError(err) } + quoteStyle, err := cmd.Flags().GetString("quote") + if err != nil { + util.HandleError(err) + } + templatePath, err := cmd.Flags().GetString("template") if err != nil { util.HandleError(err) @@ -146,7 +155,7 @@ var exportCmd = &cobra.Command{ secrets = util.FilterSecretsByTag(secrets, tagSlugs) secrets = util.SortSecretsByKeys(secrets) - output, err = formatEnvs(secrets, format) + output, err = formatEnvs(secrets, format, quoteStyle) if err != nil { util.HandleError(err) } @@ -267,6 +276,7 @@ func init() { exportCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") + exportCmd.Flags().String("quote", DotEnvQuoteStyleSingle, "Set the quote style for dotenv output (single, double, none)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets") exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") @@ -278,12 +288,12 @@ func init() { } // Format according to the format flag -func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, error) { +func formatEnvs(envs []models.SingleEnvironmentVariable, format string, quoteStyle string) (string, error) { switch strings.ToLower(format) { case FormatDotenv: - return formatAsDotEnv(envs), nil + return formatAsDotEnv(envs, quoteStyle) case FormatDotEnvExport: - return formatAsDotEnvExport(envs), nil + return formatAsDotEnvExport(envs, quoteStyle) case FormatJson: return formatAsJson(envs), nil case FormatCSV: @@ -308,21 +318,57 @@ func formatAsCSV(envs []models.SingleEnvironmentVariable) string { } // Format environment variables as a dotenv file -func formatAsDotEnv(envs []models.SingleEnvironmentVariable) string { - var dotenv string +func formatAsDotEnv(envs []models.SingleEnvironmentVariable, quoteStyle string) (string, error) { + var dotenv strings.Builder for _, env := range envs { - dotenv += fmt.Sprintf("%s='%s'\n", env.Key, escapeNewLinesIfRequired(env)) + value, err := formatDotEnvValue(escapeNewLinesIfRequired(env), quoteStyle) + if err != nil { + return "", err + } + + dotenv.WriteString(fmt.Sprintf("%s=%s\n", env.Key, value)) } - return dotenv + return dotenv.String(), nil } // Format environment variables as a dotenv file with export at the beginning -func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable) string { - var dotenv string +func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable, quoteStyle string) (string, error) { + var dotenv strings.Builder for _, env := range envs { - dotenv += fmt.Sprintf("export %s='%s'\n", env.Key, escapeNewLinesIfRequired(env)) + value, err := formatDotEnvValue(escapeNewLinesIfRequired(env), quoteStyle) + if err != nil { + return "", err + } + + dotenv.WriteString(fmt.Sprintf("export %s=%s\n", env.Key, value)) + } + return dotenv.String(), nil +} + +func formatDotEnvValue(value string, quoteStyle string) (string, error) { + switch normalizeDotEnvQuoteStyle(quoteStyle) { + case DotEnvQuoteStyleSingle: + return fmt.Sprintf("'%s'", value), nil + case DotEnvQuoteStyleDouble: + return fmt.Sprintf("\"%s\"", strings.ReplaceAll(value, `"`, `\"`)), nil + case DotEnvQuoteStyleNone: + return value, nil + default: + return "", fmt.Errorf("invalid dotenv quote style: %s. Available quote styles are %v", quoteStyle, []string{DotEnvQuoteStyleSingle, DotEnvQuoteStyleDouble, DotEnvQuoteStyleNone}) + } +} + +func normalizeDotEnvQuoteStyle(quoteStyle string) string { + switch strings.ToLower(quoteStyle) { + case "'", DotEnvQuoteStyleSingle: + return DotEnvQuoteStyleSingle + case `"`, DotEnvQuoteStyleDouble: + return DotEnvQuoteStyleDouble + case "", DotEnvQuoteStyleNone: + return DotEnvQuoteStyleNone + default: + return strings.ToLower(quoteStyle) } - return dotenv } func formatAsYaml(envs []models.SingleEnvironmentVariable) (string, error) { diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 1be0a7ed..9e26596f 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -77,3 +77,63 @@ func TestFormatAsYaml(t *testing.T) { }) } } + +func TestFormatEnvsDotEnvQuoteStyles(t *testing.T) { + envs := []models.SingleEnvironmentVariable{ + {Key: "PLAIN", Value: "value"}, + {Key: "PRIVATE_KEY", Value: "line 1\nline 2", SkipMultilineEncoding: true}, + {Key: "JSON", Value: `{"enabled":true}`}, + } + + tests := []struct { + name string + quoteStyle string + format string + expected string + }{ + { + name: "single quote style preserves existing dotenv output", + quoteStyle: DotEnvQuoteStyleSingle, + format: FormatDotenv, + expected: "PLAIN='value'\nPRIVATE_KEY='line 1\\nline 2'\nJSON='{\"enabled\":true}'\n", + }, + { + name: "double quote style wraps dotenv values in double quotes", + quoteStyle: DotEnvQuoteStyleDouble, + format: FormatDotenv, + expected: "PLAIN=\"value\"\nPRIVATE_KEY=\"line 1\\nline 2\"\nJSON=\"{\\\"enabled\\\":true}\"\n", + }, + { + name: "none quote style leaves dotenv values unquoted", + quoteStyle: DotEnvQuoteStyleNone, + format: FormatDotenv, + expected: "PLAIN=value\nPRIVATE_KEY=line 1\\nline 2\nJSON={\"enabled\":true}\n", + }, + { + name: "double quote style works with dotenv export", + quoteStyle: DotEnvQuoteStyleDouble, + format: FormatDotEnvExport, + expected: "export PLAIN=\"value\"\nexport PRIVATE_KEY=\"line 1\\nline 2\"\nexport JSON=\"{\\\"enabled\\\":true}\"\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := formatEnvs(envs, tt.format, tt.quoteStyle) + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatEnvsRejectsInvalidDotEnvQuoteStyle(t *testing.T) { + _, err := formatEnvs( + []models.SingleEnvironmentVariable{{Key: "KEY", Value: "VALUE"}}, + FormatDotenv, + "invalid", + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid dotenv quote style") +} From be7ca32d13193384a8c8fb65ba8de6265263c445 Mon Sep 17 00:00:00 2001 From: Deepak kudi Date: Wed, 3 Jun 2026 22:03:33 +0530 Subject: [PATCH 2/2] fix(export): reject unsafe quote combinations --- packages/cmd/export.go | 23 +++++++++++- packages/cmd/export_test.go | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/cmd/export.go b/packages/cmd/export.go index cdc4a4c1..0a5e1a5e 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -75,6 +75,9 @@ var exportCmd = &cobra.Command{ if err != nil { util.HandleError(err) } + if err := validateDotEnvQuoteStyleForFormat(format, quoteStyle, cmd.Flags().Changed("quote")); err != nil { + util.HandleError(err) + } templatePath, err := cmd.Flags().GetString("template") if err != nil { @@ -276,7 +279,7 @@ func init() { exportCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") - exportCmd.Flags().String("quote", DotEnvQuoteStyleSingle, "Set the quote style for dotenv output (single, double, none)") + exportCmd.Flags().String("quote", DotEnvQuoteStyleSingle, "Set the quote style for dotenv or dotenv-export output (single, double, none)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets") exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") @@ -305,6 +308,21 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string, quoteSty } } +func validateDotEnvQuoteStyleForFormat(format string, quoteStyle string, quoteStyleSet bool) error { + if !quoteStyleSet || normalizeDotEnvQuoteStyle(quoteStyle) == DotEnvQuoteStyleSingle { + return nil + } + + switch strings.ToLower(format) { + case FormatDotenv, FormatDotEnvExport: + return nil + case FormatJson, FormatCSV, FormatYaml: + return fmt.Errorf("--quote can only be used with %s or %s formats", FormatDotenv, FormatDotEnvExport) + default: + return nil + } +} + // Format environment variables as a CSV file func formatAsCSV(envs []models.SingleEnvironmentVariable) string { csvString := &strings.Builder{} @@ -348,6 +366,9 @@ func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable, quoteStyle st func formatDotEnvValue(value string, quoteStyle string) (string, error) { switch normalizeDotEnvQuoteStyle(quoteStyle) { case DotEnvQuoteStyleSingle: + if strings.ContainsRune(value, '\'') { + return "", fmt.Errorf("single quote style cannot be used for values that contain a single quote character; use --quote=double instead") + } return fmt.Sprintf("'%s'", value), nil case DotEnvQuoteStyleDouble: return fmt.Sprintf("\"%s\"", strings.ReplaceAll(value, `"`, `\"`)), nil diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 9e26596f..7352e213 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -137,3 +137,77 @@ func TestFormatEnvsRejectsInvalidDotEnvQuoteStyle(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "invalid dotenv quote style") } + +func TestFormatEnvsRejectsSingleQuoteStyleForSingleQuoteValues(t *testing.T) { + _, err := formatEnvs( + []models.SingleEnvironmentVariable{{Key: "KEY", Value: "it's private"}}, + FormatDotenv, + DotEnvQuoteStyleSingle, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "single quote style cannot be used") +} + +func TestValidateDotEnvQuoteStyleForFormatRejectsUnusedNonDefaultQuoteStyle(t *testing.T) { + tests := []struct { + name string + format string + quoteStyle string + quoteStyleSet bool + expectedErrorMsg string + }{ + { + name: "rejects double quote style for json output", + format: FormatJson, + quoteStyle: DotEnvQuoteStyleDouble, + quoteStyleSet: true, + expectedErrorMsg: "--quote can only be used with dotenv", + }, + { + name: "rejects none quote style for csv output", + format: FormatCSV, + quoteStyle: DotEnvQuoteStyleNone, + quoteStyleSet: true, + expectedErrorMsg: "--quote can only be used with dotenv", + }, + { + name: "allows unchanged default quote style for json output", + format: FormatJson, + quoteStyle: DotEnvQuoteStyleSingle, + quoteStyleSet: false, + }, + { + name: "allows explicit default quote style for yaml output", + format: FormatYaml, + quoteStyle: DotEnvQuoteStyleSingle, + quoteStyleSet: true, + }, + { + name: "allows double quote style for dotenv output", + format: FormatDotenv, + quoteStyle: DotEnvQuoteStyleDouble, + quoteStyleSet: true, + }, + { + name: "allows none quote style for dotenv export output", + format: FormatDotEnvExport, + quoteStyle: DotEnvQuoteStyleNone, + quoteStyleSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDotEnvQuoteStyleForFormat(tt.format, tt.quoteStyle, tt.quoteStyleSet) + + if tt.expectedErrorMsg == "" { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrorMsg) + }) + } +}