diff --git a/src/metadata.rs b/src/metadata.rs index d679396d1..1b3a9fa13 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -13,7 +13,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::str; use std::str::FromStr; use tracing::debug; @@ -153,21 +153,36 @@ fn normalize_license_file_path(base_dir: &Path, absolute_license_path: &Path) -> Ok(relative.to_path_buf()) } -fn validate_safe_relative_license_path(path: &Path, source: &str) -> Result<()> { - if path.components().any(|c| { - matches!( - c, - std::path::Component::ParentDir - | std::path::Component::Prefix(_) - | std::path::Component::RootDir - ) - }) { +fn resolve_pyproject_metadata_file( + pyproject_dir: &Path, + allowed_metadata_root: &Path, + field: &str, + path: &Path, +) -> Result { + if path + .components() + .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir)) + { bail!( - "{source} must be a safe relative path inside the project, got `{}`", + "`{field}` path `{}` must be relative to pyproject.toml", path.display() ); } - Ok(()) + let source = pyproject_dir.join(path); + if path.components().any(|c| matches!(c, Component::ParentDir)) { + let source = source.normalize()?.into_path_buf(); + let allowed_metadata_root = allowed_metadata_root.normalize()?.into_path_buf(); + if !source.starts_with(&allowed_metadata_root) { + bail!( + "`{field}` path `{}` resolves outside allowed metadata root `{}`", + path.display(), + allowed_metadata_root.display() + ); + } + Ok(source) + } else { + Ok(source) + } } impl Metadata24 { @@ -179,7 +194,21 @@ impl Metadata24 { pyproject_dir: impl AsRef, pyproject_toml: &PyProjectToml, ) -> Result<()> { - let pyproject_dir = pyproject_dir.as_ref(); + self.merge_pyproject_toml_with_metadata_root( + pyproject_dir.as_ref(), + pyproject_dir.as_ref(), + pyproject_toml, + ) + } + + /// Merge pyproject metadata while allowing parent-relative metadata files + /// under `allowed_metadata_root`. + pub(crate) fn merge_pyproject_toml_with_metadata_root( + &mut self, + pyproject_dir: &Path, + allowed_metadata_root: &Path, + pyproject_toml: &PyProjectToml, + ) -> Result<()> { if let Some(project) = &pyproject_toml.project { let dynamic: HashSet<&str> = project .dynamic @@ -194,8 +223,8 @@ impl Metadata24 { self.name.clone_from(&project.name); self.merge_version(pyproject_toml, project)?; - self.merge_readme(pyproject_dir, project)?; - self.merge_license(pyproject_dir, project)?; + self.merge_readme(pyproject_dir, allowed_metadata_root, project)?; + self.merge_license(pyproject_dir, allowed_metadata_root, project)?; self.merge_people(project); if let Some(description) = &project.description { @@ -283,11 +312,17 @@ impl Metadata24 { fn merge_readme( &mut self, pyproject_dir: &Path, + allowed_metadata_root: &Path, project: &pyproject_toml::Project, ) -> Result<()> { match &project.readme { Some(pyproject_toml::ReadMe::RelativePath(readme_path)) => { - let readme_path = pyproject_dir.join(readme_path); + let readme_path = resolve_pyproject_metadata_file( + pyproject_dir, + allowed_metadata_root, + "project.readme", + Path::new(readme_path), + )?; let description = Some(fs::read_to_string(&readme_path).context(format!( "Failed to read readme specified in pyproject.toml, which should be at {}", readme_path.display() @@ -306,7 +341,12 @@ impl Metadata24 { ); } if let Some(readme_path) = file { - let readme_path = pyproject_dir.join(readme_path); + let readme_path = resolve_pyproject_metadata_file( + pyproject_dir, + allowed_metadata_root, + "project.readme.file", + Path::new(readme_path), + )?; let description = Some(fs::read_to_string(&readme_path).context(format!( "Failed to read readme specified in pyproject.toml, which should be at {}", readme_path.display() @@ -327,6 +367,7 @@ impl Metadata24 { fn merge_license( &mut self, pyproject_dir: &Path, + allowed_metadata_root: &Path, project: &pyproject_toml::Project, ) -> Result<()> { if let Some(license) = &project.license { @@ -335,11 +376,34 @@ impl Metadata24 { License::Spdx(license_expr) => self.license_expression = Some(license_expr.clone()), // Deprecated by PEP 639 License::File { file } => { - let file = file.to_path_buf(); - validate_safe_relative_license_path(&file, "`project.license.file`")?; - self.license_file_sources.remove(&file); - if !self.license_files.contains(&file) { - self.license_files.push(file); + let source = resolve_pyproject_metadata_file( + pyproject_dir, + allowed_metadata_root, + "project.license.file", + file, + )?; + let target = if file.components().any(|c| matches!(c, Component::ParentDir)) { + let allowed_metadata_root = + allowed_metadata_root.normalize()?.into_path_buf(); + source + .strip_prefix(&allowed_metadata_root) + .with_context(|| { + format!( + "`project.license.file` path `{}` resolves outside allowed metadata root `{}`", + file.display(), + allowed_metadata_root.display() + ) + })? + .to_path_buf() + } else { + normalize_license_file_path(pyproject_dir, &source)? + }; + self.license_file_sources.remove(&target); + if target != *file { + self.license_file_sources.insert(target.clone(), source); + } + if !self.license_files.contains(&target) { + self.license_files.push(target); } } License::Text { text } => self.license = Some(text.clone()), @@ -1527,7 +1591,10 @@ A test project #[test] fn test_reject_unsafe_project_license_file_path() { let temp_dir = TempDir::new().unwrap(); - let crate_dir = temp_dir.path(); + let workspace_root = temp_dir.path().join("workspace"); + let crate_dir = workspace_root.join("crate"); + fs::create_dir_all(&crate_dir).unwrap(); + fs::write(temp_dir.path().join("LICENSE"), "secret license").unwrap(); let pyproject_toml_content = indoc!( r#" @@ -1538,7 +1605,7 @@ A test project [project] name = "my-crate" version = "0.1.0" - license = { file = "../LICENSE" } + license = { file = "../../LICENSE" } "# ); fs::write(crate_dir.join("pyproject.toml"), pyproject_toml_content).unwrap(); @@ -1550,8 +1617,7 @@ A test project .merge_pyproject_toml(crate_dir, &pyproject_toml) .unwrap_err(); assert!( - err.to_string() - .contains("`project.license.file` must be a safe relative path"), + err.to_string().contains("outside allowed metadata root"), "unexpected error: {err:#}" ); } diff --git a/src/project_layout.rs b/src/project_layout.rs index 324c49fbb..85851b2df 100644 --- a/src/project_layout.rs +++ b/src/project_layout.rs @@ -134,7 +134,17 @@ impl ProjectResolver { .context("Failed to parse Cargo.toml into python metadata")?; if let Some(pyproject) = pyproject { let pyproject_dir = pyproject_file.parent().unwrap(); - metadata24.merge_pyproject_toml(pyproject_dir, pyproject)?; + let workspace_root = cargo_metadata.workspace_root.as_std_path(); + let allowed_metadata_root = if pyproject_dir.starts_with(workspace_root) { + workspace_root + } else { + pyproject_dir + }; + metadata24.merge_pyproject_toml_with_metadata_root( + pyproject_dir, + allowed_metadata_root, + pyproject, + )?; } let crate_name = &cargo_toml.package.name; diff --git a/src/source_distribution/mod.rs b/src/source_distribution/mod.rs index a35369c88..5dea7961e 100644 --- a/src/source_distribution/mod.rs +++ b/src/source_distribution/mod.rs @@ -26,7 +26,10 @@ use self::cargo_toml_rewrite::{ rewrite_cargo_toml_targets, strip_non_workspace_tables, }; pub use self::path_deps::{PathDependency, find_path_deps}; -use self::pyproject::{add_pyproject_metadata, add_pyproject_toml, add_python_sources}; +use self::pyproject::{ + add_pyproject_metadata, add_pyproject_metadata_files, add_pyproject_toml, add_python_sources, + reject_parent_relative_metadata_paths, +}; pub use self::unpack::{UnpackedSdist, unpack_sdist}; use self::utils::{common_path_prefix, is_compiled_artifact, normalize_path}; @@ -831,11 +834,21 @@ fn add_workspace_manifest( /// 6. Python sources fn add_cargo_package_files_to_sdist( project: &crate::ProjectContext, + pyproject: &PyProjectToml, pyproject_toml_path: &Path, writer: &mut VirtualWriter, root_dir: &Path, ) -> Result<()> { let ctx = SdistContext::new(project, pyproject_toml_path, root_dir)?; + let workspace_root = project.cargo_metadata.workspace_root.as_std_path(); + let normalized_pyproject_dir = ctx.pyproject_dir.normalize()?.into_path_buf(); + let normalized_workspace_root = workspace_root.normalize()?.into_path_buf(); + let allowed_metadata_root = if normalized_pyproject_dir.starts_with(&normalized_workspace_root) + { + workspace_root + } else { + &ctx.pyproject_dir + }; // 1. Add local path dependencies for (name, path_dep) in ctx.known_path_deps.iter() { @@ -851,10 +864,14 @@ fn add_cargo_package_files_to_sdist( // 4. Add workspace Cargo.toml (if applicable) add_workspace_manifest(writer, &ctx)?; - // 5. Add pyproject.toml - add_pyproject_toml(writer, &ctx, pyproject_toml_path)?; + // 5. Add pyproject.toml metadata files and collect path rewrites. + let metadata_rewrites = + add_pyproject_metadata_files(writer, pyproject, &ctx, allowed_metadata_root)?; + + // 6. Add pyproject.toml + add_pyproject_toml(writer, &ctx, pyproject_toml_path, &metadata_rewrites)?; - // 6. Add python source files + // 7. Add python source files add_python_sources(writer, &ctx)?; Ok(()) @@ -903,16 +920,23 @@ pub fn source_distribution( &metadata24.get_version_escaped() )); + let pyproject_dir = pyproject_toml_path.parent().unwrap(); match pyproject.sdist_generator() { SdistGenerator::Cargo => { - add_cargo_package_files_to_sdist(project, &pyproject_toml_path, &mut writer, &root_dir)? + add_cargo_package_files_to_sdist( + project, + pyproject, + &pyproject_toml_path, + &mut writer, + &root_dir, + )?; } SdistGenerator::Git => { - add_git_tracked_files_to_sdist(&pyproject_toml_path, &mut writer, &root_dir)? + reject_parent_relative_metadata_paths(pyproject)?; + add_git_tracked_files_to_sdist(&pyproject_toml_path, &mut writer, &root_dir)?; } - } + }; - let pyproject_dir = pyproject_toml_path.parent().unwrap(); add_pyproject_metadata( &mut writer, pyproject, diff --git a/src/source_distribution/pyproject.rs b/src/source_distribution/pyproject.rs index 0e0cb6fef..c2d9de242 100644 --- a/src/source_distribution/pyproject.rs +++ b/src/source_distribution/pyproject.rs @@ -1,23 +1,149 @@ use crate::pyproject_toml::Format; use crate::{ModuleWriter, PyProjectToml, SDistWriter, VirtualWriter}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; +use normpath::PathExt as _; use path_slash::PathExt as _; use pyproject_toml::check_pep639_glob; use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use tracing::{debug, trace}; use super::SdistContext; use super::cargo_toml_rewrite::parse_toml_file; use super::utils::is_compiled_artifact; +pub(super) type PyprojectPathRewrite = (&'static str, String); + +fn parent_relative_metadata_path(field: &str, path: &Path) -> Result { + if path + .components() + .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir)) + { + bail!( + "`project.{field}.file` path `{}` must be relative to pyproject.toml", + path.display() + ); + } + Ok(path.components().any(|c| matches!(c, Component::ParentDir))) +} + +fn project_metadata_paths( + project: &pyproject_toml::Project, +) -> Vec<(&'static str, &'static str, &Path)> { + let mut paths = Vec::new(); + match project.readme.as_ref() { + Some(pyproject_toml::ReadMe::RelativePath(file)) => { + paths.push(("readme", "project.readme", Path::new(file.as_str()))); + } + Some(pyproject_toml::ReadMe::Table { + file: Some(file), .. + }) => { + paths.push(("readme", "project.readme.file", Path::new(file.as_str()))); + } + _ => {} + } + if let Some(pyproject_toml::License::File { file }) = project.license.as_ref() { + paths.push(("license", "project.license.file", file.as_path())); + } + paths +} + +/// Add readme/license files referenced from `[project]` metadata. +/// +/// Because the generated `pyproject.toml` always lives at the sdist root, any +/// parent-relative metadata path must be rewritten to the referenced file's +/// archive path. The source is still constrained by `allowed_metadata_root`. +pub(super) fn add_pyproject_metadata_files( + writer: &mut VirtualWriter, + pyproject: &PyProjectToml, + ctx: &SdistContext<'_>, + allowed_metadata_root: &Path, +) -> Result> { + let mut rewrites = Vec::new(); + let Some(project) = pyproject.project.as_ref() else { + return Ok(rewrites); + }; + let allowed_metadata_root = allowed_metadata_root.normalize()?.into_path_buf(); + let project_root = ctx.project_root.normalize()?.into_path_buf(); + + for (field, field_name, path) in project_metadata_paths(project) { + let mut source = ctx.pyproject_dir.join(path); + let mut target_relative = path.to_path_buf(); + if parent_relative_metadata_path(field_name, path)? { + if ctx.pyproject_dir == ctx.sdist_root { + bail!( + "`{field_name}` path `{}` must not contain `..` when pyproject.toml is already at the sdist root", + path.display() + ); + } + source = source.normalize()?.into_path_buf(); + if !source.starts_with(allowed_metadata_root.as_path()) { + bail!( + "`{field_name}` path `{}` resolves outside allowed metadata root `{}`", + path.display(), + allowed_metadata_root.display() + ); + } + target_relative = source + .strip_prefix(&project_root) + .with_context(|| { + format!( + "`{field_name}` path `{}` resolves outside sdist project root `{}`", + path.display(), + project_root.display() + ) + })? + .to_path_buf(); + let rewrite_path = target_relative + .to_slash() + .with_context(|| { + format!( + "`{field_name}` path `{}` is not valid UTF-8", + path.display() + ) + })? + .into_owned(); + rewrites.push((field, rewrite_path)); + } + + let target = ctx.root_dir.join(target_relative); + if !writer.contains_target(&target) { + writer.add_file(target, source, false)?; + } + } + Ok(rewrites) +} + +/// Reject parent-relative pyproject metadata paths for the git sdist generator. +/// +/// Git sdists are intentionally faithful to `git ls-files` from the +/// `pyproject.toml` directory. Since `pyproject.toml` must be placed at the +/// sdist root, parent-relative metadata paths cannot remain valid without +/// copying and rewriting extra files outside that file list. +pub(super) fn reject_parent_relative_metadata_paths(pyproject: &PyProjectToml) -> Result<()> { + let Some(project) = pyproject.project.as_ref() else { + return Ok(()); + }; + for (_, field_name, path) in project_metadata_paths(project) { + if parent_relative_metadata_path(field_name, path)? { + bail!( + "`{field_name}` path `{}` must not contain `..` when using the git sdist generator", + path.display() + ); + } + } + Ok(()) +} + /// Add pyproject.toml to the sdist (rewriting paths if necessary). pub(super) fn add_pyproject_toml( writer: &mut VirtualWriter, ctx: &SdistContext<'_>, pyproject_toml_path: &Path, + metadata_rewrites: &[PyprojectPathRewrite], ) -> Result<()> { if ctx.pyproject_dir != ctx.sdist_root { + let relative_manifest_path = ctx.relative_main_crate_manifest_dir.join("Cargo.toml"); let python_dir = &ctx.project.project_layout.python_dir; // Compute python-source relative to pyproject_dir. When python_dir is // outside pyproject_dir, compute the path relative to project_root instead. @@ -32,8 +158,9 @@ pub(super) fn add_pyproject_toml( }; let rewritten = rewrite_pyproject_toml( pyproject_toml_path, - &ctx.relative_main_crate_manifest_dir.join("Cargo.toml"), + &relative_manifest_path, relative_python_source.as_deref(), + metadata_rewrites, )?; writer.add_bytes( ctx.root_dir.join("pyproject.toml"), @@ -99,12 +226,11 @@ pub(super) fn add_python_sources( Ok(()) } -/// Add readme, license files, and include patterns from pyproject.toml metadata. +/// Add `license-files` globs and explicit include patterns from +/// `pyproject.toml` metadata. /// -/// This covers files referenced by `[project]` fields (readme, license, -/// license-files with PEP 639 glob handling) as well as explicit `include` -/// patterns from `[tool.maturin]`. Files already present in the writer -/// (e.g. from Cargo.toml metadata) are skipped to avoid duplicates. +/// Readme and `license.file` references are handled earlier for Cargo sdists; +/// for git sdists they are expected to come from `git ls-files`. pub(super) fn add_pyproject_metadata( writer: &mut VirtualWriter, pyproject: &PyProjectToml, @@ -112,47 +238,32 @@ pub(super) fn add_pyproject_metadata( root_dir: &Path, python_dir: &Path, ) -> Result<()> { - // Add readme, license from pyproject.toml - // Skip if already added (e.g. from Cargo.toml metadata) to avoid duplicates. - // See https://github.com/PyO3/maturin/issues/2358 - if let Some(project) = pyproject.project.as_ref() { - if let Some(pyproject_toml::ReadMe::RelativePath(readme)) = project.readme.as_ref() { - let target = root_dir.join(readme); - if !writer.contains_target(&target) { - writer.add_file(target, pyproject_dir.join(readme), false)?; - } - } - if let Some(pyproject_toml::License::File { file }) = project.license.as_ref() { - let target = root_dir.join(file); - if !writer.contains_target(&target) { - writer.add_file(target, pyproject_dir.join(file), false)?; - } - } - if let Some(license_files) = &project.license_files { - let escaped_pyproject_dir = - PathBuf::from(glob::Pattern::escape(pyproject_dir.to_str().unwrap())); - let mut seen = HashSet::new(); - for license_glob in license_files { - check_pep639_glob(license_glob)?; - for license_path in - glob::glob(&escaped_pyproject_dir.join(license_glob).to_string_lossy())? - { - let license_path = license_path?; - if !license_path.is_file() { - continue; - } - let license_path = license_path - .strip_prefix(pyproject_dir) - .expect("matched path starts with glob root") - .to_path_buf(); - if seen.insert(license_path.clone()) { - debug!("Including license file `{}`", license_path.display()); - writer.add_file( - root_dir.join(&license_path), - pyproject_dir.join(&license_path), - false, - )?; - } + if let Some(project) = pyproject.project.as_ref() + && let Some(license_files) = &project.license_files + { + let escaped_pyproject_dir = + PathBuf::from(glob::Pattern::escape(pyproject_dir.to_str().unwrap())); + let mut seen = HashSet::new(); + for license_glob in license_files { + check_pep639_glob(license_glob)?; + for license_path in + glob::glob(&escaped_pyproject_dir.join(license_glob).to_string_lossy())? + { + let license_path = license_path?; + if !license_path.is_file() { + continue; + } + let license_path = license_path + .strip_prefix(pyproject_dir) + .expect("matched path starts with glob root") + .to_path_buf(); + if seen.insert(license_path.clone()) { + debug!("Including license file `{}`", license_path.display()); + writer.add_file( + root_dir.join(&license_path), + pyproject_dir.join(&license_path), + false, + )?; } } } @@ -185,10 +296,14 @@ pub(super) fn add_pyproject_metadata( /// sdist root), we update `tool.maturin.manifest-path` and optionally /// `tool.maturin.python-source` so they resolve correctly from the new /// relative position inside the archive. +/// +/// `metadata` provides additional rewrites for `[project.readme]` and +/// `[project.license]` `file` paths that were elevated to the sdist root. fn rewrite_pyproject_toml( pyproject_toml_path: &Path, relative_manifest_path: &Path, relative_python_source: Option<&Path>, + metadata_rewrites: &[PyprojectPathRewrite], ) -> Result { let mut data = parse_toml_file(pyproject_toml_path, "pyproject.toml")?; let tool = data @@ -238,5 +353,51 @@ fn rewrite_pyproject_toml( ); } + if !metadata_rewrites.is_empty() { + let project = data + .entry("project") + .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new())) + .as_table_like_mut() + .with_context(|| { + format!( + "`[project]` must be a table in {}", + pyproject_toml_path.display() + ) + })?; + for rewrite in metadata_rewrites { + rewrite_pyproject_field_path(project, rewrite.0, "file", &rewrite.1)?; + } + } + Ok(data.to_string()) } + +/// Update a path field in `pyproject.toml`. The string form is replaced +/// wholesale; table or inline-table forms have only their `inner_field` +/// (e.g. `file`) updated so other keys like `content-type` are preserved. +fn rewrite_pyproject_field_path( + project: &mut dyn toml_edit::TableLike, + field: &str, + inner_field: &str, + new_path: &str, +) -> Result<()> { + let Some(item) = project.get_mut(field) else { + return Ok(()); + }; + match item { + toml_edit::Item::Value(toml_edit::Value::String(s)) => { + *s = toml_edit::Formatted::new(new_path.to_string()); + } + toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => { + table.insert( + inner_field, + toml_edit::Value::String(toml_edit::Formatted::new(new_path.to_string())), + ); + } + toml_edit::Item::Table(table) => { + table.insert(inner_field, toml_edit::value(new_path)); + } + _ => bail!("unexpected shape for `project.{field}` in pyproject.toml"), + } + Ok(()) +} diff --git a/tests/run/sdist.rs b/tests/run/sdist.rs index 95189e7f4..5834e667c 100644 --- a/tests/run/sdist.rs +++ b/tests/run/sdist.rs @@ -3,7 +3,7 @@ use expect_test::expect; use indoc::indoc; use maturin::pyproject_toml::SdistGenerator; use maturin::{BuildOptions, BuildOrchestrator, CargoOptions, OutputOptions, unpack_sdist}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use url::Url; use which::which; @@ -278,6 +278,156 @@ fn workspace_cargo_lock() { handle_result(other::test_workspace_cargo_lock()) } +fn write_workspace_bin_project( + workspace_dir: &Path, + member: &str, + project_metadata: &str, +) -> PathBuf { + fs_err::write( + workspace_dir.join("Cargo.toml"), + format!("[workspace]\nresolver = \"2\"\nmembers = [\"{member}\"]\n"), + ) + .unwrap(); + + let project_dir = workspace_dir.join(member); + fs_err::create_dir_all(project_dir.join("src")).unwrap(); + fs_err::write(project_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs_err::write( + project_dir.join("Cargo.toml"), + indoc!( + r#" + [package] + name = "python-pkg" + version = "0.1.0" + edition = "2021" + + [[bin]] + name = "python-pkg" + path = "src/main.rs" + "# + ), + ) + .unwrap(); + fs_err::write( + project_dir.join("pyproject.toml"), + format!( + indoc!( + r#" + [project] + name = "python-pkg" + version = "0.1.0" + + {} + + [build-system] + requires = ["maturin>=1.0,<2.0"] + build-backend = "maturin" + + [tool.maturin] + bindings = "bin" + "# + ), + project_metadata.trim_end() + ), + ) + .unwrap(); + project_dir +} + +/// Regression test for https://github.com/PyO3/maturin/issues/3181 + +/// follow-up: when `pyproject.toml` is in a workspace member subdirectory and +/// references path metadata outside the member directory, those files must be +/// included and the elevated `pyproject.toml` must be rewritten to the files' +/// archive paths. +#[test] +fn sdist_workspace_member_readme_in_parent() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_dir = temp_dir.path(); + let crate_group_dir = workspace_dir.join("crates"); + fs_err::create_dir_all(&crate_group_dir).unwrap(); + fs_err::write(crate_group_dir.join("README.md"), "# my-pkg\n").unwrap(); + fs_err::write(crate_group_dir.join("LICENSE"), "MIT\n").unwrap(); + + let project_dir = write_workspace_bin_project( + workspace_dir, + "crates/python-pkg", + indoc!( + r#" + [project.readme] + file = "../README.md" + content-type = "text/markdown" + + [project.license] + file = "../LICENSE" + "# + ), + ); + + handle_result(other::test_source_distribution( + &project_dir, + SdistGenerator::Cargo, + expect![[r#" + { + "python_pkg-0.1.0/Cargo.lock", + "python_pkg-0.1.0/Cargo.toml", + "python_pkg-0.1.0/PKG-INFO", + "python_pkg-0.1.0/crates/LICENSE", + "python_pkg-0.1.0/crates/README.md", + "python_pkg-0.1.0/crates/python-pkg/Cargo.toml", + "python_pkg-0.1.0/crates/python-pkg/src/main.rs", + "python_pkg-0.1.0/pyproject.toml", + } + "#]], + Some(( + Path::new("python_pkg-0.1.0/pyproject.toml"), + expect![[r#" + [project] + name = "python-pkg" + version = "0.1.0" + + [project.readme] + file = "crates/README.md" + content-type = "text/markdown" + + [project.license] + file = "crates/LICENSE" + + [build-system] + requires = ["maturin>=1.0,<2.0"] + build-backend = "maturin" + + [tool.maturin] + bindings = "bin" + manifest-path = "crates/python-pkg/Cargo.toml" + "#]], + )), + "sdist-workspace-member-readme-in-parent", + )) +} + +#[test] +fn git_sdist_rejects_parent_relative_pyproject_metadata_paths() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_dir = temp_dir.path(); + fs_err::write(workspace_dir.join("README.md"), "# my-pkg\n").unwrap(); + let project_dir = + write_workspace_bin_project(workspace_dir, "python-pkg", "readme = \"../README.md\""); + + let err = match other::build_source_distribution( + &project_dir, + SdistGenerator::Git, + "git-sdist-rejects-parent-relative-pyproject-metadata-paths", + ) { + Ok(_) => panic!("expected git sdist generator to reject parent-relative readme"), + Err(err) => err, + }; + let err = format!("{err:#}"); + assert!( + err.contains("must not contain `..` when using the git sdist generator"), + "unexpected error: {err}" + ); +} + #[test] fn build_wheels_from_sdist_hello_world() { handle_result(other::test_build_wheels_from_sdist(