Skip to content
Merged
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
234 changes: 11 additions & 223 deletions crates/socket-patch-core/src/crawlers/cargo_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,116 +3,13 @@ use std::path::{Path, PathBuf};

use super::listing::list_dir_sync;
use super::types::{CrawledPackage, CrawlerOptions};
use crate::formats::cargo::manifest::package_name_version;
use crate::patch::path_safety;
use crate::utils::fs::{is_dir, run_blocking};

#[cfg(test)]
mod oracle;

// ---------------------------------------------------------------------------
// Cargo.toml minimal parser
// ---------------------------------------------------------------------------

/// Parse `name` and `version` from a `Cargo.toml` `[package]` section.
///
/// Uses a simple line-based parser — no TOML crate dependency.
/// Handles `name = "..."` and `version = "..."` within the `[package]` table.
/// Returns `None` if `version.workspace = true` or fields are missing.
pub fn parse_cargo_toml_name_version(content: &str) -> Option<(String, String)> {
let mut in_package = false;
let mut name: Option<String> = None;
let mut version: Option<String> = None;

for line in content.lines() {
let trimmed = line.trim();

// Skip comments and empty lines
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}

// Track table headers. Use `parse_table_header` rather than an
// exact `== "[package]"` comparison so a header carrying a
// trailing inline comment (`[package] # ...`) or whitespace
// inside the brackets (`[ package ]`) is still recognized —
// both are valid TOML and a too-strict match would silently
// drop the package's name/version.
if trimmed.starts_with('[') {
if let Some(table) = parse_table_header(trimmed) {
if table == "package" {
in_package = true;
} else if in_package {
// We left the [package] section (a sibling table or
// a `[package.*]` subtable — bare keys can no longer
// follow per TOML, so stop scanning).
break;
}
}
continue;
}

if !in_package {
continue;
}

if let Some(val) = extract_string_value(trimmed, "name") {
name = Some(val);
} else if let Some(val) = extract_string_value(trimmed, "version") {
version = Some(val);
} else if trimmed.starts_with("version") && trimmed.contains("workspace") {
// version.workspace = true — cannot determine version from this file
return None;
}

if name.is_some() && version.is_some() {
break;
}
}

match (name, version) {
(Some(n), Some(v)) if !n.is_empty() && !v.is_empty() => Some((n, v)),
_ => None,
}
}

/// Extract the table name from a TOML header line.
///
/// `[package]` -> `Some("package")`, `[package] # comment` ->
/// `Some("package")`, `[ package ]` -> `Some("package")`. Returns
/// `None` for a line that is not a `[...]` header. Anything after the
/// closing `]` (typically an inline comment) is ignored.
fn parse_table_header(line: &str) -> Option<&str> {
let rest = line.strip_prefix('[')?;
let end = rest.find(']')?;
Some(rest[..end].trim())
}

/// Extract a quoted string value from a `key = "value"` line.
///
/// Handles both TOML string flavors that Cargo accepts for `name` /
/// `version`: basic strings (`"..."`) and literal strings (`'...'`).
/// A too-strict double-quote-only match would silently drop a crate
/// whose manifest uses single quotes — and in the vendor layout, where
/// the directory name carries no version, that crate would become
/// undiscoverable (and thus unpatchable).
fn extract_string_value(line: &str, key: &str) -> Option<String> {
let rest = line.strip_prefix(key)?;
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?;
let rest = rest.trim_start();
// The value must open with a quote of one kind; the matching close
// is the next quote of the *same* kind (literal strings have no
// escapes, and basic strings used for name/version never contain an
// escaped quote in practice).
let quote = match rest.chars().next()? {
c @ ('"' | '\'') => c,
_ => return None,
};
let rest = &rest[1..];
let end = rest.find(quote)?;
Some(rest[..end].to_string())
}

// ---------------------------------------------------------------------------
// CargoCrawler
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -293,7 +190,7 @@ impl CargoCrawler {
Err(_) => return false,
};

match parse_cargo_toml_name_version(&content) {
match package_name_version(&content) {
Some((n, v)) => n == name && v == version,
// Fallback: check directory name
None => path
Expand Down Expand Up @@ -403,7 +300,7 @@ fn read_crate_cargo_toml(crate_path: &Path, dir_name: &str) -> Option<(String, S
let content = crate::utils::fs::read_regular_to_string_sync(&cargo_toml_path).ok()?;

// Fallback: parse directory name as <name>-<version>
parse_cargo_toml_name_version(&content)
package_name_version(&content)
.or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
}

Expand Down Expand Up @@ -431,96 +328,6 @@ impl Default for CargoCrawler {
mod tests {
use super::*;

#[test]
fn test_parse_cargo_toml_basic() {
let content = r#"
[package]
name = "serde"
version = "1.0.200"
edition = "2021"
"#;
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
assert_eq!(name, "serde");
assert_eq!(version, "1.0.200");
}

#[test]
fn test_parse_cargo_toml_with_comments() {
let content = r#"
# This is a comment
[package]
name = "tokio" # inline comment ignored since we stop at first "
version = "1.38.0"
"#;
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
assert_eq!(name, "tokio");
assert_eq!(version, "1.38.0");
}

#[test]
fn test_parse_cargo_toml_workspace_version() {
let content = r#"
[package]
name = "my-crate"
version.workspace = true
"#;
assert!(parse_cargo_toml_name_version(content).is_none());
}

/// The inline-table spelling `version = { workspace = true }` is the
/// equally-valid sibling of the tested `version.workspace = true`.
/// `extract_string_value` must bail on the `{` (not a quote), after
/// which the `version`+`workspace` short-circuit returns `None` for
/// the whole manifest — the version cannot be determined here.
#[test]
fn test_parse_cargo_toml_inline_table_workspace_version() {
let content = "[package]\nname = \"my-crate\"\nversion = { workspace = true }\n";
assert!(parse_cargo_toml_name_version(content).is_none());
}

/// A malformed bracket line (`[oops` — no closing `]`) inside
/// `[package]` is not a table header: it must be skipped without
/// terminating the section, so keys after it are still read.
#[test]
fn test_parse_cargo_toml_malformed_header_line_skipped() {
let content = "[package]\nname = \"a\"\n[oops\nversion = \"1.0\"\n";
assert_eq!(
parse_cargo_toml_name_version(content),
Some(("a".to_string(), "1.0".to_string()))
);
}

#[test]
fn test_parse_cargo_toml_missing_fields() {
let content = r#"
[package]
name = "incomplete"
"#;
assert!(parse_cargo_toml_name_version(content).is_none());
}

#[test]
fn test_parse_cargo_toml_no_package_section() {
let content = r#"
[dependencies]
serde = "1.0"
"#;
assert!(parse_cargo_toml_name_version(content).is_none());
}

#[test]
fn test_parse_cargo_toml_stops_at_next_section() {
let content = r#"
[package]
name = "foo"

[dependencies]
version = "fake"
"#;
// Should not find version since it's under [dependencies]
assert!(parse_cargo_toml_name_version(content).is_none());
}

#[test]
fn test_parse_dir_name_version() {
assert_eq!(
Expand Down Expand Up @@ -795,25 +602,6 @@ version = "fake"

// --- regression: table-header parsing tolerance --------------------

#[test]
fn test_parse_table_header_variants() {
assert_eq!(parse_table_header("[package]"), Some("package"));
assert_eq!(
parse_table_header("[package] # main crate"),
Some("package")
);
assert_eq!(parse_table_header("[ package ]"), Some("package"));
assert_eq!(
parse_table_header("[package.metadata]"),
Some("package.metadata")
);
// Not a header line.
assert_eq!(parse_table_header("name = \"x\""), None);
// Array value lines don't start with '[' once trimmed by the caller,
// but a bare unterminated bracket is rejected.
assert_eq!(parse_table_header("[oops"), None);
}

/// A `[package]` header with a trailing inline comment is valid TOML.
/// The parser must still recognize it and read name/version — a
/// too-strict `== "[package]"` would drop the crate, and in the
Expand All @@ -826,15 +614,15 @@ version = "fake"
name = "serde"
version = "1.0.200"
"#;
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
let (name, version) = package_name_version(content).unwrap();
assert_eq!(name, "serde");
assert_eq!(version, "1.0.200");
}

#[test]
fn test_parse_cargo_toml_header_with_inner_spaces() {
let content = "[ package ]\nname = \"tokio\"\nversion = \"1.38.0\"\n";
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
let (name, version) = package_name_version(content).unwrap();
assert_eq!(name, "tokio");
assert_eq!(version, "1.38.0");
}
Expand All @@ -850,18 +638,18 @@ name = "foo"
version = "fake"
"#;
// `version` lives under the metadata subtable, not [package].
assert!(parse_cargo_toml_name_version(content).is_none());
assert!(package_name_version(content).is_none());
}

// --- regression: single-quoted (literal) string values -------------

/// TOML literal strings use single quotes and are valid in a
/// `Cargo.toml`. The minimal parser must read `name`/`version` from
/// `Cargo.toml`. The reader must read `name`/`version` from
/// them just as it does from basic (double-quoted) strings.
#[test]
fn test_parse_cargo_toml_single_quoted_values() {
let content = "[package]\nname = 'serde'\nversion = '1.0.200'\n";
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
let (name, version) = package_name_version(content).unwrap();
assert_eq!(name, "serde");
assert_eq!(version, "1.0.200");
}
Expand All @@ -870,7 +658,7 @@ version = "fake"
#[test]
fn test_parse_cargo_toml_mixed_quote_values() {
let content = "[package]\nname = 'tokio'\nversion = \"1.38.0\"\n";
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
let (name, version) = package_name_version(content).unwrap();
assert_eq!(name, "tokio");
assert_eq!(version, "1.38.0");
}
Expand All @@ -881,7 +669,7 @@ version = "fake"
#[test]
fn test_parse_cargo_toml_single_quoted_with_comment() {
let content = "[package]\nname = 'serde' # the lib\nversion = '1.0.200'\n";
let (name, version) = parse_cargo_toml_name_version(content).unwrap();
let (name, version) = package_name_version(content).unwrap();
assert_eq!(name, "serde");
assert_eq!(version, "1.0.200");
}
Expand All @@ -891,7 +679,7 @@ version = "fake"
#[test]
fn test_parse_cargo_toml_workspace_still_none_after_quote_fix() {
let content = "[package]\nname = 'my-crate'\nversion.workspace = true\n";
assert!(parse_cargo_toml_name_version(content).is_none());
assert!(package_name_version(content).is_none());
}

/// End-to-end: a vendored crate whose `Cargo.toml` uses single-quoted
Expand Down
4 changes: 2 additions & 2 deletions crates/socket-patch-core/src/crawlers/cargo_crawler/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use std::collections::HashSet;
use std::path::Path;

use super::{parse_cargo_toml_name_version, CargoCrawler};
use super::{package_name_version, CargoCrawler};
use crate::crawlers::types::{CrawledPackage, CrawlerOptions};

pub(super) struct LegacyCargoCrawler;
Expand Down Expand Up @@ -60,7 +60,7 @@ impl LegacyCargoCrawler {
let cargo_toml_path = crate_path.join("Cargo.toml");
let content = tokio::fs::read_to_string(&cargo_toml_path).await.ok()?;

let (name, version) = parse_cargo_toml_name_version(&content)
let (name, version) = package_name_version(&content)
.or_else(|| CargoCrawler::parse_dir_name_version(dir_name))?;

let purl = crate::utils::purl::build_cargo_purl(&name, &version);
Expand Down
Loading
Loading