use anyhow::{Context, Result, bail};
use camino::{Utf8Path, Utf8PathBuf};
use std::io::{BufRead, Read};
use crate::*;
pub(crate) const QUERYFORMAT: &str = concat!(
r"@@PKG@@\t%{NAME}\t%{VERSION}\t%{RELEASE}\t%{EPOCH}\t%{ARCH}",
r"\t%{LICENSE}\t%{SIZE}\t%{BUILDTIME}\t%{INSTALLTIME}",
r"\t%{SOURCERPM}\t%{FILEDIGESTALGO}\n",
r"[@@FILE@@\t%{FILENAMES}\t%{FILESIZES}\t%{FILEMODES}\t%{FILEMTIMES}",
r"\t%{FILEDIGESTS}\t%{FILEFLAGS}",
r"\t%{FILEUSERNAME}\t%{FILEGROUPNAME}\t%{FILELINKTOS}\n]",
r"[@@CL@@\t%{CHANGELOGTIME}\n]",
);
const PKG_FIELDS: usize = 11;
const FILE_FIELDS: usize = 9;
pub(crate) fn load_from_reader_impl<R: Read>(reader: R) -> Result<Packages> {
let mut packages = Packages::new();
let mut current_pkg: Option<Package> = None;
let mut skip = false;
for (line_no, line) in std::io::BufReader::new(reader).lines().enumerate() {
let line = line.context("reading line")?;
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("@@PKG@@\t") {
if let Some(pkg) = current_pkg.take() {
packages.insert(pkg.name.clone(), pkg);
}
let fields: Vec<&str> = rest.split('\t').collect();
if fields.len() != PKG_FIELDS {
bail!(
"line {}: expected {PKG_FIELDS} fields in PKG line, got {}",
line_no + 1,
fields.len()
);
}
let name = fields[0];
if name == "gpg-pubkey" {
skip = true;
continue;
}
skip = false;
let pkg = parse_pkg_header(&fields)
.with_context(|| format!("parsing package header at line {}", line_no + 1))?;
current_pkg = Some(pkg);
} else if skip {
continue;
} else if let Some(rest) = line.strip_prefix("@@FILE@@\t") {
let pkg = current_pkg
.as_mut()
.ok_or_else(|| anyhow::anyhow!("line {}: FILE line before any PKG", line_no + 1))?;
let fields: Vec<&str> = rest.split('\t').collect();
if fields.len() != FILE_FIELDS {
bail!(
"line {}: expected {} fields in FILE line for '{}', got {}",
line_no + 1,
FILE_FIELDS,
pkg.name,
fields.len()
);
}
let (path, info) = parse_file_line(&fields)
.with_context(|| format!("line {}: file in '{}'", line_no + 1, pkg.name))?;
pkg.files.insert(path, info);
} else if let Some(rest) = line.strip_prefix("@@CL@@\t") {
let pkg = current_pkg
.as_mut()
.ok_or_else(|| anyhow::anyhow!("line {}: CL line before any PKG", line_no + 1))?;
let time: u64 = rest.parse().with_context(|| {
format!(
"line {}: invalid changelog time for '{}'",
line_no + 1,
pkg.name
)
})?;
pkg.changelog_times.push(time);
} else {
bail!(
"line {}: unexpected line format: {}",
line_no + 1,
&line[..line.len().min(80)]
);
}
}
if let Some(pkg) = current_pkg.take() {
packages.insert(pkg.name.clone(), pkg);
}
Ok(packages)
}
pub(crate) fn load_from_str_impl(input: &str) -> Result<Packages> {
load_from_reader_impl(input.as_bytes())
}
fn parse_pkg_header(fields: &[&str]) -> Result<Package> {
assert_eq!(fields.len(), PKG_FIELDS); let name = fields[0];
let epoch = match parse_optional(fields[3]) {
None => None,
Some(s) => Some(
s.parse::<u32>()
.with_context(|| format!("{name}: invalid epoch '{s}'"))?,
),
};
let arch = parse_optional(fields[4])
.ok_or_else(|| anyhow::anyhow!("{name}: missing arch"))?
.to_string();
let size = fields[6]
.parse::<u64>()
.with_context(|| format!("{name}: invalid size"))?;
let buildtime = fields[7]
.parse::<u64>()
.with_context(|| format!("{name}: invalid buildtime"))?;
let installtime = fields[8]
.parse::<u64>()
.with_context(|| format!("{name}: invalid installtime"))?;
let sourcerpm = parse_optional(fields[9]).map(|s| s.to_string());
let digest_algo = match parse_optional(fields[10]) {
None => None,
Some(s) => {
let v = s
.parse::<u32>()
.with_context(|| format!("{name}: invalid filedigestalgo '{s}'"))?;
Some(
DigestAlgorithm::try_from(v)
.map_err(|_| anyhow::anyhow!("{name}: unknown digest algorithm {v}"))?,
)
}
};
Ok(Package {
name: name.to_string(),
version: fields[1].to_string(),
release: fields[2].to_string(),
epoch,
arch,
license: fields[5].to_string(),
size,
buildtime,
installtime,
sourcerpm,
digest_algo,
changelog_times: Vec::new(),
files: Files::new(),
})
}
fn parse_optional(s: &str) -> Option<&str> {
if s == "(none)" { None } else { Some(s) }
}
impl TryFrom<u32> for DigestAlgorithm {
type Error = ();
fn try_from(v: u32) -> Result<Self, Self::Error> {
match v {
x if x == Self::Md5 as u32 => Ok(Self::Md5),
x if x == Self::Sha1 as u32 => Ok(Self::Sha1),
x if x == Self::RipeMd160 as u32 => Ok(Self::RipeMd160),
x if x == Self::Md2 as u32 => Ok(Self::Md2),
x if x == Self::Tiger192 as u32 => Ok(Self::Tiger192),
x if x == Self::Haval5160 as u32 => Ok(Self::Haval5160),
x if x == Self::Sha256 as u32 => Ok(Self::Sha256),
x if x == Self::Sha384 as u32 => Ok(Self::Sha384),
x if x == Self::Sha512 as u32 => Ok(Self::Sha512),
x if x == Self::Sha224 as u32 => Ok(Self::Sha224),
x if x == Self::Sha3_256 as u32 => Ok(Self::Sha3_256),
x if x == Self::Sha3_512 as u32 => Ok(Self::Sha3_512),
_ => Err(()),
}
}
}
fn parse_file_line(fields: &[&str]) -> Result<(Utf8PathBuf, FileInfo)> {
assert_eq!(fields.len(), FILE_FIELDS); let path = Utf8Path::new(fields[0]);
let size = fields[1]
.parse::<u64>()
.with_context(|| format!("invalid filesize for {path}"))?;
let mode = fields[2]
.parse::<u16>()
.with_context(|| format!("invalid filemode for {path}"))?;
let mtime = fields[3]
.parse::<u64>()
.with_context(|| format!("invalid filemtime for {path}"))?;
let digest = if fields[4].is_empty() {
None
} else {
Some(fields[4].to_string())
};
let flags = fields[5]
.parse::<u32>()
.with_context(|| format!("invalid fileflags for {path}"))?;
let linkto = if fields[8].is_empty() {
None
} else {
Some(Utf8PathBuf::from(fields[8]))
};
let info = FileInfo {
size,
mode,
mtime,
digest,
flags: FileFlags::from_raw(flags),
user: fields[6].to_string(),
group: fields[7].to_string(),
linkto,
};
Ok((path.to_path_buf(), info))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pkg_line(name: &str) -> String {
format!(
"@@PKG@@\t{name}\t1.0\t1.fc42\t(none)\tx86_64\tMIT\t100\t1000\t2000\tfoo.src.rpm\t8\n"
)
}
fn make_file_line(path: &str) -> String {
format!("@@FILE@@\t{path}\t100\t33188\t1000\taabbccdd\t0\troot\troot\t\n")
}
#[test]
fn test_empty_input() {
let packages = load_from_str_impl("").unwrap();
assert!(packages.is_empty());
}
#[test]
fn test_gpg_pubkey_skipped() {
let input =
"@@PKG@@\tgpg-pubkey\t1.0\t1.fc42\t(none)\t(none)\tpubkey\t0\t0\t0\t(none)\t(none)\n";
let packages = load_from_str_impl(input).unwrap();
assert!(packages.is_empty());
}
#[test]
fn test_none_optional_fields() {
let input = "@@PKG@@\ttest\t1.0\t1\t(none)\tx86_64\tMIT\t0\t0\t0\t(none)\t(none)\n";
let packages = load_from_str_impl(input).unwrap();
assert_eq!(packages["test"].epoch, None);
assert_eq!(packages["test"].sourcerpm, None);
assert_eq!(packages["test"].digest_algo, None);
assert!(packages["test"].files.is_empty());
assert!(packages["test"].changelog_times.is_empty());
}
#[test]
fn test_no_files_with_changelog() {
let mut input = make_pkg_line("test");
input.push_str("@@CL@@\t3000\n");
input.push_str("@@CL@@\t2000\n");
input.push_str("@@CL@@\t1000\n");
let packages = load_from_str_impl(&input).unwrap();
assert!(packages["test"].files.is_empty());
assert_eq!(packages["test"].changelog_times, vec![3000, 2000, 1000]);
}
#[test]
fn test_with_files_no_changelog() {
let mut input = make_pkg_line("test");
input.push_str(&make_file_line("/usr/bin/foo"));
input.push_str(&make_file_line("/usr/bin/bar"));
let packages = load_from_str_impl(&input).unwrap();
assert_eq!(packages["test"].files.len(), 2);
assert!(packages["test"].changelog_times.is_empty());
}
#[test]
fn test_with_files_and_changelog() {
let mut input = make_pkg_line("test");
input.push_str(&make_file_line("/usr/bin/foo"));
input.push_str(&make_file_line("/usr/bin/bar"));
input.push_str("@@CL@@\t2000\n");
input.push_str("@@CL@@\t1000\n");
let packages = load_from_str_impl(&input).unwrap();
assert_eq!(packages["test"].files.len(), 2);
assert_eq!(packages["test"].changelog_times, vec![2000, 1000]);
}
#[test]
fn test_multiple_packages() {
let mut input = make_pkg_line("alpha");
input.push_str(&make_file_line("/usr/bin/alpha"));
input.push_str(&make_pkg_line("beta"));
input.push_str(&make_file_line("/usr/bin/beta"));
let packages = load_from_str_impl(&input).unwrap();
assert_eq!(packages.len(), 2);
assert!(packages.contains_key("alpha"));
assert!(packages.contains_key("beta"));
}
#[test]
fn test_error_conditions() {
assert!(load_from_str_impl("@@PKG@@\tfoo\t1.0\n").is_err());
let mut input = make_pkg_line("test");
input.push_str("@@FILE@@\t/a\t0\n");
assert!(load_from_str_impl(&input).is_err());
assert!(load_from_str_impl("@@FILE@@\t/a\t0\t33188\t0\t\t0\troot\troot\t\n").is_err());
assert!(load_from_str_impl("garbage\n").is_err());
}
#[test]
fn test_symlink_and_empty_digest() {
let mut input = make_pkg_line("test");
input.push_str("@@FILE@@\t/usr/bin/sh\t4\t41471\t1000\t\t0\troot\troot\tbash\n");
let packages = load_from_str_impl(&input).unwrap();
let sh = &packages["test"].files[Utf8Path::new("/usr/bin/sh")];
assert!(sh.digest.is_none());
assert_eq!(sh.linkto.as_deref(), Some(Utf8Path::new("bash")));
}
}