use crate::digest::{Digest, DigestError};
use indexmap::IndexMap;
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io;
use std::io::Write;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Checksum {
pub digest: Digest,
pub hash: String,
}
impl Checksum {
pub fn new(digest: Digest, hash: String) -> Checksum {
Checksum { digest, hash }
}
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum EntryType {
#[default]
Distfile,
Patchfile,
}
impl<P: AsRef<Path>> From<P> for EntryType {
fn from(path: P) -> Self {
if Entry::is_patch_filename(&path) {
EntryType::Patchfile
} else {
EntryType::Distfile
}
}
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Entry {
pub filename: PathBuf,
pub filepath: PathBuf,
pub size: Option<u64>,
pub checksums: Vec<Checksum>,
pub filetype: EntryType,
}
impl Entry {
pub fn is_patch_filename<P: AsRef<Path>>(path: P) -> bool {
let Some(p) = path.as_ref().file_name() else {
return false;
};
let s = p.to_string_lossy();
if s.starts_with("patch-local-")
|| s.ends_with(".orig")
|| s.ends_with(".rej")
|| s.ends_with("~")
{
return false;
}
if s.contains(".tar.") {
return false;
}
s.starts_with("patch-")
|| (s.starts_with("emul-") && s.contains("-patch-"))
}
pub fn new<P1, P2>(
filename: P1,
filepath: P2,
checksums: Vec<Checksum>,
size: Option<u64>,
) -> Entry
where
P1: AsRef<Path>,
P2: AsRef<Path>,
{
let filetype = EntryType::from(filename.as_ref());
Entry {
filename: filename.as_ref().to_path_buf(),
filepath: filepath.as_ref().to_path_buf(),
checksums,
size,
filetype,
}
}
pub fn verify_size<P: AsRef<Path>>(
&self,
path: P,
) -> Result<u64, DistinfoError> {
if let Some(size) = self.size {
let f = File::open(path)?;
let fsize = f.metadata()?.len();
if fsize != size {
return Err(DistinfoError::Size(
self.filename.clone(),
size,
fsize,
));
} else {
return Ok(size);
}
}
Err(DistinfoError::MissingSize(path.as_ref().to_path_buf()))
}
fn verify_checksum_internal<P: AsRef<Path>>(
&self,
path: P,
digest: Digest,
) -> Result<Digest, DistinfoError> {
for c in &self.checksums {
if digest != c.digest {
continue;
}
let mut f = File::open(path)?;
let hash = match self.filetype {
EntryType::Distfile => c.digest.hash_file(&mut f)?,
EntryType::Patchfile => c.digest.hash_patch(&mut f)?,
};
if hash != c.hash {
return Err(DistinfoError::Checksum(
self.filename.clone(),
c.digest,
c.hash.clone(),
hash,
));
} else {
return Ok(digest);
}
}
Err(DistinfoError::MissingChecksum(
path.as_ref().to_path_buf(),
digest,
))
}
pub fn verify_checksum<P: AsRef<Path>>(
&self,
path: P,
digest: Digest,
) -> Result<Digest, DistinfoError> {
self.verify_checksum_internal(path, digest)
}
pub fn verify_checksums<P: AsRef<Path>>(
&self,
path: P,
) -> Vec<Result<Digest, DistinfoError>> {
let mut results = vec![];
for c in &self.checksums {
results
.push(self.verify_checksum_internal(path.as_ref(), c.digest));
}
results
}
pub fn as_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::new();
for c in &self.checksums {
let _ = writeln!(
bytes,
"{} ({}) = {}",
c.digest,
self.filename.display(),
c.hash,
);
}
if let Some(size) = self.size {
let _ = writeln!(
bytes,
"Size ({}) = {} bytes",
self.filename.display(),
size,
);
}
bytes
}
}
#[derive(Debug, Eq, PartialEq)]
enum Line {
RcsId(OsString),
Size(PathBuf, u64),
Checksum(Digest, PathBuf, String),
None,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Distinfo {
rcsid: Option<OsString>,
distfiles: IndexMap<PathBuf, Entry>,
patchfiles: IndexMap<PathBuf, Entry>,
}
#[derive(Debug, Error)]
pub enum DistinfoError {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Digest(#[from] DigestError),
#[error("File not found")]
NotFound,
#[error("Checksum {1} mismatch for {0}: expected {2}, actual {3}")]
Checksum(PathBuf, Digest, String, String),
#[error("Missing {1} checksum entry for {0}")]
MissingChecksum(PathBuf, Digest),
#[error("Size mismatch for {0}: expected {1}, actual {2}")]
Size(PathBuf, u64, u64),
#[error("Missing size entry for {0}")]
MissingSize(PathBuf),
}
impl Distinfo {
pub fn new() -> Self {
Self::default()
}
pub fn rcsid(&self) -> Option<&OsString> {
self.rcsid.as_ref()
}
pub fn set_rcsid(&mut self, rcsid: impl Into<OsString>) {
self.rcsid = Some(rcsid.into());
}
pub fn distfile<P: AsRef<Path>>(&self, path: P) -> Option<&Entry> {
self.distfiles.get(path.as_ref())
}
pub fn patchfile<P: AsRef<Path>>(&self, path: P) -> Option<&Entry> {
self.patchfiles.get(path.as_ref())
}
pub fn distfiles(&self) -> Vec<&Entry> {
self.distfiles.values().collect()
}
pub fn patchfiles(&self) -> Vec<&Entry> {
self.patchfiles.values().collect()
}
pub fn calculate_size<P: AsRef<Path>>(
path: P,
) -> Result<u64, DistinfoError> {
let file = File::open(path)?;
Ok(file.metadata()?.len())
}
pub fn calculate_checksum<P: AsRef<Path>>(
path: P,
digest: Digest,
) -> Result<String, DistinfoError> {
let filetype = EntryType::from(path.as_ref());
let mut f = File::open(path)?;
match filetype {
EntryType::Distfile => Ok(digest.hash_file(&mut f)?),
EntryType::Patchfile => Ok(digest.hash_patch(&mut f)?),
}
}
pub fn insert(&mut self, entry: Entry) -> bool {
let map = match entry.filetype {
EntryType::Distfile => &mut self.distfiles,
EntryType::Patchfile => &mut self.patchfiles,
};
map.insert(entry.filename.clone(), entry).is_none()
}
pub fn find_entry<P: AsRef<Path>>(
&self,
path: P,
) -> Result<&Entry, DistinfoError> {
let filetype = EntryType::from(path.as_ref());
let mut file = PathBuf::new();
for component in path.as_ref().iter().rev() {
if file.parent().is_none() {
file = PathBuf::from(component);
} else {
file = PathBuf::from(component).join(file);
}
match filetype {
EntryType::Distfile => {
if let Some(entry) = self.distfile(&file) {
return Ok(entry);
}
}
EntryType::Patchfile => {
if let Some(entry) = self.patchfile(&file) {
return Ok(entry);
}
}
}
}
Err(DistinfoError::NotFound)
}
fn update_size<P: AsRef<Path>>(&mut self, path: P, size: u64) {
let filetype = EntryType::from(path.as_ref());
let map = match filetype {
EntryType::Distfile => &mut self.distfiles,
EntryType::Patchfile => &mut self.patchfiles,
};
match map.get_mut(path.as_ref()) {
Some(entry) => entry.size = Some(size),
None => {
map.insert(
path.as_ref().to_path_buf(),
Entry {
filename: path.as_ref().to_path_buf(),
size: Some(size),
filetype,
..Default::default()
},
);
}
};
}
fn update_checksum<P: AsRef<Path>>(
&mut self,
path: P,
digest: Digest,
hash: String,
) {
let filetype = EntryType::from(path.as_ref());
let map = match filetype {
EntryType::Distfile => &mut self.distfiles,
EntryType::Patchfile => &mut self.patchfiles,
};
match map.get_mut(path.as_ref()) {
Some(entry) => entry.checksums.push(Checksum { digest, hash }),
None => {
let v: Vec<Checksum> = vec![Checksum { digest, hash }];
map.insert(
path.as_ref().to_path_buf(),
Entry {
filename: path.as_ref().to_path_buf(),
checksums: v,
filetype,
..Default::default()
},
);
}
};
}
pub fn verify_size<P: AsRef<Path>>(
&self,
path: P,
) -> Result<u64, DistinfoError> {
let entry = self.find_entry(path.as_ref())?;
entry.verify_size(path)
}
pub fn verify_checksum<P: AsRef<Path>>(
&self,
path: P,
digest: Digest,
) -> Result<Digest, DistinfoError> {
let entry = self.find_entry(path.as_ref())?;
entry.verify_checksum_internal(path, digest)
}
pub fn verify_checksums<P: AsRef<Path>>(
&self,
path: P,
) -> Vec<Result<Digest, DistinfoError>> {
let entry = match self.find_entry(path.as_ref()) {
Ok(entry) => entry,
Err(e) => return vec![Err(e)],
};
let mut results = vec![];
for c in &entry.checksums {
results
.push(entry.verify_checksum_internal(path.as_ref(), c.digest));
}
results
}
pub fn from_bytes(bytes: &[u8]) -> Distinfo {
let mut distinfo = Distinfo::new();
for line in bytes.split(|c| *c == b'\n') {
match Line::from_bytes(line) {
Line::RcsId(s) => distinfo.rcsid = Some(s),
Line::Size(p, v) => {
distinfo.update_size(&p, v);
}
Line::Checksum(d, p, s) => {
distinfo.update_checksum(&p, d, s);
}
Line::None => {}
}
}
distinfo
}
pub fn as_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::new();
if let Some(s) = self.rcsid() {
bytes.extend_from_slice(s.as_bytes());
} else {
bytes.extend_from_slice("$NetBSD$".as_bytes());
}
bytes.extend_from_slice("\n\n".as_bytes());
for q in self.distfiles.values() {
for c in &q.checksums {
let _ = writeln!(
bytes,
"{} ({}) = {}",
c.digest,
q.filename.display(),
c.hash,
);
}
if let Some(size) = q.size {
let _ = writeln!(
bytes,
"Size ({}) = {} bytes",
q.filename.display(),
size,
);
}
}
for q in self.patchfiles.values() {
for c in &q.checksums {
let _ = writeln!(
bytes,
"{} ({}) = {}",
c.digest,
q.filename.display(),
c.hash,
);
}
}
bytes
}
}
impl Line {
fn from_bytes(bytes: &[u8]) -> Line {
for line in bytes.split(|c| *c == b'\n') {
let mut start = 0;
for ch in line.iter() {
if !(*ch as char).is_whitespace() {
break;
}
start += 1;
}
let line = &line[start..];
if line.starts_with(b"#") || line.is_empty() {
continue;
}
if line.starts_with(b"$NetBSD: ") {
return Line::RcsId(OsString::from_vec((*line).to_vec()));
}
let mut field = 0;
let mut action = String::new();
let mut path = PathBuf::new();
let mut value = String::new();
for s in line.split(|c| (*c as char).is_whitespace()) {
if s.is_empty() {
continue;
}
if field == 0 {
action = match String::from_utf8(s.to_vec()) {
Ok(s) => s,
Err(_) => return Line::None,
};
}
if field == 1 {
if s[0] == b'(' && s[s.len() - 1] == b')' {
path.push(OsStr::from_bytes(&s[1..s.len() - 1]));
} else {
return Line::None;
}
}
if field == 3 {
value = match String::from_utf8(s.to_vec()) {
Ok(s) => s,
Err(_) => return Line::None,
}
}
field += 1;
}
if action == "Size" {
match u64::from_str(&value) {
Ok(n) => return Line::Size(path, n),
Err(_) => return Line::None,
};
} else {
match Digest::from_str(&action) {
Ok(d) => return Line::Checksum(d, path, value),
Err(_) => return Line::None,
}
}
}
Line::None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_line_rcsid() {
let rcsid = "$NetBSD: distinfo,v 1.1 1970/01/01 01:01:01 ken Exp $";
let exp = Line::RcsId(rcsid.into());
assert_eq!(Line::from_bytes(rcsid.as_bytes()), exp);
assert_eq!(Line::from_bytes(format!(" {rcsid}").as_bytes()), exp);
assert_eq!(Line::from_bytes(format!("\n\n {rcsid}").as_bytes()), exp);
assert_eq!(Line::from_bytes(format!(" {rcsid}\n\n").as_bytes()), exp);
let entry = Line::from_bytes(format!("#{rcsid}").as_bytes());
assert_eq!(entry, Line::None);
}
#[test]
fn test_line_size() {
let i = "Size (foo-1.2.3.tar.gz) = 321 bytes";
let o = Line::from_bytes(i.as_bytes());
assert_eq!(o, Line::Size(PathBuf::from("foo-1.2.3.tar.gz"), 321));
let i = "Size (foo-1.2.3.tar.gz) = 321 bytes";
let o = Line::from_bytes(i.as_bytes());
assert_eq!(o, Line::Size(PathBuf::from("foo-1.2.3.tar.gz"), 321));
let i = "Size (foo-1.2.3.tar.gz) = 123";
let o = Line::from_bytes(i.as_bytes());
assert_eq!(o, Line::Size(PathBuf::from("foo-1.2.3.tar.gz"), 123));
let i = "Size (a.tar.gz) = 18446744073709551615";
let o = Line::from_bytes(i.as_bytes());
assert_eq!(
o,
Line::Size(PathBuf::from("a.tar.gz"), 18446744073709551615)
);
let i = "Size (a.tar.gz) = 18446744073709551616";
let o = Line::from_bytes(i.as_bytes());
assert_eq!(o, Line::None);
}
#[test]
fn test_line_digest() {
let i = "BLAKE2s (pkgin-23.8.1.tar.gz) = ojnk";
let o = Line::from_bytes(i.as_bytes());
assert_eq!(
o,
Line::Checksum(
Digest::BLAKE2s,
PathBuf::from("pkgin-23.8.1.tar.gz"),
"ojnk".to_string()
)
);
}
#[test]
fn test_line_none() {
let o = Line::from_bytes(String::new().as_bytes());
assert_eq!(o, Line::None);
let o = Line::from_bytes("\n \n\n".to_string().as_bytes());
assert_eq!(o, Line::None);
let o = Line::from_bytes("# \n\n".to_string().as_bytes());
assert_eq!(o, Line::None);
}
#[test]
fn test_distinfo() {
let i = r#"
$NetBSD: distinfo,v 1.80 2024/05/27 23:27:10 riastradh Exp $
BLAKE2s (pkgin-23.8.1.tar.gz) = eb0f008ba9801a3c0a35de3e2b2503edd554c3cb17235b347bb8274a18794eb7
SHA512 (pkgin-23.8.1.tar.gz) = 2561d9e4b28a9a77c3c798612ec489dd67dd9a93c61344937095b0683fa89d8432a9ab8e600d0e2995d954888ac2e75a407bab08aa1e8198e375c99d2999f233
Size (pkgin-23.8.1.tar.gz) = 267029 bytes
SHA1 (patch-configure.ac) = 53f56351fb602d9fdce2c1ed266d65919a369086
"#;
let di = Distinfo::from_bytes(i.as_bytes());
assert_eq!(
di.rcsid(),
Some(&OsString::from(
"$NetBSD: distinfo,v 1.80 2024/05/27 23:27:10 riastradh Exp $"
))
);
let f = di.distfile("pkgin-23.8.1.tar.gz");
assert!(f.is_some());
let p = di.patchfile("patch-configure.ac");
assert!(p.is_some());
assert_eq!(None, di.distfile("foo-23.8.1.tar.gz"));
assert_eq!(None, di.patchfile("patch-Makefile"));
}
#[test]
fn test_construct() {
let mut di = Distinfo::new();
let distsums: Vec<Checksum> = vec![
Checksum::new(Digest::BLAKE2s, String::new()),
Checksum::new(Digest::SHA512, String::new()),
];
let entry =
Entry::new("foo.tar.gz", "/distfiles/foo.tar.gz", distsums, None);
assert!(di.insert(entry.clone()));
assert!(!di.insert(entry.clone()));
assert_eq!(di.distfiles()[0].filetype, EntryType::Distfile);
assert_eq!(di.distfiles().len(), 1);
let patchsums: Vec<Checksum> =
vec![Checksum::new(Digest::SHA1, String::new())];
di.insert(Entry::new(
"patch-Makefile",
"patches/patch-Makefile",
patchsums,
None,
));
assert_eq!(di.patchfiles().len(), 1);
assert_eq!(di.patchfiles()[0].filetype, EntryType::Patchfile);
}
#[test]
fn test_is_patch_filename() {
assert!(Entry::is_patch_filename("patch-Makefile"));
assert!(Entry::is_patch_filename("patch-configure.ac"));
assert!(Entry::is_patch_filename("emul-linux-x86-patch-foo"));
assert!(!Entry::is_patch_filename("patch-local-foo"));
assert!(!Entry::is_patch_filename("patch-Makefile.orig"));
assert!(!Entry::is_patch_filename("patch-Makefile.rej"));
assert!(!Entry::is_patch_filename("patch-Makefile~"));
assert!(!Entry::is_patch_filename("foo-1.0.tar.gz"));
assert!(!Entry::is_patch_filename("patch-2.7.6.tar.xz"));
assert!(!Entry::is_patch_filename("emul-foo.tar.gz"));
}
#[test]
fn test_set_rcsid() {
let mut di = Distinfo::new();
assert_eq!(di.rcsid(), None);
di.set_rcsid("$NetBSD$");
assert_eq!(di.rcsid(), Some(&OsString::from("$NetBSD$")));
di.set_rcsid(OsString::from("$NetBSD: test $"));
assert_eq!(di.rcsid(), Some(&OsString::from("$NetBSD: test $")));
}
#[test]
fn test_entry_as_bytes() {
let entry = Entry::new(
"foo-1.0.tar.gz",
"/distfiles/foo-1.0.tar.gz",
vec![
Checksum::new(Digest::BLAKE2s, "abc123".to_string()),
Checksum::new(Digest::SHA512, "def456".to_string()),
],
Some(12345),
);
let bytes = entry.as_bytes();
let s = String::from_utf8(bytes).expect("valid utf8");
assert!(s.contains("BLAKE2s (foo-1.0.tar.gz) = abc123\n"));
assert!(s.contains("SHA512 (foo-1.0.tar.gz) = def456\n"));
assert!(s.contains("Size (foo-1.0.tar.gz) = 12345 bytes\n"));
}
#[test]
fn test_entry_as_bytes_no_size() {
let entry = Entry::new(
"patch-Makefile",
"patches/patch-Makefile",
vec![Checksum::new(Digest::SHA1, "abc123".to_string())],
None,
);
let bytes = entry.as_bytes();
let s = String::from_utf8(bytes).expect("valid utf8");
assert!(s.contains("SHA1 (patch-Makefile) = abc123\n"));
assert!(!s.contains("Size"));
}
#[test]
fn test_distinfo_as_bytes() {
let input = concat!(
"$NetBSD: distinfo,v 1.1 2024/01/01 00:00:00 user Exp $\n",
"\n",
"BLAKE2s (foo-1.0.tar.gz) = abc123\n",
"SHA512 (foo-1.0.tar.gz) = def456\n",
"Size (foo-1.0.tar.gz) = 99999 bytes\n",
"SHA1 (patch-Makefile) = fedcba\n",
);
let di = Distinfo::from_bytes(input.as_bytes());
let output = di.as_bytes();
let s = String::from_utf8(output).expect("valid utf8");
assert!(s.starts_with("$NetBSD: distinfo,v 1.1"));
assert!(s.contains("BLAKE2s (foo-1.0.tar.gz) = abc123\n"));
assert!(s.contains("SHA512 (foo-1.0.tar.gz) = def456\n"));
assert!(s.contains("Size (foo-1.0.tar.gz) = 99999 bytes\n"));
assert!(s.contains("SHA1 (patch-Makefile) = fedcba\n"));
}
#[test]
fn test_line_malformed() {
let o = Line::from_bytes(b"SHA1 foo = abc123");
assert_eq!(o, Line::None);
let o = Line::from_bytes(b"\xff\xfe (foo) = abc");
assert_eq!(o, Line::None);
let o = Line::from_bytes(b"SHA1 (foo) = \xff\xfe");
assert_eq!(o, Line::None);
let o = Line::from_bytes(b"BOGUS (foo) = abc");
assert_eq!(o, Line::None);
}
#[test]
fn test_calculate_size() -> Result<(), DistinfoError> {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("tests/data/digest.txt");
let size = Distinfo::calculate_size(&file)?;
assert_eq!(size, 158);
Ok(())
}
#[test]
fn test_calculate_checksum() -> Result<(), DistinfoError> {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("tests/data/digest.txt");
let hash = Distinfo::calculate_checksum(&file, Digest::BLAKE2s)?;
assert_eq!(
hash,
"555e56e8177159b7d7fe96d5068dcf5335b554b917c8daaa4c893ec4f04b5303"
);
let mut patch = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
patch.push("tests/data/patch-Makefile");
let hash = Distinfo::calculate_checksum(&patch, Digest::SHA1)?;
assert_eq!(hash, "ab5ce8a374d3aca7948eecabc35386d8195e3fbf");
Ok(())
}
#[test]
fn test_distinfo_as_bytes_no_rcsid() {
let mut di = Distinfo::new();
di.insert(Entry::new(
"foo-1.0.tar.gz",
"/distfiles/foo-1.0.tar.gz",
vec![Checksum::new(Digest::SHA1, "abc".to_string())],
None,
));
let output = di.as_bytes();
let s = String::from_utf8(output).expect("valid utf8");
assert!(s.starts_with("$NetBSD$\n\n"));
assert!(s.contains("SHA1 (foo-1.0.tar.gz) = abc\n"));
}
}