use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::project::PackageManager;
const DEFAULT_DIR: &str = ".opi/sbom";
pub const NPM_FILE: &str = "sbom-npm.cdx.json";
pub const CARGO_FILE: &str = "sbom-cargo.cdx.json";
#[derive(Debug, PartialEq, Eq)]
pub enum SbomError {
Missing {
tool: &'static str,
install: &'static str,
},
Failed(String),
}
pub fn default_dir(root: &Path) -> Result<PathBuf, SbomError> {
let state = root.join(".opi");
fs::create_dir_all(&state).map_err(|error| SbomError::Failed(error.to_string()))?;
let ignore = state.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").map_err(|error| SbomError::Failed(error.to_string()))?;
}
Ok(root.join(DEFAULT_DIR))
}
pub fn npm(manager: PackageManager, root: &Path, out: &Path) -> Result<PathBuf, SbomError> {
let locked = manager == PackageManager::Npm || root.join("package-lock.json").is_file();
let (tool, args) = npm_command(manager, locked);
let install = match manager {
PackageManager::Pnpm => "npm install -g pnpm",
_ => "npm install -g npm@latest",
};
let output = Command::new(tool)
.args(args)
.current_dir(root)
.output()
.map_err(|error| spawn_error(error, tool, install))?;
if !output.status.success() {
return Err(SbomError::Failed(first_line(&output.stderr, tool)));
}
write(out, NPM_FILE, &output.stdout)
}
pub fn cargo(root: &Path, out: &Path) -> Result<PathBuf, SbomError> {
let output = Command::new("cargo")
.args(cargo_args())
.current_dir(root)
.output()
.map_err(|error| spawn_error(error, "cargo", "rustup"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no such command") {
return Err(SbomError::Missing {
tool: "cargo-cyclonedx",
install: "cargo install cargo-cyclonedx",
});
}
if !output.status.success() {
return Err(SbomError::Failed(first_line(
&output.stderr,
"cargo cyclonedx",
)));
}
let written = root.join(CARGO_FILE);
if !written.is_file() {
return Err(SbomError::Failed(
"cargo cyclonedx wrote no file at the root; a virtual workspace gets one per member, next to its manifest".to_owned(),
));
}
let target = out.join(CARGO_FILE);
if written != target {
fs::create_dir_all(out).map_err(|error| SbomError::Failed(error.to_string()))?;
fs::copy(&written, &target).map_err(|error| SbomError::Failed(error.to_string()))?;
fs::remove_file(&written).map_err(|error| SbomError::Failed(error.to_string()))?;
}
Ok(target)
}
fn npm_command(manager: PackageManager, locked: bool) -> (&'static str, Vec<&'static str>) {
let mut args = vec!["sbom", "--sbom-format", "cyclonedx"];
if manager == PackageManager::Pnpm {
return ("pnpm", args);
}
if locked {
args.push("--package-lock-only");
}
("npm", args)
}
fn cargo_args() -> [&'static str; 5] {
[
"cyclonedx",
"--format",
"json",
"--override-filename",
"sbom-cargo.cdx",
]
}
fn write(out: &Path, name: &str, contents: &[u8]) -> Result<PathBuf, SbomError> {
fs::create_dir_all(out).map_err(|error| SbomError::Failed(error.to_string()))?;
let path = out.join(name);
fs::write(&path, contents).map_err(|error| SbomError::Failed(error.to_string()))?;
Ok(path)
}
fn spawn_error(error: io::Error, tool: &'static str, install: &'static str) -> SbomError {
if error.kind() == io::ErrorKind::NotFound {
SbomError::Missing { tool, install }
} else {
SbomError::Failed(format!("could not run {tool}: {error}"))
}
}
fn first_line(stderr: &[u8], tool: &str) -> String {
String::from_utf8_lossy(stderr)
.lines()
.find(|line| !line.trim().is_empty())
.map_or_else(|| format!("{tool} failed"), |line| line.trim().to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pnpm_asks_pnpm_and_the_rest_ask_npm() {
assert_eq!(npm_command(PackageManager::Pnpm, true).0, "pnpm");
for manager in [
PackageManager::Npm,
PackageManager::Yarn,
PackageManager::Bun,
] {
assert_eq!(npm_command(manager, false).0, "npm");
}
}
#[test]
fn the_npm_command_asks_for_cyclonedx() {
assert_eq!(
npm_command(PackageManager::Npm, false).1,
["sbom", "--sbom-format", "cyclonedx"]
);
}
#[test]
fn a_lockfile_is_read_alone() {
assert!(
npm_command(PackageManager::Npm, true)
.1
.contains(&"--package-lock-only")
);
assert!(
!npm_command(PackageManager::Pnpm, true)
.1
.contains(&"--package-lock-only")
);
}
#[test]
fn the_default_directory_ignores_itself() {
let root = std::env::temp_dir().join(format!("opi-sbom-{}", std::process::id()));
let dir = default_dir(&root).unwrap();
assert_eq!(dir, root.join(".opi/sbom"));
assert_eq!(
fs::read_to_string(root.join(".opi/.gitignore")).unwrap(),
"*\n"
);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn the_cargo_filename_matches_the_expected_file() {
let args = cargo_args();
assert_eq!(format!("{}.json", args[4]), CARGO_FILE);
}
#[test]
fn a_missing_binary_names_what_to_install() {
let error = io::Error::from(io::ErrorKind::NotFound);
assert_eq!(
spawn_error(error, "pnpm", "npm install -g pnpm"),
SbomError::Missing {
tool: "pnpm",
install: "npm install -g pnpm"
}
);
}
#[test]
fn the_first_stderr_line_is_the_reason() {
assert_eq!(
first_line(b"\n npm error missing lockfile\nmore", "npm"),
"npm error missing lockfile"
);
assert_eq!(first_line(b"", "npm"), "npm failed");
}
#[test]
fn a_document_is_written_into_a_new_directory() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("sbom");
let path = write(&out, NPM_FILE, b"{}").unwrap();
assert_eq!(fs::read(path).unwrap(), b"{}");
}
}