Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,530 changes: 0 additions & 1,530 deletions src/render/markdown.rs

This file was deleted.

185 changes: 185 additions & 0 deletions src/render/markdown/dependency_churn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use std::fmt::Write as _;

use crate::diff::ChangeSet;
use crate::render::markdown::section;

pub fn render(cs: &ChangeSet, findings_only: bool) -> String {
let mut out = String::new();

if findings_only {
if has_raw_churn(cs) {
out.push_str(
"_Raw dependency churn detail elided (`--findings-only`); risk-bearing \
sections remain below._\n\n",
);
}
return out;
}

render_added(&mut out, cs);
render_removed(&mut out, cs);
render_version_changed(&mut out, cs);

out
}

fn has_raw_churn(cs: &ChangeSet) -> bool {
!cs.added.is_empty() || !cs.removed.is_empty() || !cs.version_changed.is_empty()
}

fn render_added(out: &mut String, cs: &ChangeSet) {
if cs.added.is_empty() {
return;
}

section::open(out, "Added", cs.added.len(), None);
out.push_str("| Ecosystem | Name | Version |\n|---|---|---|\n");
for c in &cs.added {
let _ = writeln!(out, "| {} | {} | {} |", c.ecosystem, c.name, c.version);
}
section::close(out);
}

fn render_removed(out: &mut String, cs: &ChangeSet) {
if cs.removed.is_empty() {
return;
}

section::open(out, "Removed", cs.removed.len(), None);
out.push_str("| Ecosystem | Name | Version |\n|---|---|---|\n");
for c in &cs.removed {
let _ = writeln!(out, "| {} | {} | {} |", c.ecosystem, c.name, c.version);
}
section::close(out);
}

fn render_version_changed(out: &mut String, cs: &ChangeSet) {
if cs.version_changed.is_empty() {
return;
}

section::open(out, "Version changed", cs.version_changed.len(), None);
out.push_str("| Ecosystem | Name | Before | After |\n|---|---|---|---|\n");
for (b, a) in &cs.version_changed {
let _ = writeln!(
out,
"| {} | {} | {} | {} |",
a.ecosystem, a.name, b.version, a.version
);
}
section::close(out);
}

#[cfg(test)]
mod tests {
use super::*;
use crate::enrich::Enrichment;
use crate::model::{Component, Ecosystem, Relationship};
use crate::render::markdown::{Options, render_with_options};

fn comp(name: &str, version: &str, eco: Ecosystem, purl: Option<&str>) -> Component {
Component {
name: name.to_string(),
version: version.to_string(),
ecosystem: eco,
purl: purl.map(str::to_string),
licenses: Vec::new(),
supplier: None,
hashes: Vec::new(),
relationship: Relationship::Unknown,
source_url: None,
bom_ref: None,
}
}

#[test]
fn renders_added_section() {
let cs = ChangeSet {
added: vec![comp("plain-crypto-js", "4.2.1", Ecosystem::Npm, None)],
..Default::default()
};
let md = render(&cs, false);
assert!(md.contains("### Added"));
assert!(md.contains("| npm | plain-crypto-js | 4.2.1 |"));
}

#[test]
fn renders_version_change_table_columns() {
let before = comp("axios", "1.14.0", Ecosystem::Npm, None);
let after = comp("axios", "1.14.1", Ecosystem::Npm, None);
let cs = ChangeSet {
version_changed: vec![(before, after)],
..Default::default()
};
let md = render(&cs, false);
assert!(md.contains("### Version changed"));
assert!(md.contains("| Ecosystem | Name | Before | After |"));
assert!(md.contains("| npm | axios | 1.14.0 | 1.14.1 |"));
}

#[test]
fn findings_only_hides_raw_churn_but_keeps_risk_sections() {
let cs = ChangeSet {
added: vec![comp(
"axios",
"1.14.1",
Ecosystem::Npm,
Some("pkg:npm/axios@1.14.1"),
)],
version_changed: vec![(
comp("left-pad", "1.0.0", Ecosystem::Npm, None),
comp("left-pad", "4.0.0", Ecosystem::Npm, None),
)],
..Default::default()
};
let mut e = Enrichment::default();
e.vulns.insert(
"pkg:npm/axios@1.14.1".to_string(),
vec![crate::enrich::VulnRef {
id: "GHSA-xxxx-yyyy-zzzz".to_string(),
severity: crate::enrich::Severity::High,
aliases: Vec::new(),
epss_score: None,
kev: false,
}],
);

let md = render_with_options(
&cs,
&e,
Options {
findings_only: true,
..Default::default()
},
);

assert!(md.contains("| Added | 1 |"));
assert!(md.contains("| Version changed | 1 |"));
assert!(md.contains("Raw dependency churn detail elided"));
assert!(!md.contains("### Added"));
assert!(!md.contains("### Version changed"));
assert!(md.contains("### Vulnerabilities"));
assert!(md.contains("GHSA-xxxx-yyyy-zzzz"));
}

#[test]
fn sections_are_wrapped_in_collapsible_details_with_count() {
let cs = ChangeSet {
added: vec![comp("a", "1.0", Ecosystem::Npm, None)],
removed: vec![comp("b", "1.0", Ecosystem::Cargo, None)],
version_changed: vec![(
comp("c", "1.0", Ecosystem::Npm, None),
comp("c", "2.0", Ecosystem::Npm, None),
)],
..Default::default()
};
let md = render(&cs, false);
assert!(md.contains("### Added (1)\n"));
assert!(md.contains("### Removed (1)\n"));
assert!(md.contains("### Version changed (1)\n"));
assert_eq!(md.matches("<details>").count(), 3);
assert_eq!(md.matches("</summary>").count(), 3);
assert_eq!(md.matches("</details>").count(), 3);
assert!(md.contains("<details><summary>Show details"));
}
}
36 changes: 36 additions & 0 deletions src/render/markdown/deprecated.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::fmt::Write as _;

use crate::enrich::Enrichment;
use crate::render::markdown::section;

pub fn render(enrichment: &Enrichment) -> String {
if enrichment.deprecated.is_empty() {
return String::new();
}

let mut out = String::new();
section::open(
&mut out,
"Deprecated upstream",
enrichment.deprecated.len(),
None,
);
out.push_str(
"These dependencies are flagged deprecated or yanked by their package registry. \
[Why this matters](https://metbcy.github.io/bomdrift/enrichers/registry.html)\n\n",
);
out.push_str("| Ecosystem | Name | Version | Message |\n|---|---|---|---|\n");
for f in &enrichment.deprecated {
let _ = writeln!(
out,
"| {} | {} | {} | {} |",
f.component.ecosystem,
f.component.name,
f.component.version,
f.message.as_deref().unwrap_or("(deprecated upstream)"),
);
}
section::close(&mut out);

out
}
136 changes: 136 additions & 0 deletions src/render/markdown/footer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use std::fmt::Write as _;

use crate::render::markdown::platform::Platform;

/// Renders the comment footer with action affordances. Omitted entirely
/// when `repo_url` is `None` so forks / standalone CLI use don't render
/// dead links to a repo they don't own. Wrapped in `<sub>` so it doesn't
/// compete visually with the section bodies.
pub fn render(repo_url: Option<&str>, platform: Platform) -> String {
let Some(repo) = repo_url else {
return String::new();
};

let repo = repo.trim_end_matches('/');
let mut out = String::new();
out.push_str("---\n");
match platform {
Platform::GitHub => {
let _ = writeln!(
out,
"<sub>**False positive?** [Report it]({repo}/issues/new?labels=false-positive&template=false-positive.md) · \
**Suppress a finding?** Comment `/bomdrift suppress <ID>` (requires the \
[comment-suppress sub-action]({repo})) · \
[Docs](https://metbcy.github.io/bomdrift/)</sub>",
);
}
Platform::GitLab => {
let _ = writeln!(
out,
"<sub>**False positive?** [Report it]({repo}/-/issues/new?issuable_template=false-positive) · \
**Suppress a finding?** Run `bomdrift baseline add <ID>` and commit \
`.bomdrift/baseline.json` to your MR branch · \
[Docs](https://metbcy.github.io/bomdrift/)</sub>",
);
}
Platform::Bitbucket => {
let _ = writeln!(
out,
"<sub>**False positive?** [Report it]({repo}/issues/new) · \
**Suppress a finding?** Run `bomdrift baseline add <ID>` and commit \
`.bomdrift/baseline.json` to your PR branch · \
[Docs](https://metbcy.github.io/bomdrift/)</sub>",
);
}
Platform::AzureDevOps => {
let _ = writeln!(
out,
"<sub>**False positive?** [Report it]({repo}/_workitems/create?templateName=false-positive) · \
**Suppress a finding?** Run `bomdrift baseline add <ID>` and commit \
`.bomdrift/baseline.json` to your PR branch · \
[Docs](https://metbcy.github.io/bomdrift/)</sub>",
);
}
}

out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn footer_omitted_when_repo_url_unset() {
let md = render(None, Platform::GitHub);
assert!(!md.contains("False positive?"));
assert!(!md.contains("/issues/new"));
}

#[test]
fn footer_renders_when_repo_url_supplied() {
let md = render(Some("https://github.com/example/proj"), Platform::GitHub);
assert!(md.contains("False positive?"));
assert!(md.contains("https://github.com/example/proj/issues/new"));
assert!(md.contains("/bomdrift suppress"));
assert!(md.contains("https://metbcy.github.io/bomdrift/"));
}

#[test]
fn footer_strips_trailing_slash_from_repo_url() {
let md = render(Some("https://github.com/example/proj/"), Platform::GitHub);
assert!(md.contains("https://github.com/example/proj/issues/new"));
assert!(!md.contains("proj//issues"));
}

#[test]
fn footer_renders_gitlab_shape_when_platform_is_gitlab() {
let md = render(Some("https://gitlab.com/group/project"), Platform::GitLab);
assert!(md.contains("False positive?"));
assert!(
md.contains("https://gitlab.com/group/project/-/issues/new"),
"expected GitLab `/-/issues/new` URL shape; got:\n{md}"
);
assert!(
md.contains("bomdrift baseline add"),
"expected GitLab footer to point at `bomdrift baseline add`; got:\n{md}"
);
assert!(
!md.contains("/bomdrift suppress"),
"GitLab footer must NOT mention the GitHub-only `/bomdrift suppress` comment flow; got:\n{md}"
);
assert!(md.contains("https://metbcy.github.io/bomdrift/"));
}

#[test]
fn footer_default_platform_preserves_github_shape() {
assert_eq!(Platform::default(), Platform::GitHub);
let md = render(Some("https://github.com/example/proj"), Platform::default());
assert!(md.contains("/issues/new?labels=false-positive"));
assert!(md.contains("/bomdrift suppress"));
}

#[test]
fn footer_renders_bitbucket_shape() {
let md = render(Some("https://bitbucket.org/team/proj"), Platform::Bitbucket);
assert!(
md.contains("https://bitbucket.org/team/proj/issues/new"),
"expected Bitbucket /issues/new URL; got:\n{md}"
);
assert!(md.contains("bomdrift baseline add"));
assert!(!md.contains("/bomdrift suppress"));
}

#[test]
fn footer_renders_azure_devops_shape() {
let md = render(
Some("https://dev.azure.com/org/project/_git/repo"),
Platform::AzureDevOps,
);
assert!(
md.contains("/_workitems/create?templateName=false-positive"),
"expected Azure DevOps work-item URL; got:\n{md}"
);
assert!(md.contains("bomdrift baseline add"));
}
}
Loading