Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 80 additions & 13 deletions packages/cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ 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
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")
Expand Down Expand Up @@ -67,6 +71,14 @@ var exportCmd = &cobra.Command{
util.HandleError(err)
}

quoteStyle, err := cmd.Flags().GetString("quote")
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 {
util.HandleError(err)
Expand Down Expand Up @@ -146,7 +158,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)
}
Expand Down Expand Up @@ -267,6 +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 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")
Expand All @@ -278,12 +291,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:
Expand All @@ -295,6 +308,21 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string,
}
}

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{}
Expand All @@ -308,21 +336,60 @@ 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:
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
Comment on lines +368 to +372
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The single quote style wraps values in '...' but never escapes embedded single-quote characters. A secret value like it's a private key produces KEY='it's a private key' — a broken dotenv line. The double style correctly escapes " with \", so the same care is needed here. In POSIX-style dotenv parsers, single quotes don't support escape sequences, so the only safe workaround is to use '\'' (end quote, literal ', reopen quote), or to detect and error when the value contains ' under this style.

Suggested change
case DotEnvQuoteStyleSingle:
return fmt.Sprintf("'%s'", value), nil
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
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) {
Expand Down
134 changes: 134 additions & 0 deletions packages/cmd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,137 @@ 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")
}

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)
})
}
}