From 7daf360dfa08aad5b738eef260bd4a29a124eda2 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Tue, 24 Feb 2026 16:13:29 -0800 Subject: [PATCH] fix: follow symlinks in list_files_recursively with cycle detection list_files_recursively now uses followlinks=True so symlinked directories under input/ and output/ roots are traversed, matching the existing behavior of folder_paths.recursive_search for models. Tracks (st_dev, st_ino) pairs of visited directories to detect and break circular symlink loops safely. Amp-Thread-ID: https://ampcode.com/threads/T-019c9220-21b8-7678-b428-9215ff1bb011 Co-authored-by: Amp --- app/assets/services/file_utils.py | 16 +++++- tests-unit/assets_test/test_file_utils.py | 66 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/app/assets/services/file_utils.py b/app/assets/services/file_utils.py index a4fe77d40..c47ebe460 100644 --- a/app/assets/services/file_utils.py +++ b/app/assets/services/file_utils.py @@ -42,14 +42,26 @@ def is_visible(name: str) -> bool: def list_files_recursively(base_dir: str) -> list[str]: - """Recursively list all files in a directory.""" + """Recursively list all files in a directory, following symlinks.""" out: list[str] = [] base_abs = os.path.abspath(base_dir) if not os.path.isdir(base_abs): return out + # Track seen real directory identities to prevent circular symlink loops + seen_dirs: set[tuple[int, int]] = set() for dirpath, subdirs, filenames in os.walk( - base_abs, topdown=True, followlinks=False + base_abs, topdown=True, followlinks=True ): + try: + st = os.stat(dirpath) + dir_id = (st.st_dev, st.st_ino) + except OSError: + subdirs.clear() + continue + if dir_id in seen_dirs: + subdirs.clear() + continue + seen_dirs.add(dir_id) subdirs[:] = [d for d in subdirs if is_visible(d)] for name in filenames: if not is_visible(name): diff --git a/tests-unit/assets_test/test_file_utils.py b/tests-unit/assets_test/test_file_utils.py index 828b2b81a..e3591d49b 100644 --- a/tests-unit/assets_test/test_file_utils.py +++ b/tests-unit/assets_test/test_file_utils.py @@ -1,3 +1,8 @@ +import os +import sys + +import pytest + from app.assets.services.file_utils import is_visible, list_files_recursively @@ -53,3 +58,64 @@ class TestListFilesRecursively: def test_nonexistent_directory(self, tmp_path): result = list_files_recursively(str(tmp_path / "nonexistent")) assert result == [] + + @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") + def test_follows_symlinked_directories(self, tmp_path): + target = tmp_path / "real_dir" + target.mkdir() + (target / "model.safetensors").write_text("data") + + root = tmp_path / "root" + root.mkdir() + (root / "link").symlink_to(target) + + result = list_files_recursively(str(root)) + + assert len(result) == 1 + assert result[0].endswith("model.safetensors") + assert "link" in result[0] + + @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") + def test_follows_symlinked_files(self, tmp_path): + real_file = tmp_path / "real.txt" + real_file.write_text("content") + + root = tmp_path / "root" + root.mkdir() + (root / "link.txt").symlink_to(real_file) + + result = list_files_recursively(str(root)) + + assert len(result) == 1 + assert result[0].endswith("link.txt") + + @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") + def test_circular_symlinks_do_not_loop(self, tmp_path): + dir_a = tmp_path / "a" + dir_a.mkdir() + (dir_a / "file.txt").write_text("a") + # a/b -> a (circular) + (dir_a / "b").symlink_to(dir_a) + + result = list_files_recursively(str(dir_a)) + + assert len(result) == 1 + assert result[0].endswith("file.txt") + + @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") + def test_mutual_circular_symlinks(self, tmp_path): + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + dir_a.mkdir() + dir_b.mkdir() + (dir_a / "file_a.txt").write_text("a") + (dir_b / "file_b.txt").write_text("b") + # a/link_b -> b and b/link_a -> a + (dir_a / "link_b").symlink_to(dir_b) + (dir_b / "link_a").symlink_to(dir_a) + + result = list_files_recursively(str(dir_a)) + basenames = sorted(os.path.basename(p) for p in result) + + assert "file_a.txt" in basenames + assert "file_b.txt" in basenames