This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1a9091e4c5f contain gemini batch-job results file within 
results_folder (#71832)
1a9091e4c5f is described below

commit 1a9091e4c5fcf2d8ee95c7e4f485295a50061849
Author: Samina <[email protected]>
AuthorDate: Mon Sep 21 04:47:29 2026 +0530

    contain gemini batch-job results file within results_folder (#71832)
    
    * contain gemini batch-job results file within results_folder
    
    * Resolve symlinks in gemini batch-job results containment check
    
    * Add symlink-escape regression test for gemini batch-job results path
    
    * Name the results-path helper for what it does and cover the happy path
    
    Rename _results_file_path to _build_results_file_path per the action-verb
    naming rule, and annotate the job parameter as BatchJob (already imported).
    
    Add one happy-path test per operator. The four existing tests are all
    rejection cases, and the file-writing branch had no other coverage, so a
    helper that raised unconditionally would have passed the whole suite.
    Verified: sabotaging the helper to always raise fails exactly these two.
    
    Generated-by: Claude Opus 5
    
    * Handle a batch job with neither display_name nor name
    
    Both fields are optional on BatchJob, so job.name.replace(...) could raise
    AttributeError when neither is set. Annotating the parameter as BatchJob 
made
    mypy surface it; fail with a clear message instead.
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: Samin061 <[email protected]>
---
 .../providers/google/cloud/operators/gen_ai.py     |  29 +++-
 .../unit/google/cloud/operators/test_gen_ai.py     | 162 +++++++++++++++++++++
 2 files changed, 187 insertions(+), 4 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/gen_ai.py 
b/providers/google/src/airflow/providers/google/cloud/operators/gen_ai.py
index 65d8a943374..da8235e07b5 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/gen_ai.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/gen_ai.py
@@ -22,6 +22,7 @@ from __future__ import annotations
 import os.path
 import time
 from collections.abc import Sequence
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 from google.genai.errors import ClientError
@@ -57,6 +58,28 @@ if TYPE_CHECKING:
     from airflow.providers.common.compat.sdk import Context
 
 
+def _build_results_file_path(results_folder: str | None, job: BatchJob) -> str:
+    """
+    Build the local results-file path for a batch job, refusing one that 
escapes ``results_folder``.
+
+    ``display_name`` and ``name`` come from the batch-job metadata returned by 
the Gemini
+    API. ``job.name`` is already sanitized against ``/``, but ``display_name`` 
(used first)
+    is not, so a ``..`` in it would place the file outside ``results_folder`` 
(CWE-22).
+    """
+    # Both are optional on BatchJob, so neither being set leaves nothing to 
name the file after.
+    file_name = job.display_name or (job.name or "").replace("/", "-")
+    if not file_name:
+        raise ValueError("Batch job has neither display_name nor name; cannot 
derive a results file name.")
+    base = Path(f"{results_folder}").resolve()
+    path_to_file = (base / f"{file_name}.jsonl").resolve()
+    if not path_to_file.is_relative_to(base):
+        raise ValueError(
+            f"Refusing to write batch-job results outside results_folder 
{results_folder!r}: "
+            f"file name {file_name!r} resolves to {path_to_file!r}."
+        )
+    return str(path_to_file)
+
+
 class GenAIGenerateEmbeddingsOperator(GoogleCloudBaseOperator):
     """
     Uses the Gemini AI Embeddings API to generate embeddings for words, 
phrases, sentences, and code.
@@ -522,8 +545,7 @@ class 
GenAIGeminiCreateBatchJobOperator(GoogleCloudBaseOperator):
             self._validate_results_folder()
             file_content_bytes = 
self.hook.download_file(file_name=job.dest.file_name)
             file_content = file_content_bytes.decode("utf-8")
-            file_name = job.display_name or job.name.replace("/", "-")
-            path_to_file = 
os.path.abspath(f"{self.results_folder}/{file_name}.jsonl")
+            path_to_file = _build_results_file_path(self.results_folder, job)
             with open(path_to_file, "w") as file_with_results:
                 file_with_results.writelines(file_content.splitlines(True))
             results = path_to_file
@@ -972,8 +994,7 @@ class 
GenAIGeminiCreateEmbeddingsBatchJobOperator(GoogleCloudBaseOperator):
             self._validate_results_folder()
             file_content_bytes = 
self.hook.download_file(file_name=job.dest.file_name)
             file_content = file_content_bytes.decode("utf-8")
-            file_name = job.display_name or job.name.replace("/", "-")
-            path_to_file = 
os.path.abspath(f"{self.results_folder}/{file_name}.jsonl")
+            path_to_file = _build_results_file_path(self.results_folder, job)
             with open(path_to_file, "w") as file_with_results:
                 file_with_results.writelines(file_content.splitlines(True))
             results = path_to_file
diff --git a/providers/google/tests/unit/google/cloud/operators/test_gen_ai.py 
b/providers/google/tests/unit/google/cloud/operators/test_gen_ai.py
index 1071f2de829..870d4f7e66a 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_gen_ai.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_gen_ai.py
@@ -482,6 +482,87 @@ class TestGenAIGeminiCreateBatchJobOperator:
 
         mock_hook.return_value.download_file.assert_not_called()
 
+    @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
+    def test_prepare_results_for_xcom_writes_inside_results_folder(self, 
mock_hook, tmp_path):
+        results_folder = tmp_path / "results"
+        results_folder.mkdir()
+        op = GenAIGeminiCreateBatchJobOperator(
+            task_id=TASK_ID,
+            project_id=GCP_PROJECT,
+            location=GCP_LOCATION,
+            model=TEST_GEMINI_MODEL,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            input_source=TEST_FILE_NAME,
+            gemini_api_key=TEST_GEMINI_API_KEY,
+            results_folder=str(results_folder),
+        )
+        mock_hook.return_value.download_file.return_value = b"data"
+        mock_job = mock.MagicMock()
+        mock_job.dest.inlined_responses = None
+        mock_job.dest.file_name = "results-file"
+        mock_job.display_name = "ok"
+
+        results = op._prepare_results_for_xcom(mock_job)
+
+        assert results == str(results_folder.resolve() / "ok.jsonl")
+        assert (results_folder / "ok.jsonl").read_text() == "data"
+
+    @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
+    def 
test_prepare_results_for_xcom_rejects_display_name_path_traversal(self, 
mock_hook, tmp_path):
+        op = GenAIGeminiCreateBatchJobOperator(
+            task_id=TASK_ID,
+            project_id=GCP_PROJECT,
+            location=GCP_LOCATION,
+            model=TEST_GEMINI_MODEL,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            input_source=TEST_FILE_NAME,
+            gemini_api_key=TEST_GEMINI_API_KEY,
+            results_folder=str(tmp_path / "results"),
+        )
+        (tmp_path / "results").mkdir()
+        mock_hook.return_value.download_file.return_value = b"data"
+        mock_job = mock.MagicMock()
+        mock_job.dest.inlined_responses = None
+        mock_job.dest.file_name = "results-file"
+        mock_job.display_name = "../evil"
+
+        with pytest.raises(ValueError, match="Refusing to write batch-job 
results outside"):
+            op._prepare_results_for_xcom(mock_job)
+
+        assert not (tmp_path / "evil.jsonl").exists()
+
+    @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
+    def 
test_prepare_results_for_xcom_rejects_display_name_symlink_escape(self, 
mock_hook, tmp_path):
+        results_folder = tmp_path / "results"
+        results_folder.mkdir()
+        outside = tmp_path / "outside"
+        outside.mkdir()
+        (results_folder / "link").symlink_to(outside, target_is_directory=True)
+
+        op = GenAIGeminiCreateBatchJobOperator(
+            task_id=TASK_ID,
+            project_id=GCP_PROJECT,
+            location=GCP_LOCATION,
+            model=TEST_GEMINI_MODEL,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            input_source=TEST_FILE_NAME,
+            gemini_api_key=TEST_GEMINI_API_KEY,
+            results_folder=str(results_folder),
+        )
+        mock_hook.return_value.download_file.return_value = b"data"
+        mock_job = mock.MagicMock()
+        mock_job.dest.inlined_responses = None
+        mock_job.dest.file_name = "results-file"
+        mock_job.display_name = "link/evil"
+
+        with pytest.raises(ValueError, match="Refusing to write batch-job 
results outside"):
+            op._prepare_results_for_xcom(mock_job)
+
+        assert not (outside / "evil.jsonl").exists()
+
     @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
     def test__wait_until_complete_exception_raises_airflow_exception(self, 
mock_hook):
         op = GenAIGeminiCreateBatchJobOperator(
@@ -882,6 +963,87 @@ class TestGenAIGeminiCreateEmbeddingsBatchJobOperator:
 
         mock_hook.return_value.download_file.assert_not_called()
 
+    @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
+    def test_prepare_results_for_xcom_writes_inside_results_folder(self, 
mock_hook, tmp_path):
+        results_folder = tmp_path / "results"
+        results_folder.mkdir()
+        op = GenAIGeminiCreateEmbeddingsBatchJobOperator(
+            task_id=TASK_ID,
+            project_id=GCP_PROJECT,
+            location=GCP_LOCATION,
+            input_source=TEST_FILE_NAME,
+            model=EMBEDDING_MODEL,
+            gemini_api_key=TEST_GEMINI_API_KEY,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            results_folder=str(results_folder),
+        )
+        mock_hook.return_value.download_file.return_value = b"data"
+        mock_job = mock.MagicMock()
+        mock_job.dest.inlined_embed_content_responses = None
+        mock_job.dest.file_name = "results-file"
+        mock_job.display_name = "ok"
+
+        results = op._prepare_results_for_xcom(mock_job)
+
+        assert results == str(results_folder.resolve() / "ok.jsonl")
+        assert (results_folder / "ok.jsonl").read_text() == "data"
+
+    @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
+    def 
test_prepare_results_for_xcom_rejects_display_name_path_traversal(self, 
mock_hook, tmp_path):
+        op = GenAIGeminiCreateEmbeddingsBatchJobOperator(
+            task_id=TASK_ID,
+            project_id=GCP_PROJECT,
+            location=GCP_LOCATION,
+            input_source=TEST_FILE_NAME,
+            model=EMBEDDING_MODEL,
+            gemini_api_key=TEST_GEMINI_API_KEY,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            results_folder=str(tmp_path / "results"),
+        )
+        (tmp_path / "results").mkdir()
+        mock_hook.return_value.download_file.return_value = b"data"
+        mock_job = mock.MagicMock()
+        mock_job.dest.inlined_embed_content_responses = None
+        mock_job.dest.file_name = "results-file"
+        mock_job.display_name = "../evil"
+
+        with pytest.raises(ValueError, match="Refusing to write batch-job 
results outside"):
+            op._prepare_results_for_xcom(mock_job)
+
+        assert not (tmp_path / "evil.jsonl").exists()
+
+    @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
+    def 
test_prepare_results_for_xcom_rejects_display_name_symlink_escape(self, 
mock_hook, tmp_path):
+        results_folder = tmp_path / "results"
+        results_folder.mkdir()
+        outside = tmp_path / "outside"
+        outside.mkdir()
+        (results_folder / "link").symlink_to(outside, target_is_directory=True)
+
+        op = GenAIGeminiCreateEmbeddingsBatchJobOperator(
+            task_id=TASK_ID,
+            project_id=GCP_PROJECT,
+            location=GCP_LOCATION,
+            input_source=TEST_FILE_NAME,
+            model=EMBEDDING_MODEL,
+            gemini_api_key=TEST_GEMINI_API_KEY,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            results_folder=str(results_folder),
+        )
+        mock_hook.return_value.download_file.return_value = b"data"
+        mock_job = mock.MagicMock()
+        mock_job.dest.inlined_embed_content_responses = None
+        mock_job.dest.file_name = "results-file"
+        mock_job.display_name = "link/evil"
+
+        with pytest.raises(ValueError, match="Refusing to write batch-job 
results outside"):
+            op._prepare_results_for_xcom(mock_job)
+
+        assert not (outside / "evil.jsonl").exists()
+
     @mock.patch(GEN_AI_PATH.format("GenAIGeminiAPIHook"))
     def test__wait_until_complete_exception_raises_airflow_exception(self, 
mock_hook):
         op = GenAIGeminiCreateEmbeddingsBatchJobOperator(

Reply via email to