Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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
}