From 114a6903b65fe56378138c8a589f1e0659725768 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 28 Oct 2026 12:02:07 -0400 Subject: [PATCH] fix: report dangling symlinks during bundling --- rsconnect/bundle.py | 10 +++++++++- tests/test_bundle.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_main.py | 17 +++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 7969dc75..de28d511 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -512,7 +512,15 @@ def make_hasher(): def file_checksum(path: str | Path) -> str: """Calculate the md5 hex digest of the specified file""" - with open(path, "rb") as f: + try: + file = open(path, "rb") + except FileNotFoundError as exc: + if os.path.islink(path): + raise RSConnectException( + f"Cannot bundle dangling symlink '{path}'. Fix or exclude it and try again." + ) from exc + raise + with file as f: m = make_hasher() chunk_size = 64 * 1024 diff --git a/tests/test_bundle.py b/tests/test_bundle.py index e6eaf980..be95257d 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -72,6 +72,42 @@ def create_fake_quarto_rendered_output(target_dir, name): fp.write("// fake resource.js\n") +@pytest.mark.parametrize("link_name", ["data.csv", ".venv/bin/python"]) +def test_api_bundle_reports_dangling_symlink(tmp_path, link_name): + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / "requirements.txt").write_text("flask\n") + link = tmp_path / link_name + link.parent.mkdir(parents=True, exist_ok=True) + try: + link.symlink_to("missing-target") + except OSError: + pytest.skip("symlinks are unavailable") + environment = Environment.create_python_environment(str(tmp_path)) + + with pytest.raises(RSConnectException) as error: + make_api_bundle(str(tmp_path), "app:app", AppModes.PYTHON_API, environment, [], []) + + assert str(link) in str(error.value) + assert "dangling symlink" in str(error.value) + + +def test_api_bundle_keeps_valid_symlink(tmp_path): + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / "requirements.txt").write_text("flask\n") + (tmp_path / "data.csv").write_text("data\n") + try: + (tmp_path / "data-link.csv").symlink_to("data.csv") + except OSError: + pytest.skip("symlinks are unavailable") + environment = Environment.create_python_environment(str(tmp_path)) + + with make_api_bundle(str(tmp_path), "app:app", AppModes.PYTHON_API, environment, [], []) as bundle: + with tarfile.open(mode="r:gz", fileobj=bundle) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read()) + assert manifest["files"]["data-link.csv"]["checksum"] == file_checksum(tmp_path / "data.csv") + assert tar.getmember("data-link.csv").issym() + + class TestBundle(TestCase): @staticmethod def python_version(): diff --git a/tests/test_main.py b/tests/test_main.py index 94a59c2f..97ac3120 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2154,6 +2154,23 @@ def test_no_package_json(self, tmp_path): class TestWriteManifestFramework: + def test_dangling_symlink_reports_clear_error(self, tmp_path): + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / "requirements.txt").write_text("flask\n") + link = tmp_path / "data.csv" + try: + link.symlink_to("missing-target") + except OSError: + pytest.skip("symlinks are unavailable") + + result = CliRunner().invoke(cli, ["write-manifest", "api", str(tmp_path)]) + + assert result.exit_code == 1 + assert str(link) in result.output + assert "dangling symlink" in result.output + assert "Traceback" not in result.output + assert "Internal error" not in result.output + @pytest.mark.parametrize("existing_requirements", [False, True]) def test_force_generate_requirements(self, tmp_path, existing_requirements): (tmp_path / "app.py").write_text("app = object()\n")