diff --git a/lib/kamal/secrets/dotenv/inline_command_substitution.rb b/lib/kamal/secrets/dotenv/inline_command_substitution.rb index 71e0e9e28..8e699b273 100644 --- a/lib/kamal/secrets/dotenv/inline_command_substitution.rb +++ b/lib/kamal/secrets/dotenv/inline_command_substitution.rb @@ -15,7 +15,19 @@ class Kamal::Secrets::Dotenv::InlineCommandSubstitution class << self def install! - ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub } + substitutions = ::Dotenv::Parser.substitutions + substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub } + + # Run variable substitution before command substitution, so command + # output is inserted literally. Otherwise a "$word" in the output -- + # common in passwords and tokens -- is treated as a reference to a + # (usually undefined) variable and silently dropped. This matches + # POSIX shell semantics, where the output of command substitution is + # not subject to further expansion. Variable references inside the + # command itself are still substituted in #call before execution. + if substitutions.delete(::Dotenv::Substitutions::Variable) + substitutions.unshift(::Dotenv::Substitutions::Variable) + end end def call(value, env, overwrite: false) diff --git a/test/secrets_test.rb b/test/secrets_test.rb index 870ca371b..a3b3d98ad 100644 --- a/test/secrets_test.rb +++ b/test/secrets_test.rb @@ -26,6 +26,29 @@ class SecretsTest < ActiveSupport::TestCase end end + test "command interpolation preserves dollar signs in the output" do + # The command outputs pa$1wor$D. Built with tr because "$word" in the + # command text itself is substituted before running. + with_test_secrets("secrets" => "SECRET=$(echo 'paX1worXD' | tr X '$')") do + assert_equal "pa$1wor$D", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET"] + end + end + + test "command interpolation still substitutes variables in the command" do + with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=$(echo ${SECRET1}DEF)") do + assert_equal "ABCDEF", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET2"] + end + end + + test "inline kamal secrets commands preserve dollar signs in extracted values" do + # Single-quoted so the stored value matches what an inlined + # "kamal secrets fetch" returns: shell-escaped JSON. + secrets_json = { "vault/password" => "pa$1wor$D" }.to_json.shellescape + with_test_secrets("secrets" => "SECRETS='#{secrets_json}'\nPASSWORD=$(kamal secrets extract vault/password ${SECRETS})") do + assert_equal "pa$1wor$D", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["PASSWORD"] + end + end + test "variable references" do with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF") do assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET1"]