Skip to content
Merged
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
332 changes: 242 additions & 90 deletions crates/socket-patch-core/src/vex/product.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
use std::path::Path;

// npm/Node strip a BOM from package.json, cargo accepts one in Cargo.toml, and
// git reads a BOM'd `.git/config`, but serde_json and the line scanners all
// git reads a BOM'd `.git/config`, but serde_json and the git config scanner
// reject it — without this, files the user's own toolchain accepts yield no
// PURL.
use crate::formats::text::strip_bom;
Expand Down Expand Up @@ -157,14 +157,71 @@ fn parse_package_json(content: &str) -> Option<String> {
fn parse_pyproject(content: &str) -> Option<String> {
// No BOM strip here, unlike npm/cargo: tomllib (and pip's vendored
// tomli) reject a BOM'd pyproject.toml outright, so such a file is
// not a buildable Python project and must keep yielding None.
// PEP 621 `[project]` takes precedence (newer projects favor it),
// then fall back to Poetry's `[tool.poetry]` for legacy layouts.
let (name, version) = scan_toml_section(content, "project")
.or_else(|| scan_toml_section(content, "tool.poetry"))?;
// not a buildable Python project and must keep yielding None. toml_edit
// accepts a BOM, so refuse one before parsing.
if content.starts_with(crate::formats::text::BOM) {
return None;
}
let doc = content.parse::<toml_edit::DocumentMut>().ok()?;
let (name, version) = pyproject_name_version(&doc)?;
Some(format!("pkg:pypi/{name}@{version}"))
}

/// The project's name and version, read the way its build backend reads
/// them:
///
/// 1. PEP 621 `[project]` `name` + `version` (newer projects favor it).
/// 2. When Poetry builds the project, poetry-core's own merge: the name is
/// `[project].name` or else `[tool.poetry].name`, and the version is
/// `[project].version` or else `[tool.poetry].version`. This covers
/// Poetry 2's dynamic version and the legacy `[tool.poetry]` layout.
/// 3. PDM 0.x's legacy `[tool.pdm]` table (PDM 1.x migrates it to
/// `[project]`).
fn pyproject_name_version(doc: &toml_edit::DocumentMut) -> Option<(String, String)> {
let project = toml_table(doc, "project");
let project_str = |key| project.and_then(|t| table_str(t, key));
let (project_name, project_version) = (project_str("name"), project_str("version"));
if let (Some(name), Some(version)) = (project_name, project_version) {
return Some((name.to_string(), version.to_string()));
}
if poetry_version_is_static(doc) {
let poetry_name = toml_str(doc, "tool.poetry", "name");
// Poetry 2 builds `[project].name`, Poetry 1.x ignores `[project]`
// and builds `[tool.poetry].name`: when they differ, the product
// depends on the Poetry line, so name none rather than guess.
if let (Some(a), Some(b)) = (project_name, poetry_name) {
if a != b {
return None;
}
}
let name = project_name.or(poetry_name);
let version = project_version.or_else(|| toml_str(doc, "tool.poetry", "version"));
if let (Some(name), Some(version)) = (name, version) {
return Some((name.to_string(), version.to_string()));
}
}
toml_name_version(doc, "tool.pdm")
}

/// Whether Poetry builds the project from its static `[tool.poetry]`
/// metadata: no non-Poetry `[build-system].build-backend` (Poetry's own
/// projects may omit the table), and poetry-dynamic-versioning not
/// enabled. Under another backend (hatch-vcs, setuptools-scm) the table is
/// a leftover, and under the enabled plugin its version is a placeholder
/// such as `0.0.0`. The plugin's backend re-exports poetry-core's and
/// changes nothing until `enable = true`.
fn poetry_version_is_static(doc: &toml_edit::DocumentMut) -> bool {
// `poetry.core.masonry.api`, or `poetry.masonry.api` before poetry-core.
let poetry_backend = toml_str(doc, "build-system", "build-backend").is_none_or(|backend| {
backend.starts_with("poetry.") || backend == "poetry_dynamic_versioning.backend"
});
let dynamic_versioning = toml_table(doc, "tool.poetry-dynamic-versioning")
.and_then(|t| t.get("enable"))
.and_then(|e| e.as_bool())
.unwrap_or(false);
poetry_backend && !dynamic_versioning
}

fn parse_cargo_toml(content: &str) -> Option<String> {
let (name, version) = crate::formats::cargo::manifest::package_name_version(content)?;
Some(format!("pkg:cargo/{name}@{version}"))
Expand Down Expand Up @@ -355,57 +412,36 @@ async fn single_root_file_with_extension(cwd: &Path, ext: &str) -> Option<String
(found.len() == 1).then(|| found.remove(0))
}

/// Minimal line-based TOML scanner for `[<section>]` blocks. Reads
/// `name = "..."` and `version = "..."` from the named section and
/// stops at the next `[` header. Robust enough for the well-formed
/// `pyproject.toml` files we expect at the top level — no full TOML
/// parser dependency. (`Cargo.toml` reads through
/// [`crate::formats::cargo::manifest`].)
///
/// Returns `None` if either key is missing, both keys appear outside
/// the section, the value is empty, or the value is not a quoted
/// string (`version.workspace = true`).
fn scan_toml_section(content: &str, section: &str) -> Option<(String, String)> {
let mut in_section = false;
let mut name: Option<String> = None;
let mut version: Option<String> = None;
/// The `name` and `version` of the dotted `section` table (`project`,
/// `tool.poetry`), both non-empty strings. A non-string value
/// (`version.workspace = true`, `version = 42`) yields None.
fn toml_name_version(doc: &toml_edit::DocumentMut, section: &str) -> Option<(String, String)> {
let name = toml_str(doc, section, "name")?;
let version = toml_str(doc, section, "version")?;
Some((name.to_string(), version.to_string()))
}

for raw in content.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix('[') {
// A header may carry a trailing comment (`[package] # x`)
// and whitespace inside the brackets (`[ package ]`) —
// both valid TOML that cargo and tomllib accept. Anything
// else after the closing bracket means a different (or
// malformed) section.
in_section = match rest.split_once(']') {
Some((inner, after)) => {
let after = after.trim_start();
(after.is_empty() || after.starts_with('#')) && inner.trim() == section
}
None => false,
};
continue;
}
if !in_section {
continue;
}
if let Some(v) = parse_toml_string_kv(line, "name") {
name = Some(v);
} else if let Some(v) = parse_toml_string_kv(line, "version") {
version = Some(v);
}
}
/// The non-empty string at `key` in the dotted `section` table.
fn toml_str<'a>(doc: &'a toml_edit::DocumentMut, section: &str, key: &str) -> Option<&'a str> {
table_str(toml_table(doc, section)?, key)
}

let name = name?;
let version = version?;
if name.is_empty() || version.is_empty() {
return None;
}
Some((name, version))
/// The non-empty string at `key` in `table`.
fn table_str<'a>(table: &'a dyn toml_edit::TableLike, key: &str) -> Option<&'a str> {
table.get(key)?.as_str().filter(|v| !v.is_empty())
}

/// The dotted `section` table (`tool.poetry`), whether it is written as a
/// `[header]`, an inline table or dotted keys.
fn toml_table<'a>(
doc: &'a toml_edit::DocumentMut,
section: &str,
) -> Option<&'a dyn toml_edit::TableLike> {
let mut table: &dyn toml_edit::TableLike = doc.as_table();
for part in section.split('.') {
table = table.get(part)?.as_table_like()?;
}
Some(table)
}

/// The `[remote "origin"] url = ...` of the git checkout enclosing
Expand Down Expand Up @@ -677,40 +713,21 @@ fn split_remote_host_path(url: &str) -> Option<(&str, &str)> {
None
}

/// Parse `<key> = "<value>"` or `<key> = '<value>'`. Returns `None` if
/// the key doesn't match, the value isn't a quoted string literal, or
/// the value is empty. TOML permits BOTH double-quoted basic strings
/// and single-quoted literal strings, so we accept either delimiter and
/// terminate at the matching closing quote. Inline-table forms like
/// `version = { workspace = true }` and bare values like `version = 42`
/// fail this check and are skipped by the caller.
fn parse_toml_string_kv(line: &str, key: &str) -> Option<String> {
let eq = line.find('=')?;
let (lhs, rhs) = line.split_at(eq);
if lhs.trim() != key {
return None;
}
// Drop the leading '=' and surrounding whitespace. The value must
// open with a string delimiter; match it to its twin. `'` is a
// literal string (no escapes), `"` a basic string — for our purposes
// (names/versions, which never contain escaped quotes) the first
// matching delimiter terminates the value in both cases.
let rhs = rhs[1..].trim();
let quote = rhs.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let stripped = &rhs[quote.len_utf8()..];
let end = stripped.find(quote)?;
let value = &stripped[..end];
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

/// `section`'s name and version, through the reader detection uses.
fn scan_toml_section(content: &str, section: &str) -> Option<(String, String)> {
toml_name_version(&content.parse().ok()?, section)
}

/// A single `key = value` line, read as an entry of a table.
fn parse_toml_string_kv(line: &str, key: &str) -> Option<String> {
let doc = format!("[t]\n{line}\n").parse().ok()?;
toml_str(&doc, "t", key).map(str::to_string)
}

#[tokio::test]
async fn detect_package_json() {
let dir = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1245,6 +1262,143 @@ mod tests {
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/pep621-app@1.0.0"));
}

// ── Regression #1527: Poetry 2 dynamic version ────────────────
// Poetry 2 documents `[project].name` with `dynamic = ["version"]`
// and the version in `[tool.poetry].version`; `poetry version`
// reports `demo 0.3.0` for it. Neither table holds both keys.

#[tokio::test]
async fn detect_pyproject_poetry2_dynamic_version() {
let dir = tempfile::tempdir().unwrap();
let content = "[project]\nname = \"demo\"\ndynamic = [\"version\"]\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n\n[tool.poetry]\nversion = \"0.3.0\"\n\n[build-system]\nrequires = [\"poetry-core>=2.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n";
tokio::fs::write(dir.path().join("pyproject.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/demo@0.3.0"));
}

/// The `dynamic` array may span lines (taplo and other formatters
/// expand it) and list other fields too.
#[test]
fn pyproject_poetry2_multiline_dynamic_version() {
let content = "[project]\nname = \"demo\"\ndynamic = [\n \"readme\",\n \"version\", # from tool.poetry\n]\n\n[tool.poetry]\nversion = \"0.3.0\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/demo@0.3.0")
);
}

/// A static `[project].version` wins over `[tool.poetry].version`:
/// Poetry reads the latter only when `version` is dynamic.
#[test]
fn pyproject_static_version_wins_over_tool_poetry_version() {
let content =
"[project]\nname = \"demo\"\nversion = \"1.0.0\"\n\n[tool.poetry]\nversion = \"0.3.0\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/demo@1.0.0")
);
}

/// poetry-core takes `[tool.poetry].version` when `[project]` has
/// none, whether or not `dynamic` lists it (poetry-core 2.2
/// `Factory.create_poetry`): this builds `demo 0.3.0`.
#[test]
fn pyproject_tool_poetry_version_without_dynamic() {
let content = "[project]\nname = \"demo\"\n\n[tool.poetry]\nversion = \"0.3.0\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/demo@0.3.0")
);
}

/// `[project].name` and a different `[tool.poetry].name`: Poetry 2
/// builds `new`, Poetry 1.x builds `old`, so no product is named
/// rather than a guess.
#[test]
fn pyproject_conflicting_poetry_names_is_none() {
let content =
"[project]\nname = \"new\"\n\n[tool.poetry]\nname = \"old\"\nversion = \"1.0\"\n";
assert!(parse_pyproject(content).is_none());
let same =
"[project]\nname = \"same\"\n\n[tool.poetry]\nname = \"same\"\nversion = \"1.0\"\n";
assert_eq!(parse_pyproject(same).as_deref(), Some("pkg:pypi/same@1.0"));
}

/// The plugin's backend re-exports poetry-core's and patches nothing
/// until `enable = true`, so the static version is what it builds.
#[test]
fn pyproject_dynamic_versioning_backend_disabled_keeps_static_version() {
let content = "[tool.poetry]\nname = \"demo\"\nversion = \"1.2.3\"\n\n[build-system]\nrequires = [\"poetry-core\", \"poetry-dynamic-versioning\"]\nbuild-backend = \"poetry_dynamic_versioning.backend\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/demo@1.2.3")
);
}

/// A dynamic version with no `[tool.poetry].version` (setuptools-scm,
/// hatch-vcs, PDM's scm source) has no static version to report.
#[test]
fn pyproject_dynamic_version_without_tool_poetry_is_none() {
let content =
"[project]\nname = \"demo\"\ndynamic = [\"version\"]\n\n[tool.setuptools_scm]\n";
assert!(parse_pyproject(content).is_none());
}

/// Under poetry-dynamic-versioning `[tool.poetry].version` is a
/// placeholder the plugin replaces at build time, so there is no
/// static version to report, in the Poetry 2 or the legacy layout.
#[test]
fn pyproject_poetry_dynamic_versioning_placeholder_is_none() {
let poetry2 = "[project]\nname = \"demo\"\ndynamic = [\"version\"]\n\n[tool.poetry]\nversion = \"0.0.0\"\n\n[tool.poetry-dynamic-versioning]\nenable = true\n\n[build-system]\nrequires = [\"poetry-core>=2.0.0\", \"poetry-dynamic-versioning>=1.0.0,<2.0.0\"]\nbuild-backend = \"poetry_dynamic_versioning.backend\"\n";
assert!(parse_pyproject(poetry2).is_none());
let legacy = "[tool.poetry]\nname = \"demo\"\nversion = \"0.0.0\"\n\n[tool.poetry-dynamic-versioning]\nenable = true\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n";
assert!(parse_pyproject(legacy).is_none());
}

/// A `[tool.poetry].version` left over after moving to another
/// backend is not what that backend builds (hatch-vcs reads git tags).
#[test]
fn pyproject_tool_poetry_version_ignored_under_other_backend() {
let content = "[project]\nname = \"app\"\ndynamic = [\"version\"]\n\n[tool.hatch.version]\nsource = \"vcs\"\n\n[tool.poetry]\nversion = \"0.1.0\"\n\n[build-system]\nrequires = [\"hatchling\", \"hatch-vcs\"]\nbuild-backend = \"hatchling.build\"\n";
assert!(parse_pyproject(content).is_none());
}

/// Pre-poetry-core projects name `poetry.masonry.api` as the backend.
#[test]
fn pyproject_tool_poetry_under_pre_core_backend() {
let content = "[tool.poetry]\nname = \"old-app\"\nversion = \"0.2.0\"\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/old-app@0.2.0")
);
}

// ── Regression #642: PDM 0.x legacy `[tool.pdm]` metadata ─────

#[tokio::test]
async fn detect_pyproject_pdm_legacy_table() {
let dir = tempfile::tempdir().unwrap();
let content = "[tool.pdm]\nname = \"x\"\nversion = \"0.0.0\"\npython_requires = \">=3.8\"\n\n[tool.pdm.dependencies]\nurllib3 = \"==1.26.18\"\n\n[tool.pdm.dev-dependencies]\n\n[build-system]\nrequires = [\"pdm-pep517\"]\nbuild-backend = \"pdm.pep517.api\"\n";
tokio::fs::write(dir.path().join("pyproject.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/x@0.0.0"));
}

/// PDM 1.x migrates `[tool.pdm]` to `[project]`; a file carrying
/// both reports the PEP 621 identity.
#[test]
fn pyproject_project_wins_over_tool_pdm() {
let content = "[project]\nname = \"new\"\nversion = \"1.0.0\"\n\n[tool.pdm]\nname = \"old\"\nversion = \"0.1.0\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/new@1.0.0")
);
}

/// Multi-manifest combo: pyproject + Cargo.toml present, no
/// package.json. pyproject wins per the priority list.
#[tokio::test]
Expand Down Expand Up @@ -1372,8 +1526,7 @@ mod tests {
);
}

/// `Cargo.toml` with `name` only (no `version`) → None. Exercises
/// the `version?` early-return path inside `scan_toml_section`.
/// `Cargo.toml` with `name` only (no `version`) → None.
#[tokio::test]
async fn cargo_toml_missing_version_returns_none() {
let dir = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -2325,9 +2478,8 @@ mod tests {
}

// ── Coverage: unclosed TOML table headers ─────────────────────
// A `[`-line with no closing `]` hits the `None => false` arm of
// the header match: it never OPENS a section, and — unlike the
// git scanner's fall-through — it safely CLOSES the current one.
// A `[`-line with no closing `]` is invalid TOML, so the document
// yields no name/version at all — never a misattributed one.

/// `[package` (unclosed) must not open the section, so the
/// name/version lines below it are never attributed.
Expand Down
Loading