Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions backend/src/hatchling/builders/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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}"
Expand Down
31 changes: 31 additions & 0 deletions tests/backend/builders/test_sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions tests/backend/metadata/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
)
Expand Down