1use std::fs;
24use std::path::Path;
25
26use log::warn;
27use packageurl::PackageUrl;
28use serde_yaml::{Mapping, Value};
29
30use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
31
32use super::PackageParser;
33
34const FIELD_NAME: &str = "name";
35const FIELD_VERSION: &str = "version";
36const FIELD_DESCRIPTION: &str = "description";
37const FIELD_HOMEPAGE: &str = "homepage";
38const FIELD_LICENSE: &str = "license";
39const FIELD_REPOSITORY: &str = "repository";
40const FIELD_AUTHOR: &str = "author";
41const FIELD_AUTHORS: &str = "authors";
42const FIELD_DEPENDENCIES: &str = "dependencies";
43const FIELD_DEV_DEPENDENCIES: &str = "dev_dependencies";
44const FIELD_DEPENDENCY_OVERRIDES: &str = "dependency_overrides";
45const FIELD_ENVIRONMENT: &str = "environment";
46const FIELD_ISSUE_TRACKER: &str = "issue_tracker";
47const FIELD_DOCUMENTATION: &str = "documentation";
48const FIELD_EXECUTABLES: &str = "executables";
49const FIELD_PUBLISH_TO: &str = "publish_to";
50const FIELD_PACKAGES: &str = "packages";
51const FIELD_SDKS: &str = "sdks";
52const FIELD_DEPENDENCY: &str = "dependency";
53const FIELD_SHA256: &str = "sha256";
54
55pub struct PubspecYamlParser;
57
58impl PackageParser for PubspecYamlParser {
59 const PACKAGE_TYPE: PackageType = PackageType::Dart;
60
61 fn extract_packages(path: &Path) -> Vec<PackageData> {
62 let yaml_content = match read_yaml_file(path) {
63 Ok(content) => content,
64 Err(e) => {
65 warn!("Failed to read pubspec.yaml at {:?}: {}", path, e);
66 return vec![default_package_data()];
67 }
68 };
69
70 vec![parse_pubspec_yaml(&yaml_content)]
71 }
72
73 fn is_match(path: &Path) -> bool {
74 path.file_name().is_some_and(|name| name == "pubspec.yaml")
75 }
76}
77
78pub struct PubspecLockParser;
80
81impl PackageParser for PubspecLockParser {
82 const PACKAGE_TYPE: PackageType = PackageType::Pubspec;
83
84 fn extract_packages(path: &Path) -> Vec<PackageData> {
85 let yaml_content = match read_yaml_file(path) {
86 Ok(content) => content,
87 Err(e) => {
88 warn!("Failed to read pubspec.lock at {:?}: {}", path, e);
89 return vec![default_package_data()];
90 }
91 };
92
93 vec![parse_pubspec_lock(&yaml_content)]
94 }
95
96 fn is_match(path: &Path) -> bool {
97 path.file_name().is_some_and(|name| name == "pubspec.lock")
98 }
99}
100
101fn read_yaml_file(path: &Path) -> Result<Value, String> {
102 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
103 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))
104}
105
106fn parse_pubspec_yaml(yaml_content: &Value) -> PackageData {
107 let name = extract_string_field(yaml_content, FIELD_NAME);
108 let version = extract_string_field(yaml_content, FIELD_VERSION);
109 let description = extract_description_field(yaml_content);
110 let homepage_url = extract_string_field(yaml_content, FIELD_HOMEPAGE);
111 let raw_license = extract_string_field(yaml_content, FIELD_LICENSE);
112 let vcs_url = extract_string_field(yaml_content, FIELD_REPOSITORY);
113
114 let parties = extract_authors(yaml_content);
115
116 let declared_license_expression = None;
118 let declared_license_expression_spdx = None;
119 let license_detections = Vec::new();
120
121 let dependencies = [
122 collect_dependencies(
123 yaml_content,
124 FIELD_DEPENDENCIES,
125 Some("dependencies"),
126 true,
127 false,
128 ),
129 collect_dependencies(
130 yaml_content,
131 FIELD_DEV_DEPENDENCIES,
132 Some("dev_dependencies"),
133 false,
134 true,
135 ),
136 collect_dependencies(
137 yaml_content,
138 FIELD_DEPENDENCY_OVERRIDES,
139 Some("dependency_overrides"),
140 true,
141 false,
142 ),
143 collect_dependencies(
144 yaml_content,
145 FIELD_ENVIRONMENT,
146 Some("environment"),
147 true,
148 false,
149 ),
150 ]
151 .concat();
152
153 let extra_data = build_extra_data(yaml_content);
154
155 let purl = name
156 .as_ref()
157 .and_then(|name| build_purl(name, version.as_deref()));
158
159 let (api_data_url, repository_homepage_url, repository_download_url) =
160 if let (Some(name_val), Some(version_val)) = (&name, &version) {
161 (
162 Some(format!(
163 "https://pub.dev/api/packages/{}/versions/{}",
164 name_val, version_val
165 )),
166 Some(format!(
167 "https://pub.dev/packages/{}/versions/{}",
168 name_val, version_val
169 )),
170 Some(format!(
171 "https://pub.dartlang.org/packages/{}/versions/{}.tar.gz",
172 name_val, version_val
173 )),
174 )
175 } else {
176 (None, None, None)
177 };
178
179 let download_url = repository_download_url.clone();
180
181 PackageData {
182 package_type: Some(PubspecYamlParser::PACKAGE_TYPE),
183 namespace: None,
184 name,
185 version,
186 qualifiers: None,
187 subpath: None,
188 primary_language: Some("dart".to_string()),
189 description,
190 release_date: None,
191 parties,
192 keywords: Vec::new(),
193 homepage_url,
194 download_url,
195 size: None,
196 sha1: None,
197 md5: None,
198 sha256: None,
199 sha512: None,
200 bug_tracking_url: None,
201 code_view_url: None,
202 vcs_url,
203 copyright: None,
204 holder: None,
205 declared_license_expression,
206 declared_license_expression_spdx,
207 license_detections,
208 other_license_expression: None,
209 other_license_expression_spdx: None,
210 other_license_detections: Vec::new(),
211 extracted_license_statement: raw_license,
212 notice_text: None,
213 source_packages: Vec::new(),
214 file_references: Vec::new(),
215 is_private: false,
216 is_virtual: false,
217 extra_data,
218 dependencies,
219 repository_homepage_url,
220 repository_download_url,
221 api_data_url,
222 datasource_id: Some(DatasourceId::PubspecYaml),
223 purl,
224 }
225}
226
227fn parse_pubspec_lock(yaml_content: &Value) -> PackageData {
228 let dependencies = extract_lock_dependencies(yaml_content);
229
230 let mut package_data = default_package_data_with_type(PubspecLockParser::PACKAGE_TYPE.as_str());
231 package_data.dependencies = dependencies;
232 package_data.datasource_id = Some(DatasourceId::PubspecLock);
233 package_data
234}
235
236fn extract_lock_dependencies(lock_data: &Value) -> Vec<Dependency> {
237 let mut dependencies = Vec::new();
238
239 if let Some(sdks) = lock_data.get(FIELD_SDKS).and_then(Value::as_mapping) {
240 for (name_value, version_value) in sdks {
241 if let (Some(name), Some(version_str)) = (name_value.as_str(), version_value.as_str()) {
242 let purl = build_dependency_purl(name, None);
243 dependencies.push(Dependency {
244 purl,
245 extracted_requirement: Some(version_str.to_string()),
246 scope: Some("sdk".to_string()),
247 is_runtime: Some(true),
248 is_optional: Some(false),
249 is_pinned: Some(false),
250 is_direct: Some(true),
251 resolved_package: None,
252 extra_data: None,
253 });
254 }
255 }
256 }
257
258 let Some(packages) = lock_data.get(FIELD_PACKAGES).and_then(Value::as_mapping) else {
259 return dependencies;
260 };
261
262 for (name_value, details_value) in packages {
263 let name = match name_value.as_str() {
264 Some(value) => value,
265 None => continue,
266 };
267 let Some(details) = details_value.as_mapping() else {
268 continue;
269 };
270
271 let version = mapping_get(details, FIELD_VERSION)
272 .and_then(Value::as_str)
273 .map(|value| value.to_string());
274 let dependency_kind = mapping_get(details, FIELD_DEPENDENCY)
275 .and_then(Value::as_str)
276 .map(|value| value.to_string());
277
278 let is_runtime = dependency_kind.as_deref() != Some("direct dev");
279
280 let is_pinned = version
281 .as_ref()
282 .is_some_and(|value| !value.trim().is_empty());
283
284 let purl = build_dependency_purl(name, version.as_deref());
285 let sha256 = extract_sha256(details);
286 let resolved_dependencies = extract_lock_package_dependencies(details);
287 let resolved_package =
288 build_resolved_package(name, &version, sha256, resolved_dependencies);
289
290 dependencies.push(Dependency {
291 purl,
292 extracted_requirement: version.clone(),
293 scope: dependency_kind,
294 is_runtime: Some(is_runtime),
295 is_optional: Some(false),
296 is_pinned: Some(is_pinned),
297 is_direct: Some(true),
298 resolved_package: Some(Box::new(resolved_package)),
299 extra_data: None,
300 });
301 }
302
303 dependencies
304}
305
306fn extract_lock_package_dependencies(details: &Mapping) -> Vec<Dependency> {
307 let mut dependencies = Vec::new();
308
309 let Some(dep_map) = mapping_get(details, FIELD_DEPENDENCIES).and_then(Value::as_mapping) else {
310 return dependencies;
311 };
312
313 for (name_value, requirement_value) in dep_map {
314 let name = match name_value.as_str() {
315 Some(value) => value,
316 None => continue,
317 };
318
319 let requirement = match dependency_requirement_from_value(requirement_value) {
320 Some(value) => value,
321 None => continue,
322 };
323 let is_pinned = is_pubspec_version_pinned(&requirement);
324 let purl = if is_pinned {
325 build_dependency_purl(name, Some(requirement.as_str()))
326 } else {
327 build_dependency_purl(name, None)
328 };
329
330 dependencies.push(Dependency {
331 purl,
332 extracted_requirement: Some(requirement),
333 scope: Some(FIELD_DEPENDENCIES.to_string()),
334 is_runtime: Some(true),
335 is_optional: Some(false),
336 is_pinned: Some(is_pinned),
337 is_direct: Some(false),
338 resolved_package: None,
339 extra_data: None,
340 });
341 }
342
343 dependencies
344}
345
346fn extract_sha256(details: &Mapping) -> Option<String> {
347 let direct = mapping_get(details, FIELD_SHA256)
348 .and_then(Value::as_str)
349 .map(|value| value.to_string());
350
351 if direct.is_some() {
352 return direct;
353 }
354
355 mapping_get(details, FIELD_DESCRIPTION)
356 .and_then(Value::as_mapping)
357 .and_then(|desc_map| mapping_get(desc_map, FIELD_SHA256))
358 .and_then(Value::as_str)
359 .map(|value| value.to_string())
360}
361
362fn build_resolved_package(
363 name: &str,
364 version: &Option<String>,
365 sha256: Option<String>,
366 dependencies: Vec<Dependency>,
367) -> ResolvedPackage {
368 ResolvedPackage {
369 package_type: PubspecLockParser::PACKAGE_TYPE,
370 namespace: String::new(),
371 name: name.to_string(),
372 version: version.clone().unwrap_or_default(),
373 primary_language: Some("dart".to_string()),
374 download_url: None,
375 sha1: None,
376 sha256,
377 sha512: None,
378 md5: None,
379 is_virtual: true,
380 extra_data: None,
381 dependencies,
382 repository_homepage_url: None,
383 repository_download_url: None,
384 api_data_url: None,
385 datasource_id: None,
386 purl: None,
387 }
388}
389
390fn collect_dependencies(
391 yaml_content: &Value,
392 field: &str,
393 scope: Option<&str>,
394 is_runtime: bool,
395 is_optional: bool,
396) -> Vec<Dependency> {
397 let mut dependencies = Vec::new();
398
399 let Some(dep_map) = yaml_content.get(field).and_then(Value::as_mapping) else {
400 return dependencies;
401 };
402
403 for (name_value, requirement_value) in dep_map {
404 let name = match name_value.as_str() {
405 Some(value) => value,
406 None => continue,
407 };
408 let requirement = match dependency_requirement_from_value(requirement_value) {
409 Some(value) => value,
410 None => continue,
411 };
412
413 let is_pinned = is_pubspec_version_pinned(&requirement);
414 let purl = if is_pinned {
415 build_dependency_purl(name, Some(requirement.as_str()))
416 } else {
417 build_dependency_purl(name, None)
418 };
419
420 dependencies.push(Dependency {
421 purl,
422 extracted_requirement: Some(requirement),
423 scope: scope.map(|value| value.to_string()),
424 is_runtime: Some(is_runtime),
425 is_optional: Some(is_optional),
426 is_pinned: Some(is_pinned),
427 is_direct: Some(true),
428 resolved_package: None,
429 extra_data: None,
430 });
431 }
432
433 dependencies
434}
435
436fn dependency_requirement_from_value(value: &Value) -> Option<String> {
437 if let Some(value) = value.as_str() {
438 let trimmed = value.trim();
439 if trimmed.is_empty() {
440 return None;
441 }
442 return Some(trimmed.to_string());
443 }
444
445 if let Some(value) = value.as_i64() {
446 return Some(value.to_string());
447 }
448
449 if let Some(value) = value.as_f64() {
450 return Some(value.to_string());
451 }
452
453 if let Some(map) = value.as_mapping() {
454 return format_dependency_mapping(map);
455 }
456
457 None
458}
459
460fn format_dependency_mapping(map: &Mapping) -> Option<String> {
461 let mut parts = Vec::new();
462
463 for (key, value) in map {
464 let Some(key_str) = key.as_str() else {
465 continue;
466 };
467
468 let value_str = if let Some(value) = value.as_str() {
469 value.to_string()
470 } else if let Some(value) = value.as_i64() {
471 value.to_string()
472 } else if let Some(value) = value.as_f64() {
473 value.to_string()
474 } else {
475 continue;
476 };
477
478 parts.push(format!("{}: {}", key_str, value_str));
479 }
480
481 if parts.is_empty() {
482 None
483 } else {
484 Some(parts.join(", "))
485 }
486}
487
488fn is_pubspec_version_pinned(version: &str) -> bool {
489 let trimmed = version.trim();
490 if trimmed.is_empty() {
491 return false;
492 }
493
494 trimmed
495 .chars()
496 .all(|character| character.is_ascii_digit() || character == '.')
497}
498
499fn build_purl(name: &str, version: Option<&str>) -> Option<String> {
500 build_purl_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str(), name, version)
501}
502
503fn build_dependency_purl(name: &str, version: Option<&str>) -> Option<String> {
504 build_purl_with_type("pubspec", name, version)
505}
506
507fn build_purl_with_type(package_type: &str, name: &str, version: Option<&str>) -> Option<String> {
508 let mut package_url = match PackageUrl::new(package_type, name) {
509 Ok(purl) => purl,
510 Err(e) => {
511 warn!(
512 "Failed to create PackageUrl for {} dependency '{}': {}",
513 package_type, name, e
514 );
515 return None;
516 }
517 };
518
519 if let Some(version) = version
520 && let Err(e) = package_url.with_version(version)
521 {
522 warn!(
523 "Failed to set version '{}' for {} dependency '{}': {}",
524 version, package_type, name, e
525 );
526 return None;
527 }
528
529 Some(package_url.to_string())
530}
531
532fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
533 yaml_content
534 .get(field)
535 .and_then(Value::as_str)
536 .map(|value| value.trim().to_string())
537 .filter(|value| !value.is_empty())
538}
539
540fn extract_description_field(yaml_content: &Value) -> Option<String> {
541 yaml_content
544 .get(FIELD_DESCRIPTION)
545 .and_then(Value::as_str)
546 .and_then(|value| {
547 let trimmed = value.trim_start();
549 if trimmed.is_empty() {
550 None
551 } else {
552 Some(trimmed.to_string())
553 }
554 })
555}
556
557fn mapping_get<'a>(map: &'a Mapping, key: &str) -> Option<&'a Value> {
558 map.get(Value::String(key.to_string()))
559}
560
561fn default_package_data() -> PackageData {
562 default_package_data_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str())
563}
564
565fn default_package_data_with_type(package_type: &str) -> PackageData {
566 PackageData {
567 package_type: package_type.parse::<PackageType>().ok(),
568 primary_language: Some("dart".to_string()),
569 ..Default::default()
570 }
571}
572
573fn extract_authors(yaml_content: &Value) -> Vec<crate::models::Party> {
574 use crate::models::Party;
575 let mut parties = Vec::new();
576
577 if let Some(author) = extract_string_field(yaml_content, FIELD_AUTHOR) {
578 parties.push(Party {
579 r#type: None,
580 role: Some("author".to_string()),
581 name: Some(author),
582 email: None,
583 url: None,
584 organization: None,
585 organization_url: None,
586 timezone: None,
587 });
588 }
589
590 if let Some(authors_value) = yaml_content.get(FIELD_AUTHORS)
591 && let Some(authors_array) = authors_value.as_sequence()
592 {
593 for author_value in authors_array {
594 if let Some(author_str) = author_value.as_str() {
595 parties.push(Party {
596 r#type: None,
597 role: Some("author".to_string()),
598 name: Some(author_str.to_string()),
599 email: None,
600 url: None,
601 organization: None,
602 organization_url: None,
603 timezone: None,
604 });
605 }
606 }
607 }
608
609 parties
610}
611
612fn build_extra_data(
613 yaml_content: &Value,
614) -> Option<std::collections::HashMap<String, serde_json::Value>> {
615 use std::collections::HashMap;
616 let mut extra_data = HashMap::new();
617
618 if let Some(issue_tracker) = extract_string_field(yaml_content, FIELD_ISSUE_TRACKER) {
619 extra_data.insert(
620 FIELD_ISSUE_TRACKER.to_string(),
621 serde_json::Value::String(issue_tracker),
622 );
623 }
624
625 if let Some(documentation) = extract_string_field(yaml_content, FIELD_DOCUMENTATION) {
626 extra_data.insert(
627 FIELD_DOCUMENTATION.to_string(),
628 serde_json::Value::String(documentation),
629 );
630 }
631
632 if let Some(executables) = yaml_content.get(FIELD_EXECUTABLES) {
633 if let Ok(json_value) = serde_json::to_value(executables) {
635 extra_data.insert(FIELD_EXECUTABLES.to_string(), json_value);
636 }
637 }
638
639 if let Some(publish_to) = extract_string_field(yaml_content, FIELD_PUBLISH_TO) {
640 extra_data.insert(
641 FIELD_PUBLISH_TO.to_string(),
642 serde_json::Value::String(publish_to),
643 );
644 }
645
646 if extra_data.is_empty() {
647 None
648 } else {
649 Some(extra_data)
650 }
651}
652
653crate::register_parser!(
654 "Dart pubspec.yaml manifest",
655 &["**/pubspec.yaml", "**/pubspec.lock"],
656 "pub",
657 "Dart",
658 Some("https://dart.dev/tools/pub/pubspec"),
659);