diff --git a/lib/kamal/secrets/dotenv/inline_command_substitution.rb b/lib/kamal/secrets/dotenv/inline_command_substitution.rb index c9ef98799..bc4f05508 100644 --- a/lib/kamal/secrets/dotenv/inline_command_substitution.rb +++ b/lib/kamal/secrets/dotenv/inline_command_substitution.rb @@ -4,9 +4,36 @@ def install! ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub } end + # Improved version of Dotenv::Substitutions::Command's INTERPOLATED_SHELL_COMMAND + # Handles: + # $(echo 'foo)') + # $(echo "foo)") + # $(echo foo\)) + # $(echo "foo\")") + # $(echo foo\\) + # $(echo 'foo'"'"')') + INTERPOLATED_SHELL_COMMAND = / + (?\\)? # (1) Optional backslash (escaped '$') + \$ # (2) Match a literal '$' (start of command) + (? # (3) Capture the command within '$()' as 'cmd' + \( # (4) Require an opening parenthesis '(' + (?: # (5) Match either: + [^()\\'"]+ # - Any non-parens, non-escape, non-quotes (normal chars) + | \\ (?!\)) . # - Escaped character (e.g., `\(`, `\'`, `\"`), but **not** `\)` alone + | \\\\ \) # - Special case: Match `\\)` as a literal `\)` + | '(?:[^'\\]* (?:\\.[^'\\]*)*)' # - Single-quoted strings with escaped quotes (`\'`) + | "(?:[^"\\]* (?:\\.[^"\\]*)*)" # - Double-quoted strings with escaped quotes (`\"`) + | '(?:[^']*)' (?:"[^"]*")* # - Single-quoted, followed by optional mixed double-quoted parts + | "(?:[^"]*)" (?:'[^']*')* # - Double-quoted, followed by optional mixed single-quoted parts + | \g # - Nested `$()` expressions (recursive call) + )* # (6) Repeat to allow full parsing + \) # (7) Require a closing parenthesis ')' + ) + /x + def call(value, _env, overwrite: false) # Process interpolated shell commands - value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*| + value.gsub(INTERPOLATED_SHELL_COMMAND) do |*| # Eliminate opening and closing parentheses command = $LAST_MATCH_INFO[:cmd][1..-2] diff --git a/test/secrets_test.rb b/test/secrets_test.rb index f0ca7e299..9d0bed9c3 100644 --- a/test/secrets_test.rb +++ b/test/secrets_test.rb @@ -34,6 +34,30 @@ class SecretsTest < ActiveSupport::TestCase end end + test "secret with open bracket" do + with_test_secrets("secrets" => "SECRET1=$(echo 'foo)')") do + assert_equal "foo)", Kamal::Secrets.new["SECRET1"] + end + end + + test "secret with close bracket" do + with_test_secrets("secrets" => "SECRET1=$(echo 'foo(')") do + assert_equal "foo(", Kamal::Secrets.new["SECRET1"] + end + end + + test "secret with escaped quote" do + with_test_secrets("secrets" => "SECRET1=$(echo \"foo\\\")") do + assert_equal "foo", Kamal::Secrets.new["SECRET1"] + end + end + + test "secret with escaped single quote" do + with_test_secrets("secrets" => "SECRET1= $(echo 'foo'\"'\"'bar')") do + assert_equal "foo'bar", Kamal::Secrets.new["SECRET1"] + end + end + test "destinations" do with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do assert_equal "ABC", Kamal::Secrets.new["SECRET"]