Skip to main content

provenant/output_schema/
file_info.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize, Serializer};
5use serde_json::Map;
6
7use super::author::OutputAuthor;
8use super::copyright::OutputCopyright;
9use super::email::OutputEmail;
10use super::file_type::OutputFileType;
11use super::holder::OutputHolder;
12use super::license_detection::OutputLicenseDetection;
13use super::license_match::OutputMatch;
14use super::license_policy_entry::OutputLicensePolicyEntry;
15use super::package_data::OutputPackageData;
16use super::serde_helpers::insert_json;
17use super::tallies::OutputTallies;
18use super::url::OutputURL;
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct OutputFileInfo {
22    #[serde(default)]
23    pub name: String,
24    #[serde(default)]
25    pub base_name: String,
26    #[serde(default)]
27    pub extension: String,
28    pub path: String,
29    #[serde(rename = "type")]
30    pub file_type: OutputFileType,
31    pub mime_type: Option<String>,
32    #[serde(rename = "file_type")]
33    pub file_type_label: Option<String>,
34    #[serde(default)]
35    pub size: u64,
36    pub date: Option<String>,
37    pub sha1: Option<String>,
38    pub md5: Option<String>,
39    pub sha256: Option<String>,
40    pub sha1_git: Option<String>,
41    pub programming_language: Option<String>,
42    #[serde(default)]
43    pub package_data: Vec<OutputPackageData>,
44    #[serde(rename = "detected_license_expression_spdx")]
45    pub license_expression: Option<String>,
46    #[serde(default)]
47    pub license_detections: Vec<OutputLicenseDetection>,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub license_clues: Vec<OutputMatch>,
50    pub percentage_of_license_text: Option<f64>,
51    #[serde(default)]
52    pub copyrights: Vec<OutputCopyright>,
53    #[serde(default)]
54    pub holders: Vec<OutputHolder>,
55    #[serde(default)]
56    pub authors: Vec<OutputAuthor>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub emails: Vec<OutputEmail>,
59    #[serde(default)]
60    pub urls: Vec<OutputURL>,
61    #[serde(default)]
62    pub for_packages: Vec<String>,
63    #[serde(default)]
64    pub scan_errors: Vec<String>,
65    pub license_policy: Option<Vec<OutputLicensePolicyEntry>>,
66    pub is_generated: Option<bool>,
67    pub is_binary: Option<bool>,
68    pub is_text: Option<bool>,
69    pub is_archive: Option<bool>,
70    pub is_media: Option<bool>,
71    pub is_source: Option<bool>,
72    pub is_script: Option<bool>,
73    pub files_count: Option<usize>,
74    pub dirs_count: Option<usize>,
75    pub size_count: Option<u64>,
76    pub source_count: Option<usize>,
77    #[serde(default, skip_serializing_if = "is_false")]
78    pub is_legal: bool,
79    #[serde(default, skip_serializing_if = "is_false")]
80    pub is_manifest: bool,
81    #[serde(default, skip_serializing_if = "is_false")]
82    pub is_readme: bool,
83    #[serde(default, skip_serializing_if = "is_false")]
84    pub is_top_level: bool,
85    #[serde(default, skip_serializing_if = "is_false")]
86    pub is_key_file: bool,
87    #[serde(default, skip_serializing_if = "is_false")]
88    pub is_referenced: bool,
89    #[serde(default, skip_serializing_if = "is_false")]
90    pub is_community: bool,
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub facets: Vec<String>,
93    pub tallies: Option<OutputTallies>,
94}
95
96impl OutputFileInfo {
97    pub(crate) fn should_serialize_info_surface(&self) -> bool {
98        self.date.is_some()
99            || self.sha1.is_some()
100            || self.md5.is_some()
101            || self.sha256.is_some()
102            || self.sha1_git.is_some()
103            || self.mime_type.is_some()
104            || self.file_type_label.is_some()
105            || self.programming_language.is_some()
106            || self.is_binary.is_some()
107            || self.is_text.is_some()
108            || self.is_archive.is_some()
109            || self.is_media.is_some()
110            || self.is_source.is_some()
111            || self.is_script.is_some()
112            || self.files_count.is_some()
113            || self.dirs_count.is_some()
114            || self.size_count.is_some()
115    }
116
117    pub(crate) fn should_serialize_license_surface(&self) -> bool {
118        self.license_expression.is_some()
119            || !self.license_detections.is_empty()
120            || !self.license_clues.is_empty()
121            || self.percentage_of_license_text.is_some()
122    }
123
124    /// The scancode-key counterpart of [`Self::detected_license_expression_spdx`].
125    /// Mirrors the same three-tier fallback (file detections, then package-data
126    /// detections, then the carried expression) but on the non-SPDX
127    /// `license_expression` and with the non-strict combiner, since scancode keys
128    /// such as `proprietary-license` are not valid SPDX tokens.
129    pub(crate) fn detected_license_expression(&self) -> Option<String> {
130        let combine = |expressions: Vec<String>| -> Option<String> {
131            let expressions: Vec<String> = expressions
132                .into_iter()
133                .filter(|expression| !expression.is_empty())
134                .collect();
135            if expressions.is_empty() {
136                return None;
137            }
138            crate::utils::spdx::select_primary_license_expression(expressions.clone()).or_else(
139                || {
140                    crate::utils::spdx::combine_license_expressions_preserving_structure(
141                        expressions,
142                    )
143                },
144            )
145        };
146
147        combine(
148            self.license_detections
149                .iter()
150                .map(|detection| detection.license_expression.clone())
151                .collect(),
152        )
153        .or_else(|| {
154            combine(
155                self.package_data
156                    .iter()
157                    .flat_map(|package_data| package_data.license_detections.iter())
158                    .map(|detection| detection.license_expression.clone())
159                    .collect(),
160            )
161        })
162        .or_else(|| {
163            self.license_expression
164                .clone()
165                .filter(|expression| !expression.is_empty())
166        })
167    }
168
169    pub(crate) fn detected_license_expression_spdx(&self) -> Option<String> {
170        {
171            let expressions: Option<Vec<String>> = self
172                .license_detections
173                .iter()
174                .map(|detection| {
175                    (!detection.license_expression_spdx.is_empty())
176                        .then(|| detection.license_expression_spdx.clone())
177                })
178                .collect();
179            expressions.and_then(|expressions| {
180                crate::utils::spdx::select_primary_license_expression_strict(expressions.clone())
181                    .or_else(|| {
182                        crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
183                            expressions,
184                        )
185                    })
186            })
187        }
188        .or_else(|| {
189            let expressions: Option<Vec<String>> = self
190                .package_data
191                .iter()
192                .flat_map(|package_data| package_data.license_detections.iter())
193                .map(|detection| {
194                    (!detection.license_expression_spdx.is_empty())
195                        .then(|| detection.license_expression_spdx.clone())
196                })
197                .collect();
198            expressions.and_then(|expressions| {
199                crate::utils::spdx::select_primary_license_expression_strict(expressions.clone())
200                    .or_else(|| {
201                        crate::utils::spdx::combine_license_expressions_preserving_structure_strict(
202                            expressions,
203                        )
204                    })
205            })
206        })
207        .or_else(|| {
208            self.license_expression
209                .clone()
210                .filter(|expression| !expression.is_empty())
211                .and_then(|expression| {
212                    crate::utils::spdx::combine_license_expressions_preserving_structure_strict([
213                        expression,
214                    ])
215                })
216        })
217    }
218}
219
220impl Serialize for OutputFileInfo {
221    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
222    where
223        S: Serializer,
224    {
225        let mut map = Map::new();
226        insert_json(&mut map, "path", &self.path)?;
227        insert_json(&mut map, "type", self.file_type)?;
228        insert_json(&mut map, "name", &self.name)?;
229        insert_json(&mut map, "base_name", &self.base_name)?;
230        insert_json(&mut map, "extension", &self.extension)?;
231        insert_json(&mut map, "size", self.size)?;
232
233        if self.should_serialize_info_surface() {
234            insert_json(&mut map, "date", &self.date)?;
235            insert_json(&mut map, "sha1", self.sha1.as_ref())?;
236            insert_json(&mut map, "md5", self.md5.as_ref())?;
237            insert_json(&mut map, "sha256", self.sha256.as_ref())?;
238            insert_json(&mut map, "sha1_git", self.sha1_git.as_ref())?;
239            insert_json(&mut map, "mime_type", &self.mime_type)?;
240            insert_json(&mut map, "file_type", &self.file_type_label)?;
241            insert_json(&mut map, "programming_language", &self.programming_language)?;
242            insert_json(&mut map, "is_binary", self.is_binary)?;
243            insert_json(&mut map, "is_text", self.is_text)?;
244            insert_json(&mut map, "is_archive", self.is_archive)?;
245            insert_json(&mut map, "is_media", self.is_media)?;
246            insert_json(&mut map, "is_source", self.is_source)?;
247            insert_json(&mut map, "is_script", self.is_script)?;
248            insert_json(&mut map, "files_count", self.files_count)?;
249            insert_json(&mut map, "dirs_count", self.dirs_count)?;
250            insert_json(&mut map, "size_count", self.size_count)?;
251        }
252
253        insert_json(&mut map, "package_data", &self.package_data)?;
254        insert_json(
255            &mut map,
256            "detected_license_expression",
257            self.detected_license_expression(),
258        )?;
259        insert_json(
260            &mut map,
261            "detected_license_expression_spdx",
262            self.detected_license_expression_spdx(),
263        )?;
264        insert_json(&mut map, "license_detections", &self.license_detections)?;
265        if self.should_serialize_license_surface() {
266            insert_json(&mut map, "license_clues", &self.license_clues)?;
267        }
268        if self.percentage_of_license_text.is_some() {
269            insert_json(
270                &mut map,
271                "percentage_of_license_text",
272                self.percentage_of_license_text,
273            )?;
274        }
275        insert_json(&mut map, "copyrights", &self.copyrights)?;
276        insert_json(&mut map, "holders", &self.holders)?;
277        insert_json(&mut map, "authors", &self.authors)?;
278        if !self.emails.is_empty() {
279            insert_json(&mut map, "emails", &self.emails)?;
280        }
281        insert_json(&mut map, "urls", &self.urls)?;
282        insert_json(&mut map, "for_packages", &self.for_packages)?;
283        insert_json(&mut map, "scan_errors", &self.scan_errors)?;
284        if self.license_policy.is_some() {
285            insert_json(&mut map, "license_policy", &self.license_policy)?;
286        }
287        if self.is_generated.is_some() {
288            insert_json(&mut map, "is_generated", self.is_generated)?;
289        }
290        if self.source_count.is_some() {
291            insert_json(&mut map, "source_count", self.source_count)?;
292        }
293        if self.is_legal {
294            insert_json(&mut map, "is_legal", self.is_legal)?;
295        }
296        if self.is_manifest {
297            insert_json(&mut map, "is_manifest", self.is_manifest)?;
298        }
299        if self.is_readme {
300            insert_json(&mut map, "is_readme", self.is_readme)?;
301        }
302        if self.is_top_level {
303            insert_json(&mut map, "is_top_level", self.is_top_level)?;
304        }
305        if self.is_key_file {
306            insert_json(&mut map, "is_key_file", self.is_key_file)?;
307        }
308        if self.is_referenced {
309            insert_json(&mut map, "is_referenced", self.is_referenced)?;
310        }
311        if self.is_community {
312            insert_json(&mut map, "is_community", self.is_community)?;
313        }
314        if !self.facets.is_empty() {
315            insert_json(&mut map, "facets", &self.facets)?;
316        }
317        if self.tallies.is_some() {
318            insert_json(&mut map, "tallies", &self.tallies)?;
319        }
320
321        map.serialize(serializer)
322    }
323}
324
325impl From<&crate::models::FileInfo> for OutputFileInfo {
326    fn from(value: &crate::models::FileInfo) -> Self {
327        Self::from_with_compat_mode(value, crate::cli::CompatibilityMode::Native)
328    }
329}
330
331impl OutputFileInfo {
332    pub fn from_with_compat_mode(
333        value: &crate::models::FileInfo,
334        mode: crate::cli::CompatibilityMode,
335    ) -> Self {
336        Self {
337            name: value.name.clone(),
338            base_name: value.base_name.clone(),
339            extension: value.extension.clone(),
340            path: value.path.clone(),
341            file_type: OutputFileType::from(&value.file_type),
342            mime_type: value.mime_type.clone(),
343            file_type_label: value.file_type_label.clone(),
344            size: value.size,
345            date: value.date.clone(),
346            sha1: value.sha1.as_ref().map(|d| d.as_hex()),
347            md5: value.md5.as_ref().map(|d| d.as_hex()),
348            sha256: value.sha256.as_ref().map(|d| d.as_hex()),
349            sha1_git: value.sha1_git.as_ref().map(|d| d.as_hex()),
350            programming_language: value.programming_language.clone(),
351            package_data: value
352                .package_data
353                .iter()
354                .map(OutputPackageData::from)
355                .collect(),
356            license_expression: value.detected_license_expression.clone(),
357            license_detections: value
358                .license_detections
359                .iter()
360                .map(OutputLicenseDetection::from)
361                .collect(),
362            license_clues: value.license_clues.iter().map(OutputMatch::from).collect(),
363            percentage_of_license_text: value.percentage_of_license_text,
364            copyrights: value
365                .copyrights
366                .iter()
367                .map(|copyright| OutputCopyright::from_with_compat_mode(copyright, mode))
368                .collect(),
369            holders: value.holders.iter().map(OutputHolder::from).collect(),
370            authors: value.authors.iter().map(OutputAuthor::from).collect(),
371            emails: value.emails.iter().map(OutputEmail::from).collect(),
372            urls: value.urls.iter().map(OutputURL::from).collect(),
373            for_packages: value
374                .for_packages
375                .iter()
376                .map(|uid| uid.to_string())
377                .collect(),
378            scan_errors: value
379                .scan_diagnostics
380                .iter()
381                .map(|d| d.message.clone())
382                .collect(),
383            license_policy: value
384                .license_policy
385                .as_ref()
386                .map(|v| v.iter().map(OutputLicensePolicyEntry::from).collect()),
387            is_generated: value.is_generated,
388            is_binary: value.is_binary,
389            is_text: value.is_text,
390            is_archive: value.is_archive,
391            is_media: value.is_media,
392            is_source: value.is_source,
393            is_script: value.is_script,
394            files_count: value.files_count,
395            dirs_count: value.dirs_count,
396            size_count: value.size_count,
397            source_count: value.source_count,
398            is_legal: value.is_legal,
399            is_manifest: value.is_manifest,
400            is_readme: value.is_readme,
401            is_top_level: value.is_top_level,
402            is_key_file: value.is_key_file,
403            is_referenced: value.is_referenced,
404            is_community: value.is_community,
405            facets: value.facets.clone(),
406            tallies: value.tallies.as_ref().map(OutputTallies::from),
407        }
408    }
409}
410
411impl TryFrom<&OutputFileInfo> for crate::models::FileInfo {
412    type Error = String;
413    fn try_from(value: &OutputFileInfo) -> Result<Self, Self::Error> {
414        let mut package_data = Vec::with_capacity(value.package_data.len());
415        for p in &value.package_data {
416            package_data.push(crate::models::PackageData::try_from(p)?);
417        }
418        let mut license_detections = Vec::with_capacity(value.license_detections.len());
419        for d in &value.license_detections {
420            license_detections.push(crate::models::LicenseDetection::try_from(d)?);
421        }
422        let mut license_clues = Vec::with_capacity(value.license_clues.len());
423        for m in &value.license_clues {
424            license_clues.push(crate::models::Match::try_from(m)?);
425        }
426        let mut copyrights = Vec::with_capacity(value.copyrights.len());
427        for c in &value.copyrights {
428            copyrights.push(crate::models::Copyright::try_from(c)?);
429        }
430        let mut holders = Vec::with_capacity(value.holders.len());
431        for h in &value.holders {
432            holders.push(crate::models::Holder::try_from(h)?);
433        }
434        let mut authors = Vec::with_capacity(value.authors.len());
435        for a in &value.authors {
436            authors.push(crate::models::Author::try_from(a)?);
437        }
438        let mut emails = Vec::with_capacity(value.emails.len());
439        for e in &value.emails {
440            emails.push(crate::models::OutputEmail::try_from(e)?);
441        }
442        let mut urls = Vec::with_capacity(value.urls.len());
443        for u in &value.urls {
444            urls.push(crate::models::OutputURL::try_from(u)?);
445        }
446        let license_policy = value
447            .license_policy
448            .as_ref()
449            .map(|v| {
450                v.iter()
451                    .map(crate::models::LicensePolicyEntry::try_from)
452                    .collect::<Result<Vec<_>, _>>()
453            })
454            .transpose()?;
455        Ok(Self {
456            name: value.name.clone(),
457            base_name: value.base_name.clone(),
458            extension: value.extension.clone(),
459            path: value.path.clone(),
460            file_type: crate::models::FileType::try_from(value.file_type)?,
461            mime_type: value.mime_type.clone(),
462            file_type_label: value.file_type_label.clone(),
463            size: value.size,
464            date: value.date.clone(),
465            sha1: value
466                .sha1
467                .as_ref()
468                .map(|s| crate::models::Sha1Digest::from_hex(s))
469                .transpose()
470                .map_err(|e| format!("invalid sha1: {}", e))?,
471            md5: value
472                .md5
473                .as_ref()
474                .map(|s| crate::models::Md5Digest::from_hex(s))
475                .transpose()
476                .map_err(|e| format!("invalid md5: {}", e))?,
477            sha256: value
478                .sha256
479                .as_ref()
480                .map(|s| crate::models::Sha256Digest::from_hex(s))
481                .transpose()
482                .map_err(|e| format!("invalid sha256: {}", e))?,
483            sha1_git: value
484                .sha1_git
485                .as_ref()
486                .map(|s| crate::models::GitSha1::from_hex(s))
487                .transpose()
488                .map_err(|e| format!("invalid sha1_git: {}", e))?,
489            programming_language: value.programming_language.clone(),
490            package_data,
491            detected_license_expression: value.license_expression.clone(),
492            license_detections,
493            license_clues,
494            percentage_of_license_text: value.percentage_of_license_text,
495            copyrights,
496            holders,
497            authors,
498            emails,
499            urls,
500            for_packages: value
501                .for_packages
502                .iter()
503                .map(|s| crate::models::PackageUid::from_raw(s.clone()))
504                .collect(),
505            scan_diagnostics: crate::models::diagnostics_from_legacy_scan_errors(
506                &value.scan_errors,
507            ),
508            license_policy,
509            is_generated: value.is_generated,
510            is_binary: value.is_binary,
511            is_text: value.is_text,
512            is_archive: value.is_archive,
513            is_media: value.is_media,
514            is_source: value.is_source,
515            is_script: value.is_script,
516            files_count: value.files_count,
517            dirs_count: value.dirs_count,
518            size_count: value.size_count,
519            source_count: value.source_count,
520            is_legal: value.is_legal,
521            is_manifest: value.is_manifest,
522            is_readme: value.is_readme,
523            is_top_level: value.is_top_level,
524            is_key_file: value.is_key_file,
525            is_referenced: value.is_referenced,
526            is_community: value.is_community,
527            facets: value.facets.clone(),
528            tallies: value
529                .tallies
530                .as_ref()
531                .map(crate::models::Tallies::try_from)
532                .transpose()?,
533        })
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::OutputFileInfo;
540    use crate::output_schema::OutputFileType;
541    use crate::output_schema::license_detection::OutputLicenseDetection;
542    use serde_json::json;
543
544    fn base_output_file_info() -> OutputFileInfo {
545        OutputFileInfo {
546            name: "mod.rs".to_string(),
547            base_name: "mod".to_string(),
548            extension: ".rs".to_string(),
549            path: "mod.rs".to_string(),
550            file_type: OutputFileType::File,
551            mime_type: None,
552            file_type_label: None,
553            size: 0,
554            date: None,
555            sha1: None,
556            md5: None,
557            sha256: None,
558            sha1_git: None,
559            programming_language: None,
560            package_data: Vec::new(),
561            license_expression: None,
562            license_detections: Vec::new(),
563            license_clues: Vec::new(),
564            percentage_of_license_text: None,
565            copyrights: Vec::new(),
566            holders: Vec::new(),
567            authors: Vec::new(),
568            emails: Vec::new(),
569            urls: Vec::new(),
570            for_packages: Vec::new(),
571            scan_errors: Vec::new(),
572            license_policy: None,
573            is_generated: None,
574            is_binary: None,
575            is_text: None,
576            is_archive: None,
577            is_media: None,
578            is_source: None,
579            is_script: None,
580            files_count: None,
581            dirs_count: None,
582            size_count: None,
583            source_count: None,
584            is_legal: false,
585            is_manifest: false,
586            is_readme: false,
587            is_top_level: false,
588            is_key_file: false,
589            is_referenced: false,
590            is_community: false,
591            facets: Vec::new(),
592            tallies: None,
593        }
594    }
595
596    #[test]
597    fn detected_license_expression_spdx_does_not_recombine_partial_detection_spdx() {
598        let mut file_info = base_output_file_info();
599        file_info.license_expression = Some("Apache-2.0 AND MIT".to_string());
600        file_info.license_detections = vec![
601            OutputLicenseDetection {
602                license_expression: "apache-2.0".to_string(),
603                license_expression_spdx: "Apache-2.0".to_string(),
604                matches: Vec::new(),
605                detection_log: Vec::new(),
606                identifier: None,
607            },
608            OutputLicenseDetection {
609                license_expression: "mit".to_string(),
610                license_expression_spdx: String::new(),
611                matches: Vec::new(),
612                detection_log: Vec::new(),
613                identifier: None,
614            },
615        ];
616
617        assert_eq!(
618            file_info.detected_license_expression_spdx().as_deref(),
619            Some("Apache-2.0 AND MIT")
620        );
621    }
622
623    #[test]
624    fn detected_license_expression_spdx_rejects_invalid_fallback_expression() {
625        let mut file_info = base_output_file_info();
626        file_info.license_expression = Some("MIT\" or malformed".to_string());
627
628        assert_eq!(file_info.detected_license_expression_spdx(), None);
629    }
630
631    #[test]
632    fn serialize_includes_is_referenced_only_when_true() {
633        let mut file_info = base_output_file_info();
634        let without_flag = serde_json::to_value(&file_info).expect("file should serialize");
635        assert_eq!(
636            without_flag,
637            json!({
638                "path": "mod.rs",
639                "type": "file",
640                "name": "mod.rs",
641                "base_name": "mod",
642                "extension": ".rs",
643                "size": 0,
644                "package_data": [],
645                "detected_license_expression": null,
646                "detected_license_expression_spdx": null,
647                "license_detections": [],
648                "copyrights": [],
649                "holders": [],
650                "authors": [],
651                "urls": [],
652                "for_packages": [],
653                "scan_errors": []
654            })
655        );
656
657        file_info.is_referenced = true;
658        let with_flag = serde_json::to_value(&file_info).expect("file should serialize");
659        assert_eq!(with_flag["is_referenced"], json!(true));
660    }
661}