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