Skip to content

Commit 1415782

Browse files
committed
Guard JavaCoordinator JAR scan against symlink cycles
1 parent c1299bd commit 1415782

2 files changed

Lines changed: 87 additions & 3 deletions

File tree

task-sdk/src/airflow/sdk/coordinators/java/coordinator.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import selectors
2727
import signal
2828
import socket
29+
import stat
2930
import subprocess
3031
import time
3132
import zipfile
@@ -62,10 +63,36 @@ def _start_server() -> socket.socket:
6263

6364

6465
def _find_jars(items: Iterable[pathlib.Path]) -> Iterator[pathlib.Path]:
66+
"""
67+
Yield JAR files under *items*, descending into directories.
68+
69+
A symlink loop or a directory that hardlinks into one of its ancestors
70+
would otherwise recurse until the interpreter stack is exhausted, so
71+
directories are deduplicated by ``(st_dev, st_ino)`` for the duration
72+
of a single scan.
73+
"""
74+
seen_dirs: set[tuple[int, int]] = set()
75+
yield from _walk_jars(items, seen_dirs)
76+
77+
78+
def _walk_jars(items: Iterable[pathlib.Path], seen_dirs: set[tuple[int, int]]) -> Iterator[pathlib.Path]:
6579
for item in items:
66-
if item.is_dir():
67-
yield from _find_jars(item.iterdir())
68-
elif item.is_file() and item.suffix == ".jar":
80+
try:
81+
st = item.stat()
82+
except OSError:
83+
continue
84+
if stat.S_ISDIR(st.st_mode):
85+
key = (st.st_dev, st.st_ino)
86+
if key in seen_dirs:
87+
log.debug("Skipping already-visited directory", path=str(item))
88+
continue
89+
seen_dirs.add(key)
90+
try:
91+
children = list(item.iterdir())
92+
except OSError:
93+
continue
94+
yield from _walk_jars(children, seen_dirs)
95+
elif stat.S_ISREG(st.st_mode) and item.suffix == ".jar":
6996
yield item
7097

7198

task-sdk/tests/task_sdk/coordinators/java/test_coordinator.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
_JavaActivitySubprocess,
4141
_ResourceTracker,
4242
_start_server,
43+
_walk_jars,
4344
)
4445
from airflow.sdk.execution_time.coordinator import BaseCoordinator
4546
from airflow.sdk.execution_time.supervisor import ActivitySubprocess
@@ -214,6 +215,62 @@ def test_find_by_explicit_main_class_not_present_raises(self, tmp_path):
214215
with pytest.raises(FileNotFoundError, match="com.example.Missing"):
215216
_JarInfo.find([tmp_path], "com.example.Missing")
216217

218+
def test_symlink_cycle_does_not_infinite_recurse(self, tmp_path):
219+
nested = tmp_path / "inner"
220+
nested.mkdir()
221+
_make_jar(nested / "app.jar", main_class="com.example.Loop", schema_version="2026-06-16")
222+
loop = nested / "loop"
223+
try:
224+
loop.symlink_to(tmp_path)
225+
except (OSError, NotImplementedError):
226+
pytest.skip("symlinks not supported on this platform")
227+
228+
result = _JarInfo.find([tmp_path], "com.example.Loop")
229+
assert result == _JarInfo("com.example.Loop", "2026-06-16")
230+
231+
232+
class TestWalkJars:
233+
def test_skips_directory_whose_key_is_already_in_seen_dirs(self, tmp_path):
234+
"""A directory whose (st_dev, st_ino) is already in seen_dirs is skipped."""
235+
_make_jar(tmp_path / "app.jar", main_class="com.example.Main", schema_version="2026-06-16")
236+
st = tmp_path.stat()
237+
seen_dirs: set[tuple[int, int]] = {(st.st_dev, st.st_ino)}
238+
assert list(_walk_jars([tmp_path], seen_dirs)) == []
239+
240+
def test_records_visited_directories_in_seen_dirs(self, tmp_path):
241+
"""Every directory descended into is added to seen_dirs."""
242+
sub = tmp_path / "sub"
243+
sub.mkdir()
244+
_make_jar(sub / "app.jar", main_class="com.example.Main", schema_version="2026-06-16")
245+
seen_dirs: set[tuple[int, int]] = set()
246+
list(_walk_jars([tmp_path], seen_dirs))
247+
assert (tmp_path.stat().st_dev, tmp_path.stat().st_ino) in seen_dirs
248+
assert (sub.stat().st_dev, sub.stat().st_ino) in seen_dirs
249+
250+
def test_symlink_cycle_yields_each_jar_once(self, tmp_path):
251+
"""A symlink that loops back to an ancestor must not yield the same JAR twice."""
252+
nested = tmp_path / "inner"
253+
nested.mkdir()
254+
jar = _make_jar(nested / "app.jar", main_class="com.example.Loop", schema_version="2026-06-16")
255+
loop = nested / "loop"
256+
try:
257+
loop.symlink_to(tmp_path)
258+
except (OSError, NotImplementedError):
259+
pytest.skip("symlinks not supported on this platform")
260+
261+
seen_dirs: set[tuple[int, int]] = set()
262+
yielded = list(_walk_jars([tmp_path], seen_dirs))
263+
assert [p.resolve() for p in yielded] == [jar.resolve()]
264+
265+
def test_skip_logged_when_directory_revisited(self, tmp_path):
266+
"""A revisited directory triggers the 'Skipping already-visited directory' debug log."""
267+
sub = tmp_path / "sub"
268+
sub.mkdir()
269+
seen_dirs: set[tuple[int, int]] = {(sub.stat().st_dev, sub.stat().st_ino)}
270+
with patch("airflow.sdk.coordinators.java.coordinator.log") as mock_log:
271+
list(_walk_jars([sub], seen_dirs))
272+
mock_log.debug.assert_any_call("Skipping already-visited directory", path=str(sub))
273+
217274

218275
class TestAcceptConnections:
219276
def _connect_after_delay(self, addr: tuple[str, int], delay: float = 0.0) -> None:

0 commit comments

Comments
 (0)