diff --git a/src/specify_cli/bundles/packager.py b/src/specify_cli/bundles/packager.py index bd80bf7b7c..31f66b9240 100644 --- a/src/specify_cli/bundles/packager.py +++ b/src/specify_cli/bundles/packager.py @@ -48,6 +48,17 @@ def build_bundle( "README.md describing it." ) + # The packager never packages symlinked files, so a symlinked manifest or + # README would pass the checks above yet be left out of the artifact, + # producing a zip that 'specify bundle install' rejects. Fail the build. + for required in (manifest_path, bundle_dir / "README.md"): + if required.is_symlink(): + raise BundlerError( + f"'{required.name}' in '{bundle_dir}' is a symlink. The manifest " + "and README.md must be regular files so they are included in " + "the artifact." + ) + manifest = BundleManifest.from_file(manifest_path) report = validate_manifest(manifest) if not report.ok: diff --git a/tests/specify_cli/bundles/test_packager.py b/tests/specify_cli/bundles/test_packager.py index 8587535ee6..077b75b837 100644 --- a/tests/specify_cli/bundles/test_packager.py +++ b/tests/specify_cli/bundles/test_packager.py @@ -170,6 +170,22 @@ def test_symlinked_directory_is_not_followed(tmp_path: Path): assert not any("secret" in name for name in names) +@pytest.mark.parametrize("name", ["bundle.yml", "README.md"]) +def test_build_refuses_symlinked_manifest_or_readme(tmp_path: Path, name: str): + bundle = _make_bundle(tmp_path / "b") + (bundle / "shared").mkdir() + (bundle / name).rename(bundle / "shared" / name) + try: + (bundle / name).symlink_to(Path("shared") / name) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + # The packager skips symlinked files, so building would otherwise succeed + # and emit an artifact without the manifest or README at its root. + with pytest.raises(BundlerError, match="symlink"): + build_bundle(bundle, output_dir=tmp_path / "out") + assert not list((tmp_path / "out").glob("*.zip")) + + def test_unsafe_bundle_id_is_rejected_before_build(tmp_path: Path): data = valid_manifest_dict() data["bundle"]["id"] = "../evil"