use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use once_cell::sync::Lazy;
use regex::Regex;
static OS_RELEASE_ID_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(?m)^ID=(?:"([^"]*)"|(.*))\s*$"#).unwrap());
static DPKG_SEARCH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^:]+):").unwrap());
static DPKG_VERSION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^Version:\s*(\S+)").unwrap());
static APK_WHO_OWNS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r" is owned by (.+)-(\d[^\s]*)$").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvidedBy {
pub package_type: String,
pub package_name: String,
pub package_version: String,
pub distro: Option<String>,
}
impl ProvidedBy {
pub fn purl(&self) -> String {
let mut s = format!("pkg:{}/", self.package_type);
if let Some(distro) = &self.distro {
s.push_str(&purl_encode(distro));
s.push('/');
}
s.push_str(&purl_encode(&self.package_name));
s.push('@');
s.push_str(&purl_encode(&self.package_version));
s
}
}
pub(super) fn purl_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
fn dpkg_provides(filepath: &str, distro: &str) -> Option<ProvidedBy> {
let out = Command::new("dpkg").args(["-S", filepath]).output().ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let package_name = DPKG_SEARCH_RE.captures(&stdout)?.get(1)?.as_str();
let out = Command::new("dpkg")
.args(["-s", package_name])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let version = DPKG_VERSION_RE.captures(&stdout)?.get(1)?.as_str();
Some(ProvidedBy {
package_type: "deb".into(),
package_name: package_name.into(),
package_version: version.into(),
distro: Some(distro.into()),
})
}
fn rpm_provides(filepath: &str, distro: &str) -> Option<ProvidedBy> {
let out = Command::new("rpm")
.args([
"-qf",
"--queryformat",
"%{NAME} %{VERSION} %{RELEASE}",
filepath,
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut parts = stdout.trim().splitn(3, ' ');
let name = parts.next()?;
let version = parts.next()?;
let release = parts.next()?;
Some(ProvidedBy {
package_type: "rpm".into(),
package_name: name.into(),
package_version: format!("{version}-{release}"),
distro: Some(distro.into()),
})
}
fn apk_provides(filepath: &str, distro: &str) -> Option<ProvidedBy> {
let out = Command::new("apk")
.args(["info", "--who-owns", filepath])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let caps = APK_WHO_OWNS_RE.captures(stdout.trim())?;
Some(ProvidedBy {
package_type: "apk".into(),
package_name: caps[1].into(),
package_version: caps[2].into(),
distro: Some(distro.into()),
})
}
pub fn whichprovides(filepaths: &[PathBuf], sysroot: &Path) -> HashMap<PathBuf, ProvidedBy> {
let mut results = HashMap::new();
let distro = read_distro_id(sysroot);
let distro = match distro.as_deref() {
Some(d) => d,
None => return results,
};
let provider: fn(&str, &str) -> Option<ProvidedBy> = if which::which("dpkg").is_ok() {
dpkg_provides
} else if which::which("rpm").is_ok() {
rpm_provides
} else if which::which("apk").is_ok() {
apk_provides
} else {
return results;
};
let canon_sysroot = sysroot.canonicalize().unwrap_or_else(|_| sysroot.into());
for filepath in filepaths {
let resolved = filepath.canonicalize().unwrap_or_else(|_| filepath.clone());
let resolved_str = resolved.to_string_lossy();
if let Some(provided_by) = provider(&resolved_str, distro) {
results.insert(filepath.clone(), provided_by);
continue;
}
if canon_sysroot != Path::new("/")
&& let Ok(rel) = resolved.strip_prefix(&canon_sysroot)
{
let host_path = Path::new("/").join(rel);
if let Some(provided_by) = provider(&host_path.to_string_lossy(), distro) {
results.insert(filepath.clone(), provided_by);
}
}
}
results
}
fn read_distro_id(sysroot: &Path) -> Option<String> {
if sysroot != Path::new("/")
&& let Some(id) = read_os_release_id(&sysroot.join("etc/os-release"))
{
return Some(id);
}
read_os_release_id(Path::new("/etc/os-release"))
}
fn read_os_release_id(path: &Path) -> Option<String> {
let content = fs_err::read_to_string(path).ok()?;
let caps = OS_RELEASE_ID_RE.captures(&content)?;
caps.get(1)
.or_else(|| caps.get(2))
.map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_purl_encode() {
assert_eq!(purl_encode("zlib1g"), "zlib1g");
assert_eq!(purl_encode("foo bar"), "foo%20bar");
assert_eq!(purl_encode("a/b"), "a%2Fb");
}
#[test]
fn test_purl_with_distro() {
let p = ProvidedBy {
package_type: "deb".into(),
package_name: "zlib1g".into(),
package_version: "1:1.2.11".into(),
distro: Some("ubuntu".into()),
};
assert_eq!(p.purl(), "pkg:deb/ubuntu/zlib1g@1%3A1.2.11");
}
#[test]
fn test_purl_without_distro() {
let p = ProvidedBy {
package_type: "rpm".into(),
package_name: "zlib".into(),
package_version: "1.2.11-31.el9".into(),
distro: None,
};
assert_eq!(p.purl(), "pkg:rpm/zlib@1.2.11-31.el9");
}
#[test]
fn test_whichprovides_empty_input() {
assert!(whichprovides(&[], Path::new("/")).is_empty());
}
}