diff --git a/crates/socket-patch-core/src/utils/poetry_lock.rs b/crates/socket-patch-core/src/utils/poetry_lock.rs index 1d2b2b370..86a6b59fa 100644 --- a/crates/socket-patch-core/src/utils/poetry_lock.rs +++ b/crates/socket-patch-core/src/utils/poetry_lock.rs @@ -33,7 +33,7 @@ pub(crate) fn package_files(package: &Table) -> Vec<&dyn TableLike> { /// The lock-wide `[metadata.files]` entry for package `name` (lock /// 1.0/1.1; keys compared PEP 503-canonical). -pub(crate) fn metadata_files<'d>(lock: &'d DocumentMut, name: &str) -> Vec<&'d dyn TableLike> { +pub(crate) fn metadata_files<'d>(lock: &'d Table, name: &str) -> Vec<&'d dyn TableLike> { let canon = canonicalize_pypi_name(name); let entry = lock .get("metadata") @@ -52,6 +52,10 @@ pub(crate) fn metadata_files<'d>(lock: &'d DocumentMut, name: &str) -> Vec<&'d d /// Whether a `[metadata.files]` entry is laid out one file per line, which is /// how Poetry 1.0/1.1 write every NON-empty entry (tomlkit `multiline(True)`); /// an empty one is `[]`. +/// +/// Reads the array's layout, so `item` must come from a `DocumentMut`: a +/// read-only `toml_edit::Document` keeps layout as spans into its source +/// and this would answer `false` for every entry. pub(crate) fn is_multiline_array(item: &Item) -> bool { item.as_array().is_some_and(|array| { let has_break = |raw: Option<&str>| raw.is_some_and(|s| s.contains('\n')); @@ -135,13 +139,10 @@ fn multiline_files(files: Array) -> Item { /// bumps the minor additively — 2.0 → 2.1 kept every shape we rewrite, and the /// vendored loader already accepts newer minors with an advisory; the hosted /// path must not refuse what the vendored path accepts). -pub fn lock_version(lock: &DocumentMut) -> Result<&str, String> { - lock_version_of(lock) -} - -/// [`lock_version`] of a parsed lock's root table (a `DocumentMut` or a -/// spanned `Document`). -fn lock_version_of(lock: &Table) -> Result<&str, String> { +/// +/// Takes the root table of either a `DocumentMut` or a read-only spanned +/// `Document`. +pub fn lock_version(lock: &Table) -> Result<&str, String> { let metadata = lock .get("metadata") .filter(|item| item.is_table_like()) @@ -616,7 +617,7 @@ fn rewrite_poetry_lock_batch( &names, |index, lock| { if version.is_none() { - *version = lock_version_of(lock).ok().map(str::to_string); + *version = lock_version(lock).ok().map(str::to_string); } let dep = &deps[index]; let sha256 = checked_sha256( @@ -690,7 +691,7 @@ fn plan_poetry_rewrite( source_url: &str, sha256: &str, ) -> Result, String> { - let format = lock_version_of(lock)?.to_string(); + let format = lock_version(lock)?.to_string(); if format == "0" && source_type == "url" { return Err("Poetry 0.x ignores URL sources; hosted patches require Poetry >= 1.0".into()); } diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index d2f3ef080..6b41dab16 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -34,7 +34,7 @@ impl ArtifactSource<'_> { /// (uv 0.2.x) or `package` (uv ≥ 0.2.35 and PEP 723 script locks). The /// lock inventory, lockfile discovery and `lock_inventory:: /// wired_vendor_integrity` all pick the array with this one rule. -pub(crate) fn lock_package_collection(doc: &DocumentMut) -> (&'static str, bool) { +pub(crate) fn lock_package_collection(doc: &Table) -> (&'static str, bool) { if doc.contains_key("lock-version") { ("packages", true) } else if doc.contains_key("distribution") { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs index 2d612e248..95600fd18 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs @@ -6,7 +6,7 @@ use std::path::Path; use serde_json::Value; -use toml_edit::{DocumentMut, Item, TableLike}; +use toml_edit::{Item, Table, TableLike}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::formats::text::strip_bom; @@ -326,7 +326,8 @@ pub(super) fn portable_wheel_artifact( } pub(super) fn python_lock_inventory(text: &str) -> Option> { - let document: DocumentMut = text.parse().ok()?; + // Read-only parse, as in lockfile discovery (`toml_or_diag`, #1473). + let document = toml_edit::Document::parse(text).ok()?; let (collection, pep751) = lock_package_collection(&document); // Only the lock formats the fetch layer understands. let supported = if pep751 { @@ -395,7 +396,7 @@ pub(super) fn python_lock_inventory(text: &str) -> Option> { /// can be chosen there). A lock that is not TOML contributes nothing. async fn inventory_poetry_lock(view: &ProjectView<'_>) -> Option> { let text = view.read_text("poetry.lock").await.ok()?; - let document: DocumentMut = text.parse().ok()?; + let document = toml_edit::Document::parse(text.as_str()).ok()?; let pure_wheel_sha = |files: Vec<&dyn TableLike>| { files.into_iter().find_map(|entry| { let file = entry.get("file")?.as_str()?; @@ -429,7 +430,7 @@ async fn inventory_poetry_lock(view: &ProjectView<'_>) -> Option Vec<(String, String, String, &toml_edit::Table)> { +fn toml_package_coords(document: &Table) -> Vec<(String, String, String, &Table)> { let Some(packages) = document.get("package").and_then(Item::as_array_of_tables) else { return Vec::new(); }; @@ -588,7 +589,7 @@ async fn inventory_pipfile_lock(view: &ProjectView<'_>) -> Option) -> Option> { let text = view.read_text("pdm.lock").await.ok()?; - let document: DocumentMut = text.parse().ok()?; + let document = toml_edit::Document::parse(text.as_str()).ok()?; let out: Vec = toml_package_coords(&document) .into_iter() .map(|(name, version, purl, _)| LockfileEntry { diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index c9c5832b9..40e6ab008 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -312,7 +312,7 @@ async fn read_toml(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) -> Op } fn parse_toml(file: &str, text: &str, out: &mut Discovery) -> Option { - toml_or_diag(file, text, TomlDiag::TrimEnd, out) + toml_or_diag(file, text, TomlDiag::TrimEnd, out).map(toml_edit::Document::into_mut) } /// The config file cargo actually reads ([`effective_config_rel_in`]), parsed, diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ff3323d42..05731aa72 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2245,13 +2245,24 @@ pub(crate) enum TomlDiag { /// Parse `text` as TOML, recording [`DIAG_LOCKFILE_UNPARSEABLE`] for /// root-relative `file` (in `style`) on failure. -pub(crate) fn toml_or_diag( +/// +/// Discovery only reads, so this is toml_edit's read-only parse: the +/// document keeps byte spans into `text` instead of copying every key, +/// value and comment out of it the way a [`toml_edit::DocumentMut`] does. +/// On a large `poetry.lock` that copy, and freeing it, cost more than the +/// parse itself (#1473). Values read the same either way (`as_str` and +/// friends return the decoded value); only an item's layout (its raw +/// decor, as `poetry_lock::is_multiline_array` reads) needs the source. +/// The PyPI lock extractors read this document directly; the cargo and +/// pyproject/hatch readers still convert it with `into_mut`, as their +/// helpers take a `DocumentMut`. +pub(crate) fn toml_or_diag>( file: &str, - text: &str, + text: S, style: TomlDiag, out: &mut Discovery, -) -> Option { - match text.parse::() { +) -> Option> { + match toml_edit::Document::parse(text) { Ok(doc) => Some(doc), Err(e) => { let reason = match style { @@ -3158,6 +3169,63 @@ mod tests { use super::testing::*; use super::*; + /// The read-only parse [`toml_or_diag`] hands extractors reads every + /// value the way the `DocumentMut` it replaced did (#1473): escaped, + /// literal and multi-line strings decode, quoted and dotted keys + /// resolve, CRLF and a comment between entries change nothing. + #[test] + fn read_only_toml_reads_values_like_a_mutable_parse() { + let text = "# lock\r\n\ + [[package]]\r\n\ + name = \"a\\u0062c\" # escaped\r\n\ + version = '1.0'\r\n\ + \"quoted key\" = \"\"\"\r\nmulti\r\nline\"\"\"\r\n\ + source.url = 'https://h/x\\y.whl'\r\n\ + files = [\r\n {file = \"x.whl\", hash = \"sha256:ab\"}, # one\r\n]\r\n\ + \r\n\ + [metadata.files]\r\n\ + abc = [{file = \"x.whl\", hash = \"sha256:ab\"}]\r\n"; + let mutable: toml_edit::DocumentMut = text.parse().unwrap(); + let mut out = Discovery::default(); + let read_only = toml_or_diag("poetry.lock", text, TomlDiag::Raw, &mut out).unwrap(); + assert!(out.diagnostics.is_empty()); + let package = |t: &toml_edit::Table| { + t["package"] + .as_array_of_tables() + .unwrap() + .get(0) + .cloned() + .unwrap() + }; + let (a, b) = (package(&mutable), package(&read_only)); + for key in ["name", "version", "quoted key"] { + assert_eq!(a[key].as_str(), b[key].as_str(), "{key}"); + } + assert_eq!(b["name"].as_str(), Some("abc")); + assert_eq!(b["quoted key"].as_str(), Some("multi\r\nline")); + assert_eq!(b["source"]["url"].as_str(), Some("https://h/x\\y.whl")); + let hash = |p: &toml_edit::Table| { + p["files"] + .as_array() + .unwrap() + .get(0) + .unwrap() + .as_inline_table() + .unwrap()["hash"] + .as_str() + .map(str::to_string) + }; + assert_eq!(hash(&a), hash(&b)); + assert_eq!( + crate::utils::poetry_lock::metadata_files(&read_only, "ABC").len(), + crate::utils::poetry_lock::metadata_files(&mutable, "ABC").len(), + ); + assert_eq!( + crate::utils::poetry_lock::metadata_files(&read_only, "ABC").len(), + 1 + ); + } + /// A pyproject-only pypi project (hatch, or any flavor before its first /// lock) has no install-deciding file: `pyproject.toml` / `hatch.toml` /// are manifests, so even a pyproject that no longer names the vendored diff --git a/crates/socket-patch-core/src/vex/discover/pypi_locks.rs b/crates/socket-patch-core/src/vex/discover/pypi_locks.rs index 3c64e4017..5bcaf5867 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_locks.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_locks.rs @@ -297,7 +297,7 @@ fn str_of<'t>(table: &'t dyn TableLike, key: &str) -> Option<&'t str> { fn package_tables<'d>( out: &mut Discovery, file: &str, - doc: &'d DocumentMut, + doc: &'d Table, collection: &str, ) -> Option<&'d toml_edit::ArrayOfTables> { let item = doc.get(collection)?; @@ -874,7 +874,7 @@ async fn extract_poetry(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { /// The pin Poetry verifies `filename` against (see the module docs). fn poetry_pin( - doc: &DocumentMut, + doc: &Table, package: &Table, name: &str, filename: &str, diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index b8ecbc066..e9dee4f2f 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -343,7 +343,7 @@ async fn extract_hatch(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { async fn read_toml(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) -> Option { let text = ctx.read_text(file, out).await?; // The TOML lexer skips one leading BOM itself (`formats::text`'s rule). - toml_or_diag(file, &text, TomlDiag::BomFlattened, out) + toml_or_diag(file, &text, TomlDiag::BomFlattened, out).map(toml_edit::Document::into_mut) } /// One PEP 508 dependency string. `root_uri_expanded`: whether Hatch