diff --git a/backend/src/hatchling/builders/sdist.py b/backend/src/hatchling/builders/sdist.py index d92371bc7..91c284f38 100644 --- a/backend/src/hatchling/builders/sdist.py +++ b/backend/src/hatchling/builders/sdist.py @@ -341,8 +341,13 @@ def get_default_build_data(self) -> dict[str, Any]: readme_path = self.metadata.core.readme_path if readme_path: - readme_path = normalize_relative_path(readme_path) - force_include[os.path.join(self.root, readme_path)] = readme_path + normalized_readme = normalize_relative_path(readme_path) + # The readme content is always embedded in PKG-INFO, so files + # outside the project root are not added to the archive to + # avoid path traversal entries in the file list. + resolved_readme = os.path.abspath(os.path.join(self.root, normalized_readme)) + if not os.path.relpath(resolved_readme, os.path.abspath(self.root)).startswith(os.pardir): + force_include[resolved_readme] = normalized_readme license_files = self.metadata.core.license_files if license_files: diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index d6dcb6f14..4f3b73f9c 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -529,8 +529,8 @@ def readme(self) -> str: raise TypeError(message) readme_path = os.path.normpath(os.path.join(self.root, readme)) - if os.path.isabs(readme) or os.path.relpath(readme_path, self.root).startswith(".."): - message = f"Readme path must be within the project directory: {readme}" + if os.path.isabs(readme): + message = f"Readme path must be relative to the project directory: {readme}" raise ValueError(message) if not os.path.isfile(readme_path): message = f"Readme file does not exist: {readme}" @@ -569,8 +569,8 @@ def readme(self) -> str: raise TypeError(message) path = os.path.normpath(os.path.join(self.root, relative_path)) - if os.path.isabs(relative_path) or os.path.relpath(path, self.root).startswith(".."): - message = f"Readme path must be within the project directory: {relative_path}" + if os.path.isabs(relative_path): + message = f"Readme path must be relative to the project directory: {relative_path}" raise ValueError(message) if not os.path.isfile(path): message = f"Readme file does not exist: {relative_path}" diff --git a/tests/backend/builders/test_sdist.py b/tests/backend/builders/test_sdist.py index 5941b217c..e274cba0c 100644 --- a/tests/backend/builders/test_sdist.py +++ b/tests/backend/builders/test_sdist.py @@ -1216,6 +1216,37 @@ def test_readme_always_included(self, hatch, helpers, temp_dir, config_file): stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO")) assert stat.st_mtime == get_reproducible_timestamp() + def test_readme_outside_project_not_in_archive(self, temp_dir): + project_path = temp_dir / "my-app" + project_path.mkdir() + + # Shared README in the parent (monorepo) directory + readme_path = temp_dir / "README.md" + readme_path.write_text("test content\n") + + config = { + "project": {"name": "my-app", "version": "0.1.0", "readme": "../README.md"}, + "tool": {"hatch": {"build": {"targets": {"sdist": {"versions": ["standard"]}}}}}, + } + builder = SdistBuilder(str(project_path), config=config) + + build_path = project_path / "dist" + build_path.mkdir() + + with project_path.as_cwd(): + artifacts = list(builder.build(directory=str(build_path))) + + assert len(artifacts) == 1 + with tarfile.open(artifacts[0], "r:gz") as tar_archive: + # The readme content is embedded in PKG-INFO and the file itself + # must not be added with a backward-relative archive path. + assert "../README.md" not in tar_archive.getnames() + pkg_info = tar_archive.extractfile( + next(name for name in tar_archive.getnames() if name.endswith("PKG-INFO")) + ) + assert pkg_info is not None + assert "test content" in pkg_info.read().decode() + def test_include_license_files(self, hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["src-layout"] = False config_file.save() diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py index abbca4494..d1c5d4f4e 100644 --- a/tests/backend/metadata/test_core.py +++ b/tests/backend/metadata/test_core.py @@ -386,6 +386,29 @@ def test_string_path_nonexistent(self, isolation): with pytest.raises(OSError, match="Readme file does not exist: foo/bar\\.md"): _ = metadata.core.readme + def test_string_path_outside_project(self, temp_dir): + # Monorepos often share a README from the repository root via a + # backward-relative path; the content is embedded in the metadata. + readme_path = temp_dir.parent / "README.md" + readme_path.write_text("test content") + + metadata = ProjectMetadata(str(temp_dir), None, {"project": {"readme": "../README.md"}}) + + assert metadata.core.readme == metadata.core.readme == "test content" + assert metadata.core.readme_content_type == metadata.core.readme_content_type == "text/markdown" + assert metadata.core.readme_path == metadata.core.readme_path == "../README.md" + + def test_table_file_path_outside_project(self, temp_dir): + readme_path = temp_dir.parent / "README.md" + readme_path.write_text("test content") + + metadata = ProjectMetadata( + str(temp_dir), None, {"project": {"readme": {"file": "../README.md", "content-type": "text/markdown"}}} + ) + + assert metadata.core.readme == metadata.core.readme == "test content" + assert metadata.core.readme_path == metadata.core.readme_path == "../README.md" + @pytest.mark.parametrize( ("extension", "content_type"), [(".md", "text/markdown"), (".rst", "text/x-rst"), (".txt", "text/plain")] )