socket_patch_core/utils/
purl.rs1pub fn strip_purl_qualifiers(purl: &str) -> &str {
5 match purl.find('?') {
6 Some(idx) => &purl[..idx],
7 None => purl,
8 }
9}
10
11pub fn is_pypi_purl(purl: &str) -> bool {
13 purl.starts_with("pkg:pypi/")
14}
15
16pub fn is_npm_purl(purl: &str) -> bool {
18 purl.starts_with("pkg:npm/")
19}
20
21pub 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
36pub 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 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 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#[cfg(feature = "cargo")]
69pub fn is_cargo_purl(purl: &str) -> bool {
70 purl.starts_with("pkg:cargo/")
71}
72
73#[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#[cfg(feature = "cargo")]
91pub fn build_cargo_purl(name: &str, version: &str) -> String {
92 format!("pkg:cargo/{name}@{version}")
93}
94
95pub 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
130pub fn is_purl(s: &str) -> bool {
132 s.starts_with("pkg:")
133}
134
135pub 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
143pub 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}