From a4032ae6aef2e5dbd8a63b5a78fa3b334ab8e98f Mon Sep 17 00:00:00 2001
From: Avielle Wolfe <awolfe@gitlab.com>
Date: Mon, 26 Feb 2024 14:58:54 +0000
Subject: [PATCH] Fix boolean and number input types

Boolean and number input types were not be respected by CI
interpolation. They were being cast to strings when interpolated by
`gsub`. This commit fixes that by replacing the entire node with the
input value when the interpolation block is the entire length of the
node. In other cases, we can assume that the input is being inserted
into a string, so it's safe for us to use `gsub` to cast the value to a
string and perform the insertion.

This commit also ensures that the ability to match and replace multiple
interpolation blocks in a single node is not affected. A previous
attempt to fix this bug caused an incident because it only interpolated
the first block in a node.

Changelog: fixed
Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/434826
---
 .../gitlab_com_derisk/ci_fix_input_types.yml  |  9 +++
 lib/gitlab/ci/config/interpolation/block.rb   | 22 ++++--
 .../ci/config/interpolation/template.rb       | 71 ++++++++++++++++---
 .../ci/config/interpolation/block_spec.rb     | 12 ++++
 .../ci/config/interpolation/template_spec.rb  | 34 +++++++--
 spec/lib/gitlab/ci/config/yaml/loader_spec.rb |  4 +-
 6 files changed, 131 insertions(+), 21 deletions(-)
 create mode 100644 config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml

diff --git a/config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml b/config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml
new file mode 100644
index 000000000000..b910822c618d
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml
@@ -0,0 +1,9 @@
+---
+name: ci_fix_input_types
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/434826
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145257
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443301
+milestone: '16.10'
+group: group::pipeline authoring
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/lib/gitlab/ci/config/interpolation/block.rb b/lib/gitlab/ci/config/interpolation/block.rb
index dc1ca423a816..0c9a83fed1d5 100644
--- a/lib/gitlab/ci/config/interpolation/block.rb
+++ b/lib/gitlab/ci/config/interpolation/block.rb
@@ -17,7 +17,15 @@ class Block
           PATTERN = /(?<block>\$\[\[\s*(?<data>\S{1}.*?\S{1})\s*\]\])/
           MAX_FUNCTIONS = 3
 
-          attr_reader :block, :data, :ctx, :errors
+          attr_reader :data, :ctx, :errors
+
+          def self.match(data)
+            return data unless data.is_a?(String) && data.include?(PREFIX)
+
+            data.gsub(PATTERN) do
+              yield ::Regexp.last_match(1), ::Regexp.last_match(2)
+            end
+          end
 
           def initialize(block, data, ctx)
             @block = block
@@ -43,16 +51,18 @@ def value
             @value
           end
 
-          def self.match(data)
-            return data unless data.is_a?(String) && data.include?(PREFIX)
+          def length
+            block.length
+          end
 
-            data.gsub(PATTERN) do
-              yield ::Regexp.last_match(1), ::Regexp.last_match(2)
-            end
+          def to_s
+            block
           end
 
           private
 
+          attr_reader :block
+
           # We expect the block data to be a string with one or more entities delimited by pipes:
           # <access> | <function1> | <function2> | ... <functionN>
           def evaluate!
diff --git a/lib/gitlab/ci/config/interpolation/template.rb b/lib/gitlab/ci/config/interpolation/template.rb
index 2a0f98480024..697be852c81b 100644
--- a/lib/gitlab/ci/config/interpolation/template.rb
+++ b/lib/gitlab/ci/config/interpolation/template.rb
@@ -10,6 +10,8 @@ class Template
           attr_reader :blocks, :ctx
 
           MAX_BLOCKS = 10_000
+          BLOCK_PATTERN = /(?<block>\$\[\[\s*(?<data>\S{1}.*?\S{1})\s*\]\])/
+          BLOCK_PREFIX = '$[['
 
           def initialize(config, ctx)
             @config = Interpolation::Config.fabricate(config)
@@ -39,20 +41,73 @@ def interpolated
           private
 
           def interpolate!
-            @result = @config.replace! do |data|
+            @result = @config.replace! do |node|
               break if @errors.any?
 
-              Interpolation::Block.match(data) do |block, data|
-                block = (@blocks[block] ||= Interpolation::Block.new(block, data, ctx))
-
-                break @errors.push('too many interpolation blocks') if @blocks.size > MAX_BLOCKS
-                break unless block.valid?
-
-                block.value
+              if Feature.enabled?(:ci_fix_input_types, Feature.current_request, type: :gitlab_com_derisk)
+                interpolate_with_fixed_types!(node)
+              else
+                legacy_interpolate!(node)
               end
             end
           end
           strong_memoize_attr :interpolate!
+
+          def interpolate_with_fixed_types!(node)
+            return node unless node_might_contain_interpolation_block?(node)
+
+            matches = node.scan(BLOCK_PATTERN)
+            return node if matches.empty?
+
+            blocks = interpolate_blocks(matches)
+            return unless @errors.none? && blocks.present?
+
+            get_interpolated_node_content!(node, blocks)
+          end
+
+          def legacy_interpolate!(node)
+            Interpolation::Block.match(node) do |block, data|
+              block = (@blocks[block] ||= Interpolation::Block.new(block, data, ctx))
+
+              break @errors.push('too many interpolation blocks') if @blocks.size > MAX_BLOCKS
+              break unless block.valid?
+
+              block.value
+            end
+          end
+
+          def node_might_contain_interpolation_block?(node)
+            node.is_a?(String) && node.include?(BLOCK_PREFIX)
+          end
+
+          def interpolate_blocks(matches)
+            matches.map do |match, data|
+              block = (@blocks[match] ||= Interpolation::Block.new(match, data, ctx))
+
+              break @errors.push('too many interpolation blocks') if @blocks.size > MAX_BLOCKS
+              break unless block.valid?
+
+              block
+            end
+          end
+
+          def get_interpolated_node_content!(node, blocks)
+            if used_inside_a_string?(node, blocks)
+              interpolate_string_node!(node, blocks)
+            else
+              blocks.first.value
+            end
+          end
+
+          def interpolate_string_node!(node, blocks)
+            blocks.reduce(node) do |interpolated_node, block|
+              interpolated_node.gsub(block.to_s, block.value.to_s)
+            end
+          end
+
+          def used_inside_a_string?(node, blocks)
+            blocks.count > 1 || node.length != blocks.first.length
+          end
         end
       end
     end
diff --git a/spec/lib/gitlab/ci/config/interpolation/block_spec.rb b/spec/lib/gitlab/ci/config/interpolation/block_spec.rb
index 7b9111f11d37..02bbbcb5a17a 100644
--- a/spec/lib/gitlab/ci/config/interpolation/block_spec.rb
+++ b/spec/lib/gitlab/ci/config/interpolation/block_spec.rb
@@ -109,4 +109,16 @@
       end
     end
   end
+
+  describe '#to_s' do
+    it 'returns the interpolation block' do
+      expect(subject.to_s).to eq(block)
+    end
+  end
+
+  describe '#length' do
+    it 'returns the length of the interpolation block' do
+      expect(subject.length).to eq(block.length)
+    end
+  end
 end
diff --git a/spec/lib/gitlab/ci/config/interpolation/template_spec.rb b/spec/lib/gitlab/ci/config/interpolation/template_spec.rb
index c7d888225584..647c698edb79 100644
--- a/spec/lib/gitlab/ci/config/interpolation/template_spec.rb
+++ b/spec/lib/gitlab/ci/config/interpolation/template_spec.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-require 'fast_spec_helper'
+require 'spec_helper'
 
 RSpec.describe Gitlab::Ci::Config::Interpolation::Template, feature_category: :pipeline_composition do
   subject { described_class.new(YAML.safe_load(config), ctx) }
@@ -13,16 +13,18 @@
 
     $[[ inputs.key ]]:
       name: $[[ inputs.key ]]
-      script: my-value
+      parallel: $[[ inputs.parallel ]]
+      allow_failure: $[[ inputs.allow_failure ]]
+      script: 'echo "This job makes $[[ inputs.parallel ]] jobs for the $[[ inputs.env ]] env"'
     CFG
   end
 
   let(:ctx) do
-    { inputs: { env: 'dev', key: 'abc' } }
+    { inputs: { allow_failure: true, env: 'dev', key: 'abc', parallel: 6 } }
   end
 
   it 'collects interpolation blocks' do
-    expect(subject.size).to eq 2
+    expect(subject.size).to eq 4
   end
 
   it 'interpolates the values properly' do
@@ -33,10 +35,32 @@
 
     abc:
       name: abc
-      script: my-value
+      parallel: 6
+      allow_failure: true
+      script: 'echo "This job makes 6 jobs for the dev env"'
     RESULT
   end
 
+  context 'when ci_fix_input_types is disabled' do
+    before do
+      stub_feature_flags(ci_fix_input_types: false)
+    end
+
+    it 'interpolates all values as strings' do
+      expect(subject.interpolated).to eq YAML.safe_load <<~RESULT
+      test:
+        spec:
+          env: dev
+
+      abc:
+        name: abc
+        parallel: '6'
+        allow_failure: 'true'
+        script: 'echo "This job makes 6 jobs for the dev env"'
+      RESULT
+    end
+  end
+
   context 'when interpolation can not be performed' do
     let(:config) { '$[[ xxx.yyy ]]: abc' }
 
diff --git a/spec/lib/gitlab/ci/config/yaml/loader_spec.rb b/spec/lib/gitlab/ci/config/yaml/loader_spec.rb
index f8563533b04c..2b1b85baeaea 100644
--- a/spec/lib/gitlab/ci/config/yaml/loader_spec.rb
+++ b/spec/lib/gitlab/ci/config/yaml/loader_spec.rb
@@ -16,7 +16,7 @@
             'echo "Building with clang and optimization level 3"',
             'echo "1.0.0"'
           ],
-          parallel: '2'
+          parallel: 2
         },
         'my-job-test': {
           stage: 'build',
@@ -24,7 +24,7 @@
             'echo "Testing with pytest"',
             'if [ true == true ]; then echo "Coverage is enabled"; fi'
           ],
-          allow_failure: 'false'
+          allow_failure: false
         },
         'my-job-deploy': {
           stage: 'build',
-- 
GitLab