Skip to main content

socket_patch_core/utils/
purl.rs

1/// Strip query string qualifiers from a PURL.
2///
3/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `"pkg:pypi/requests@2.28.0"`
4pub fn strip_purl_qualifiers(purl: &str) -> &str {
5    match purl.find('?') {
6        Some(idx) => &purl[..idx],
7        None => purl,
8    }
9}
10
11/// Check if a PURL is a PyPI package.
12pub fn is_pypi_purl(purl: &str) -> bool {
13    purl.starts_with("pkg:pypi/")
14}
15
16/// Check if a PURL is an npm package.
17pub fn is_npm_purl(purl: &str) -> bool {
18    purl.starts_with("pkg:npm/")
19}
20
21/// Parse a PyPI PURL to extract name and version.
22///
23/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `Some(("requests", "2.28.0"))`
24pub fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> {
25    let base = strip_purl_qualifiers(purl);
26    let rest = base.strip_prefix("pkg:pypi/")?;
27    let at_idx = rest.rfind('@')?;
28    let name = &rest[..at_idx];
29    let version = &rest[at_idx + 1..];
30    if name.is_empty() || version.is_empty() {
31        return None;
32    }
33    Some((name, version))
34}
35
36/// Parse an npm PURL to extract namespace, name, and version.
37///
38/// e.g., `"pkg:npm/@types/node@20.0.0"` -> `Some((Some("@types"), "node", "20.0.0"))`
39/// e.g., `"pkg:npm/lodash@4.17.21"` -> `Some((None, "lodash", "4.17.21"))`
40pub fn parse_npm_purl(purl: &str) -> Option<(Option<&str>, &str, &str)> {
41    let base = strip_purl_qualifiers(purl);
42    let rest = base.strip_prefix("pkg:npm/")?;
43
44    // Find the last @ that separates name from version
45    let at_idx = rest.rfind('@')?;
46    let name_part = &rest[..at_idx];
47    let version = &rest[at_idx + 1..];
48
49    if name_part.is_empty() || version.is_empty() {
50        return None;
51    }
52
53    // Check for scoped package (@scope/name)
54    if name_part.starts_with('@') {
55        let slash_idx = name_part.find('/')?;
56        let namespace = &name_part[..slash_idx];
57        let name = &name_part[slash_idx + 1..];
58        if name.is_empty() {
59            return None;
60        }
61        Some((Some(namespace), name, version))
62    } else {
63        Some((None, name_part, version))
64    }
65}
66
67/// Check if a PURL is a Cargo/Rust crate.
68#[cfg(feature = "cargo")]
69pub fn is_cargo_purl(purl: &str) -> bool {
70    purl.starts_with("pkg:cargo/")
71}
72
73/// Parse a Cargo PURL to extract name and version.
74///
75/// e.g., `"pkg:cargo/serde@1.0.200"` -> `Some(("serde", "1.0.200"))`
76#[cfg(feature = "cargo")]
77pub fn parse_cargo_purl(purl: &str) -> Option<(&str, &str)> {
78    let base = strip_purl_qualifiers(purl);
79    let rest = base.strip_prefix("pkg:cargo/")?;
80    let at_idx = rest.rfind('@')?;
81    let name = &rest[..at_idx];
82    let version = &rest[at_idx + 1..];
83    if name.is_empty() || version.is_empty() {
84        return None;
85    }
86    Some((name, version))
87}
88
89/// Build a Cargo PURL from components.
90#[cfg(feature = "cargo")]
91pub fn build_cargo_purl(name: &str, version: &str) -> String {
92    format!("pkg:cargo/{name}@{version}")
93}
94
95/// Parse a PURL into ecosystem, package directory path, and version.
96/// Supports npm, pypi, and (with `cargo` feature) cargo PURLs.
97pub fn parse_purl(purl: &str) -> Option<(&str, String, &str)> {
98    let base = strip_purl_qualifiers(purl);
99    if let Some(rest) = base.strip_prefix("pkg:npm/") {
100        let at_idx = rest.rfind('@')?;
101        let pkg_dir = &rest[..at_idx];
102        let version = &rest[at_idx + 1..];
103        if pkg_dir.is_empty() || version.is_empty() {
104            return None;
105        }
106        Some(("npm", pkg_dir.to_string(), version))
107    } else if let Some(rest) = base.strip_prefix("pkg:pypi/") {
108        let at_idx = rest.rfind('@')?;
109        let name = &rest[..at_idx];
110        let version = &rest[at_idx + 1..];
111        if name.is_empty() || version.is_empty() {
112            return None;
113        }
114        Some(("pypi", name.to_string(), version))
115    } else {
116        #[cfg(feature = "cargo")]
117        if let Some(rest) = base.strip_prefix("pkg:cargo/") {
118            let at_idx = rest.rfind('@')?;
119            let name = &rest[..at_idx];
120            let version = &rest[at_idx + 1..];
121            if name.is_empty() || version.is_empty() {
122                return None;
123            }
124            return Some(("cargo", name.to_string(), version));
125        }
126        None
127    }
128}
129
130/// Check if a string looks like a PURL.
131pub fn is_purl(s: &str) -> bool {
132    s.starts_with("pkg:")
133}
134
135/// Build an npm PURL from components.
136pub fn build_npm_purl(namespace: Option<&str>, name: &str, version: &str) -> String {
137    match namespace {
138        Some(ns) => format!("pkg:npm/{}/{name}@{version}", ns),
139        None => format!("pkg:npm/{name}@{version}"),
140    }
141}
142
143/// Build a PyPI PURL from components.
144pub fn build_pypi_purl(name: &str, version: &str) -> String {
145    format!("pkg:pypi/{name}@{version}")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_strip_qualifiers() {
154        assert_eq!(
155            strip_purl_qualifiers("pkg:pypi/requests@2.28.0?artifact_id=abc"),
156            "pkg:pypi/requests@2.28.0"
157        );
158        assert_eq!(
159            strip_purl_qualifiers("pkg:npm/lodash@4.17.21"),
160            "pkg:npm/lodash@4.17.21"
161        );
162    }
163
164    #[test]
165    fn test_is_pypi_purl() {
166        assert!(is_pypi_purl("pkg:pypi/requests@2.28.0"));
167        assert!(!is_pypi_purl("pkg:npm/lodash@4.17.21"));
168    }
169
170    #[test]
171    fn test_is_npm_purl() {
172        assert!(is_npm_purl("pkg:npm/lodash@4.17.21"));
173        assert!(!is_npm_purl("pkg:pypi/requests@2.28.0"));
174    }
175
176    #[test]
177    fn test_parse_pypi_purl() {
178        assert_eq!(
179            parse_pypi_purl("pkg:pypi/requests@2.28.0"),
180            Some(("requests", "2.28.0"))
181        );
182        assert_eq!(
183            parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc"),
184            Some(("requests", "2.28.0"))
185        );
186        assert_eq!(parse_pypi_purl("pkg:npm/lodash@4.17.21"), None);
187        assert_eq!(parse_pypi_purl("pkg:pypi/@2.28.0"), None);
188        assert_eq!(parse_pypi_purl("pkg:pypi/requests@"), None);
189    }
190
191    #[test]
192    fn test_parse_npm_purl() {
193        assert_eq!(
194            parse_npm_purl("pkg:npm/lodash@4.17.21"),
195            Some((None, "lodash", "4.17.21"))
196        );
197        assert_eq!(
198            parse_npm_purl("pkg:npm/@types/node@20.0.0"),
199            Some((Some("@types"), "node", "20.0.0"))
200        );
201        assert_eq!(parse_npm_purl("pkg:pypi/requests@2.28.0"), None);
202    }
203
204    #[test]
205    fn test_parse_purl() {
206        let (eco, dir, ver) = parse_purl("pkg:npm/lodash@4.17.21").unwrap();
207        assert_eq!(eco, "npm");
208        assert_eq!(dir, "lodash");
209        assert_eq!(ver, "4.17.21");
210
211        let (eco, dir, ver) = parse_purl("pkg:npm/@types/node@20.0.0").unwrap();
212        assert_eq!(eco, "npm");
213        assert_eq!(dir, "@types/node");
214        assert_eq!(ver, "20.0.0");
215
216        let (eco, dir, ver) = parse_purl("pkg:pypi/requests@2.28.0").unwrap();
217        assert_eq!(eco, "pypi");
218        assert_eq!(dir, "requests");
219        assert_eq!(ver, "2.28.0");
220    }
221
222    #[test]
223    fn test_is_purl() {
224        assert!(is_purl("pkg:npm/lodash@4.17.21"));
225        assert!(is_purl("pkg:pypi/requests@2.28.0"));
226        assert!(!is_purl("lodash"));
227        assert!(!is_purl("CVE-2024-1234"));
228    }
229
230    #[test]
231    fn test_build_npm_purl() {
232        assert_eq!(
233            build_npm_purl(None, "lodash", "4.17.21"),
234            "pkg:npm/lodash@4.17.21"
235        );
236        assert_eq!(
237            build_npm_purl(Some("@types"), "node", "20.0.0"),
238            "pkg:npm/@types/node@20.0.0"
239        );
240    }
241
242    #[test]
243    fn test_build_pypi_purl() {
244        assert_eq!(
245            build_pypi_purl("requests", "2.28.0"),
246            "pkg:pypi/requests@2.28.0"
247        );
248    }
249
250    #[cfg(feature = "cargo")]
251    #[test]
252    fn test_is_cargo_purl() {
253        assert!(is_cargo_purl("pkg:cargo/serde@1.0.200"));
254        assert!(!is_cargo_purl("pkg:npm/lodash@4.17.21"));
255        assert!(!is_cargo_purl("pkg:pypi/requests@2.28.0"));
256    }
257
258    #[cfg(feature = "cargo")]
259    #[test]
260    fn test_parse_cargo_purl() {
261        assert_eq!(
262            parse_cargo_purl("pkg:cargo/serde@1.0.200"),
263            Some(("serde", "1.0.200"))
264        );
265        assert_eq!(
266            parse_cargo_purl("pkg:cargo/serde_json@1.0.120"),
267            Some(("serde_json", "1.0.120"))
268        );
269        assert_eq!(parse_cargo_purl("pkg:npm/lodash@4.17.21"), None);
270        assert_eq!(parse_cargo_purl("pkg:cargo/@1.0.0"), None);
271        assert_eq!(parse_cargo_purl("pkg:cargo/serde@"), None);
272    }
273
274    #[cfg(feature = "cargo")]
275    #[test]
276    fn test_build_cargo_purl() {
277        assert_eq!(
278            build_cargo_purl("serde", "1.0.200"),
279            "pkg:cargo/serde@1.0.200"
280        );
281    }
282
283    #[cfg(feature = "cargo")]
284    #[test]
285    fn test_cargo_purl_round_trip() {
286        let purl = build_cargo_purl("tokio", "1.38.0");
287        let (name, version) = parse_cargo_purl(&purl).unwrap();
288        assert_eq!(name, "tokio");
289        assert_eq!(version, "1.38.0");
290    }
291
292    #[cfg(feature = "cargo")]
293    #[test]
294    fn test_parse_purl_cargo() {
295        let (eco, dir, ver) = parse_purl("pkg:cargo/serde@1.0.200").unwrap();
296        assert_eq!(eco, "cargo");
297        assert_eq!(dir, "serde");
298        assert_eq!(ver, "1.0.200");
299    }
300}