From cb493cd1d29e9010a2b1158081611feb78463a63 Mon Sep 17 00:00:00 2001
From: Albert Salim <asalim@gitlab.com>
Date: Fri, 21 Jul 2023 03:13:24 +0000
Subject: [PATCH] Fix file variable expansion on downstream pipeline

This change is behind feature flag ci_prevent_file_var_expansion_downstream_pipeline.

Previously, variables passed into downstream pipelines that
contain reference on file variables have the file variable content expanded into a
new environment variable.

When this feature flag is turned on, the file variables are not expanded.
Instead, the file variables that are needed for expansion are forwarded
as file variables into the downstream pipelines.
This allows the downstream pipeline jobs to access the file variables
as files in the filesystem.
---
 app/models/ci/bridge.rb                       |   6 +
 ...file_var_expansion_downstream_pipeline.yml |   8 +
 .../expandable_variable_generator.rb          |  35 ++-
 .../ci/variables/downstream/generator.rb      |   4 +-
 .../file_variable_downstream_pipeline_spec.rb | 241 ++++++++++++++++++
 .../expandable_variable_generator_spec.rb     |  59 ++++-
 .../ci/variables/downstream/generator_spec.rb |  57 +++++
 spec/models/ci/bridge_spec.rb                 | 178 ++++++++++++-
 8 files changed, 570 insertions(+), 18 deletions(-)
 create mode 100644 config/feature_flags/development/ci_prevent_file_var_expansion_downstream_pipeline.yml
 create mode 100644 qa/qa/specs/features/api/4_verify/file_variable_downstream_pipeline_spec.rb

diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 5052d84378fc5..77fb72382984d 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -266,6 +266,12 @@ def forward_pipeline_variables?
       end
     end
 
+    def expand_file_refs?
+      strong_memoize(:expand_file_refs) do
+        !Feature.enabled?(:ci_prevent_file_var_expansion_downstream_pipeline, project)
+      end
+    end
+
     private
 
     def cross_project_params
diff --git a/config/feature_flags/development/ci_prevent_file_var_expansion_downstream_pipeline.yml b/config/feature_flags/development/ci_prevent_file_var_expansion_downstream_pipeline.yml
new file mode 100644
index 0000000000000..f0bcacbe2bd44
--- /dev/null
+++ b/config/feature_flags/development/ci_prevent_file_var_expansion_downstream_pipeline.yml
@@ -0,0 +1,8 @@
+---
+name: ci_prevent_file_var_expansion_downstream_pipeline
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124320
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/414583
+milestone: '16.3'
+type: development
+group: group::pipeline security
+default_enabled: false
diff --git a/lib/gitlab/ci/variables/downstream/expandable_variable_generator.rb b/lib/gitlab/ci/variables/downstream/expandable_variable_generator.rb
index 6690e9f1c1f2f..bb7a6e7ab59b2 100644
--- a/lib/gitlab/ci/variables/downstream/expandable_variable_generator.rb
+++ b/lib/gitlab/ci/variables/downstream/expandable_variable_generator.rb
@@ -6,9 +6,40 @@ module Variables
       module Downstream
         class ExpandableVariableGenerator < Base
           def for(item)
-            expanded_value = ::ExpandVariables.expand(item.value, context.all_bridge_variables)
+            expanded_var = expanded_var_for(item)
+            file_vars = file_var_dependencies_for(item)
 
-            [{ key: item.key, value: expanded_value }]
+            [expanded_var].concat(file_vars)
+          end
+
+          private
+
+          def expanded_var_for(item)
+            {
+              key: item.key,
+              value: ::ExpandVariables.expand(
+                item.value,
+                context.all_bridge_variables,
+                expand_file_refs: context.expand_file_refs
+              )
+            }
+          end
+
+          def file_var_dependencies_for(item)
+            return [] if context.expand_file_refs
+            return [] unless item.depends_on
+
+            item.depends_on.filter_map do |dependency|
+              dependency_variable = context.all_bridge_variables[dependency]
+
+              if dependency_variable&.file?
+                {
+                  key: dependency_variable.key,
+                  value: dependency_variable.value,
+                  variable_type: :file
+                }
+              end
+            end
           end
         end
       end
diff --git a/lib/gitlab/ci/variables/downstream/generator.rb b/lib/gitlab/ci/variables/downstream/generator.rb
index 93c995cc918fe..350d29958cfc1 100644
--- a/lib/gitlab/ci/variables/downstream/generator.rb
+++ b/lib/gitlab/ci/variables/downstream/generator.rb
@@ -7,12 +7,12 @@ module Downstream
         class Generator
           include Gitlab::Utils::StrongMemoize
 
-          Context = Struct.new(:all_bridge_variables, keyword_init: true)
+          Context = Struct.new(:all_bridge_variables, :expand_file_refs, keyword_init: true)
 
           def initialize(bridge)
             @bridge = bridge
 
-            context = Context.new(all_bridge_variables: bridge.variables)
+            context = Context.new(all_bridge_variables: bridge.variables, expand_file_refs: bridge.expand_file_refs?)
 
             @raw_variable_generator = RawVariableGenerator.new(context)
             @expandable_variable_generator = ExpandableVariableGenerator.new(context)
diff --git a/qa/qa/specs/features/api/4_verify/file_variable_downstream_pipeline_spec.rb b/qa/qa/specs/features/api/4_verify/file_variable_downstream_pipeline_spec.rb
new file mode 100644
index 0000000000000..95cc6d95c90de
--- /dev/null
+++ b/qa/qa/specs/features/api/4_verify/file_variable_downstream_pipeline_spec.rb
@@ -0,0 +1,241 @@
+# frozen_string_literal: true
+
+module QA
+  RSpec.describe 'Verify', :runner, product_group: :pipeline_security, feature_flag: {
+    name: 'ci_prevent_file_var_expansion_downstream_pipeline',
+    scope: :project
+  } do
+    describe 'Pipeline with file variables and downstream pipelines' do
+      let(:random_string) { Faker::Alphanumeric.alphanumeric(number: 8) }
+      let(:executor) { "qa-runner-#{Faker::Alphanumeric.alphanumeric(number: 8)}" }
+
+      let!(:project) do
+        Resource::Project.fabricate_via_api! do |project|
+          project.name = 'upstream-project-with-file-variables'
+        end
+      end
+
+      let!(:downstream_project) do
+        Resource::Project.fabricate_via_api! do |project|
+          project.name = 'downstream-project'
+        end
+      end
+
+      let!(:project_runner) do
+        Resource::ProjectRunner.fabricate! do |runner|
+          runner.project = project
+          runner.name = executor
+          runner.tags = [executor]
+        end
+      end
+
+      let!(:downstream_project_runner) do
+        Resource::ProjectRunner.fabricate! do |runner|
+          runner.project = downstream_project
+          runner.name = "#{executor}-downstream"
+          runner.tags = [executor]
+        end
+      end
+
+      let(:add_ci_file) do
+        Resource::Repository::Commit.fabricate_via_api! do |commit|
+          commit.project = project
+          commit.commit_message = 'Add .gitlab-ci.yml and child.yml'
+          commit.add_files(
+            [
+              {
+                file_path: '.gitlab-ci.yml',
+                content: <<~YAML
+                  default:
+                    tags: [#{executor}]
+
+                  variables:
+                    EXTRA_ARGS: "-f $TEST_PROJECT_FILE"
+                    DOCKER_REMOTE_ARGS: --tlscacert="$DOCKER_CA_CERT"
+                    EXTRACTED_CRT_FILE: ${DOCKER_CA_CERT}.crt
+                    MY_FILE_VAR: $TEST_PROJECT_FILE
+
+                  trigger_child:
+                    trigger:
+                      strategy: depend
+                      include:
+                        - local: child.yml
+
+                  trigger_downstream_project:
+                    trigger:
+                      strategy: depend
+                      project: #{downstream_project.path_with_namespace}
+
+                YAML
+              },
+              {
+                file_path: 'child.yml',
+                content: <<~YAML
+                  default:
+                    tags: [#{executor}]
+
+                  child_job_echo:
+                    script:
+                      - echo "run something $EXTRA_ARGS"
+                      - echo "docker run $DOCKER_REMOTE_ARGS"
+                      - echo "run --output=$EXTRACTED_CRT_FILE"
+                      - echo "Will read private key from $MY_FILE_VAR"
+
+                  child_job_cat:
+                    script:
+                      - cat "$MY_FILE_VAR"
+                      - cat "$DOCKER_CA_CERT"
+                YAML
+              }
+            ]
+          )
+        end
+      end
+
+      let(:add_downstream_project_ci_file) do
+        Resource::Repository::Commit.fabricate_via_api! do |commit|
+          commit.project = downstream_project
+          commit.commit_message = 'Add .gitlab-ci.yml'
+          commit.add_files(
+            [
+              {
+                file_path: '.gitlab-ci.yml',
+                content: <<~YAML
+                  default:
+                    tags: [#{executor}]
+
+                  downstream_job_echo:
+                    script:
+                      - echo "run something $EXTRA_ARGS"
+                      - echo "docker run $DOCKER_REMOTE_ARGS"
+                      - echo "run --output=$EXTRACTED_CRT_FILE"
+                      - echo "Will read private key from $MY_FILE_VAR"
+
+                  downstream_job_cat:
+                    script:
+                      - cat "$MY_FILE_VAR"
+                      - cat "$DOCKER_CA_CERT"
+                YAML
+              }
+            ]
+          )
+        end
+      end
+
+      let(:add_project_file_variables) do
+        {
+          'TEST_PROJECT_FILE' => "hello, this is test\n",
+          'DOCKER_CA_CERT' => "This is secret\n"
+        }.each do |file_name, content|
+          add_file_variable_to_project(project, file_name, content)
+        end
+      end
+
+      let(:upstream_pipeline) do
+        Resource::Pipeline.fabricate_via_api! do |pipeline|
+          pipeline.project = project
+        end
+      end
+
+      def child_pipeline
+        Resource::Pipeline.fabricate_via_api! do |pipeline|
+          pipeline.project = project
+          pipeline.id = upstream_pipeline.downstream_pipeline_id(bridge_name: 'trigger_child')
+        end
+      end
+
+      def downstream_project_pipeline
+        Resource::Pipeline.fabricate_via_api! do |pipeline|
+          pipeline.project = downstream_project
+          pipeline.id = upstream_pipeline.downstream_pipeline_id(bridge_name: 'trigger_downstream_project')
+        end
+      end
+
+      around do |example|
+        Runtime::Feature.enable(:ci_prevent_file_var_expansion_downstream_pipeline, project: project)
+        example.run
+        Runtime::Feature.disable(:ci_prevent_file_var_expansion_downstream_pipeline, project: project)
+      end
+
+      before do
+        add_project_file_variables
+        add_downstream_project_ci_file
+        add_ci_file
+        upstream_pipeline
+        wait_for_pipelines
+      end
+
+      after do
+        project_runner.remove_via_api!
+        downstream_project_runner.remove_via_api!
+      end
+
+      it(
+        'creates variable with file path in downstream pipelines and can read file variable content',
+        testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/416337'
+      ) do
+        child_echo_job = Resource::Job.fabricate_via_api! do |job|
+          job.project = project
+          job.id = project.job_by_name('child_job_echo')[:id]
+        end
+
+        child_cat_job = Resource::Job.fabricate_via_api! do |job|
+          job.project = project
+          job.id = project.job_by_name('child_job_cat')[:id]
+        end
+
+        downstream_project_echo_job = Resource::Job.fabricate_via_api! do |job|
+          job.project = downstream_project
+          job.id = downstream_project.job_by_name('downstream_job_echo')[:id]
+        end
+
+        downstream_project_cat_job = Resource::Job.fabricate_via_api! do |job|
+          job.project = downstream_project
+          job.id = downstream_project.job_by_name('downstream_job_cat')[:id]
+        end
+
+        aggregate_failures do
+          trace = child_echo_job.trace
+          expect(trace).to include('run something -f', "#{project.name}.tmp/TEST_PROJECT_FILE")
+          expect(trace).to include('docker run --tlscacert=', "#{project.name}.tmp/DOCKER_CA_CERT")
+          expect(trace).to include('run --output=', "#{project.name}.tmp/DOCKER_CA_CERT.crt")
+          expect(trace).to include('Will read private key from', "#{project.name}.tmp/TEST_PROJECT_FILE")
+
+          trace = child_cat_job.trace
+          expect(trace).to have_content('hello, this is test')
+          expect(trace).to have_content('This is secret')
+
+          trace = downstream_project_echo_job.trace
+          expect(trace).to include('run something -f', "#{downstream_project.name}.tmp/TEST_PROJECT_FILE")
+          expect(trace).to include('docker run --tlscacert=', "#{downstream_project.name}.tmp/DOCKER_CA_CERT")
+          expect(trace).to include('run --output=', "#{downstream_project.name}.tmp/DOCKER_CA_CERT.crt")
+          expect(trace).to include('Will read private key from', "#{downstream_project.name}.tmp/TEST_PROJECT_FILE")
+
+          trace = downstream_project_cat_job.trace
+          expect(trace).to have_content('hello, this is test')
+          expect(trace).to have_content('This is secret')
+        end
+      end
+
+      private
+
+      def add_file_variable_to_project(project, key, value)
+        Resource::CiVariable.fabricate_via_api! do |ci_variable|
+          ci_variable.project = project
+          ci_variable.key = key
+          ci_variable.value = value
+          ci_variable.variable_type = 'file'
+        end
+      end
+
+      def wait_for_pipelines
+        Support::Waiter.wait_until(max_duration: 300, sleep_interval: 10) do
+          upstream_pipeline.reload!
+          upstream_pipeline.status == 'success' &&
+            child_pipeline.status == 'success' &&
+            downstream_project_pipeline.status == 'success'
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/ci/variables/downstream/expandable_variable_generator_spec.rb b/spec/lib/gitlab/ci/variables/downstream/expandable_variable_generator_spec.rb
index 5b33527e06ce0..95d0f089f6d99 100644
--- a/spec/lib/gitlab/ci/variables/downstream/expandable_variable_generator_spec.rb
+++ b/spec/lib/gitlab/ci/variables/downstream/expandable_variable_generator_spec.rb
@@ -7,13 +7,19 @@
     Gitlab::Ci::Variables::Collection.fabricate(
       [
         { key: 'REF1', value: 'ref 1' },
-        { key: 'REF2', value: 'ref 2' }
+        { key: 'REF2', value: 'ref 2' },
+        { key: 'NESTED_REF1', value: 'nested $REF1' }
       ]
     )
   end
 
+  let(:expand_file_refs) { false }
+
   let(:context) do
-    Gitlab::Ci::Variables::Downstream::Generator::Context.new(all_bridge_variables: all_bridge_variables)
+    Gitlab::Ci::Variables::Downstream::Generator::Context.new(
+      all_bridge_variables: all_bridge_variables,
+      expand_file_refs: expand_file_refs
+    )
   end
 
   subject(:generator) { described_class.new(context) }
@@ -34,5 +40,54 @@
         expect(generator.for(var)).to match_array([{ key: 'VAR1', value: 'ref 1 ref 2 ' }])
       end
     end
+
+    context 'when given a variable with nested interpolation' do
+      it 'returns an array containing the expanded variables' do
+        var = Gitlab::Ci::Variables::Collection::Item.fabricate({ key: 'VAR1', value: '$REF1 $REF2 $NESTED_REF1' })
+
+        expect(generator.for(var)).to match_array([{ key: 'VAR1', value: 'ref 1 ref 2 nested $REF1' }])
+      end
+    end
+
+    context 'when given a variable with expansion on a file variable' do
+      let(:all_bridge_variables) do
+        Gitlab::Ci::Variables::Collection.fabricate(
+          [
+            { key: 'REF1', value: 'ref 1' },
+            { key: 'FILE_REF2', value: 'ref 2', file: true },
+            { key: 'NESTED_REF3', value: 'ref 3 $REF1 and $FILE_REF2', file: true }
+          ]
+        )
+      end
+
+      context 'when expand_file_refs is false' do
+        let(:expand_file_refs) { false }
+
+        it 'returns an array containing the unexpanded variable and the file variable dependency' do
+          var = { key: 'VAR1', value: '$REF1 $FILE_REF2 $FILE_REF3 $NESTED_REF3' }
+          var = Gitlab::Ci::Variables::Collection::Item.fabricate(var)
+
+          expected = [
+            { key: 'VAR1', value: 'ref 1 $FILE_REF2  $NESTED_REF3' },
+            { key: 'FILE_REF2', value: 'ref 2', variable_type: :file },
+            { key: 'NESTED_REF3', value: 'ref 3 $REF1 and $FILE_REF2', variable_type: :file }
+          ]
+
+          expect(generator.for(var)).to match_array(expected)
+        end
+      end
+
+      context 'when expand_file_refs is true' do
+        let(:expand_file_refs) { true }
+
+        it 'returns an array containing the expanded variables' do
+          var = { key: 'VAR1', value: '$REF1 $FILE_REF2 $FILE_REF3 $NESTED_REF3' }
+          var = Gitlab::Ci::Variables::Collection::Item.fabricate(var)
+
+          expected = { key: 'VAR1', value: 'ref 1 ref 2  ref 3 $REF1 and $FILE_REF2' }
+          expect(generator.for(var)).to contain_exactly(expected)
+        end
+      end
+    end
   end
 end
diff --git a/spec/lib/gitlab/ci/variables/downstream/generator_spec.rb b/spec/lib/gitlab/ci/variables/downstream/generator_spec.rb
index 61e8b9a8c4ad4..cd68b0cdf2bb7 100644
--- a/spec/lib/gitlab/ci/variables/downstream/generator_spec.rb
+++ b/spec/lib/gitlab/ci/variables/downstream/generator_spec.rb
@@ -45,6 +45,7 @@
       variables: bridge_variables,
       forward_yaml_variables?: true,
       forward_pipeline_variables?: true,
+      expand_file_refs?: false,
       yaml_variables: yaml_variables,
       pipeline_variables: pipeline_variables,
       pipeline_schedule_variables: pipeline_schedule_variables
@@ -81,5 +82,61 @@
 
       expect(generator.calculate).to be_empty
     end
+
+    context 'with file variable interpolation' do
+      let(:bridge_variables) do
+        Gitlab::Ci::Variables::Collection.fabricate(
+          [
+            { key: 'REF1', value: 'ref 1' },
+            { key: 'FILE_REF3', value: 'ref 3', file: true }
+          ]
+        )
+      end
+
+      let(:yaml_variables) do
+        [{ key: 'INTERPOLATION_VAR', value: 'interpolate $REF1 $REF2 $FILE_REF3 $FILE_REF4' }]
+      end
+
+      let(:pipeline_variables) do
+        [{ key: 'PIPELINE_INTERPOLATION_VAR', value: 'interpolate $REF1 $REF2 $FILE_REF3 $FILE_REF4' }]
+      end
+
+      let(:pipeline_schedule_variables) do
+        [{ key: 'PIPELINE_SCHEDULE_INTERPOLATION_VAR', value: 'interpolate $REF1 $REF2 $FILE_REF3 $FILE_REF4' }]
+      end
+
+      context 'when expand_file_refs is true' do
+        before do
+          allow(bridge).to receive(:expand_file_refs?).and_return(true)
+        end
+
+        it 'expands file variables' do
+          expected = [
+            { key: 'INTERPOLATION_VAR', value: 'interpolate ref 1  ref 3 ' },
+            { key: 'PIPELINE_INTERPOLATION_VAR', value: 'interpolate ref 1  ref 3 ' },
+            { key: 'PIPELINE_SCHEDULE_INTERPOLATION_VAR', value: 'interpolate ref 1  ref 3 ' }
+          ]
+
+          expect(generator.calculate).to contain_exactly(*expected)
+        end
+      end
+
+      context 'when expand_file_refs is false' do
+        before do
+          allow(bridge).to receive(:expand_file_refs?).and_return(false)
+        end
+
+        it 'does not expand file variables and adds file variables' do
+          expected = [
+            { key: 'INTERPOLATION_VAR', value: 'interpolate ref 1  $FILE_REF3 ' },
+            { key: 'PIPELINE_INTERPOLATION_VAR', value: 'interpolate ref 1  $FILE_REF3 ' },
+            { key: 'PIPELINE_SCHEDULE_INTERPOLATION_VAR', value: 'interpolate ref 1  $FILE_REF3 ' },
+            { key: 'FILE_REF3', value: 'ref 3', variable_type: :file }
+          ]
+
+          expect(generator.calculate).to contain_exactly(*expected)
+        end
+      end
+    end
   end
 end
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index d93250af1777e..b284cacf35452 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -3,7 +3,7 @@
 require 'spec_helper'
 
 RSpec.describe Ci::Bridge, feature_category: :continuous_integration do
-  let_it_be(:project) { create(:project) }
+  let_it_be(:project) { create(:project, :in_group) }
   let_it_be(:target_project) { create(:project, name: 'project', namespace: create(:namespace, name: 'my')) }
   let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
 
@@ -196,11 +196,18 @@
   end
 
   describe '#downstream_variables' do
+    # A new pipeline needs to be created in each test.
+    # The pipeline #variables_builder is memoized. The builder internally also memoizes variables.
+    # Having pipeline in a let_it_be might lead to flaky tests
+    # because a test might expect new variables but the variables builder does not
+    # return the new variables due to memoized results from previous tests.
+    let(:pipeline) { create(:ci_pipeline, project: project) }
+
     subject(:downstream_variables) { bridge.downstream_variables }
 
     it 'returns variables that are going to be passed downstream' do
       expect(bridge.downstream_variables)
-        .to include(key: 'BRIDGE', value: 'cross')
+        .to contain_exactly(key: 'BRIDGE', value: 'cross')
     end
 
     context 'when using variables interpolation' do
@@ -241,14 +248,49 @@
       end
     end
 
+    context 'when using variables interpolation on file variables' do
+      let(:yaml_variables) do
+        [
+          {
+            key: 'EXPANDED_FILE',
+            value: '$TEST_FILE_VAR'
+          }
+        ]
+      end
+
+      before do
+        bridge.yaml_variables = yaml_variables
+        create(:ci_variable, :file, project: bridge.pipeline.project, key: 'TEST_FILE_VAR', value: 'test-file-value')
+      end
+
+      it 'does not expand file variable and forwards the file variable' do
+        expected_vars = [
+          { key: 'EXPANDED_FILE', value: '$TEST_FILE_VAR' },
+          { key: 'TEST_FILE_VAR', value: 'test-file-value', variable_type: :file }
+        ]
+
+        expect(bridge.downstream_variables).to contain_exactly(*expected_vars)
+      end
+
+      context 'and feature flag is disabled' do
+        before do
+          stub_feature_flags(ci_prevent_file_var_expansion_downstream_pipeline: false)
+        end
+
+        it 'expands the file variable' do
+          expect(bridge.downstream_variables).to contain_exactly({ key: 'EXPANDED_FILE', value: 'test-file-value' })
+        end
+      end
+    end
+
     context 'when recursive interpolation has been used' do
       before do
-        bridge.yaml_variables << { key: 'EXPANDED', value: '$EXPANDED', public: true }
+        bridge.yaml_variables = [{ key: 'EXPANDED', value: '$EXPANDED', public: true }]
       end
 
       it 'does not expand variable recursively' do
         expect(bridge.downstream_variables)
-          .to include(key: 'EXPANDED', value: '$EXPANDED')
+          .to contain_exactly(key: 'EXPANDED', value: '$EXPANDED')
       end
     end
 
@@ -279,26 +321,82 @@
           }
         end
 
+        before do
+          create(:ci_pipeline_variable, pipeline: pipeline, key: 'PVAR1', value: 'PVAL1')
+        end
+
         it 'returns variables according to the forward value' do
           expect(bridge.downstream_variables.map { |v| v[:key] }).to contain_exactly(*variables)
         end
       end
 
       context 'when sending a variable via both yaml and pipeline' do
-        let(:pipeline) { create(:ci_pipeline, project: project) }
-
         let(:options) do
           { trigger: { project: 'my/project', forward: { pipeline_variables: true } } }
         end
 
         before do
-          create(:ci_pipeline_variable, pipeline: pipeline, key: 'BRIDGE', value: 'new value')
+          bridge.yaml_variables = [{ key: 'SHARED_KEY', value: 'old_value' }]
+          create(:ci_pipeline_variable, pipeline: pipeline, key: 'SHARED_KEY', value: 'new value')
         end
 
         it 'uses the pipeline variable' do
-          expect(bridge.downstream_variables).to contain_exactly(
-            { key: 'BRIDGE', value: 'new value' }
-          )
+          expect(bridge.downstream_variables).to contain_exactly({ key: 'SHARED_KEY', value: 'new value' })
+        end
+      end
+
+      context 'when sending a file variable from pipeline variable' do
+        let(:options) do
+          { trigger: { project: 'my/project', forward: { pipeline_variables: true } } }
+        end
+
+        before do
+          bridge.yaml_variables = [{ key: 'FILE_VAR', value: 'old_value' }]
+          create(:ci_pipeline_variable, :file, pipeline: pipeline, key: 'FILE_VAR', value: 'new value')
+        end
+
+        # The current behaviour forwards the file variable as an environment variable.
+        # TODO: decide whether to forward as a file var in https://gitlab.com/gitlab-org/gitlab/-/issues/416334
+        it 'forwards the pipeline file variable' do
+          expect(bridge.downstream_variables).to contain_exactly({ key: 'FILE_VAR', value: 'new value' })
+        end
+      end
+
+      context 'when a pipeline variable interpolates a scoped file variable' do
+        let(:options) do
+          { trigger: { project: 'my/project', forward: { pipeline_variables: true } } }
+        end
+
+        before do
+          bridge.yaml_variables = [{ key: 'YAML_VAR', value: '$PROJECT_FILE_VAR' }]
+
+          create(:ci_variable, :file, project: pipeline.project, key: 'PROJECT_FILE_VAR', value: 'project file')
+          create(:ci_pipeline_variable, pipeline: pipeline, key: 'FILE_VAR', value: '$PROJECT_FILE_VAR')
+        end
+
+        it 'does not expand the scoped file variable and forwards the file variable' do
+          expected_vars = [
+            { key: 'FILE_VAR', value: '$PROJECT_FILE_VAR' },
+            { key: 'YAML_VAR', value: '$PROJECT_FILE_VAR' },
+            { key: 'PROJECT_FILE_VAR', value: 'project file', variable_type: :file }
+          ]
+
+          expect(bridge.downstream_variables).to contain_exactly(*expected_vars)
+        end
+
+        context 'and feature flag is disabled' do
+          before do
+            stub_feature_flags(ci_prevent_file_var_expansion_downstream_pipeline: false)
+          end
+
+          it 'expands the file variable' do
+            expected_vars = [
+              { key: 'FILE_VAR', value: 'project file' },
+              { key: 'YAML_VAR', value: 'project file' }
+            ]
+
+            expect(bridge.downstream_variables).to contain_exactly(*expected_vars)
+          end
         end
       end
 
@@ -315,10 +413,66 @@
         end
 
         it 'adds the schedule variable' do
-          expect(bridge.downstream_variables).to contain_exactly(
+          expected_vars = [
             { key: 'BRIDGE', value: 'cross' },
             { key: 'schedule_var_key', value: 'schedule var value' }
-          )
+          ]
+
+          expect(bridge.downstream_variables).to contain_exactly(*expected_vars)
+        end
+      end
+    end
+
+    context 'when sending a file variable from pipeline schedule' do
+      let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) }
+      let(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) }
+
+      let(:options) do
+        { trigger: { project: 'my/project', forward: { pipeline_variables: true } } }
+      end
+
+      before do
+        bridge.yaml_variables = []
+        pipeline_schedule.variables.create!(key: 'schedule_var_key', value: 'schedule var value', variable_type: :file)
+      end
+
+      # The current behaviour forwards the file variable as an environment variable.
+      # TODO: decide whether to forward as a file var in https://gitlab.com/gitlab-org/gitlab/-/issues/416334
+      it 'forwards the schedule file variable' do
+        expect(bridge.downstream_variables).to contain_exactly({ key: 'schedule_var_key', value: 'schedule var value' })
+      end
+    end
+
+    context 'when a pipeline schedule variable interpolates a scoped file variable' do
+      let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) }
+      let(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) }
+
+      let(:options) do
+        { trigger: { project: 'my/project', forward: { pipeline_variables: true } } }
+      end
+
+      before do
+        bridge.yaml_variables = []
+        create(:ci_variable, :file, project: pipeline.project, key: 'PROJECT_FILE_VAR', value: 'project file')
+        pipeline_schedule.variables.create!(key: 'schedule_var_key', value: '$PROJECT_FILE_VAR')
+      end
+
+      it 'does not expand the scoped file variable and forwards the file variable' do
+        expected_vars = [
+          { key: 'schedule_var_key', value: '$PROJECT_FILE_VAR' },
+          { key: 'PROJECT_FILE_VAR', value: 'project file', variable_type: :file }
+        ]
+
+        expect(bridge.downstream_variables).to contain_exactly(*expected_vars)
+      end
+
+      context 'and feature flag is disabled' do
+        before do
+          stub_feature_flags(ci_prevent_file_var_expansion_downstream_pipeline: false)
+        end
+
+        it 'expands the file variable' do
+          expect(bridge.downstream_variables).to contain_exactly({ key: 'schedule_var_key', value: 'project file' })
         end
       end
     end
-- 
GitLab