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

shahar1 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 58af2f521bb Close the ssh askpass helper in GitHook before ssh 
executes it (#73427)
58af2f521bb is described below

commit 58af2f521bb27c5a01c48d6136bcb16838ae8faa
Author: MichalJaroslawKrzywanski-TomTom <[email protected]>
AuthorDate: Mon Sep 28 15:07:42 2026 +0200

    Close the ssh askpass helper in GitHook before ssh executes it (#73427)
    
    GitHook writes the SSH_ASKPASS helper that unlocks a passphrase-protected
    private key with NamedTemporaryFile(delete=True) and keeps the handle open 
for
    writing while ssh runs. Linux refuses to exec a file that is still open for
    writing (ETXTBSY, "Text file busy"), so a clone or fetch with a
    passphrase-protected key fails with "cannot exec ...: Text file busy" and 
then
    falls back to prompting for the passphrase. macOS does not enforce this, 
which
    hid the bug.
    
    Write the helper through a small module-level context manager that closes 
the
    file before yielding its path and unlinks it in finally. The added test runs
    the helper through configure_hook_env and asserts its output; on Linux it 
fails
    without the fix with "OSError: [Errno 26] Text file busy".
    
    The token path hit the same constraint. #64105 fixed it there by replacing
    GIT_ASKPASS with a credential helper written and closed before git runs, 
which
    is why this change is now limited to SSH_ASKPASS.
    
    related: #73425
    related: #64105
---
 .../git/src/airflow/providers/git/hooks/git.py     | 31 +++++++++++++++++-----
 providers/git/tests/unit/git/hooks/test_git.py     | 19 +++++++++++++
 2 files changed, 43 insertions(+), 7 deletions(-)

diff --git a/providers/git/src/airflow/providers/git/hooks/git.py 
b/providers/git/src/airflow/providers/git/hooks/git.py
index 4327c2c0ccb..c802cb169ad 100644
--- a/providers/git/src/airflow/providers/git/hooks/git.py
+++ b/providers/git/src/airflow/providers/git/hooks/git.py
@@ -37,6 +37,25 @@ from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureEx
 log = logging.getLogger(__name__)
 
 
[email protected]
+def _executable_script(content: str) -> Generator[str]:
+    """
+    Write ``content`` to a private temporary file and yield its path.
+
+    git and ssh execute the askpass helpers, so the file must be closed before
+    they run: Linux refuses to exec a file that is still open for writing
+    (``ETXTBSY``, "Text file busy").
+    """
+    fd, path = tempfile.mkstemp(suffix=".sh")
+    try:
+        with os.fdopen(fd, "w") as script:
+            script.write(content)
+        os.chmod(path, stat.S_IRWXU)
+        yield path
+    finally:
+        os.unlink(path)
+
+
 class GitHook(BaseHook):
     """
     Hook for git repositories.
@@ -345,20 +364,18 @@ printf 'username=%s\npassword=%s\n' "$AIRFLOW_GIT_USER" 
"$AIRFLOW_GIT_TOKEN"
             yield
             return
 
-        with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=True) 
as askpass_script:
-            askpass_script.write(f"#!/bin/sh\necho 
{shlex.quote(self.private_key_passphrase)}\n")
-            askpass_script.flush()
-            os.chmod(askpass_script.name, stat.S_IRWXU)
-
+        with _executable_script(
+            f"#!/bin/sh\necho {shlex.quote(self.private_key_passphrase)}\n"
+        ) as askpass_path:
             old_askpass = os.environ.get("SSH_ASKPASS")
             old_display = os.environ.get("DISPLAY")
             old_askpass_require = os.environ.get("SSH_ASKPASS_REQUIRE")
             try:
-                os.environ["SSH_ASKPASS"] = askpass_script.name
+                os.environ["SSH_ASKPASS"] = askpass_path
                 os.environ["SSH_ASKPASS_REQUIRE"] = "force"
                 # DISPLAY must be set for SSH_ASKPASS to be used
                 os.environ.setdefault("DISPLAY", ":")
-                self.env["SSH_ASKPASS"] = askpass_script.name
+                self.env["SSH_ASKPASS"] = askpass_path
                 self.env["SSH_ASKPASS_REQUIRE"] = "force"
                 self.env.setdefault("DISPLAY", os.environ["DISPLAY"])
                 yield
diff --git a/providers/git/tests/unit/git/hooks/test_git.py 
b/providers/git/tests/unit/git/hooks/test_git.py
index e14a7672850..6a888852185 100644
--- a/providers/git/tests/unit/git/hooks/test_git.py
+++ b/providers/git/tests/unit/git/hooks/test_git.py
@@ -780,6 +780,25 @@ class TestGitHook:
             assert "GIT_CONFIG_COUNT" not in hook.env
             assert "AIRFLOW_GIT_TOKEN" not in hook.env
 
+    def test_passphrase_askpass_script_is_executable(self, 
create_connection_without_db):
+        """ssh must be able to exec the helper: it has to be closed before it 
runs (ETXTBSY on Linux)."""
+        create_connection_without_db(
+            Connection(
+                conn_id="git_passphrase_exec",
+                host=AIRFLOW_GIT,
+                conn_type="git",
+                extra={
+                    "key_file": "/files/pkey.pem",
+                    "private_key_passphrase": "my_secret",
+                },
+            )
+        )
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_passphrase_exec")
+        with hook.configure_hook_env():
+            result = subprocess.run([hook.env["SSH_ASKPASS"]], 
capture_output=True, text=True, check=True)
+        assert result.stdout.strip() == "my_secret"
+
     # --- GitHub App auth tests ---
 
     def test_only_app_id_without_installation_id_raises(self):

Reply via email to