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

o-nikolas 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 6570b8282f2 Skip S3 keys that map to the local sync directory (#74322)
6570b8282f2 is described below

commit 6570b8282f2bc1b8b53a60ef7834ae0c80cb5c9c
Author: Jingi Kim <[email protected]>
AuthorDate: Sat Oct 24 00:36:05 2026 +0900

    Skip S3 keys that map to the local sync directory (#74322)
    
    Period-only object keys can interrupt Dag bundle refreshes because their 
download destination is the bundle directory itself. Also Skip nested S3 keys 
resolving to directories: parent-directory segments can resolve back to the 
bundle root and interrupt refresh even after passing the containment check.
---
 .../src/airflow/providers/amazon/aws/hooks/s3.py   |  4 +-
 .../tests/unit/amazon/aws/bundles/test_s3.py       | 18 ++++++++
 .../amazon/tests/unit/amazon/aws/hooks/test_s3.py  | 51 ++++++++++++++++++++++
 3 files changed, 72 insertions(+), 1 deletion(-)

diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
index e9089f95bfc..53ef3cd3805 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
@@ -1877,11 +1877,13 @@ class S3Hook(AwsBaseHook):
             obj_path = Path(obj.key)
             local_target_path = 
local_dir.joinpath(obj_path.relative_to(s3_prefix))
             try:
-                local_target_path.resolve().relative_to(local_dir_resolved)
+                resolved_relative_path = 
local_target_path.resolve().relative_to(local_dir_resolved)
             except ValueError:
                 raise S3HookPathTraversalError(
                     f"S3 object key {obj.key!r} resolves outside local 
directory {local_dir}"
                 ) from None
+            if resolved_relative_path == Path(".") or obj.key.endswith(("/.", 
"/..")):
+                continue
             if not local_target_path.parent.exists():
                 local_target_path.parent.mkdir(parents=True, exist_ok=True)
                 self.log.debug("Created local directory: %s", 
local_target_path.parent)
diff --git a/providers/amazon/tests/unit/amazon/aws/bundles/test_s3.py 
b/providers/amazon/tests/unit/amazon/aws/bundles/test_s3.py
index 9df5202fe71..e8899a2f201 100644
--- a/providers/amazon/tests/unit/amazon/aws/bundles/test_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/bundles/test_s3.py
@@ -222,6 +222,24 @@ class TestS3DagBundle:
         assert bundle._log.debug.call_count == 2
         assert bundle._log.debug.call_args_list == [download_log_call, 
download_log_call]
 
+    def test_refresh_skips_key_mapping_to_bundle_root(self, s3_client):
+        s3_client.create_bucket(Bucket=S3_BUCKET_NAME)
+        s3_client.put_object(Bucket=S3_BUCKET_NAME, Key="dags/old.py", 
Body=b"old content")
+        bundle = S3DagBundle(name="test", bucket_name=S3_BUCKET_NAME, 
prefix="dags")
+        bundle.initialize()
+        assert (bundle.path / "old.py").read_bytes() == b"old content"
+
+        s3_client.delete_object(Bucket=S3_BUCKET_NAME, Key="dags/old.py")
+        s3_client.put_object(Bucket=S3_BUCKET_NAME, Key="dags/.", 
Body=b"directory key")
+        s3_client.put_object(Bucket=S3_BUCKET_NAME, Key="dags/new.py", 
Body=b"new content")
+
+        bundle.refresh()
+        bundle.refresh()
+
+        assert bundle.path.is_dir()
+        assert not (bundle.path / "old.py").exists()
+        assert (bundle.path / "new.py").read_bytes() == b"new content"
+
     @pytest.mark.parametrize("prefix", ["dags", "dags/", "project/dags", 
"project/dags/"])
     @pytest.mark.parametrize("extra_suffix", ["_archive/other.py", 
pytest.param("", id="key_equals_prefix")])
     def test_refresh_uses_directory_prefix(self, s3_client, prefix, 
extra_suffix):
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
index c5f373a4e28..5a4d4d9fe63 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
@@ -2148,6 +2148,57 @@ class TestAwsS3Hook:
         assert "local file last modified" in logs_string
         assert "Downloaded dag_04.py to" in logs_string
 
+    @pytest.mark.parametrize(
+        ("prefix", "key"),
+        [
+            pytest.param("dags/", "dags/.", id="prefix-dot"),
+            pytest.param("dags/", "dags/./.", id="repeated-dot"),
+            pytest.param("dags/", "dags/../s3_sync_dir", 
id="parent-directory"),
+            pytest.param("", ".", id="bucket-root-dot"),
+        ],
+    )
+    def test_sync_to_local_dir_skips_keys_mapping_to_root(self, s3_bucket, 
s3_client, tmp_path, prefix, key):
+        s3_client.put_object(Bucket=s3_bucket, Key=key, Body=b"directory key")
+        s3_client.put_object(Bucket=s3_bucket, Key=f"{prefix}nested/dag.py", 
Body=b"dag content")
+        s3_client.put_object(Bucket=s3_bucket, Key=f"{prefix}.airflowignore", 
Body=b"ignore content")
+        sync_local_dir = tmp_path / "s3_sync_dir"
+        sync_local_dir.mkdir()
+        stale_file = sync_local_dir / "stale.py"
+        stale_file.write_bytes(b"stale")
+
+        S3Hook().sync_to_local_dir(bucket_name=s3_bucket, 
local_dir=sync_local_dir, s3_prefix=prefix)
+
+        assert sync_local_dir.is_dir()
+        assert (sync_local_dir / "nested/dag.py").read_bytes() == b"dag 
content"
+        assert (sync_local_dir / ".airflowignore").read_bytes() == b"ignore 
content"
+        assert not stale_file.exists()
+        assert s3_client.get_object(Bucket=s3_bucket, Key=key)["Body"].read() 
== b"directory key"
+
+    @pytest.mark.parametrize(
+        ("key", "when_added"),
+        [
+            pytest.param("dags/nested/.", "before", id="fresh_sync_dot"),
+            pytest.param("dags/nested/deep/..", "after", id="refresh_parent"),
+        ],
+    )
+    def test_sync_to_local_dir_skips_keys_mapping_to_nested_directory(
+        self, s3_bucket, s3_client, tmp_path, key, when_added
+    ):
+        if when_added == "before":
+            s3_client.put_object(Bucket=s3_bucket, Key=key, Body=b"directory 
key")
+        s3_client.put_object(Bucket=s3_bucket, Key="dags/nested/dag.py", 
Body=b"dag content")
+        sync_local_dir = tmp_path / "s3_sync_dir"
+        sync_local_dir.mkdir()
+
+        hook = S3Hook()
+        hook.sync_to_local_dir(bucket_name=s3_bucket, 
local_dir=sync_local_dir, s3_prefix="dags/")
+        assert (sync_local_dir / "nested/dag.py").read_bytes() == b"dag 
content"
+
+        if when_added == "after":
+            s3_client.put_object(Bucket=s3_bucket, Key=key, Body=b"directory 
key")
+        hook.sync_to_local_dir(bucket_name=s3_bucket, 
local_dir=sync_local_dir, s3_prefix="dags/")
+        assert (sync_local_dir / "nested/dag.py").read_bytes() == b"dag 
content"
+
     def test_sync_to_local_dir_rejects_key_path_traversal(self, s3_bucket, 
s3_client, tmp_path):
         s3_client.put_object(Bucket=s3_bucket, Key="dags/../../outside.py", 
Body=b"test data")
 

Reply via email to