1use crate::models::{DatasourceId, FileReference, PackageData, PackageType, Party, PartyType};
30use crate::parser_warn as warn;
31use crate::parsers::utils::{read_file_to_string, truncate_field};
32use packageurl::PackageUrl;
33use std::path::Path;
34use std::str::FromStr;
35use url::Url;
36use yaml_serde::Value;
37
38use super::PackageParser;
39use super::license_normalization::{
40 DeclaredLicenseMatchMetadata, build_declared_license_data, normalize_spdx_declared_license,
41 normalize_spdx_expression,
42};
43
44const FIELD_TYPE: &str = "type";
45const FIELD_PURL: &str = "purl";
46const FIELD_PACKAGE_URL: &str = "package_url";
47const FIELD_NAMESPACE: &str = "namespace";
48const FIELD_NAME: &str = "name";
49const FIELD_VERSION: &str = "version";
50const FIELD_HOME_URL: &str = "home_url";
51const FIELD_HOMEPAGE_URL: &str = "homepage_url";
52const FIELD_DOWNLOAD_URL: &str = "download_url";
53const FIELD_COPYRIGHT: &str = "copyright";
54const FIELD_LICENSE_EXPRESSION: &str = "license_expression";
55const FIELD_OWNER: &str = "owner";
56const FIELD_ABOUT_RESOURCE: &str = "about_resource";
57
58pub struct AboutFileParser;
63
64#[derive(Clone)]
65struct InferredAboutIdentity {
66 package_type: PackageType,
67 namespace: Option<String>,
68 name: Option<String>,
69 version: Option<String>,
70}
71
72impl PackageParser for AboutFileParser {
73 const PACKAGE_TYPE: PackageType = PackageType::About;
74
75 fn extract_packages(path: &Path) -> Vec<PackageData> {
76 let yaml = match read_and_parse_yaml(path) {
77 Ok(yaml) => yaml,
78 Err(e) => {
79 warn!("Failed to read or parse .ABOUT file at {:?}: {}", path, e);
80 return vec![default_package_data()];
81 }
82 };
83
84 let about_type = yaml
86 .get(FIELD_TYPE)
87 .and_then(|v| v.as_str())
88 .map(String::from);
89
90 let about_namespace = yaml
91 .get(FIELD_NAMESPACE)
92 .and_then(|v| v.as_str())
93 .map(|v| truncate_field(v.to_string()));
94
95 let purl_string = yaml
96 .get(FIELD_PURL)
97 .and_then(|v| v.as_str())
98 .or_else(|| yaml.get(FIELD_PACKAGE_URL).and_then(|v| v.as_str()))
99 .map(|v| truncate_field(v.to_string()));
100
101 let (purl_type, purl_namespace, purl_name, purl_version) =
103 if let Some(ref purl_str) = purl_string {
104 match PackageUrl::from_str(purl_str) {
105 Ok(purl) => (
106 Some(truncate_field(purl.ty().to_string())),
107 purl.namespace().map(|v| truncate_field(v.to_string())),
108 Some(truncate_field(purl.name().to_string())),
109 purl.version().map(|v| truncate_field(v.to_string())),
110 ),
111 Err(e) => {
112 warn!("Failed to parse purl '{}': {}", purl_str, e);
113 (None, None, None, None)
114 }
115 }
116 } else {
117 (None, None, None, None)
118 };
119
120 let inferred = infer_about_from_download_url(
121 yaml.get(FIELD_DOWNLOAD_URL).and_then(|v| v.as_str()),
122 yaml.get(FIELD_NAME)
123 .and_then(yaml_value_to_string)
124 .as_deref(),
125 yaml.get(FIELD_VERSION)
126 .and_then(yaml_value_to_string)
127 .as_deref(),
128 );
129
130 let explicit_package_type = about_type
131 .clone()
132 .and_then(|s| s.parse::<crate::models::PackageType>().ok());
133 let parsed_purl_type = purl_type
134 .clone()
135 .and_then(|s| s.parse::<crate::models::PackageType>().ok());
136 let has_parsed_purl_identity = parsed_purl_type.is_some()
137 || purl_namespace.is_some()
138 || purl_name.is_some()
139 || purl_version.is_some();
140 let inferred_identity = if explicit_package_type.is_none() && !has_parsed_purl_identity {
141 inferred
142 } else {
143 None
144 };
145
146 let package_type = explicit_package_type
147 .or(parsed_purl_type)
148 .or_else(|| {
149 inferred_identity
150 .as_ref()
151 .map(|identity| identity.package_type)
152 })
153 .unwrap_or(Self::PACKAGE_TYPE);
154
155 let namespace = about_namespace
157 .clone()
158 .or(purl_namespace.clone())
159 .or_else(|| {
160 inferred_identity
161 .as_ref()
162 .and_then(|identity| identity.namespace.clone())
163 })
164 .map(truncate_field);
165
166 let name = yaml
168 .get(FIELD_NAME)
169 .and_then(yaml_value_to_string)
170 .or(purl_name.clone())
171 .or_else(|| {
172 inferred_identity
173 .as_ref()
174 .and_then(|identity| identity.name.clone())
175 })
176 .map(truncate_field);
177
178 let version = yaml
179 .get(FIELD_VERSION)
180 .and_then(yaml_value_to_string)
181 .or(purl_version.clone())
182 .or_else(|| {
183 inferred_identity
184 .as_ref()
185 .and_then(|identity| identity.version.clone())
186 })
187 .map(truncate_field);
188
189 let homepage_url = yaml
191 .get(FIELD_HOME_URL)
192 .and_then(|v| v.as_str())
193 .or_else(|| yaml.get(FIELD_HOMEPAGE_URL).and_then(|v| v.as_str()))
194 .map(|v| truncate_field(v.to_string()));
195
196 let download_url = yaml
197 .get(FIELD_DOWNLOAD_URL)
198 .and_then(|v| v.as_str())
199 .map(|v| truncate_field(v.to_string()));
200
201 let copyright = yaml
202 .get(FIELD_COPYRIGHT)
203 .and_then(|v| v.as_str())
204 .map(|v| truncate_field(v.to_string()));
205
206 let extracted_license_statement = yaml
207 .get(FIELD_LICENSE_EXPRESSION)
208 .and_then(|v| v.as_str())
209 .map(|v| truncate_field(v.to_string()));
210 let file_references = extract_file_references(&yaml);
211 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
212 extracted_license_statement
213 .as_deref()
214 .and_then(normalize_spdx_expression)
215 .map(|normalized| {
216 build_declared_license_data(
217 normalized,
218 DeclaredLicenseMatchMetadata::single_line(
219 extracted_license_statement.as_deref().unwrap_or_default(),
220 ),
221 )
222 })
223 .unwrap_or_else(|| {
224 normalize_spdx_declared_license(extracted_license_statement.as_deref())
225 });
226
227 let vcs_url = yaml
228 .get(Value::String("vcs_url".to_string()))
229 .and_then(|v| v.as_str())
230 .map(|v| truncate_field(v.to_string()));
231
232 let extra_data = build_extra_data(&yaml);
233
234 let purl = purl_string
235 .or_else(|| {
236 let name = yaml
237 .get(FIELD_NAME)
238 .and_then(yaml_value_to_string)
239 .or(purl_name.clone())
240 .or_else(|| {
241 inferred_identity
242 .as_ref()
243 .and_then(|identity| identity.name.clone())
244 });
245 let version = yaml
246 .get(FIELD_VERSION)
247 .and_then(yaml_value_to_string)
248 .or(purl_version.clone())
249 .or_else(|| {
250 inferred_identity
251 .as_ref()
252 .and_then(|identity| identity.version.clone())
253 });
254 let namespace = about_namespace.clone().or_else(|| {
255 inferred_identity
256 .as_ref()
257 .and_then(|identity| identity.namespace.clone())
258 });
259 build_about_purl(
260 package_type,
261 namespace.as_deref(),
262 name.as_deref(),
263 version.as_deref(),
264 )
265 })
266 .map(truncate_field);
267
268 let parties = extract_owner_party(&yaml);
270
271 vec![PackageData {
273 package_type: Some(package_type),
274 namespace,
275 name,
276 version,
277 qualifiers: None,
278 subpath: None,
279 primary_language: None,
280 description: None,
281 release_date: None,
282 parties,
283 keywords: Vec::new(),
284 homepage_url,
285 download_url,
286 size: None,
287 sha1: None,
288 md5: None,
289 sha256: None,
290 sha512: None,
291 bug_tracking_url: None,
292 code_view_url: None,
293 vcs_url,
294 copyright,
295 holder: None,
296 declared_license_expression,
297 declared_license_expression_spdx,
298 license_detections,
299 other_license_expression: None,
300 other_license_expression_spdx: None,
301 other_license_detections: Vec::new(),
302 extracted_license_statement,
303 notice_text: None,
304 source_packages: Vec::new(),
305 file_references,
306 is_private: false,
307 is_virtual: false,
308 extra_data,
309 dependencies: Vec::new(),
310 repository_homepage_url: None,
311 repository_download_url: None,
312 api_data_url: None,
313 datasource_id: Some(DatasourceId::AboutFile),
314 purl,
315 }]
316 }
317
318 fn is_match(path: &Path) -> bool {
319 path.extension()
320 .and_then(|ext| ext.to_str())
321 .is_some_and(|ext| ext == "ABOUT")
322 }
323
324 fn metadata() -> Vec<super::metadata::ParserMetadata> {
325 vec![super::metadata::ParserMetadata {
326 description: "AboutCode .ABOUT metadata file",
327 file_patterns: &["**/*.ABOUT"],
328 package_type: "about",
329 primary_language: "",
330 documentation_url: Some(
331 "https://aboutcode-toolkit.readthedocs.io/en/latest/specification.html",
332 ),
333 }]
334 }
335}
336
337fn read_and_parse_yaml(path: &Path) -> Result<yaml_serde::Mapping, String> {
339 let content =
340 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
341
342 parse_yaml_mapping(&content)
343 .or_else(|yaml_error| parse_shallow_scalar_mapping(&content).ok_or(yaml_error))
344}
345
346fn parse_yaml_mapping(content: &str) -> Result<yaml_serde::Mapping, String> {
347 let value: Value =
348 yaml_serde::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
349
350 match value {
351 Value::Mapping(map) => Ok(map),
352 _ => Err("Expected YAML mapping at root".to_string()),
353 }
354}
355
356fn parse_shallow_scalar_mapping(content: &str) -> Option<yaml_serde::Mapping> {
357 let mut map = yaml_serde::Mapping::new();
358 let mut saw_mapping_entry = false;
359
360 for line in content.lines() {
361 let trimmed = line.trim();
362 if trimmed.is_empty() || trimmed.starts_with('#') {
363 continue;
364 }
365 if line.starts_with(char::is_whitespace) {
366 return None;
367 }
368
369 let (raw_key, raw_value) = trimmed.split_once(':')?;
370 let key = raw_key.trim();
371 if key.is_empty()
372 || !key.chars().all(|character| {
373 character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
374 })
375 {
376 return None;
377 }
378
379 let value = raw_value.trim();
380 if value.is_empty() {
381 return None;
382 }
383
384 saw_mapping_entry = true;
385 map.insert(
386 Value::String(key.to_string()),
387 Value::String(unquote_yaml_scalar(value)),
388 );
389 }
390
391 saw_mapping_entry.then_some(map)
392}
393
394fn unquote_yaml_scalar(value: &str) -> String {
395 if value.len() >= 2 {
396 let mut characters = value.chars();
397 let first = characters.next();
398 let last = value.chars().last();
399 if matches!(
400 (first, last),
401 (Some('"'), Some('"')) | (Some('\''), Some('\''))
402 ) {
403 return value[1..value.len() - 1].to_string();
404 }
405 }
406 value.to_string()
407}
408
409fn yaml_value_to_string(value: &Value) -> Option<String> {
411 match value {
412 Value::String(s) => Some(s.clone()),
413 Value::Number(n) => Some(n.to_string()),
414 Value::Bool(b) => Some(b.to_string()),
415 _ => None,
416 }
417}
418
419fn extract_owner_party(yaml: &yaml_serde::Mapping) -> Vec<Party> {
421 let owner = yaml
422 .get(Value::String(FIELD_OWNER.to_string()))
423 .map(|v| match v {
424 Value::String(s) => truncate_field(s.clone()),
425 _ => truncate_field(format!("{:?}", v)),
426 });
427
428 if let Some(owner_name) = owner {
429 if !owner_name.is_empty() {
430 vec![Party {
431 r#type: Some(PartyType::Person),
432 role: Some("owner".to_string()),
433 name: Some(owner_name),
434 email: None,
435 url: None,
436 organization: None,
437 organization_url: None,
438 timezone: None,
439 }]
440 } else {
441 Vec::new()
442 }
443 } else {
444 Vec::new()
445 }
446}
447
448fn extract_file_references(yaml: &yaml_serde::Mapping) -> Vec<FileReference> {
450 let about_resource = yaml
451 .get(Value::String(FIELD_ABOUT_RESOURCE.to_string()))
452 .and_then(|v| v.as_str());
453 let license_file = yaml
454 .get(Value::String("license_file".to_string()))
455 .and_then(|v| v.as_str());
456 let notice_file = yaml
457 .get(Value::String("notice_file".to_string()))
458 .and_then(|v| v.as_str());
459
460 let mut refs = Vec::new();
461
462 if let Some(path) = about_resource {
463 refs.push(FileReference {
464 path: truncate_field(path.to_string()),
465 size: None,
466 sha1: None,
467 md5: None,
468 sha256: None,
469 sha512: None,
470 extra_data: None,
471 });
472 }
473
474 for path in [license_file, notice_file].into_iter().flatten() {
475 refs.push(FileReference {
476 path: truncate_field(path.to_string()),
477 size: None,
478 sha1: None,
479 md5: None,
480 sha256: None,
481 sha512: None,
482 extra_data: None,
483 });
484 }
485
486 refs
487}
488
489fn default_package_data() -> PackageData {
491 PackageData {
492 package_type: Some(PackageType::About),
493 datasource_id: Some(DatasourceId::AboutFile),
494 ..Default::default()
495 }
496}
497
498fn infer_about_from_download_url(
499 download_url: Option<&str>,
500 about_name: Option<&str>,
501 about_version: Option<&str>,
502) -> Option<InferredAboutIdentity> {
503 let url = Url::parse(download_url?).ok()?;
504 let host = url.host_str()?;
505
506 if matches!(host, "pypi.python.org" | "files.pythonhosted.org") {
507 let name = about_name.map(str::to_string)?;
508 let version = about_version.map(str::to_string);
509 return Some(InferredAboutIdentity {
510 package_type: PackageType::Pypi,
511 namespace: None,
512 name: Some(name),
513 version,
514 });
515 }
516
517 if matches!(host, "raw.githubusercontent.com" | "github.com") {
518 let mut segments = url.path_segments()?;
519 let owner = segments.next()?.to_string();
520 let repo = segments.next()?.to_string();
521 return Some(InferredAboutIdentity {
522 package_type: PackageType::Github,
523 namespace: Some(owner),
524 name: Some(repo),
525 version: None,
526 });
527 }
528
529 None
530}
531
532fn build_about_purl(
533 package_type: PackageType,
534 namespace: Option<&str>,
535 name: Option<&str>,
536 version: Option<&str>,
537) -> Option<String> {
538 if package_type == PackageType::About {
539 return None;
540 }
541
542 let name = name?;
543 let mut purl = PackageUrl::new(package_type.as_str(), name).ok()?;
544 if let Some(namespace) = namespace {
545 purl.with_namespace(namespace).ok()?;
546 }
547 if let Some(version) = version {
548 purl.with_version(version).ok()?;
549 }
550 Some(purl.to_string())
551}
552
553fn build_extra_data(
554 yaml: &yaml_serde::Mapping,
555) -> Option<std::collections::HashMap<String, serde_json::Value>> {
556 let mut extra_data = std::collections::HashMap::new();
557 for key in ["license_file", "notice_file", "notes"] {
558 if let Some(value) = yaml.get(Value::String(key.to_string()))
559 && let Some(value) = yaml_value_to_string(value)
560 {
561 extra_data.insert(
562 key.to_string(),
563 serde_json::Value::String(truncate_field(value)),
564 );
565 }
566 }
567 (!extra_data.is_empty()).then_some(extra_data)
568}