1use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange, GroupedDiff, MetadataChange};
11use sbom_model::{Component, DependencyKind};
12use serde::Serialize;
13use std::collections::{BTreeMap, BTreeSet};
14use std::io::Write;
15
16#[derive(Debug, Clone, Default)]
18pub struct RenderOptions {
19 pub group_by_ecosystem: bool,
21 pub show_warnings: bool,
23 pub old_warnings: Vec<String>,
25 pub new_warnings: Vec<String>,
27}
28
29impl RenderOptions {
30 pub fn has_warnings(&self) -> bool {
32 self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
33 }
34
35 pub fn warning_count(&self) -> usize {
37 self.old_warnings.len() + self.new_warnings.len()
38 }
39}
40
41fn kind_suffix(kind: &DependencyKind) -> &'static str {
44 match kind {
45 DependencyKind::Runtime => "",
46 DependencyKind::Dev => " (dev)",
47 DependencyKind::Build => " (build)",
48 DependencyKind::Test => " (test)",
49 DependencyKind::Optional => " (optional)",
50 DependencyKind::Provided => " (provided)",
51 }
52}
53
54fn format_option(opt: &Option<String>) -> &str {
55 opt.as_deref().unwrap_or("<none>")
56}
57
58fn format_set(set: &BTreeSet<String>) -> String {
59 if set.is_empty() {
60 "<none>".to_string()
61 } else {
62 set.iter().cloned().collect::<Vec<_>>().join(", ")
63 }
64}
65
66pub trait Renderer {
68 fn render<W: Write>(
70 &self,
71 diff: &Diff,
72 opts: &RenderOptions,
73 writer: &mut W,
74 ) -> anyhow::Result<()>;
75}
76
77pub trait SummaryRenderer {
81 fn render_summary<W: Write>(
83 &self,
84 diff: &Diff,
85 opts: &RenderOptions,
86 writer: &mut W,
87 ) -> anyhow::Result<()>;
88}
89
90trait FieldChangeFormatter {
93 fn field_change<W: Write>(
94 &self,
95 w: &mut W,
96 name: &str,
97 old: &str,
98 new: &str,
99 ) -> std::io::Result<()>;
100 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
101 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
102 fn hash_changed<W: Write>(
103 &self,
104 w: &mut W,
105 algo: &str,
106 old: &str,
107 new: &str,
108 ) -> std::io::Result<()>;
109 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
110 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
111}
112
113fn write_field_changes<F: FieldChangeFormatter, W: Write>(
114 fmt: &F,
115 writer: &mut W,
116 changes: &[FieldChange],
117) -> std::io::Result<()> {
118 for change in changes {
119 match change {
120 FieldChange::Version(old, new) => {
121 fmt.field_change(writer, "Version", format_option(old), format_option(new))?;
122 }
123 FieldChange::License(old, new) => {
124 fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
125 }
126 FieldChange::Supplier(old, new) => {
127 fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
128 }
129 FieldChange::Purl(old, new) => {
130 fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
131 }
132 FieldChange::Description(old, new) => {
133 fmt.field_change(
134 writer,
135 "Description",
136 format_option(old),
137 format_option(new),
138 )?;
139 }
140 FieldChange::Hashes(old, new) => {
141 fmt.hash_header(writer)?;
142 for (algo, digest) in old {
143 if !new.contains_key(algo) {
144 fmt.hash_removed(writer, algo, digest)?;
145 } else if new[algo] != *digest {
146 fmt.hash_changed(writer, algo, digest, &new[algo])?;
147 }
148 }
149 for (algo, digest) in new {
150 if !old.contains_key(algo) {
151 fmt.hash_added(writer, algo, digest)?;
152 }
153 }
154 }
155 FieldChange::Ecosystem(old, new) => {
156 fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
157 }
158 }
159 }
160 Ok(())
161}
162
163fn write_changed<F: FieldChangeFormatter, W: Write>(
164 fmt: &F,
165 writer: &mut W,
166 changes: &[ComponentChange],
167) -> std::io::Result<()> {
168 for c in changes {
169 fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
170 write_field_changes(fmt, writer, &c.changes)?;
171 }
172 Ok(())
173}
174
175fn write_text_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
178 for c in components {
179 writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
180 }
181 Ok(())
182}
183
184pub struct TextRenderer;
186
187impl FieldChangeFormatter for TextRenderer {
188 fn field_change<W: Write>(
189 &self,
190 w: &mut W,
191 name: &str,
192 old: &str,
193 new: &str,
194 ) -> std::io::Result<()> {
195 writeln!(w, " {}: {} -> {}", name, old, new)
196 }
197
198 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
199 writeln!(w, " Hashes:")
200 }
201
202 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
203 writeln!(w, " - {}: {}", algo, digest)
204 }
205
206 fn hash_changed<W: Write>(
207 &self,
208 w: &mut W,
209 algo: &str,
210 old: &str,
211 new: &str,
212 ) -> std::io::Result<()> {
213 writeln!(w, " ~ {}: {} -> {}", algo, old, new)
214 }
215
216 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
217 writeln!(w, " + {}: {}", algo, digest)
218 }
219
220 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
221 writeln!(w, "{}", id)
222 }
223}
224
225impl Renderer for TextRenderer {
226 fn render<W: Write>(
227 &self,
228 diff: &Diff,
229 opts: &RenderOptions,
230 writer: &mut W,
231 ) -> anyhow::Result<()> {
232 if opts.has_warnings() {
233 writeln!(writer, "[!] Warnings")?;
234 writeln!(writer, "------------")?;
235 for w in &opts.old_warnings {
236 writeln!(writer, "[old] {}", w)?;
237 }
238 for w in &opts.new_warnings {
239 writeln!(writer, "[new] {}", w)?;
240 }
241 writeln!(writer)?;
242 }
243
244 writeln!(writer, "Diff Summary")?;
245 writeln!(writer, "============")?;
246 writeln!(writer, "Old total: {} components", diff.old_total)?;
247 writeln!(writer, "New total: {} components", diff.new_total)?;
248 writeln!(writer, "Unchanged: {}", diff.unchanged)?;
249 writeln!(writer, "Added: {}", diff.added.len())?;
250 writeln!(writer, "Removed: {}", diff.removed.len())?;
251 writeln!(writer, "Changed: {}", diff.changed.len())?;
252 writeln!(writer, "Edge changes: {}", diff.edge_diffs.len())?;
253 writeln!(
254 writer,
255 "Metadata changed: {}",
256 if diff.metadata_changed.is_some() {
257 "yes"
258 } else {
259 "no"
260 }
261 )?;
262 writeln!(writer)?;
263
264 if opts.group_by_ecosystem {
265 let grouped = diff.group_by_ecosystem();
266 let breakdown = grouped.ecosystem_breakdown();
267
268 writeln!(writer, "By Ecosystem")?;
269 writeln!(writer, "------------")?;
270 for (eco, counts) in &breakdown {
271 writeln!(
272 writer,
273 "{}: {} added, {} removed, {} changed",
274 eco, counts.added, counts.removed, counts.changed
275 )?;
276 }
277 writeln!(writer)?;
278
279 for (eco, eco_diff) in &grouped.by_ecosystem {
280 writeln!(writer, "[{}]", eco)?;
281 writeln!(writer)?;
282 if !eco_diff.added.is_empty() {
283 writeln!(writer, "[+] Added")?;
284 writeln!(writer, "---------")?;
285 write_text_added(writer, &eco_diff.added)?;
286 writeln!(writer)?;
287 }
288 if !eco_diff.removed.is_empty() {
289 writeln!(writer, "[-] Removed")?;
290 writeln!(writer, "-----------")?;
291 write_text_added(writer, &eco_diff.removed)?;
292 writeln!(writer)?;
293 }
294 if !eco_diff.changed.is_empty() {
295 writeln!(writer, "[~] Changed")?;
296 writeln!(writer, "-----------")?;
297 write_changed(self, writer, &eco_diff.changed)?;
298 writeln!(writer)?;
299 }
300 }
301 } else {
302 if !diff.added.is_empty() {
303 writeln!(writer, "[+] Added")?;
304 writeln!(writer, "---------")?;
305 write_text_added(writer, &diff.added)?;
306 writeln!(writer)?;
307 }
308
309 if !diff.removed.is_empty() {
310 writeln!(writer, "[-] Removed")?;
311 writeln!(writer, "-----------")?;
312 write_text_added(writer, &diff.removed)?;
313 writeln!(writer)?;
314 }
315
316 if !diff.changed.is_empty() {
317 writeln!(writer, "[~] Changed")?;
318 writeln!(writer, "-----------")?;
319 write_changed(self, writer, &diff.changed)?;
320 writeln!(writer)?;
321 }
322 }
323
324 if !diff.edge_diffs.is_empty() {
325 writeln!(writer, "[~] Edge Changes")?;
326 writeln!(writer, "----------------")?;
327 for edge in &diff.edge_diffs {
328 writeln!(writer, "{}", diff.display_name(&edge.parent))?;
329 for (removed, kind) in &edge.removed {
330 writeln!(
331 writer,
332 " - {}{}",
333 diff.display_name(removed),
334 kind_suffix(kind)
335 )?;
336 }
337 for (added, kind) in &edge.added {
338 writeln!(
339 writer,
340 " + {}{}",
341 diff.display_name(added),
342 kind_suffix(kind)
343 )?;
344 }
345 for (changed, (old_kind, new_kind)) in &edge.kind_changed {
346 writeln!(
347 writer,
348 " ~ {} ({} -> {})",
349 diff.display_name(changed),
350 old_kind,
351 new_kind
352 )?;
353 }
354 }
355 }
356
357 if let Some(mc) = &diff.metadata_changed {
358 writeln!(writer)?;
359 write_text_metadata(writer, mc)?;
360 }
361
362 Ok(())
363 }
364}
365
366fn write_text_metadata<W: Write>(writer: &mut W, mc: &MetadataChange) -> std::io::Result<()> {
367 writeln!(writer, "[~] Metadata Changes")?;
368 writeln!(writer, "--------------------")?;
369 if let Some((ref old, ref new)) = mc.timestamp {
370 writeln!(
371 writer,
372 " Timestamp: {} -> {}",
373 old.as_deref().unwrap_or("<none>"),
374 new.as_deref().unwrap_or("<none>")
375 )?;
376 }
377 if let Some((ref old, ref new)) = mc.tools {
378 writeln!(
379 writer,
380 " Tools: {} -> {}",
381 format_vec_or_none(old),
382 format_vec_or_none(new)
383 )?;
384 }
385 if let Some((ref old, ref new)) = mc.authors {
386 writeln!(
387 writer,
388 " Authors: {} -> {}",
389 format_vec_or_none(old),
390 format_vec_or_none(new)
391 )?;
392 }
393 Ok(())
394}
395
396fn format_vec_or_none(v: &[String]) -> String {
397 if v.is_empty() {
398 "<none>".to_string()
399 } else {
400 v.join(", ")
401 }
402}
403
404fn write_md_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
407 for c in components {
408 writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
409 }
410 Ok(())
411}
412
413pub struct MarkdownRenderer;
417
418impl FieldChangeFormatter for MarkdownRenderer {
419 fn field_change<W: Write>(
420 &self,
421 w: &mut W,
422 name: &str,
423 old: &str,
424 new: &str,
425 ) -> std::io::Result<()> {
426 writeln!(w, "- **{}**: `{}` → `{}`", name, old, new)
427 }
428
429 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
430 writeln!(w, "- **Hashes**:")
431 }
432
433 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
434 writeln!(w, " - `{}`: removed `{}`", algo, digest)
435 }
436
437 fn hash_changed<W: Write>(
438 &self,
439 w: &mut W,
440 algo: &str,
441 old: &str,
442 new: &str,
443 ) -> std::io::Result<()> {
444 writeln!(w, " - `{}`: `{}` → `{}`", algo, old, new)
445 }
446
447 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
448 writeln!(w, " - `{}`: added `{}`", algo, digest)
449 }
450
451 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
452 writeln!(w, "#### `{}`", id)
453 }
454}
455
456impl Renderer for MarkdownRenderer {
457 fn render<W: Write>(
458 &self,
459 diff: &Diff,
460 opts: &RenderOptions,
461 writer: &mut W,
462 ) -> anyhow::Result<()> {
463 if opts.has_warnings() {
464 writeln!(
465 writer,
466 "<details><summary><b>Warnings ({})</b></summary>",
467 opts.warning_count()
468 )?;
469 writeln!(writer)?;
470 for w in &opts.old_warnings {
471 writeln!(writer, "- **old:** {}", w)?;
472 }
473 for w in &opts.new_warnings {
474 writeln!(writer, "- **new:** {}", w)?;
475 }
476 writeln!(writer, "</details>")?;
477 writeln!(writer)?;
478 }
479
480 writeln!(writer, "### SBOM Diff Summary")?;
481 writeln!(writer)?;
482 writeln!(writer, "| Metric | Count |")?;
483 writeln!(writer, "| --- | --- |")?;
484 writeln!(writer, "| Old total | {} |", diff.old_total)?;
485 writeln!(writer, "| New total | {} |", diff.new_total)?;
486 writeln!(writer, "| Unchanged | {} |", diff.unchanged)?;
487 writeln!(writer, "| Added | {} |", diff.added.len())?;
488 writeln!(writer, "| Removed | {} |", diff.removed.len())?;
489 writeln!(writer, "| Changed | {} |", diff.changed.len())?;
490 writeln!(writer, "| Edge changes | {} |", diff.edge_diffs.len())?;
491 writeln!(
492 writer,
493 "| Metadata changed | {} |",
494 if diff.metadata_changed.is_some() {
495 "yes"
496 } else {
497 "no"
498 }
499 )?;
500 writeln!(writer)?;
501
502 if opts.group_by_ecosystem {
503 let grouped = diff.group_by_ecosystem();
504 let breakdown = grouped.ecosystem_breakdown();
505
506 writeln!(writer, "#### By Ecosystem")?;
507 writeln!(writer)?;
508 writeln!(writer, "| Ecosystem | Added | Removed | Changed |")?;
509 writeln!(writer, "| --- | --- | --- | --- |")?;
510 for (eco, counts) in &breakdown {
511 writeln!(
512 writer,
513 "| {} | {} | {} | {} |",
514 eco, counts.added, counts.removed, counts.changed
515 )?;
516 }
517 writeln!(writer)?;
518
519 for (eco, eco_diff) in &grouped.by_ecosystem {
520 writeln!(writer, "#### {}", eco)?;
521 writeln!(writer)?;
522 if !eco_diff.added.is_empty() {
523 writeln!(
524 writer,
525 "<details><summary><b>Added ({})</b></summary>",
526 eco_diff.added.len()
527 )?;
528 writeln!(writer)?;
529 write_md_added(writer, &eco_diff.added)?;
530 writeln!(writer, "</details>")?;
531 writeln!(writer)?;
532 }
533 if !eco_diff.removed.is_empty() {
534 writeln!(
535 writer,
536 "<details><summary><b>Removed ({})</b></summary>",
537 eco_diff.removed.len()
538 )?;
539 writeln!(writer)?;
540 write_md_added(writer, &eco_diff.removed)?;
541 writeln!(writer, "</details>")?;
542 writeln!(writer)?;
543 }
544 if !eco_diff.changed.is_empty() {
545 writeln!(
546 writer,
547 "<details><summary><b>Changed ({})</b></summary>",
548 eco_diff.changed.len()
549 )?;
550 writeln!(writer)?;
551 write_changed(self, writer, &eco_diff.changed)?;
552 writeln!(writer, "</details>")?;
553 writeln!(writer)?;
554 }
555 }
556 } else {
557 if !diff.added.is_empty() {
558 writeln!(
559 writer,
560 "<details><summary><b>Added ({})</b></summary>",
561 diff.added.len()
562 )?;
563 writeln!(writer)?;
564 write_md_added(writer, &diff.added)?;
565 writeln!(writer, "</details>")?;
566 writeln!(writer)?;
567 }
568
569 if !diff.removed.is_empty() {
570 writeln!(
571 writer,
572 "<details><summary><b>Removed ({})</b></summary>",
573 diff.removed.len()
574 )?;
575 writeln!(writer)?;
576 write_md_added(writer, &diff.removed)?;
577 writeln!(writer, "</details>")?;
578 writeln!(writer)?;
579 }
580
581 if !diff.changed.is_empty() {
582 writeln!(
583 writer,
584 "<details><summary><b>Changed ({})</b></summary>",
585 diff.changed.len()
586 )?;
587 writeln!(writer)?;
588 write_changed(self, writer, &diff.changed)?;
589 writeln!(writer, "</details>")?;
590 writeln!(writer)?;
591 }
592 }
593
594 if !diff.edge_diffs.is_empty() {
595 writeln!(
596 writer,
597 "<details><summary><b>Edge Changes ({})</b></summary>",
598 diff.edge_diffs.len()
599 )?;
600 writeln!(writer)?;
601 for edge in &diff.edge_diffs {
602 writeln!(writer, "#### `{}`", diff.display_name(&edge.parent))?;
603 if !edge.removed.is_empty() {
604 writeln!(writer, "**Removed dependencies:**")?;
605 for (removed, kind) in &edge.removed {
606 writeln!(
607 writer,
608 "- `{}`{}",
609 diff.display_name(removed),
610 kind_suffix(kind)
611 )?;
612 }
613 }
614 if !edge.added.is_empty() {
615 writeln!(writer, "**Added dependencies:**")?;
616 for (added, kind) in &edge.added {
617 writeln!(
618 writer,
619 "- `{}`{}",
620 diff.display_name(added),
621 kind_suffix(kind)
622 )?;
623 }
624 }
625 if !edge.kind_changed.is_empty() {
626 writeln!(writer, "**Kind changed:**")?;
627 for (changed, (old_kind, new_kind)) in &edge.kind_changed {
628 writeln!(
629 writer,
630 "- `{}`: {} → {}",
631 diff.display_name(changed),
632 old_kind,
633 new_kind
634 )?;
635 }
636 }
637 writeln!(writer)?;
638 }
639 writeln!(writer, "</details>")?;
640 }
641
642 if let Some(mc) = &diff.metadata_changed {
643 writeln!(writer)?;
644 write_md_metadata(writer, mc)?;
645 }
646
647 Ok(())
648 }
649}
650
651fn write_md_metadata<W: Write>(writer: &mut W, mc: &MetadataChange) -> std::io::Result<()> {
652 writeln!(
653 writer,
654 "<details><summary><b>Metadata Changes</b></summary>"
655 )?;
656 writeln!(writer)?;
657 if let Some((ref old, ref new)) = mc.timestamp {
658 writeln!(
659 writer,
660 "- **Timestamp**: `{}` → `{}`",
661 old.as_deref().unwrap_or("<none>"),
662 new.as_deref().unwrap_or("<none>")
663 )?;
664 }
665 if let Some((ref old, ref new)) = mc.tools {
666 writeln!(
667 writer,
668 "- **Tools**: `{}` → `{}`",
669 format_vec_or_none(old),
670 format_vec_or_none(new)
671 )?;
672 }
673 if let Some((ref old, ref new)) = mc.authors {
674 writeln!(
675 writer,
676 "- **Authors**: `{}` → `{}`",
677 format_vec_or_none(old),
678 format_vec_or_none(new)
679 )?;
680 }
681 writeln!(writer, "</details>")?;
682 Ok(())
683}
684
685pub struct JsonRenderer;
692
693#[derive(Serialize)]
695struct JsonOutput<'a> {
696 #[serde(flatten)]
697 diff: &'a Diff,
698 #[serde(skip_serializing_if = "Option::is_none")]
699 ecosystem_breakdown: Option<BTreeMap<String, EcosystemCounts>>,
700 #[serde(skip_serializing_if = "Option::is_none")]
701 by_ecosystem: Option<&'a GroupedDiff>,
702 #[serde(skip_serializing_if = "Option::is_none")]
703 warnings: Option<JsonWarnings<'a>>,
704}
705
706#[derive(Serialize)]
707struct JsonWarnings<'a> {
708 old: &'a Vec<String>,
709 new: &'a Vec<String>,
710}
711
712impl Renderer for JsonRenderer {
713 fn render<W: Write>(
714 &self,
715 diff: &Diff,
716 opts: &RenderOptions,
717 writer: &mut W,
718 ) -> anyhow::Result<()> {
719 let warnings = if opts.has_warnings() {
720 Some(JsonWarnings {
721 old: &opts.old_warnings,
722 new: &opts.new_warnings,
723 })
724 } else {
725 None
726 };
727
728 if opts.group_by_ecosystem {
729 let grouped = diff.group_by_ecosystem();
730 let output = JsonOutput {
731 diff,
732 ecosystem_breakdown: Some(grouped.ecosystem_breakdown()),
733 by_ecosystem: Some(&grouped),
734 warnings,
735 };
736 serde_json::to_writer_pretty(writer, &output)?;
737 } else {
738 let output = JsonOutput {
739 diff,
740 ecosystem_breakdown: None,
741 by_ecosystem: None,
742 warnings,
743 };
744 serde_json::to_writer_pretty(writer, &output)?;
745 }
746 Ok(())
747 }
748}
749
750const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
753const SARIF_VERSION: &str = "2.1.0";
754
755const RULE_COMPONENT_ADDED: usize = 0;
757const RULE_COMPONENT_REMOVED: usize = 1;
758const RULE_COMPONENT_CHANGED: usize = 2;
759const RULE_DEPENDENCY_CHANGED: usize = 3;
760const RULE_METADATA_CHANGED: usize = 4;
761const RULE_PARSER_WARNING: usize = 5;
762
763#[derive(Clone, Copy)]
764struct RuleInfo {
765 id: &'static str,
766 short_desc: &'static str,
767 full_desc: &'static str,
768 level: &'static str,
769}
770
771const SARIF_RULES: &[RuleInfo] = &[
772 RuleInfo {
773 id: "component-added",
774 short_desc: "Component added",
775 full_desc: "A new component was added to the SBOM",
776 level: "note",
777 },
778 RuleInfo {
779 id: "component-removed",
780 short_desc: "Component removed",
781 full_desc: "A component was removed from the SBOM",
782 level: "warning",
783 },
784 RuleInfo {
785 id: "component-changed",
786 short_desc: "Component changed",
787 full_desc: "A component's metadata changed between SBOMs",
788 level: "warning",
789 },
790 RuleInfo {
791 id: "dependency-changed",
792 short_desc: "Dependency changed",
793 full_desc: "A dependency edge was added, removed, or changed kind",
794 level: "note",
795 },
796 RuleInfo {
797 id: "metadata-changed",
798 short_desc: "Metadata changed",
799 full_desc: "Document metadata (timestamp, tools, or authors) changed between SBOMs",
800 level: "note",
801 },
802 RuleInfo {
803 id: "parser-warning",
804 short_desc: "Parser warning",
805 full_desc: "The SBOM parser emitted a warning about the input document",
806 level: "note",
807 },
808];
809
810#[derive(Serialize)]
811struct SarifLog {
812 #[serde(rename = "$schema")]
813 schema: &'static str,
814 version: &'static str,
815 runs: Vec<SarifRun>,
816}
817
818#[derive(Serialize)]
819struct SarifRun {
820 tool: SarifTool,
821 results: Vec<SarifResultEntry>,
822}
823
824#[derive(Serialize)]
825struct SarifTool {
826 driver: SarifDriverInfo,
827}
828
829#[derive(Serialize)]
830#[serde(rename_all = "camelCase")]
831struct SarifDriverInfo {
832 name: &'static str,
833 version: &'static str,
834 information_uri: &'static str,
835 rules: Vec<SarifRuleDescriptor>,
836}
837
838#[derive(Serialize)]
839#[serde(rename_all = "camelCase")]
840struct SarifRuleDescriptor {
841 id: &'static str,
842 short_description: SarifMultiformatMessage,
843 full_description: SarifMultiformatMessage,
844 default_configuration: SarifDefaultConfiguration,
845}
846
847#[derive(Serialize)]
848struct SarifDefaultConfiguration {
849 level: &'static str,
850}
851
852#[derive(Serialize)]
853struct SarifMultiformatMessage {
854 text: &'static str,
855}
856
857#[derive(Serialize)]
858#[serde(rename_all = "camelCase")]
859struct SarifResultEntry {
860 rule_id: &'static str,
861 rule_index: usize,
862 level: &'static str,
863 message: SarifTextMessage,
864 locations: Vec<SarifLocation>,
865}
866
867#[derive(Serialize)]
868struct SarifTextMessage {
869 text: String,
870}
871
872#[derive(Serialize)]
873#[serde(rename_all = "camelCase")]
874struct SarifLocation {
875 logical_locations: Vec<SarifLogicalLocation>,
876}
877
878#[derive(Serialize)]
879#[serde(rename_all = "camelCase")]
880struct SarifLogicalLocation {
881 fully_qualified_name: String,
882 kind: &'static str,
883}
884
885pub struct SarifRenderer;
891
892impl SarifRenderer {
893 fn build_rules() -> Vec<SarifRuleDescriptor> {
894 SARIF_RULES
895 .iter()
896 .map(|r| SarifRuleDescriptor {
897 id: r.id,
898 short_description: SarifMultiformatMessage { text: r.short_desc },
899 full_description: SarifMultiformatMessage { text: r.full_desc },
900 default_configuration: SarifDefaultConfiguration { level: r.level },
901 })
902 .collect()
903 }
904
905 fn component_display(comp: &Component) -> &str {
906 comp.purl.as_deref().unwrap_or(comp.id.as_str())
907 }
908
909 fn component_location(comp: &Component) -> Vec<SarifLocation> {
910 vec![SarifLocation {
911 logical_locations: vec![SarifLogicalLocation {
912 fully_qualified_name: Self::component_display(comp).to_string(),
913 kind: "package",
914 }],
915 }]
916 }
917
918 fn format_field_change(fc: &FieldChange) -> String {
919 match fc {
920 FieldChange::Version(old, new) => {
921 format!("version: {} -> {}", format_option(old), format_option(new))
922 }
923 FieldChange::License(old, new) => {
924 format!("license: {} -> {}", format_set(old), format_set(new))
925 }
926 FieldChange::Supplier(old, new) => {
927 format!("supplier: {} -> {}", format_option(old), format_option(new))
928 }
929 FieldChange::Purl(old, new) => {
930 format!("purl: {} -> {}", format_option(old), format_option(new))
931 }
932 FieldChange::Description(old, new) => {
933 format!(
934 "description: {} -> {}",
935 format_option(old),
936 format_option(new)
937 )
938 }
939 FieldChange::Hashes(_, _) => "hashes changed".to_string(),
940 FieldChange::Ecosystem(old, new) => {
941 format!(
942 "ecosystem: {} -> {}",
943 format_option(old),
944 format_option(new)
945 )
946 }
947 }
948 }
949
950 fn build_results(diff: &Diff, opts: &RenderOptions) -> Vec<SarifResultEntry> {
951 let mut results = Vec::new();
952
953 if opts.has_warnings() {
954 for w in &opts.old_warnings {
955 results.push(SarifResultEntry {
956 rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
957 rule_index: RULE_PARSER_WARNING,
958 level: SARIF_RULES[RULE_PARSER_WARNING].level,
959 message: SarifTextMessage {
960 text: format!("Parser warning (old SBOM): {}", w),
961 },
962 locations: vec![SarifLocation {
963 logical_locations: vec![SarifLogicalLocation {
964 fully_qualified_name: "old-sbom".to_string(),
965 kind: "module",
966 }],
967 }],
968 });
969 }
970 for w in &opts.new_warnings {
971 results.push(SarifResultEntry {
972 rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
973 rule_index: RULE_PARSER_WARNING,
974 level: SARIF_RULES[RULE_PARSER_WARNING].level,
975 message: SarifTextMessage {
976 text: format!("Parser warning (new SBOM): {}", w),
977 },
978 locations: vec![SarifLocation {
979 logical_locations: vec![SarifLogicalLocation {
980 fully_qualified_name: "new-sbom".to_string(),
981 kind: "module",
982 }],
983 }],
984 });
985 }
986 }
987
988 for comp in &diff.added {
989 results.push(SarifResultEntry {
990 rule_id: SARIF_RULES[RULE_COMPONENT_ADDED].id,
991 rule_index: RULE_COMPONENT_ADDED,
992 level: SARIF_RULES[RULE_COMPONENT_ADDED].level,
993 message: SarifTextMessage {
994 text: format!("Component added: {}", Self::component_display(comp)),
995 },
996 locations: Self::component_location(comp),
997 });
998 }
999
1000 for comp in &diff.removed {
1001 results.push(SarifResultEntry {
1002 rule_id: SARIF_RULES[RULE_COMPONENT_REMOVED].id,
1003 rule_index: RULE_COMPONENT_REMOVED,
1004 level: SARIF_RULES[RULE_COMPONENT_REMOVED].level,
1005 message: SarifTextMessage {
1006 text: format!("Component removed: {}", Self::component_display(comp)),
1007 },
1008 locations: Self::component_location(comp),
1009 });
1010 }
1011
1012 for change in &diff.changed {
1013 let display = Self::component_display(&change.new);
1014 let field_changes: Vec<String> = change
1015 .changes
1016 .iter()
1017 .map(Self::format_field_change)
1018 .collect();
1019
1020 results.push(SarifResultEntry {
1021 rule_id: SARIF_RULES[RULE_COMPONENT_CHANGED].id,
1022 rule_index: RULE_COMPONENT_CHANGED,
1023 level: SARIF_RULES[RULE_COMPONENT_CHANGED].level,
1024 message: SarifTextMessage {
1025 text: format!(
1026 "Component changed: {} ({})",
1027 display,
1028 field_changes.join("; "),
1029 ),
1030 },
1031 locations: Self::component_location(&change.new),
1032 });
1033 }
1034
1035 for edge in &diff.edge_diffs {
1036 let parent = diff.display_name(&edge.parent);
1037 let mut parts = Vec::new();
1038
1039 for (child, kind) in &edge.added {
1040 parts.push(format!(
1041 "added {} -> {}{}",
1042 parent,
1043 diff.display_name(child),
1044 kind_suffix(kind)
1045 ));
1046 }
1047 for (child, kind) in &edge.removed {
1048 parts.push(format!(
1049 "removed {} -> {}{}",
1050 parent,
1051 diff.display_name(child),
1052 kind_suffix(kind)
1053 ));
1054 }
1055 for (child, (old_kind, new_kind)) in &edge.kind_changed {
1056 parts.push(format!(
1057 "{} -> {} kind: {} -> {}",
1058 parent,
1059 diff.display_name(child),
1060 old_kind,
1061 new_kind
1062 ));
1063 }
1064
1065 if !parts.is_empty() {
1066 results.push(SarifResultEntry {
1067 rule_id: SARIF_RULES[RULE_DEPENDENCY_CHANGED].id,
1068 rule_index: RULE_DEPENDENCY_CHANGED,
1069 level: SARIF_RULES[RULE_DEPENDENCY_CHANGED].level,
1070 message: SarifTextMessage {
1071 text: format!("Dependency changed: {}", parts.join("; ")),
1072 },
1073 locations: vec![SarifLocation {
1074 logical_locations: vec![SarifLogicalLocation {
1075 fully_qualified_name: parent.to_string(),
1076 kind: "package",
1077 }],
1078 }],
1079 });
1080 }
1081 }
1082
1083 if let Some(mc) = &diff.metadata_changed {
1084 let mut parts = Vec::new();
1085 if let Some((ref old, ref new)) = mc.timestamp {
1086 parts.push(format!(
1087 "timestamp: {} -> {}",
1088 old.as_deref().unwrap_or("<none>"),
1089 new.as_deref().unwrap_or("<none>")
1090 ));
1091 }
1092 if let Some((ref old, ref new)) = mc.tools {
1093 parts.push(format!(
1094 "tools: {} -> {}",
1095 format_vec_or_none(old),
1096 format_vec_or_none(new)
1097 ));
1098 }
1099 if let Some((ref old, ref new)) = mc.authors {
1100 parts.push(format!(
1101 "authors: {} -> {}",
1102 format_vec_or_none(old),
1103 format_vec_or_none(new)
1104 ));
1105 }
1106
1107 if !parts.is_empty() {
1108 results.push(SarifResultEntry {
1109 rule_id: SARIF_RULES[RULE_METADATA_CHANGED].id,
1110 rule_index: RULE_METADATA_CHANGED,
1111 level: SARIF_RULES[RULE_METADATA_CHANGED].level,
1112 message: SarifTextMessage {
1113 text: format!("Metadata changed: {}", parts.join("; ")),
1114 },
1115 locations: vec![SarifLocation {
1116 logical_locations: vec![SarifLogicalLocation {
1117 fully_qualified_name: "metadata".to_string(),
1118 kind: "module",
1119 }],
1120 }],
1121 });
1122 }
1123 }
1124
1125 results
1126 }
1127}
1128
1129impl Renderer for SarifRenderer {
1130 fn render<W: Write>(
1131 &self,
1132 diff: &Diff,
1133 opts: &RenderOptions,
1134 writer: &mut W,
1135 ) -> anyhow::Result<()> {
1136 let log = SarifLog {
1137 schema: SARIF_SCHEMA,
1138 version: SARIF_VERSION,
1139 runs: vec![SarifRun {
1140 tool: SarifTool {
1141 driver: SarifDriverInfo {
1142 name: "sbom-diff",
1143 version: env!("CARGO_PKG_VERSION"),
1144 information_uri: "https://github.com/cyberwitchery/sbom-diff",
1145 rules: Self::build_rules(),
1146 },
1147 },
1148 results: Self::build_results(diff, opts),
1149 }],
1150 };
1151 serde_json::to_writer_pretty(writer, &log)?;
1152 Ok(())
1153 }
1154}
1155
1156impl SummaryRenderer for SarifRenderer {
1157 fn render_summary<W: Write>(
1158 &self,
1159 diff: &Diff,
1160 opts: &RenderOptions,
1161 writer: &mut W,
1162 ) -> anyhow::Result<()> {
1163 self.render(diff, opts, writer)
1164 }
1165}
1166
1167trait SummaryFormatter {
1176 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
1177 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
1178 fn write_ecosystem_breakdown<W: Write>(
1179 &self,
1180 w: &mut W,
1181 breakdown: &BTreeMap<String, EcosystemCounts>,
1182 ) -> std::io::Result<()>;
1183}
1184
1185fn write_summary<F: SummaryFormatter, W: Write>(
1186 fmt: &F,
1187 diff: &Diff,
1188 opts: &RenderOptions,
1189 writer: &mut W,
1190) -> std::io::Result<()> {
1191 if opts.has_warnings() {
1192 fmt.write_warnings(writer, opts)?;
1193 }
1194 fmt.write_counts(writer, diff)?;
1195 if opts.group_by_ecosystem {
1196 let breakdown = diff.ecosystem_breakdown();
1197 if !breakdown.is_empty() {
1198 fmt.write_ecosystem_breakdown(writer, &breakdown)?;
1199 }
1200 }
1201 Ok(())
1202}
1203
1204impl SummaryFormatter for TextRenderer {
1205 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
1206 writeln!(w, "Warnings: {}", opts.warning_count())?;
1207 for warning in &opts.old_warnings {
1208 writeln!(w, " [old] {}", warning)?;
1209 }
1210 for warning in &opts.new_warnings {
1211 writeln!(w, " [new] {}", warning)?;
1212 }
1213 writeln!(w)
1214 }
1215
1216 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
1217 writeln!(w, "Old total: {} components", diff.old_total)?;
1218 writeln!(w, "New total: {} components", diff.new_total)?;
1219 writeln!(w, "Unchanged: {}", diff.unchanged)?;
1220 writeln!(w, "Added: {}", diff.added.len())?;
1221 writeln!(w, "Removed: {}", diff.removed.len())?;
1222 writeln!(w, "Changed: {}", diff.changed.len())?;
1223 writeln!(w, "Edge changes: {}", diff.edge_diffs.len())?;
1224 writeln!(
1225 w,
1226 "Metadata changed: {}",
1227 if diff.metadata_changed.is_some() {
1228 "yes"
1229 } else {
1230 "no"
1231 }
1232 )
1233 }
1234
1235 fn write_ecosystem_breakdown<W: Write>(
1236 &self,
1237 w: &mut W,
1238 breakdown: &BTreeMap<String, EcosystemCounts>,
1239 ) -> std::io::Result<()> {
1240 writeln!(w)?;
1241 writeln!(w, "By ecosystem:")?;
1242 for (eco, counts) in breakdown {
1243 writeln!(
1244 w,
1245 " {}: {} added, {} removed, {} changed",
1246 eco, counts.added, counts.removed, counts.changed
1247 )?;
1248 }
1249 Ok(())
1250 }
1251}
1252
1253impl SummaryRenderer for TextRenderer {
1254 fn render_summary<W: Write>(
1255 &self,
1256 diff: &Diff,
1257 opts: &RenderOptions,
1258 writer: &mut W,
1259 ) -> anyhow::Result<()> {
1260 write_summary(self, diff, opts, writer)?;
1261 Ok(())
1262 }
1263}
1264
1265impl SummaryFormatter for MarkdownRenderer {
1266 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
1267 writeln!(
1268 w,
1269 "<details><summary><b>Warnings ({})</b></summary>",
1270 opts.warning_count()
1271 )?;
1272 writeln!(w)?;
1273 for warning in &opts.old_warnings {
1274 writeln!(w, "- **old:** {}", warning)?;
1275 }
1276 for warning in &opts.new_warnings {
1277 writeln!(w, "- **new:** {}", warning)?;
1278 }
1279 writeln!(w, "</details>")?;
1280 writeln!(w)
1281 }
1282
1283 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
1284 writeln!(w, "### SBOM Diff Summary")?;
1285 writeln!(w)?;
1286 writeln!(w, "| Metric | Count |")?;
1287 writeln!(w, "| --- | --- |")?;
1288 writeln!(w, "| Old total | {} |", diff.old_total)?;
1289 writeln!(w, "| New total | {} |", diff.new_total)?;
1290 writeln!(w, "| Unchanged | {} |", diff.unchanged)?;
1291 writeln!(w, "| Added | {} |", diff.added.len())?;
1292 writeln!(w, "| Removed | {} |", diff.removed.len())?;
1293 writeln!(w, "| Changed | {} |", diff.changed.len())?;
1294 writeln!(w, "| Edge changes | {} |", diff.edge_diffs.len())?;
1295 writeln!(
1296 w,
1297 "| Metadata changed | {} |",
1298 if diff.metadata_changed.is_some() {
1299 "yes"
1300 } else {
1301 "no"
1302 }
1303 )
1304 }
1305
1306 fn write_ecosystem_breakdown<W: Write>(
1307 &self,
1308 w: &mut W,
1309 breakdown: &BTreeMap<String, EcosystemCounts>,
1310 ) -> std::io::Result<()> {
1311 writeln!(w)?;
1312 writeln!(w, "#### By Ecosystem")?;
1313 writeln!(w)?;
1314 writeln!(w, "| Ecosystem | Added | Removed | Changed |")?;
1315 writeln!(w, "| --- | --- | --- | --- |")?;
1316 for (eco, counts) in breakdown {
1317 writeln!(
1318 w,
1319 "| {} | {} | {} | {} |",
1320 eco, counts.added, counts.removed, counts.changed
1321 )?;
1322 }
1323 Ok(())
1324 }
1325}
1326
1327impl SummaryRenderer for MarkdownRenderer {
1328 fn render_summary<W: Write>(
1329 &self,
1330 diff: &Diff,
1331 opts: &RenderOptions,
1332 writer: &mut W,
1333 ) -> anyhow::Result<()> {
1334 write_summary(self, diff, opts, writer)?;
1335 Ok(())
1336 }
1337}
1338
1339impl SummaryRenderer for JsonRenderer {
1340 fn render_summary<W: Write>(
1341 &self,
1342 diff: &Diff,
1343 opts: &RenderOptions,
1344 writer: &mut W,
1345 ) -> anyhow::Result<()> {
1346 let mut summary = serde_json::json!({
1347 "old_total": diff.old_total,
1348 "new_total": diff.new_total,
1349 "unchanged": diff.unchanged,
1350 "added": diff.added.len(),
1351 "removed": diff.removed.len(),
1352 "changed": diff.changed.len(),
1353 "edge_changes": diff.edge_diffs.len(),
1354 "metadata_changed": diff.metadata_changed.is_some(),
1355 });
1356
1357 if let Some(mc) = &diff.metadata_changed {
1358 summary["metadata_changes"] = serde_json::to_value(mc)?;
1359 }
1360
1361 if opts.has_warnings() {
1362 summary["warnings"] = serde_json::json!({
1363 "old": opts.old_warnings,
1364 "new": opts.new_warnings,
1365 });
1366 }
1367
1368 if opts.group_by_ecosystem {
1369 let breakdown = diff.ecosystem_breakdown();
1370 if !breakdown.is_empty() {
1371 summary["ecosystem_breakdown"] = serde_json::to_value(&breakdown)?;
1372 }
1373 }
1374
1375 serde_json::to_writer_pretty(writer, &summary)
1376 .map_err(|e| anyhow::anyhow!("json summary: {}", e))
1377 }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 use super::*;
1383 use crate::{ComponentChange, Diff, FieldChange};
1384 use sbom_model::Component;
1385 use std::collections::BTreeMap;
1386
1387 fn mock_diff() -> Diff {
1388 let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
1389 let mut c2 = c1.clone();
1390 c2.version = Some("1.1".into());
1391
1392 Diff {
1393 added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
1394 removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
1395 changed: vec![ComponentChange {
1396 id: c2.id.clone(),
1397 old: c1,
1398 new: c2,
1399 changes: vec![FieldChange::Version(Some("1.0".into()), Some("1.1".into()))],
1400 }],
1401 edge_diffs: vec![],
1402 ..Diff::default()
1403 }
1404 }
1405
1406 fn mock_diff_all_field_changes() -> Diff {
1407 use sbom_model::{ComponentId, DependencyKind};
1408
1409 let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
1410 let mut c2 = c1.clone();
1411 c2.version = Some("1.1".into());
1412
1413 Diff {
1414 added: vec![],
1415 removed: vec![],
1416 changed: vec![ComponentChange {
1417 id: c2.id.clone(),
1418 old: c1,
1419 new: c2,
1420 changes: vec![
1421 FieldChange::Version(Some("1.0".into()), Some("1.1".into())),
1422 FieldChange::License(
1423 BTreeSet::from(["MIT".into()]),
1424 BTreeSet::from(["Apache-2.0".into()]),
1425 ),
1426 FieldChange::Supplier(Some("Old Corp".into()), Some("New Corp".into())),
1427 FieldChange::Purl(
1428 Some("pkg:npm/pkg-a@1.0".into()),
1429 Some("pkg:npm/pkg-a@1.1".into()),
1430 ),
1431 FieldChange::Description(
1432 Some("Old description".into()),
1433 Some("New description".into()),
1434 ),
1435 FieldChange::Hashes(
1436 BTreeMap::from([("sha256".into(), "aaa".into())]),
1437 BTreeMap::from([("sha256".into(), "bbb".into())]),
1438 ),
1439 FieldChange::Ecosystem(Some("npm".into()), Some("cargo".into())),
1440 ],
1441 }],
1442 edge_diffs: vec![crate::EdgeDiff {
1443 parent: ComponentId::new(None, &[("name", "parent")]),
1444 added: BTreeMap::from([(
1445 ComponentId::new(None, &[("name", "child-b")]),
1446 DependencyKind::Runtime,
1447 )]),
1448 removed: BTreeMap::from([(
1449 ComponentId::new(None, &[("name", "child-a")]),
1450 DependencyKind::Runtime,
1451 )]),
1452 kind_changed: BTreeMap::new(),
1453 }],
1454 ..Diff::default()
1455 }
1456 }
1457
1458 fn mock_diff_empty() -> Diff {
1459 Diff {
1460 added: vec![],
1461 removed: vec![],
1462 changed: vec![],
1463 edge_diffs: vec![],
1464 ..Diff::default()
1465 }
1466 }
1467
1468 #[test]
1469 fn test_text_renderer() {
1470 let diff = mock_diff();
1471 let mut buf = Vec::new();
1472 TextRenderer
1473 .render(&diff, &RenderOptions::default(), &mut buf)
1474 .unwrap();
1475 let out = String::from_utf8(buf).unwrap();
1476 assert!(out.contains("Diff Summary"));
1477 assert!(out.contains("[+] Added"));
1478 assert!(out.contains("[-] Removed"));
1479 assert!(out.contains("[~] Changed"));
1480 }
1481
1482 #[test]
1483 fn test_text_renderer_all_field_changes() {
1484 let diff = mock_diff_all_field_changes();
1485 let mut buf = Vec::new();
1486 TextRenderer
1487 .render(&diff, &RenderOptions::default(), &mut buf)
1488 .unwrap();
1489 let out = String::from_utf8(buf).unwrap();
1490
1491 assert!(out.contains("Version: 1.0 -> 1.1"));
1492 assert!(out.contains("License:"));
1493 assert!(out.contains("MIT"));
1494 assert!(out.contains("Apache-2.0"));
1495 assert!(out.contains("Supplier:"));
1496 assert!(out.contains("Old Corp"));
1497 assert!(out.contains("New Corp"));
1498 assert!(out.contains("Purl:"));
1499 assert!(out.contains("Description:"));
1500 assert!(out.contains("Old description"));
1501 assert!(out.contains("New description"));
1502 assert!(out.contains("Hashes:"));
1503 assert!(out.contains("~ sha256: aaa -> bbb"));
1504 assert!(out.contains("Ecosystem: npm -> cargo"));
1505 assert!(out.contains("[~] Edge Changes"));
1506 }
1507
1508 #[test]
1509 fn test_text_renderer_empty_diff() {
1510 let diff = mock_diff_empty();
1511 let mut buf = Vec::new();
1512 TextRenderer
1513 .render(&diff, &RenderOptions::default(), &mut buf)
1514 .unwrap();
1515 let out = String::from_utf8(buf).unwrap();
1516
1517 assert!(out.contains("Old total: 0 components"));
1518 assert!(out.contains("New total: 0 components"));
1519 assert!(out.contains("Unchanged: 0"));
1520 assert!(out.contains("Added: 0"));
1521 assert!(out.contains("Removed: 0"));
1522 assert!(out.contains("Changed: 0"));
1523 assert!(out.contains("Edge changes: 0"));
1524 assert!(out.contains("Metadata changed: no"));
1525 assert!(!out.contains("[+] Added"));
1526 assert!(!out.contains("[-] Removed"));
1527 assert!(!out.contains("[~] Changed"));
1528 }
1529
1530 #[test]
1531 fn test_markdown_renderer() {
1532 let diff = mock_diff();
1533 let mut buf = Vec::new();
1534 MarkdownRenderer
1535 .render(&diff, &RenderOptions::default(), &mut buf)
1536 .unwrap();
1537 let out = String::from_utf8(buf).unwrap();
1538 assert!(out.contains("### SBOM Diff Summary"));
1539 assert!(out.contains("<details>"));
1540 }
1541
1542 #[test]
1543 fn test_markdown_renderer_all_field_changes() {
1544 let diff = mock_diff_all_field_changes();
1545 let mut buf = Vec::new();
1546 MarkdownRenderer
1547 .render(&diff, &RenderOptions::default(), &mut buf)
1548 .unwrap();
1549 let out = String::from_utf8(buf).unwrap();
1550
1551 assert!(out.contains("**Version**"));
1552 assert!(out.contains("**License**"));
1553 assert!(out.contains("**Supplier**"));
1554 assert!(out.contains("**Purl**"));
1555 assert!(out.contains("**Description**"));
1556 assert!(out.contains("**Hashes**:"));
1557 assert!(out.contains("`sha256`: `aaa` → `bbb`"));
1558 assert!(out.contains("**Ecosystem**"));
1559 assert!(out.contains("Edge Changes"));
1560 assert!(out.contains("**Removed dependencies:**"));
1561 assert!(out.contains("**Added dependencies:**"));
1562 }
1563
1564 #[test]
1565 fn test_markdown_renderer_empty_diff() {
1566 let diff = mock_diff_empty();
1567 let mut buf = Vec::new();
1568 MarkdownRenderer
1569 .render(&diff, &RenderOptions::default(), &mut buf)
1570 .unwrap();
1571 let out = String::from_utf8(buf).unwrap();
1572
1573 assert!(out.contains("| Added | 0 |"));
1574 assert!(!out.contains("<details>"));
1575 }
1576
1577 #[test]
1578 fn test_json_renderer() {
1579 let diff = mock_diff();
1580 let mut buf = Vec::new();
1581 JsonRenderer
1582 .render(&diff, &RenderOptions::default(), &mut buf)
1583 .unwrap();
1584 let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1585 }
1586
1587 #[test]
1588 fn test_json_renderer_all_field_changes() {
1589 let diff = mock_diff_all_field_changes();
1590 let mut buf = Vec::new();
1591 JsonRenderer
1592 .render(&diff, &RenderOptions::default(), &mut buf)
1593 .unwrap();
1594 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1595
1596 assert_eq!(val["changed"].as_array().unwrap().len(), 1);
1597 assert_eq!(val["changed"][0]["changes"].as_array().unwrap().len(), 7);
1598 assert_eq!(val["edge_diffs"].as_array().unwrap().len(), 1);
1599 }
1600
1601 #[test]
1602 fn test_json_renderer_roundtrip() {
1603 let diff = mock_diff_all_field_changes();
1604 let mut buf = Vec::new();
1605 JsonRenderer
1606 .render(&diff, &RenderOptions::default(), &mut buf)
1607 .unwrap();
1608
1609 let deserialized: Diff = serde_json::from_slice(&buf).unwrap();
1610 assert_eq!(deserialized.changed.len(), diff.changed.len());
1611 assert_eq!(deserialized.edge_diffs.len(), diff.edge_diffs.len());
1612 assert_eq!(deserialized.changed[0].changes, diff.changed[0].changes);
1613 }
1614
1615 fn mock_diff_with_ecosystems() -> Diff {
1616 let mut added_npm = Component::new("express".into(), Some("4.18.0".into()));
1617 added_npm.ecosystem = Some("npm".into());
1618 let mut added_cargo = Component::new("serde".into(), Some("1.0.0".into()));
1619 added_cargo.ecosystem = Some("cargo".into());
1620
1621 let mut removed = Component::new("lodash".into(), Some("4.17.21".into()));
1622 removed.ecosystem = Some("npm".into());
1623
1624 let mut old = Component::new("react".into(), Some("17.0.0".into()));
1625 old.ecosystem = Some("npm".into());
1626 let mut new = old.clone();
1627 new.version = Some("18.0.0".into());
1628
1629 Diff {
1630 added: vec![added_npm, added_cargo],
1631 removed: vec![removed],
1632 changed: vec![ComponentChange {
1633 id: new.id.clone(),
1634 old,
1635 new,
1636 changes: vec![FieldChange::Version(
1637 Some("17.0.0".into()),
1638 Some("18.0.0".into()),
1639 )],
1640 }],
1641 edge_diffs: vec![],
1642 ..Diff::default()
1643 }
1644 }
1645
1646 #[test]
1647 fn test_text_renderer_group_by_ecosystem() {
1648 let diff = mock_diff_with_ecosystems();
1649 let opts = RenderOptions {
1650 group_by_ecosystem: true,
1651 ..Default::default()
1652 };
1653 let mut buf = Vec::new();
1654 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1655 let out = String::from_utf8(buf).unwrap();
1656
1657 assert!(out.contains("By Ecosystem"));
1658 assert!(out.contains("cargo: 1 added, 0 removed, 0 changed"));
1659 assert!(out.contains("npm: 1 added, 1 removed, 1 changed"));
1660 }
1661
1662 #[test]
1663 fn test_text_renderer_no_ecosystem_by_default() {
1664 let diff = mock_diff_with_ecosystems();
1665 let mut buf = Vec::new();
1666 TextRenderer
1667 .render(&diff, &RenderOptions::default(), &mut buf)
1668 .unwrap();
1669 let out = String::from_utf8(buf).unwrap();
1670
1671 assert!(!out.contains("By Ecosystem"));
1672 }
1673
1674 #[test]
1675 fn test_markdown_renderer_group_by_ecosystem() {
1676 let diff = mock_diff_with_ecosystems();
1677 let opts = RenderOptions {
1678 group_by_ecosystem: true,
1679 ..Default::default()
1680 };
1681 let mut buf = Vec::new();
1682 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1683 let out = String::from_utf8(buf).unwrap();
1684
1685 assert!(out.contains("#### By Ecosystem"));
1686 assert!(out.contains("| Ecosystem | Added | Removed | Changed |"));
1687 assert!(out.contains("| cargo | 1 | 0 | 0 |"));
1688 assert!(out.contains("| npm | 1 | 1 | 1 |"));
1689 }
1690
1691 #[test]
1692 fn test_json_renderer_group_by_ecosystem() {
1693 let diff = mock_diff_with_ecosystems();
1694 let opts = RenderOptions {
1695 group_by_ecosystem: true,
1696 ..Default::default()
1697 };
1698 let mut buf = Vec::new();
1699 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1700 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1701
1702 let breakdown = &val["ecosystem_breakdown"];
1703 assert!(breakdown.is_object());
1704 assert_eq!(breakdown["npm"]["added"], 1);
1705 assert_eq!(breakdown["npm"]["removed"], 1);
1706 assert_eq!(breakdown["npm"]["changed"], 1);
1707 assert_eq!(breakdown["cargo"]["added"], 1);
1708 assert_eq!(breakdown["cargo"]["removed"], 0);
1709 }
1710
1711 #[test]
1712 fn test_json_renderer_no_ecosystem_by_default() {
1713 let diff = mock_diff_with_ecosystems();
1714 let mut buf = Vec::new();
1715 JsonRenderer
1716 .render(&diff, &RenderOptions::default(), &mut buf)
1717 .unwrap();
1718 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1719
1720 assert!(val.get("ecosystem_breakdown").is_none());
1721 }
1722
1723 fn opts_with_warnings() -> RenderOptions {
1724 RenderOptions {
1725 show_warnings: true,
1726 old_warnings: vec!["SPDX: orphaned ref 'SPDXRef-foo'".into()],
1727 new_warnings: vec!["CycloneDX: unknown bom-ref 'bar'".into()],
1728 ..Default::default()
1729 }
1730 }
1731
1732 #[test]
1733 fn test_text_renderer_shows_warnings() {
1734 let diff = mock_diff();
1735 let opts = opts_with_warnings();
1736 let mut buf = Vec::new();
1737 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1738 let out = String::from_utf8(buf).unwrap();
1739
1740 assert!(out.contains("[!] Warnings"));
1741 assert!(out.contains("[old] SPDX: orphaned ref 'SPDXRef-foo'"));
1742 assert!(out.contains("[new] CycloneDX: unknown bom-ref 'bar'"));
1743 }
1744
1745 #[test]
1746 fn test_text_renderer_hides_warnings_by_default() {
1747 let diff = mock_diff();
1748 let mut buf = Vec::new();
1749 TextRenderer
1750 .render(&diff, &RenderOptions::default(), &mut buf)
1751 .unwrap();
1752 let out = String::from_utf8(buf).unwrap();
1753
1754 assert!(!out.contains("[!] Warnings"));
1755 }
1756
1757 #[test]
1758 fn test_markdown_renderer_shows_warnings() {
1759 let diff = mock_diff();
1760 let opts = opts_with_warnings();
1761 let mut buf = Vec::new();
1762 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1763 let out = String::from_utf8(buf).unwrap();
1764
1765 assert!(out.contains("<details><summary><b>Warnings (2)</b></summary>"));
1766 assert!(out.contains("- **old:** SPDX: orphaned ref 'SPDXRef-foo'"));
1767 assert!(out.contains("- **new:** CycloneDX: unknown bom-ref 'bar'"));
1768 }
1769
1770 #[test]
1771 fn test_markdown_renderer_hides_warnings_by_default() {
1772 let diff = mock_diff();
1773 let mut buf = Vec::new();
1774 MarkdownRenderer
1775 .render(&diff, &RenderOptions::default(), &mut buf)
1776 .unwrap();
1777 let out = String::from_utf8(buf).unwrap();
1778
1779 assert!(!out.contains("Warnings"));
1780 }
1781
1782 #[test]
1783 fn test_json_renderer_shows_warnings() {
1784 let diff = mock_diff();
1785 let opts = opts_with_warnings();
1786 let mut buf = Vec::new();
1787 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1788 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1789
1790 let warnings = &val["warnings"];
1791 let old = warnings["old"].as_array().unwrap();
1792 let new = warnings["new"].as_array().unwrap();
1793 assert_eq!(old.len(), 1);
1794 assert_eq!(new.len(), 1);
1795 assert_eq!(old[0], "SPDX: orphaned ref 'SPDXRef-foo'");
1796 assert_eq!(new[0], "CycloneDX: unknown bom-ref 'bar'");
1797 }
1798
1799 #[test]
1800 fn test_json_renderer_hides_warnings_by_default() {
1801 let diff = mock_diff();
1802 let mut buf = Vec::new();
1803 JsonRenderer
1804 .render(&diff, &RenderOptions::default(), &mut buf)
1805 .unwrap();
1806 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1807
1808 assert!(val.get("warnings").is_none());
1809 }
1810
1811 #[test]
1812 fn test_empty_warnings_not_shown() {
1813 let diff = mock_diff();
1814 let opts = RenderOptions {
1815 show_warnings: true,
1816 ..Default::default()
1817 };
1818
1819 let mut buf = Vec::new();
1820 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1821 let out = String::from_utf8(buf).unwrap();
1822 assert!(!out.contains("[!] Warnings"));
1823
1824 let mut buf = Vec::new();
1825 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1826 let out = String::from_utf8(buf).unwrap();
1827 assert!(!out.contains("Warnings"));
1828
1829 let mut buf = Vec::new();
1830 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1831 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1832 assert!(val.get("warnings").is_none());
1833 }
1834
1835 fn mock_diff_with_hash_edge_diffs() -> Diff {
1836 use sbom_model::{ComponentId, DependencyKind};
1837
1838 let parent_id = ComponentId::new(None, &[("name", "parent")]);
1839 let child_a_id = ComponentId::new(None, &[("name", "child-a")]);
1840 let child_b_id = ComponentId::new(None, &[("name", "child-b")]);
1841
1842 let mut names = BTreeMap::new();
1843 names.insert(parent_id.clone(), "my-app@1.0".to_string());
1844 names.insert(child_a_id.clone(), "old-dep@0.1".to_string());
1845 names.insert(child_b_id.clone(), "new-dep@0.2".to_string());
1846
1847 Diff {
1848 edge_diffs: vec![crate::EdgeDiff {
1849 parent: parent_id,
1850 added: BTreeMap::from([(child_b_id, DependencyKind::Runtime)]),
1851 removed: BTreeMap::from([(child_a_id, DependencyKind::Runtime)]),
1852 kind_changed: BTreeMap::new(),
1853 }],
1854 old_total: 10,
1855 new_total: 12,
1856 unchanged: 5,
1857 component_names: names,
1858 ..Diff::default()
1859 }
1860 }
1861
1862 #[test]
1863 fn test_text_renderer_resolves_edge_diff_names() {
1864 let diff = mock_diff_with_hash_edge_diffs();
1865 let mut buf = Vec::new();
1866 TextRenderer
1867 .render(&diff, &RenderOptions::default(), &mut buf)
1868 .unwrap();
1869 let out = String::from_utf8(buf).unwrap();
1870
1871 assert!(out.contains("my-app@1.0"));
1872 assert!(out.contains("- old-dep@0.1"));
1873 assert!(out.contains("+ new-dep@0.2"));
1874 assert!(!out.contains("h:"));
1876 }
1877
1878 #[test]
1879 fn test_text_renderer_shows_totals() {
1880 let diff = mock_diff_with_hash_edge_diffs();
1881 let mut buf = Vec::new();
1882 TextRenderer
1883 .render(&diff, &RenderOptions::default(), &mut buf)
1884 .unwrap();
1885 let out = String::from_utf8(buf).unwrap();
1886
1887 assert!(out.contains("Old total: 10 components"));
1888 assert!(out.contains("New total: 12 components"));
1889 assert!(out.contains("Unchanged: 5"));
1890 }
1891
1892 #[test]
1893 fn test_markdown_renderer_resolves_edge_diff_names() {
1894 let diff = mock_diff_with_hash_edge_diffs();
1895 let mut buf = Vec::new();
1896 MarkdownRenderer
1897 .render(&diff, &RenderOptions::default(), &mut buf)
1898 .unwrap();
1899 let out = String::from_utf8(buf).unwrap();
1900
1901 assert!(out.contains("`my-app@1.0`"));
1902 assert!(out.contains("`old-dep@0.1`"));
1903 assert!(out.contains("`new-dep@0.2`"));
1904 assert!(!out.contains("h:"));
1905 }
1906
1907 #[test]
1908 fn test_markdown_renderer_shows_totals() {
1909 let diff = mock_diff_with_hash_edge_diffs();
1910 let mut buf = Vec::new();
1911 MarkdownRenderer
1912 .render(&diff, &RenderOptions::default(), &mut buf)
1913 .unwrap();
1914 let out = String::from_utf8(buf).unwrap();
1915
1916 assert!(out.contains("| Old total | 10 |"));
1917 assert!(out.contains("| New total | 12 |"));
1918 assert!(out.contains("| Unchanged | 5 |"));
1919 }
1920
1921 #[test]
1922 fn test_json_renderer_includes_totals() {
1923 let diff = mock_diff_with_hash_edge_diffs();
1924 let mut buf = Vec::new();
1925 JsonRenderer
1926 .render(&diff, &RenderOptions::default(), &mut buf)
1927 .unwrap();
1928 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1929
1930 assert_eq!(val["old_total"], 10);
1931 assert_eq!(val["new_total"], 12);
1932 assert_eq!(val["unchanged"], 5);
1933 }
1934
1935 #[test]
1936 fn test_json_renderer_includes_component_names() {
1937 let diff = mock_diff_with_hash_edge_diffs();
1938 let mut buf = Vec::new();
1939 JsonRenderer
1940 .render(&diff, &RenderOptions::default(), &mut buf)
1941 .unwrap();
1942 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1943
1944 let names = &val["component_names"];
1945 assert!(names.is_object());
1946 assert!(names
1947 .as_object()
1948 .unwrap()
1949 .values()
1950 .any(|v| v == "my-app@1.0"));
1951 }
1952
1953 #[test]
1954 fn test_json_renderer_omits_empty_component_names() {
1955 let diff = mock_diff();
1956 let mut buf = Vec::new();
1957 JsonRenderer
1958 .render(&diff, &RenderOptions::default(), &mut buf)
1959 .unwrap();
1960 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1961
1962 assert!(val.get("component_names").is_none());
1963 }
1964
1965 fn mock_diff_with_metadata_change() -> Diff {
1966 Diff {
1967 metadata_changed: Some(crate::MetadataChange {
1968 timestamp: Some((Some("2024-01-01".into()), Some("2024-01-02".into()))),
1969 tools: Some((vec!["syft".into()], vec!["trivy".into()])),
1970 authors: None,
1971 }),
1972 ..Diff::default()
1973 }
1974 }
1975
1976 #[test]
1977 fn test_text_renderer_metadata_change() {
1978 let diff = mock_diff_with_metadata_change();
1979 let mut buf = Vec::new();
1980 TextRenderer
1981 .render(&diff, &RenderOptions::default(), &mut buf)
1982 .unwrap();
1983 let out = String::from_utf8(buf).unwrap();
1984
1985 assert!(out.contains("[~] Metadata Changes"));
1986 assert!(out.contains("Timestamp: 2024-01-01 -> 2024-01-02"));
1987 assert!(out.contains("Tools: syft -> trivy"));
1988 assert!(!out.contains("Authors:"));
1990 }
1991
1992 #[test]
1993 fn test_text_renderer_no_metadata_section_when_unchanged() {
1994 let diff = mock_diff_empty();
1995 let mut buf = Vec::new();
1996 TextRenderer
1997 .render(&diff, &RenderOptions::default(), &mut buf)
1998 .unwrap();
1999 let out = String::from_utf8(buf).unwrap();
2000
2001 assert!(!out.contains("Metadata Changes"));
2002 }
2003
2004 #[test]
2005 fn test_markdown_renderer_metadata_change() {
2006 let diff = mock_diff_with_metadata_change();
2007 let mut buf = Vec::new();
2008 MarkdownRenderer
2009 .render(&diff, &RenderOptions::default(), &mut buf)
2010 .unwrap();
2011 let out = String::from_utf8(buf).unwrap();
2012
2013 assert!(out.contains("<details><summary><b>Metadata Changes</b></summary>"));
2014 assert!(out.contains("**Timestamp**"));
2015 assert!(out.contains("`2024-01-01` → `2024-01-02`"));
2016 assert!(out.contains("**Tools**"));
2017 assert!(out.contains("`syft` → `trivy`"));
2018 assert!(!out.contains("**Authors**"));
2019 assert!(out.contains("</details>"));
2020 }
2021
2022 #[test]
2023 fn test_markdown_renderer_no_metadata_section_when_unchanged() {
2024 let diff = mock_diff_empty();
2025 let mut buf = Vec::new();
2026 MarkdownRenderer
2027 .render(&diff, &RenderOptions::default(), &mut buf)
2028 .unwrap();
2029 let out = String::from_utf8(buf).unwrap();
2030
2031 assert!(!out.contains("Metadata Changes"));
2032 }
2033
2034 #[test]
2035 fn test_json_renderer_metadata_change() {
2036 let diff = mock_diff_with_metadata_change();
2037 let mut buf = Vec::new();
2038 JsonRenderer
2039 .render(&diff, &RenderOptions::default(), &mut buf)
2040 .unwrap();
2041 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2042
2043 let mc = &val["metadata_changed"];
2044 assert!(mc.is_object());
2045 let ts = mc["timestamp"].as_array().unwrap();
2046 assert_eq!(ts[0], "2024-01-01");
2047 assert_eq!(ts[1], "2024-01-02");
2048 let tools = mc["tools"].as_array().unwrap();
2049 assert_eq!(tools[0], serde_json::json!(["syft"]));
2050 assert_eq!(tools[1], serde_json::json!(["trivy"]));
2051 assert!(mc.get("authors").is_none());
2053 }
2054
2055 #[test]
2056 fn test_json_renderer_no_metadata_when_unchanged() {
2057 let diff = mock_diff_empty();
2058 let mut buf = Vec::new();
2059 JsonRenderer
2060 .render(&diff, &RenderOptions::default(), &mut buf)
2061 .unwrap();
2062 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2063
2064 assert!(val.get("metadata_changed").is_none());
2065 }
2066
2067 #[test]
2068 fn test_text_summary_metadata_changed() {
2069 let diff = mock_diff_with_metadata_change();
2070 let mut buf = Vec::new();
2071 TextRenderer
2072 .render_summary(&diff, &RenderOptions::default(), &mut buf)
2073 .unwrap();
2074 let out = String::from_utf8(buf).unwrap();
2075
2076 assert!(out.contains("Metadata changed: yes"));
2077 }
2078
2079 #[test]
2080 fn test_text_summary_metadata_unchanged() {
2081 let diff = mock_diff_empty();
2082 let mut buf = Vec::new();
2083 TextRenderer
2084 .render_summary(&diff, &RenderOptions::default(), &mut buf)
2085 .unwrap();
2086 let out = String::from_utf8(buf).unwrap();
2087
2088 assert!(out.contains("Metadata changed: no"));
2089 }
2090
2091 #[test]
2092 fn test_markdown_summary_metadata_changed() {
2093 let diff = mock_diff_with_metadata_change();
2094 let mut buf = Vec::new();
2095 MarkdownRenderer
2096 .render_summary(&diff, &RenderOptions::default(), &mut buf)
2097 .unwrap();
2098 let out = String::from_utf8(buf).unwrap();
2099
2100 assert!(out.contains("| Metadata changed | yes |"));
2101 }
2102
2103 #[test]
2104 fn test_markdown_summary_metadata_unchanged() {
2105 let diff = mock_diff_empty();
2106 let mut buf = Vec::new();
2107 MarkdownRenderer
2108 .render_summary(&diff, &RenderOptions::default(), &mut buf)
2109 .unwrap();
2110 let out = String::from_utf8(buf).unwrap();
2111
2112 assert!(out.contains("| Metadata changed | no |"));
2113 }
2114
2115 #[test]
2116 fn test_json_summary_metadata_changed() {
2117 let diff = mock_diff_with_metadata_change();
2118 let mut buf = Vec::new();
2119 JsonRenderer
2120 .render_summary(&diff, &RenderOptions::default(), &mut buf)
2121 .unwrap();
2122 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2123
2124 assert_eq!(val["metadata_changed"], true);
2125 let mc = &val["metadata_changes"];
2126 assert!(mc.is_object());
2127 assert!(mc["timestamp"].is_array());
2128 }
2129
2130 #[test]
2131 fn test_json_summary_metadata_unchanged() {
2132 let diff = mock_diff_empty();
2133 let mut buf = Vec::new();
2134 JsonRenderer
2135 .render_summary(&diff, &RenderOptions::default(), &mut buf)
2136 .unwrap();
2137 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2138
2139 assert_eq!(val["metadata_changed"], false);
2140 assert!(val.get("metadata_changes").is_none());
2141 }
2142
2143 fn sarif_parse(buf: &[u8]) -> serde_json::Value {
2146 serde_json::from_slice(buf).unwrap()
2147 }
2148
2149 #[test]
2150 fn test_sarif_renderer_schema_and_version() {
2151 let diff = mock_diff();
2152 let mut buf = Vec::new();
2153 SarifRenderer
2154 .render(&diff, &RenderOptions::default(), &mut buf)
2155 .unwrap();
2156 let val = sarif_parse(&buf);
2157
2158 assert_eq!(
2159 val["$schema"],
2160 "https://json.schemastore.org/sarif-2.1.0.json"
2161 );
2162 assert_eq!(val["version"], "2.1.0");
2163 assert!(val["runs"].is_array());
2164 assert_eq!(val["runs"].as_array().unwrap().len(), 1);
2165 }
2166
2167 #[test]
2168 fn test_sarif_renderer_tool_driver() {
2169 let diff = mock_diff_empty();
2170 let mut buf = Vec::new();
2171 SarifRenderer
2172 .render(&diff, &RenderOptions::default(), &mut buf)
2173 .unwrap();
2174 let val = sarif_parse(&buf);
2175
2176 let driver = &val["runs"][0]["tool"]["driver"];
2177 assert_eq!(driver["name"], "sbom-diff");
2178 assert!(driver["version"].is_string());
2179 assert_eq!(
2180 driver["informationUri"],
2181 "https://github.com/cyberwitchery/sbom-diff"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_sarif_renderer_rules() {
2187 let diff = mock_diff_empty();
2188 let mut buf = Vec::new();
2189 SarifRenderer
2190 .render(&diff, &RenderOptions::default(), &mut buf)
2191 .unwrap();
2192 let val = sarif_parse(&buf);
2193
2194 let rules = val["runs"][0]["tool"]["driver"]["rules"]
2195 .as_array()
2196 .unwrap();
2197 assert_eq!(rules.len(), 6);
2198
2199 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2200 assert_eq!(
2201 rule_ids,
2202 vec![
2203 "component-added",
2204 "component-removed",
2205 "component-changed",
2206 "dependency-changed",
2207 "metadata-changed",
2208 "parser-warning",
2209 ]
2210 );
2211
2212 for rule in rules {
2214 assert!(rule["shortDescription"]["text"].is_string());
2215 assert!(rule["fullDescription"]["text"].is_string());
2216 assert!(rule["defaultConfiguration"]["level"].is_string());
2217 }
2218 }
2219
2220 #[test]
2221 fn test_sarif_renderer_empty_diff() {
2222 let diff = mock_diff_empty();
2223 let mut buf = Vec::new();
2224 SarifRenderer
2225 .render(&diff, &RenderOptions::default(), &mut buf)
2226 .unwrap();
2227 let val = sarif_parse(&buf);
2228
2229 let results = val["runs"][0]["results"].as_array().unwrap();
2230 assert!(results.is_empty());
2231 }
2232
2233 #[test]
2234 fn test_sarif_renderer_added_removed_changed() {
2235 let diff = mock_diff();
2236 let mut buf = Vec::new();
2237 SarifRenderer
2238 .render(&diff, &RenderOptions::default(), &mut buf)
2239 .unwrap();
2240 let val = sarif_parse(&buf);
2241
2242 let results = val["runs"][0]["results"].as_array().unwrap();
2243 assert_eq!(results.len(), 3); let rule_ids: Vec<&str> = results
2247 .iter()
2248 .map(|r| r["ruleId"].as_str().unwrap())
2249 .collect();
2250 assert!(rule_ids.contains(&"component-added"));
2251 assert!(rule_ids.contains(&"component-removed"));
2252 assert!(rule_ids.contains(&"component-changed"));
2253
2254 let added = results
2256 .iter()
2257 .find(|r| r["ruleId"] == "component-added")
2258 .unwrap();
2259 assert_eq!(added["level"], "note");
2260 assert!(added["message"]["text"].as_str().unwrap().contains("added"));
2261
2262 let removed = results
2264 .iter()
2265 .find(|r| r["ruleId"] == "component-removed")
2266 .unwrap();
2267 assert_eq!(removed["level"], "warning");
2268
2269 let changed = results
2271 .iter()
2272 .find(|r| r["ruleId"] == "component-changed")
2273 .unwrap();
2274 assert_eq!(changed["level"], "warning");
2275 let msg = changed["message"]["text"].as_str().unwrap();
2276 assert!(msg.contains("version:"));
2277 }
2278
2279 #[test]
2280 fn test_sarif_renderer_all_field_changes() {
2281 let diff = mock_diff_all_field_changes();
2282 let mut buf = Vec::new();
2283 SarifRenderer
2284 .render(&diff, &RenderOptions::default(), &mut buf)
2285 .unwrap();
2286 let val = sarif_parse(&buf);
2287
2288 let results = val["runs"][0]["results"].as_array().unwrap();
2289
2290 let changed = results
2292 .iter()
2293 .find(|r| r["ruleId"] == "component-changed")
2294 .unwrap();
2295 let msg = changed["message"]["text"].as_str().unwrap();
2296 assert!(msg.contains("version:"));
2297 assert!(msg.contains("license:"));
2298 assert!(msg.contains("supplier:"));
2299 assert!(msg.contains("purl:"));
2300 assert!(msg.contains("description:"));
2301 assert!(msg.contains("hashes changed"));
2302 assert!(msg.contains("ecosystem:"));
2303
2304 let dep = results
2305 .iter()
2306 .find(|r| r["ruleId"] == "dependency-changed")
2307 .unwrap();
2308 assert_eq!(dep["level"], "note");
2309 let dep_msg = dep["message"]["text"].as_str().unwrap();
2310 assert!(dep_msg.contains("Dependency changed:"));
2311 }
2312
2313 #[test]
2314 fn test_sarif_renderer_rule_index() {
2315 let diff = mock_diff();
2316 let mut buf = Vec::new();
2317 SarifRenderer
2318 .render(&diff, &RenderOptions::default(), &mut buf)
2319 .unwrap();
2320 let val = sarif_parse(&buf);
2321
2322 let results = val["runs"][0]["results"].as_array().unwrap();
2323
2324 for result in results {
2326 let rule_id = result["ruleId"].as_str().unwrap();
2327 let rule_index = result["ruleIndex"].as_u64().unwrap() as usize;
2328 let rules = val["runs"][0]["tool"]["driver"]["rules"]
2329 .as_array()
2330 .unwrap();
2331 assert_eq!(rules[rule_index]["id"].as_str().unwrap(), rule_id);
2332 }
2333 }
2334
2335 #[test]
2336 fn test_sarif_renderer_metadata_change() {
2337 let diff = mock_diff_with_metadata_change();
2338 let mut buf = Vec::new();
2339 SarifRenderer
2340 .render(&diff, &RenderOptions::default(), &mut buf)
2341 .unwrap();
2342 let val = sarif_parse(&buf);
2343
2344 let results = val["runs"][0]["results"].as_array().unwrap();
2345 let meta = results
2346 .iter()
2347 .find(|r| r["ruleId"] == "metadata-changed")
2348 .unwrap();
2349 assert_eq!(meta["level"], "note");
2350 let msg = meta["message"]["text"].as_str().unwrap();
2351 assert!(msg.contains("timestamp:"));
2352 assert!(msg.contains("tools:"));
2353 }
2354
2355 #[test]
2356 fn test_sarif_renderer_no_metadata_when_unchanged() {
2357 let diff = mock_diff_empty();
2358 let mut buf = Vec::new();
2359 SarifRenderer
2360 .render(&diff, &RenderOptions::default(), &mut buf)
2361 .unwrap();
2362 let val = sarif_parse(&buf);
2363
2364 let results = val["runs"][0]["results"].as_array().unwrap();
2365 assert!(!results.iter().any(|r| r["ruleId"] == "metadata-changed"));
2366 }
2367
2368 #[test]
2369 fn test_sarif_renderer_summary_same_as_full() {
2370 let diff = mock_diff();
2371 let opts = RenderOptions::default();
2372
2373 let mut buf_full = Vec::new();
2374 SarifRenderer.render(&diff, &opts, &mut buf_full).unwrap();
2375
2376 let mut buf_summary = Vec::new();
2377 SarifRenderer
2378 .render_summary(&diff, &opts, &mut buf_summary)
2379 .unwrap();
2380
2381 assert_eq!(buf_full, buf_summary);
2382 }
2383
2384 #[test]
2385 fn test_sarif_renderer_edge_diffs_with_names() {
2386 let diff = mock_diff_with_hash_edge_diffs();
2387 let mut buf = Vec::new();
2388 SarifRenderer
2389 .render(&diff, &RenderOptions::default(), &mut buf)
2390 .unwrap();
2391 let val = sarif_parse(&buf);
2392
2393 let results = val["runs"][0]["results"].as_array().unwrap();
2394 let dep = results
2395 .iter()
2396 .find(|r| r["ruleId"] == "dependency-changed")
2397 .unwrap();
2398 let msg = dep["message"]["text"].as_str().unwrap();
2399 assert!(msg.contains("my-app@1.0"));
2401 assert!(msg.contains("old-dep@0.1"));
2402 assert!(msg.contains("new-dep@0.2"));
2403 }
2404
2405 #[test]
2406 fn test_sarif_renderer_locations_present_and_well_formed() {
2407 let diff = mock_diff();
2409 let mut buf = Vec::new();
2410 SarifRenderer
2411 .render(&diff, &RenderOptions::default(), &mut buf)
2412 .unwrap();
2413 let val = sarif_parse(&buf);
2414 let results = val["runs"][0]["results"].as_array().unwrap();
2415
2416 for rule_id in ["component-added", "component-removed", "component-changed"] {
2417 let result = results
2418 .iter()
2419 .find(|r| r["ruleId"] == rule_id)
2420 .unwrap_or_else(|| panic!("missing result for {rule_id}"));
2421 let locs = result["locations"]
2422 .as_array()
2423 .unwrap_or_else(|| panic!("{rule_id}: locations missing"));
2424 assert_eq!(locs.len(), 1, "{rule_id}: expected 1 location");
2425 let ll = locs[0]["logicalLocations"]
2426 .as_array()
2427 .unwrap_or_else(|| panic!("{rule_id}: logicalLocations missing"));
2428 assert_eq!(ll.len(), 1, "{rule_id}: expected 1 logicalLocation");
2429 assert!(
2430 !ll[0]["fullyQualifiedName"].as_str().unwrap().is_empty(),
2431 "{rule_id}: fullyQualifiedName should be non-empty"
2432 );
2433 assert_eq!(
2434 ll[0]["kind"], "package",
2435 "{rule_id}: kind should be package"
2436 );
2437 }
2438
2439 let diff = mock_diff_with_hash_edge_diffs();
2441 let mut buf = Vec::new();
2442 SarifRenderer
2443 .render(&diff, &RenderOptions::default(), &mut buf)
2444 .unwrap();
2445 let val = sarif_parse(&buf);
2446 let results = val["runs"][0]["results"].as_array().unwrap();
2447
2448 let dep = results
2449 .iter()
2450 .find(|r| r["ruleId"] == "dependency-changed")
2451 .unwrap();
2452 let locs = dep["locations"].as_array().unwrap();
2453 assert_eq!(locs.len(), 1);
2454 let ll = locs[0]["logicalLocations"].as_array().unwrap();
2455 assert_eq!(ll[0]["fullyQualifiedName"], "my-app@1.0");
2456 assert_eq!(ll[0]["kind"], "package");
2457
2458 let diff = mock_diff_with_metadata_change();
2460 let mut buf = Vec::new();
2461 SarifRenderer
2462 .render(&diff, &RenderOptions::default(), &mut buf)
2463 .unwrap();
2464 let val = sarif_parse(&buf);
2465 let results = val["runs"][0]["results"].as_array().unwrap();
2466
2467 let meta = results
2468 .iter()
2469 .find(|r| r["ruleId"] == "metadata-changed")
2470 .unwrap();
2471 let locs = meta["locations"].as_array().unwrap();
2472 assert_eq!(locs.len(), 1);
2473 let ll = locs[0]["logicalLocations"].as_array().unwrap();
2474 assert_eq!(ll[0]["fullyQualifiedName"], "metadata");
2475 assert_eq!(ll[0]["kind"], "module");
2476 }
2477
2478 #[test]
2479 fn test_sarif_renderer_shows_warnings() {
2480 let diff = mock_diff();
2481 let opts = opts_with_warnings();
2482 let mut buf = Vec::new();
2483 SarifRenderer.render(&diff, &opts, &mut buf).unwrap();
2484 let val = sarif_parse(&buf);
2485
2486 let results = val["runs"][0]["results"].as_array().unwrap();
2487 let warnings: Vec<_> = results
2488 .iter()
2489 .filter(|r| r["ruleId"] == "parser-warning")
2490 .collect();
2491 assert_eq!(warnings.len(), 2);
2492
2493 for w in &warnings {
2495 assert_eq!(w["level"], "note");
2496 }
2497
2498 let old_warning = warnings
2500 .iter()
2501 .find(|w| w["message"]["text"].as_str().unwrap().contains("old SBOM"))
2502 .expect("should have old SBOM warning");
2503 assert!(old_warning["message"]["text"]
2504 .as_str()
2505 .unwrap()
2506 .contains("orphaned ref 'SPDXRef-foo'"));
2507 let ll = old_warning["locations"][0]["logicalLocations"]
2508 .as_array()
2509 .unwrap();
2510 assert_eq!(ll[0]["fullyQualifiedName"], "old-sbom");
2511 assert_eq!(ll[0]["kind"], "module");
2512
2513 let new_warning = warnings
2515 .iter()
2516 .find(|w| w["message"]["text"].as_str().unwrap().contains("new SBOM"))
2517 .expect("should have new SBOM warning");
2518 assert!(new_warning["message"]["text"]
2519 .as_str()
2520 .unwrap()
2521 .contains("unknown bom-ref 'bar'"));
2522 let ll = new_warning["locations"][0]["logicalLocations"]
2523 .as_array()
2524 .unwrap();
2525 assert_eq!(ll[0]["fullyQualifiedName"], "new-sbom");
2526 assert_eq!(ll[0]["kind"], "module");
2527 }
2528
2529 #[test]
2530 fn test_sarif_renderer_hides_warnings_by_default() {
2531 let diff = mock_diff();
2532 let mut buf = Vec::new();
2533 SarifRenderer
2534 .render(&diff, &RenderOptions::default(), &mut buf)
2535 .unwrap();
2536 let val = sarif_parse(&buf);
2537
2538 let results = val["runs"][0]["results"].as_array().unwrap();
2539 assert!(
2540 !results.iter().any(|r| r["ruleId"] == "parser-warning"),
2541 "should not emit parser-warning results without show_warnings"
2542 );
2543 }
2544
2545 #[test]
2546 fn test_sarif_renderer_no_warnings_when_empty() {
2547 let diff = mock_diff();
2548 let opts = RenderOptions {
2549 show_warnings: true,
2550 ..Default::default()
2551 };
2552 let mut buf = Vec::new();
2553 SarifRenderer.render(&diff, &opts, &mut buf).unwrap();
2554 let val = sarif_parse(&buf);
2555
2556 let results = val["runs"][0]["results"].as_array().unwrap();
2557 assert!(
2558 !results.iter().any(|r| r["ruleId"] == "parser-warning"),
2559 "should not emit parser-warning results when warning lists are empty"
2560 );
2561 }
2562
2563 #[test]
2564 fn test_sarif_renderer_warnings_rule_index() {
2565 let diff = mock_diff_empty();
2566 let opts = opts_with_warnings();
2567 let mut buf = Vec::new();
2568 SarifRenderer.render(&diff, &opts, &mut buf).unwrap();
2569 let val = sarif_parse(&buf);
2570
2571 let rules = val["runs"][0]["tool"]["driver"]["rules"]
2572 .as_array()
2573 .unwrap();
2574 let results = val["runs"][0]["results"].as_array().unwrap();
2575
2576 for result in results.iter().filter(|r| r["ruleId"] == "parser-warning") {
2577 let rule_index = result["ruleIndex"].as_u64().unwrap() as usize;
2578 assert_eq!(rules[rule_index]["id"], "parser-warning");
2579 }
2580 }
2581
2582 #[test]
2583 fn test_sarif_renderer_warnings_rule_present() {
2584 let diff = mock_diff_empty();
2585 let mut buf = Vec::new();
2586 SarifRenderer
2587 .render(&diff, &RenderOptions::default(), &mut buf)
2588 .unwrap();
2589 let val = sarif_parse(&buf);
2590
2591 let rules = val["runs"][0]["tool"]["driver"]["rules"]
2592 .as_array()
2593 .unwrap();
2594 assert_eq!(rules.len(), 6);
2595 assert_eq!(rules[5]["id"], "parser-warning");
2596 assert_eq!(rules[5]["defaultConfiguration"]["level"], "note");
2597 }
2598
2599 #[test]
2600 fn test_sarif_renderer_no_metadata_when_all_none_subfields() {
2601 let diff = Diff {
2602 metadata_changed: Some(crate::MetadataChange {
2603 timestamp: None,
2604 tools: None,
2605 authors: None,
2606 }),
2607 ..Diff::default()
2608 };
2609 let mut buf = Vec::new();
2610 SarifRenderer
2611 .render(&diff, &RenderOptions::default(), &mut buf)
2612 .unwrap();
2613 let val = sarif_parse(&buf);
2614
2615 let results = val["runs"][0]["results"].as_array().unwrap();
2616 assert!(
2617 !results.iter().any(|r| r["ruleId"] == "metadata-changed"),
2618 "MetadataChange with all-None subfields should not emit a result"
2619 );
2620 }
2621}