crates/heimdall-scan/src/purl.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! Package URLs (`pkg:type/namespace/name@version?qualifiers#subpath`), the
//! package identity a static-scanner job is about. Heimdall is a defensive
//! sandbox. Only what the scanners need: parse, percent-decode, and the
//! ecosystem key and package name modgunn uses.
use std::collections::BTreeMap;
use std::fmt;
/// The longest purl accepted.
pub const MAX_PURL_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Purl {
/// The purl type, lower case (`npm`, `pypi`, `cargo`, `golang`, …).
pub ty: String,
/// The namespace (npm scope with its `@`, Maven group, Go path head).
pub namespace: Option<String>,
pub name: String,
pub version: Option<String>,
pub qualifiers: BTreeMap<String, String>,
pub subpath: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PurlError {
TooLong,
NoScheme,
NoType,
BadType(String),
NoName,
BadPercent,
NotUtf8,
BadQualifier(String),
}
impl fmt::Display for PurlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PurlError::TooLong => write!(f, "purl longer than {MAX_PURL_BYTES} bytes"),
PurlError::NoScheme => write!(f, "purl does not start with `pkg:`"),
PurlError::NoType => write!(f, "purl has no type"),
PurlError::BadType(t) => write!(f, "purl type {t:?} is not [a-z0-9.+-]"),
PurlError::NoName => write!(f, "purl has no name"),
PurlError::BadPercent => write!(f, "purl has a bad percent escape"),
PurlError::NotUtf8 => write!(f, "purl decodes to bytes that are not UTF-8"),
PurlError::BadQualifier(q) => write!(f, "purl qualifier {q:?} is not key=value"),
}
}
}
impl std::error::Error for PurlError {}
impl Purl {
pub fn parse(s: &str) -> Result<Purl, PurlError> {
if s.len() > MAX_PURL_BYTES {
return Err(PurlError::TooLong);
}
if s.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err(PurlError::BadType("control or space in purl".into()));
}
let rest = s
.strip_prefix("pkg:")
.or_else(|| s.strip_prefix("PKG:"))
.ok_or(PurlError::NoScheme)?;
let rest = rest.trim_start_matches('/');
let (rest, subpath) = match rest.split_once('#') {
Some((a, b)) => (a, Some(b)),
None => (rest, None),
};
let (rest, quals) = match rest.split_once('?') {
Some((a, b)) => (a, Some(b)),
None => (rest, None),
};
let (ty, path) = rest.split_once('/').ok_or(PurlError::NoName)?;
if ty.is_empty() {
return Err(PurlError::NoType);
}
let ty = ty.to_ascii_lowercase();
if !ty
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"+-.".contains(&b))
|| ty.as_bytes()[0].is_ascii_digit()
{
return Err(PurlError::BadType(ty));
}
let path = path.trim_matches('/');
// The version is after the LAST '@' of the last segment (an npm scope
// in the namespace starts with an encoded or a plain '@').
let (path, version) = match path.rfind('@') {
Some(i) if !path[i..].contains('/') && i > 0 && !path[..i].ends_with('/') => {
(&path[..i], Some(decode(&path[i + 1..])?))
}
_ => (path, None),
};
let mut segs: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
let name = decode(segs.pop().ok_or(PurlError::NoName)?)?;
if name.is_empty() {
return Err(PurlError::NoName);
}
let namespace = if segs.is_empty() {
None
} else {
let parts: Result<Vec<String>, PurlError> = segs.iter().map(|p| decode(p)).collect();
Some(parts?.join("/"))
};
let mut qualifiers = BTreeMap::new();
if let Some(q) = quals {
for kv in q.split('&').filter(|p| !p.is_empty()) {
let (k, v) = kv
.split_once('=')
.ok_or_else(|| PurlError::BadQualifier(kv.into()))?;
if k.is_empty() {
return Err(PurlError::BadQualifier(kv.into()));
}
qualifiers.insert(k.to_ascii_lowercase(), decode(v)?);
}
}
let subpath = match subpath {
Some(sp) => Some(decode(sp.trim_matches('/'))?),
None => None,
};
let version = version.filter(|v| !v.is_empty());
Ok(Purl {
ty,
namespace,
name,
version,
qualifiers,
subpath,
})
}
/// The ecosystem key modgunn uses (`cargo`, `npm`, `pypi`, `go`, `maven`,
/// `gem`, `nuget`, `composer`, …); unknown types pass through.
pub fn ecosystem(&self) -> &str {
match self.ty.as_str() {
"golang" => "go",
"gem" => "gem",
other => other,
}
}
/// The package name as its registry spells it: npm `@scope/name`, Go
/// `host/path/name`, Maven `group:artifact`, PyPI normalized (PEP 503),
/// otherwise the purl name.
pub fn package_name(&self) -> String {
match (self.ty.as_str(), &self.namespace) {
("maven", Some(ns)) => format!("{ns}:{}", self.name),
("pypi", _) => pep503(&self.name),
(_, Some(ns)) => format!("{ns}/{}", self.name),
(_, None) => self.name.clone(),
}
}
}
/// PEP 503 name normalization: lower case, runs of `-`, `_`, `.` become `-`.
pub fn pep503(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut sep = false;
for c in name.chars() {
if matches!(c, '-' | '_' | '.') {
sep = true;
} else {
if sep && !out.is_empty() {
out.push('-');
}
sep = false;
out.extend(c.to_lowercase());
}
}
out
}
fn decode(s: &str) -> Result<String, PurlError> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
let hex = b.get(i + 1..i + 3).ok_or(PurlError::BadPercent)?;
let hex = std::str::from_utf8(hex).map_err(|_| PurlError::BadPercent)?;
out.push(u8::from_str_radix(hex, 16).map_err(|_| PurlError::BadPercent)?);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
let s = String::from_utf8(out).map_err(|_| PurlError::NotUtf8)?;
if s.chars().any(char::is_control) {
return Err(PurlError::NotUtf8);
}
Ok(s)
}
impl fmt::Display for Purl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "pkg:{}/", self.ty)?;
if let Some(ns) = &self.namespace {
for seg in ns.split('/') {
write!(f, "{}/", encode(seg))?;
}
}
write!(f, "{}", encode(&self.name))?;
if let Some(v) = &self.version {
write!(f, "@{}", encode(v))?;
}
if !self.qualifiers.is_empty() {
let q: Vec<String> = self
.qualifiers
.iter()
.map(|(k, v)| format!("{k}={}", encode(v)))
.collect();
write!(f, "?{}", q.join("&"))?;
}
if let Some(sp) = &self.subpath {
write!(f, "#{sp}")?;
}
Ok(())
}
}
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}