build,debian: extract reusable Debian format primitives from build/
Move the generic components out of src/build/ into a new src/debian/ module so they can be reused independently of the build pipeline: deb822 control parsing (plus the debian/control model), file checksum registry, debian/files registry, Debian version handling and changelog entry parsing. Merge OpenPGP clearsigning into utils/gpg.rs next to the existing key discovery helper, making signing available outside of builds. Delegate changelog.rs header/footer parsing to the new debian::changelog parser, removing the duplicate regex implementation. src/build/ keeps only build-specific logic: the pipeline driver, build types, environment setup and the .buildinfo/.changes writers.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
//! Debian changelog entry parsing (`debian/changelog`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::DateTime;
|
||||
|
||||
use super::version::DebianVersion;
|
||||
|
||||
/// A parsed `debian/changelog` entry (the most recent one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Source package name.
|
||||
pub source: String,
|
||||
/// Parsed version.
|
||||
pub version: DebianVersion,
|
||||
/// Raw distribution(s) field, e.g. `"unstable"` or `"focal"`.
|
||||
pub distribution: String,
|
||||
/// Urgency value, e.g. `"medium"`.
|
||||
pub urgency: String,
|
||||
/// True for binNMU-style entries (`binary-only=yes` header parameter).
|
||||
pub binary_only: bool,
|
||||
/// Maintainer name from the trailer line.
|
||||
pub maintainer_name: String,
|
||||
/// Maintainer email from the trailer line.
|
||||
pub maintainer_email: String,
|
||||
/// Verbatim trailer date string (RFC2822-ish).
|
||||
pub date_raw: String,
|
||||
/// Trailer date parsed as a Unix timestamp.
|
||||
pub timestamp: i64,
|
||||
/// Value for the `.changes` `Changes` field: header line, blank lines
|
||||
/// converted to `.`, body lines verbatim; without the trailer line.
|
||||
pub changes_field: String,
|
||||
}
|
||||
|
||||
/// Parse the most recent entry of a Debian changelog file.
|
||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||
format!(
|
||||
"failed to read changelog '{}': {}. Make sure you are running \
|
||||
from the root of a source package.",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut lines = content.lines().peekable();
|
||||
|
||||
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
||||
let header = loop {
|
||||
match lines.next() {
|
||||
Some(l) if l.trim().is_empty() => continue,
|
||||
Some(l) => break l.trim_end(),
|
||||
None => {
|
||||
return Err(format!("changelog '{}' is empty", path.display()).into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let open = header
|
||||
.find('(')
|
||||
.ok_or_else(|| format!("invalid changelog header in '{}': {}", path.display(), header))?;
|
||||
let close = header[open..]
|
||||
.find(')')
|
||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
|
||||
let source = header[..open].trim().to_string();
|
||||
if source.is_empty() || source.contains(' ') {
|
||||
return Err(format!("invalid source name in changelog header '{}'", header).into());
|
||||
}
|
||||
let version = DebianVersion::parse(&header[open + 1..open + close])?;
|
||||
|
||||
let after_version = &header[open + close + 1..];
|
||||
let (distributions_part, params_part) = match after_version.split_once(';') {
|
||||
Some((d, p)) => (d, p),
|
||||
None => (after_version, ""),
|
||||
};
|
||||
let distribution = distributions_part.trim().to_string();
|
||||
if distribution.is_empty() {
|
||||
return Err(format!("missing distribution in changelog header '{}'", header).into());
|
||||
}
|
||||
|
||||
let mut urgency = String::from("unknown");
|
||||
let mut binary_only = false;
|
||||
for param in params_part.split(',') {
|
||||
let param = param.trim();
|
||||
if let Some(value) = param.strip_prefix("urgency=") {
|
||||
urgency = value.trim().to_string();
|
||||
} else if param == "binary-only=yes" || param == "binary-only=yes," {
|
||||
binary_only = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Body until trailer line ` -- Name <email> Date`
|
||||
let mut body_lines: Vec<String> = Vec::new();
|
||||
let mut trailer: Option<String> = None;
|
||||
for line in lines {
|
||||
let line = line.trim_end();
|
||||
if line.starts_with(" -- ") {
|
||||
trailer = Some(line.to_string());
|
||||
break;
|
||||
}
|
||||
// Stop at an emacs local-variables block or a new entry header.
|
||||
if line.starts_with("Local variables:") {
|
||||
break;
|
||||
}
|
||||
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Blank lines become "." like dpkg does for the Changes field.
|
||||
if line.trim().is_empty() {
|
||||
body_lines.push(".".to_string());
|
||||
} else {
|
||||
body_lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let trailer_line = trailer.ok_or_else(|| {
|
||||
format!(
|
||||
"no maintainer trailer found in '{}': expected a line of the form \
|
||||
' -- Name <email> Date'",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Strip trailing "." lines left over from blank lines before the trailer.
|
||||
while body_lines.last().map(|l| l == ".").unwrap_or(false) {
|
||||
body_lines.pop();
|
||||
}
|
||||
|
||||
let trailer_body = trailer_line.strip_prefix(" -- ").unwrap_or(&trailer_line);
|
||||
let lt = trailer_body
|
||||
.find('<')
|
||||
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
|
||||
let gt = trailer_body[lt..]
|
||||
.find('>')
|
||||
.map(|i| i + lt)
|
||||
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
|
||||
let maintainer_name = trailer_body[..lt].trim().to_string();
|
||||
let maintainer_email = trailer_body[lt + 1..gt].trim().to_string();
|
||||
let date_raw = trailer_body[gt + 1..].trim().to_string();
|
||||
|
||||
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"cannot parse changelog date '{}' in '{}': {}",
|
||||
date_raw,
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?
|
||||
.timestamp();
|
||||
|
||||
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
|
||||
// field, like dpkg's own representation): header + blank-as-dot + body,
|
||||
// without the trailer line.
|
||||
let mut changes_field = String::from("\n");
|
||||
changes_field.push_str(header);
|
||||
if !body_lines.is_empty() {
|
||||
changes_field.push('\n');
|
||||
changes_field.push_str(&body_lines.join("\n"));
|
||||
}
|
||||
|
||||
Ok(ChangelogEntry {
|
||||
source,
|
||||
version,
|
||||
distribution,
|
||||
urgency,
|
||||
binary_only,
|
||||
maintainer_name,
|
||||
maintainer_email,
|
||||
date_raw,
|
||||
timestamp,
|
||||
changes_field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Heuristic check for a changelog entry header line
|
||||
/// (`name (version) dist; urgency=...`).
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
// Headers are never indented.
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
return false;
|
||||
}
|
||||
match line.find('(') {
|
||||
Some(open) => {
|
||||
let name = line[..open].trim();
|
||||
!name.is_empty() && !name.contains(' ')
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changelog_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkh-hello (0.1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
* Second change line.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Sat, 22 Aug 2026 10:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert_eq!(entry.source, "pkh-hello");
|
||||
assert_eq!(entry.version.full(), "0.1");
|
||||
assert_eq!(entry.distribution, "unstable");
|
||||
assert_eq!(entry.urgency, "medium");
|
||||
assert!(!entry.binary_only);
|
||||
assert_eq!(entry.maintainer_name, "Pkh Tester");
|
||||
assert_eq!(entry.maintainer_email, "pkh@example.com");
|
||||
assert_eq!(entry.timestamp, 1787392800);
|
||||
assert_eq!(
|
||||
entry.changes_field,
|
||||
"\npkh-hello (0.1) unstable; urgency=medium\n.\n * Initial release.\n * Second change line."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bin_nmu() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
|
||||
* Binary-only non-maintainer upload.
|
||||
-- Builder <b@example.com> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert!(entry.binary_only);
|
||||
assert_eq!(entry.version.full(), "1.0-1+b1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! File checksum computation and formatting for `.changes` / `.buildinfo`
|
||||
//! fields (MD5, SHA-1, SHA-256 + size), mirroring `Dpkg::Checksums`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use md5::Md5;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Checksums and size of a single file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Lowercase hexadecimal MD5 digest.
|
||||
pub md5: String,
|
||||
/// Lowercase hexadecimal SHA-1 digest.
|
||||
pub sha1: String,
|
||||
/// Lowercase hexadecimal SHA-256 digest.
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Compute all supported checksums of a file.
|
||||
fn compute(path: &Path) -> Result<Entry, Box<dyn std::error::Error>> {
|
||||
let mut file = std::fs::File::open(path)
|
||||
.map_err(|e| format!("cannot open '{}' for checksumming: {}", path.display(), e))?;
|
||||
|
||||
let mut md5_hasher = Md5::new();
|
||||
let mut sha1_hasher = Sha1::new();
|
||||
let mut sha256_hasher = Sha256::new();
|
||||
let mut size: u64 = 0;
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
md5_hasher.update(&buf[..n]);
|
||||
sha1_hasher.update(&buf[..n]);
|
||||
sha256_hasher.update(&buf[..n]);
|
||||
size += n as u64;
|
||||
}
|
||||
|
||||
Ok(Entry {
|
||||
size,
|
||||
md5: hex::encode(md5_hasher.finalize()),
|
||||
sha1: hex::encode(sha1_hasher.finalize()),
|
||||
sha256: hex::encode(sha256_hasher.finalize()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A registry of checksummed files, keyed by the name they are distributed
|
||||
/// under (which may differ from the on-disk path).
|
||||
///
|
||||
/// Insertion order is preserved, matching the order in which
|
||||
/// `dpkg-genchanges` accumulates artifacts (dsc, tarballs, debs, buildinfo).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileChecksums {
|
||||
entries: Vec<(String, Entry)>,
|
||||
index: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl FileChecksums {
|
||||
/// Create an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a file, registering it under its own file name.
|
||||
pub fn add_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let key = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| format!("invalid file name: {}", path.display()))?
|
||||
.to_string();
|
||||
self.add_file_as(path, &key)
|
||||
}
|
||||
|
||||
/// Add a file, registering it under an explicit distribution key.
|
||||
pub fn add_file_as(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let entry = compute(path)?;
|
||||
self.insert_entry(key, entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a pre-computed entry (e.g. taken from a `.dsc` checksum field).
|
||||
/// Re-inserting an existing key updates it in place, keeping its position.
|
||||
pub fn insert_entry(&mut self, key: &str, entry: Entry) {
|
||||
if let Some(&pos) = self.index.get(key) {
|
||||
self.entries[pos].1 = entry;
|
||||
return;
|
||||
}
|
||||
self.index.insert(key.to_string(), self.entries.len());
|
||||
self.entries.push((key.to_string(), entry));
|
||||
}
|
||||
|
||||
/// Remove a file from the registry. Returns true if it was present.
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
match self.index.remove(key) {
|
||||
Some(pos) => {
|
||||
self.entries.remove(pos);
|
||||
// Reindex the shifted tail.
|
||||
for (i, (k, _)) in self.entries.iter().enumerate().skip(pos) {
|
||||
self.index.insert(k.clone(), i);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the entry for a given key.
|
||||
pub fn get(&self, key: &str) -> Option<&Entry> {
|
||||
self.index.get(key).map(|&pos| &self.entries[pos].1)
|
||||
}
|
||||
|
||||
/// Iterate over `(key, entry)` pairs in insertion order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &Entry)> {
|
||||
self.entries.iter().map(|(k, e)| (k, e))
|
||||
}
|
||||
|
||||
/// Number of registered files.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// True if no file is registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Format a `Checksums-*` style field value: one `\n`-separated line per
|
||||
/// file of the form `" <hash> <size> <key>"`.
|
||||
fn format_field<F>(&self, hash_of: F) -> String
|
||||
where
|
||||
F: Fn(&Entry) -> &str,
|
||||
{
|
||||
let mut out = String::new();
|
||||
for (key, e) in self.iter() {
|
||||
out.push('\n');
|
||||
out.push_str(hash_of(e));
|
||||
out.push(' ');
|
||||
out.push_str(&e.size.to_string());
|
||||
out.push(' ');
|
||||
out.push_str(key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Md5` field (empty string if no file).
|
||||
pub fn field_md5(&self) -> String {
|
||||
self.format_field(|e| &e.md5)
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Sha1` field (empty string if no file).
|
||||
pub fn field_sha1(&self) -> String {
|
||||
self.format_field(|e| &e.sha1)
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Sha256` field (empty string if no file).
|
||||
pub fn field_sha256(&self) -> String {
|
||||
self.format_field(|e| &e.sha256)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_digests() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("sample.txt");
|
||||
std::fs::write(&p, b"hello world\n").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&p).unwrap();
|
||||
|
||||
let e = cs.get("sample.txt").unwrap();
|
||||
// Verified with coreutils: echo "hello world" | md5sum / sha1sum / sha256sum
|
||||
assert_eq!(e.md5, "6f5902ac237024bdd0c176cb93063dc4");
|
||||
assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511");
|
||||
assert_eq!(
|
||||
e.sha256,
|
||||
"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
|
||||
);
|
||||
assert_eq!(e.size, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insertion_order_preserved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
std::fs::write(&a, b"aaa").unwrap();
|
||||
std::fs::write(&b, b"bb").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
// Insert b first: insertion order (not alphabetical) must be kept,
|
||||
// matching dpkg's artifact accumulation order.
|
||||
cs.add_file(&b).unwrap();
|
||||
cs.add_file(&a).unwrap();
|
||||
|
||||
let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, vec!["b.txt", "a.txt"]);
|
||||
|
||||
assert_eq!(
|
||||
cs.field_md5(),
|
||||
"\n21ad0bd836b90d08f4cf640b4c298e7c 2 b.txt\n47bce5c74f589f4867dbd57e9ca9f808 3 a.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinsert_updates_in_place() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
std::fs::write(&a, b"aaa").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&a).unwrap();
|
||||
std::fs::write(&a, b"bbbb").unwrap();
|
||||
cs.add_file(&a).unwrap(); // updated in place, same position
|
||||
|
||||
assert_eq!(cs.len(), 1);
|
||||
assert_eq!(cs.get("a.txt").unwrap().size, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keeps_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
let c = dir.path().join("c.txt");
|
||||
std::fs::write(&a, b"1").unwrap();
|
||||
std::fs::write(&b, b"2").unwrap();
|
||||
std::fs::write(&c, b"3").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&a).unwrap();
|
||||
cs.add_file(&b).unwrap();
|
||||
cs.add_file(&c).unwrap();
|
||||
assert!(cs.remove("b.txt"));
|
||||
assert!(!cs.remove("b.txt"));
|
||||
|
||||
let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, vec!["a.txt", "c.txt"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Debian control-file handling: a minimal deb822 paragraph parser/writer
|
||||
//! plus a `debian/control` model.
|
||||
//!
|
||||
//! Implements the subset of RFC822-ish parsing needed for `debian/control`,
|
||||
//! `debian/files`, `.dsc`, `.changes` and `.buildinfo` files: paragraphs
|
||||
//! separated by blank lines, `Field: value` entries with continuation lines
|
||||
//! starting by a single space or tab, and `#` comments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs.
|
||||
///
|
||||
/// Values are stored with continuation-line breaks as `\n` and without the
|
||||
/// leading whitespace of continuation lines. Serialization re-adds a single
|
||||
/// leading space in front of every continuation line, matching dpkg output.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Paragraph {
|
||||
/// Create an empty paragraph.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up a field value (case-insensitive field name).
|
||||
pub fn get(&self, field: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Set a field value, replacing any previous occurrence (case-insensitive).
|
||||
/// Appends the field at the end if it did not exist yet.
|
||||
pub fn set(&mut self, field: &str, value: &str) {
|
||||
for (k, v) in self.fields.iter_mut() {
|
||||
if k.eq_ignore_ascii_case(field) {
|
||||
*v = value.to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.fields.push((field.to_string(), value.to_string()));
|
||||
}
|
||||
|
||||
/// Remove a field (case-insensitive). Returns true if it was present.
|
||||
pub fn remove(&mut self, field: &str) -> bool {
|
||||
let before = self.fields.len();
|
||||
self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field));
|
||||
self.fields.len() != before
|
||||
}
|
||||
|
||||
/// Iterate over the `(field, value)` pairs in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
}
|
||||
|
||||
/// Return true if the paragraph holds no field.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a deb822 document into a list of paragraphs.
|
||||
///
|
||||
/// Comment lines (starting with `#`) are ignored. Blank lines separate
|
||||
/// paragraphs. Continuation lines must start with a space or a tab; exactly
|
||||
/// one leading space (or tab) is stripped from the stored value.
|
||||
pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut current = Paragraph::new();
|
||||
let mut last_field: Option<String> = None;
|
||||
|
||||
for raw_line in input.lines() {
|
||||
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
||||
|
||||
// Comments and blank lines
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(std::mem::take(&mut current));
|
||||
last_field = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
let content = line.strip_prefix(' ').unwrap_or(line);
|
||||
if let Some(field) = &last_field {
|
||||
if let Some((_, v)) = current
|
||||
.fields
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
{
|
||||
v.push('\n');
|
||||
v.push_str(content);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Continuation without a preceding field line: skip it (malformed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field line: `Name: value`
|
||||
if let Some(colon) = line.find(':') {
|
||||
let name = line[..colon].trim();
|
||||
let value = line[colon + 1..].trim_start();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current.fields.push((name.to_string(), value.to_string()));
|
||||
last_field = Some(name.to_string());
|
||||
}
|
||||
// Anything else is malformed: ignore the line
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(current);
|
||||
}
|
||||
|
||||
paragraphs
|
||||
}
|
||||
|
||||
/// Serialize a paragraph to its deb822 textual representation (with a
|
||||
/// trailing newline).
|
||||
///
|
||||
/// A value starting with `\n` is rendered as a field with no inline first
|
||||
/// line (`Field:` followed by ` line` continuations), matching dpkg output
|
||||
/// for pre-wrapped values such as `Changes`, `Files` or `Environment`.
|
||||
pub fn write_paragraph(p: &Paragraph) -> String {
|
||||
let mut out = String::new();
|
||||
for (name, value) in p.iter() {
|
||||
out.push_str(name);
|
||||
out.push(':');
|
||||
let mut lines = value.split('\n').peekable();
|
||||
// An empty first segment means: no value on the field header line;
|
||||
// discard it so it is not rendered as an empty continuation line.
|
||||
if lines.peek().is_some_and(|first| !first.is_empty()) {
|
||||
out.push(' ');
|
||||
out.push_str(lines.next().unwrap());
|
||||
} else {
|
||||
lines.next();
|
||||
}
|
||||
for line in lines {
|
||||
out.push('\n');
|
||||
out.push(' ');
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_control() {
|
||||
let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n";
|
||||
let paras = parse_paragraphs(input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(paras[0].get("Source"), Some("hello"));
|
||||
assert_eq!(paras[0].get("section"), Some("devel"));
|
||||
assert_eq!(paras[1].get("Package"), Some("hello"));
|
||||
assert_eq!(paras[1].get("Depends"), Some("libc6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiline_and_comments() {
|
||||
let input = "# a comment\nDescription: short\n long description\n" //
|
||||
.to_string() //
|
||||
+ " spanning lines\n\nPackage: x\n";
|
||||
let paras = parse_paragraphs(&input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(
|
||||
paras[0].get("Description"),
|
||||
Some("short\nlong description\nspanning lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_multiline() {
|
||||
let value = "short\nlong description\nspanning lines";
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Description", value);
|
||||
let text = write_paragraph(&p);
|
||||
assert_eq!(text, "Description: short\n long description\n spanning lines\n");
|
||||
let reparsed = parse_paragraphs(&text);
|
||||
assert_eq!(reparsed[0].get("Description"), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_case_insensitive() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Source", "a");
|
||||
p.set("source", "b");
|
||||
assert_eq!(p.get("SOURCE"), Some("b"));
|
||||
assert_eq!(p.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_field() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("A", "1");
|
||||
assert!(p.remove("a"));
|
||||
assert!(!p.remove("a"));
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlInfo {
|
||||
/// First paragraph (source package stanza).
|
||||
pub source: Paragraph,
|
||||
/// Remaining paragraphs (binary package stanzas).
|
||||
pub binaries: Vec<Paragraph>,
|
||||
}
|
||||
|
||||
impl ControlInfo {
|
||||
/// Parse a `debian/control` file.
|
||||
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
|
||||
Self::from_str(&content)
|
||||
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse control content from a string.
|
||||
pub fn from_str(content: &str) -> Result<ControlInfo, String> {
|
||||
let paragraphs = parse_paragraphs(content);
|
||||
let mut iter = paragraphs.into_iter();
|
||||
let source = iter
|
||||
.next()
|
||||
.ok_or_else(|| "control file has no paragraphs".to_string())?;
|
||||
if source.get("Source").is_none() {
|
||||
return Err("first control paragraph has no 'Source' field".to_string());
|
||||
}
|
||||
let binaries: Vec<Paragraph> = iter.collect();
|
||||
for bin in &binaries {
|
||||
if bin.get("Package").is_none() {
|
||||
return Err("binary control paragraph has no 'Package' field".to_string());
|
||||
}
|
||||
}
|
||||
Ok(ControlInfo { source, binaries })
|
||||
}
|
||||
|
||||
/// The source package name.
|
||||
pub fn source_name(&self) -> &str {
|
||||
self.source.get("Source").expect("checked at parse")
|
||||
}
|
||||
|
||||
/// Section from the source stanza, or `'-'`.
|
||||
pub fn section(&self) -> &str {
|
||||
self.source.get("Section").unwrap_or("-")
|
||||
}
|
||||
|
||||
/// Priority from the source stanza, or `'-'`.
|
||||
pub fn priority(&self) -> &str {
|
||||
self.source.get("Priority").unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_info_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_parsing() {
|
||||
let ci = ControlInfo::from_str(
|
||||
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ci.source_name(), "hello");
|
||||
assert_eq!(ci.section(), "utils");
|
||||
assert_eq!(ci.priority(), "optional");
|
||||
assert_eq!(ci.binaries.len(), 1);
|
||||
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_defaults() {
|
||||
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
|
||||
assert_eq!(ci.section(), "-");
|
||||
assert_eq!(ci.priority(), "-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! `debian/files` registry: the contract between the build (`dh_builddeb`,
|
||||
//! `dpkg-gencontrol`, ...) and the artifact generators, mirroring
|
||||
//! `Dpkg::Dist::Files`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// One registered artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FilesEntry {
|
||||
/// File name (relative to the parent directory of the package tree).
|
||||
pub filename: String,
|
||||
/// Archive section (e.g. `utils`).
|
||||
pub section: String,
|
||||
/// Archive priority (e.g. `optional`).
|
||||
pub priority: String,
|
||||
/// Package name parsed from the file name pattern, if any.
|
||||
pub package: Option<String>,
|
||||
/// Version parsed from the file name pattern, if any.
|
||||
pub version: Option<String>,
|
||||
/// Architecture parsed from the file name pattern, if any.
|
||||
pub arch: Option<String>,
|
||||
/// Artifact type parsed from the file name extension
|
||||
/// (`deb`, `udeb`, `buildinfo`, `changes`, ...).
|
||||
pub package_type: Option<String>,
|
||||
/// Extra `key=value` attributes on the line (e.g. `automatic=yes`).
|
||||
pub attrs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FilesEntry {
|
||||
/// Create a minimal entry with only name/section/priority; the pattern
|
||||
/// fields are derived from the file name.
|
||||
pub fn new(filename: &str, section: &str, priority: &str) -> FilesEntry {
|
||||
let mut entry = parse_filename(filename).unwrap_or_else(|| FilesEntry {
|
||||
filename: filename.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: None,
|
||||
version: None,
|
||||
arch: None,
|
||||
package_type: None,
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
entry.section = section.to_string();
|
||||
entry.priority = priority.to_string();
|
||||
entry
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive metadata from an artifact file name following the common
|
||||
/// `<package>_<version>_<arch>.<type>` pattern, like
|
||||
/// `Dpkg::Dist::Files::parse_filename()`.
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
pub fn parse_filename(name: &str) -> Option<FilesEntry> {
|
||||
// Common pattern: name_version_arch.type where type is dot-separated
|
||||
// (e.g. tar.xz must not match here since it has no leading underscores).
|
||||
let parts: Vec<&str> = name.split('_').collect();
|
||||
if parts.len() == 3 {
|
||||
let (pkg, version, rest) = (parts[0], parts[1], parts[2]);
|
||||
if let Some(dot) = rest.rfind('.') {
|
||||
let arch = &rest[..dot];
|
||||
let ptype = &rest[dot + 1..];
|
||||
let valid = |s: &str| !s.is_empty()
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || "-+.:~".contains(c));
|
||||
if valid(pkg) && valid(version) && valid(arch) && valid(ptype) {
|
||||
return Some(FilesEntry {
|
||||
filename: name.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: Some(pkg.to_string()),
|
||||
version: Some(version.to_string()),
|
||||
arch: Some(arch.to_string()),
|
||||
package_type: Some(ptype.to_string()),
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: accept a conservative file-name character set.
|
||||
if !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-+.:,_~".contains(c))
|
||||
{
|
||||
return Some(FilesEntry {
|
||||
filename: name.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: None,
|
||||
version: None,
|
||||
arch: None,
|
||||
package_type: None,
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The full `debian/files` registry, ordered by file name.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FilesList {
|
||||
files: BTreeMap<String, FilesEntry>,
|
||||
}
|
||||
|
||||
impl FilesList {
|
||||
/// An empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load `debian/files`. A missing file yields an empty registry.
|
||||
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
|
||||
let mut list = FilesList::new();
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(list),
|
||||
Err(e) => {
|
||||
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
||||
}
|
||||
};
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() < 3 {
|
||||
return Err(format!("badly formed line in '{}': {}", path.display(), line).into());
|
||||
}
|
||||
let mut entry = parse_filename(tokens[0]).ok_or_else(|| {
|
||||
format!("badly formed file name in '{}': {}", path.display(), tokens[0])
|
||||
})?;
|
||||
entry.section = tokens[1].to_string();
|
||||
entry.priority = tokens[2].to_string();
|
||||
for attr in &tokens[3..] {
|
||||
if let Some((k, v)) = attr.split_once('=') {
|
||||
entry.attrs.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
list.files.insert(entry.filename.clone(), entry);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Register (or replace) an entry.
|
||||
pub fn add(&mut self, entry: FilesEntry) {
|
||||
self.files.insert(entry.filename.clone(), entry);
|
||||
}
|
||||
|
||||
/// Remove entries matching a predicate. Returns how many were removed.
|
||||
pub fn retain<F: FnMut(&FilesEntry) -> bool>(&mut self, mut keep: F) -> usize {
|
||||
let before = self.files.len();
|
||||
self.files.retain(|_, e| keep(e));
|
||||
before - self.files.len()
|
||||
}
|
||||
|
||||
/// Iterate over entries sorted by file name.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &FilesEntry> {
|
||||
self.files.values()
|
||||
}
|
||||
|
||||
/// Look up an entry by file name.
|
||||
pub fn get(&self, filename: &str) -> Option<&FilesEntry> {
|
||||
self.files.get(filename)
|
||||
}
|
||||
|
||||
/// Number of registered files.
|
||||
pub fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// True if empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
/// Save atomically: write `<path>.new` then rename over `path`, like
|
||||
/// dpkg does.
|
||||
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
for entry in self.iter() {
|
||||
write!(f, "{} {} {}", entry.filename, entry.section, entry.priority)?;
|
||||
for (k, v) in &entry.attrs {
|
||||
write!(f, " {}={}", k, v)?;
|
||||
}
|
||||
writeln!(f)?;
|
||||
}
|
||||
f.flush()?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filename_pattern_parsing() {
|
||||
let e = parse_filename("hello_2.10-3_amd64.deb").unwrap();
|
||||
assert_eq!(e.package.as_deref(), Some("hello"));
|
||||
assert_eq!(e.version.as_deref(), Some("2.10-3"));
|
||||
assert_eq!(e.arch.as_deref(), Some("amd64"));
|
||||
assert_eq!(e.package_type.as_deref(), Some("deb"));
|
||||
|
||||
let e = parse_filename("hello_0.1_source.buildinfo").unwrap();
|
||||
assert_eq!(e.package.as_deref(), Some("hello"));
|
||||
assert_eq!(e.arch.as_deref(), Some("source"));
|
||||
assert_eq!(e.package_type.as_deref(), Some("buildinfo"));
|
||||
|
||||
// Tarballs do not follow the 3-component pattern.
|
||||
let e = parse_filename("hello_0.1.tar.xz").unwrap();
|
||||
assert_eq!(e.package, None);
|
||||
assert_eq!(e.package_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_save_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("files");
|
||||
|
||||
let mut list = FilesList::new();
|
||||
list.add(FilesEntry::new("hello_1.0_amd64.deb", "devel", "optional"));
|
||||
list.add(FilesEntry::new("hello_1.0_source.buildinfo", "-", "-"));
|
||||
list.save_atomic(&path).unwrap();
|
||||
|
||||
let reloaded = FilesList::load(&path).unwrap();
|
||||
assert_eq!(reloaded.len(), 2);
|
||||
let deb = reloaded.get("hello_1.0_amd64.deb").unwrap();
|
||||
assert_eq!(deb.section, "devel");
|
||||
assert_eq!(deb.priority, "optional");
|
||||
|
||||
// Missing file loads as empty.
|
||||
let missing = FilesList::load(&dir.path().join("nonexistent")).unwrap();
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_removes_matching() {
|
||||
let mut list = FilesList::new();
|
||||
list.add(FilesEntry::new("x_1_source.buildinfo", "-", "-"));
|
||||
list.add(FilesEntry::new("x_1_amd64.deb", "-", "-"));
|
||||
let removed = list.retain(|e| e.package_type.as_deref() != Some("buildinfo"));
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Reusable Debian format primitives.
|
||||
//!
|
||||
//! These components are independent from any build orchestration and can be
|
||||
//! used by any pkh submodule (or external consumers of the library):
|
||||
//!
|
||||
//! - [`control`]: deb822 paragraph parsing/writing and `debian/control`
|
||||
//! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent)
|
||||
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
|
||||
//! - [`version`]: Debian version splitting/validation
|
||||
//! - [`changelog`]: `debian/changelog` entry parsing
|
||||
|
||||
pub mod changelog;
|
||||
pub mod checksums;
|
||||
pub mod control;
|
||||
pub mod files;
|
||||
pub mod version;
|
||||
|
||||
pub use changelog::{parse_changelog_entry, ChangelogEntry};
|
||||
pub use checksums::{Entry as ChecksumEntry, FileChecksums};
|
||||
pub use control::{parse_paragraphs, write_paragraph, ControlInfo, Paragraph};
|
||||
pub use files::{FilesEntry, FilesList};
|
||||
pub use version::DebianVersion;
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Debian version handling: splitting and validation of
|
||||
//! `[epoch:]upstream[-revision]` version strings.
|
||||
|
||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DebianVersion {
|
||||
/// Optional numeric epoch (part before the first `:`).
|
||||
pub epoch: Option<u32>,
|
||||
/// Upstream version (may itself contain `-` when there is no revision).
|
||||
pub upstream: String,
|
||||
/// Optional Debian revision (part after the last `-`).
|
||||
pub debian_revision: Option<String>,
|
||||
}
|
||||
|
||||
impl DebianVersion {
|
||||
/// Parse and validate a Debian version string.
|
||||
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("empty version string".to_string());
|
||||
}
|
||||
|
||||
let (epoch, rest) = match raw.split_once(':') {
|
||||
Some((e, r)) => {
|
||||
let epoch: u32 = e
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
|
||||
(Some(epoch), r)
|
||||
}
|
||||
None => (None, raw),
|
||||
};
|
||||
|
||||
// The revision is everything after the last hyphen.
|
||||
let (upstream, debian_revision) = match rest.rsplit_once('-') {
|
||||
Some((u, r)) => (u.to_string(), Some(r.to_string())),
|
||||
None => (rest.to_string(), None),
|
||||
};
|
||||
|
||||
if upstream.is_empty() {
|
||||
return Err(format!("missing upstream version in '{}'", raw));
|
||||
}
|
||||
for c in upstream.chars() {
|
||||
if !(c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|
||||
|| !c.is_ascii())
|
||||
{
|
||||
return Err(format!("invalid character '{}' in version '{}'", c, raw));
|
||||
}
|
||||
}
|
||||
if let Some(rev) = &debian_revision {
|
||||
for c in rev.chars() {
|
||||
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
|
||||
return Err(format!(
|
||||
"invalid character '{}' in revision of version '{}'",
|
||||
c, raw
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DebianVersion {
|
||||
epoch,
|
||||
upstream,
|
||||
debian_revision,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
|
||||
pub fn full(&self) -> String {
|
||||
match (&self.epoch, &self.debian_revision) {
|
||||
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
|
||||
(Some(e), None) => format!("{}:{}", e, self.upstream),
|
||||
(None, Some(r)) => format!("{}-{}", self.upstream, r),
|
||||
(None, None) => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Version string without the epoch (`upstream[-rev]`), used in artifact
|
||||
/// file names.
|
||||
pub fn no_epoch(&self) -> String {
|
||||
match &self.debian_revision {
|
||||
Some(r) => format!("{}-{}", self.upstream, r),
|
||||
None => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_splitting() {
|
||||
let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap();
|
||||
assert_eq!(v.epoch, None);
|
||||
assert_eq!(v.upstream, "1.2.3");
|
||||
assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5"));
|
||||
assert_eq!(v.full(), "1.2.3-4ubuntu5");
|
||||
assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5");
|
||||
|
||||
let v = DebianVersion::parse("3:2.10-3").unwrap();
|
||||
assert_eq!(v.epoch, Some(3));
|
||||
assert_eq!(v.upstream, "2.10");
|
||||
assert_eq!(v.no_epoch(), "2.10-3");
|
||||
assert_eq!(v.full(), "3:2.10-3");
|
||||
|
||||
let v = DebianVersion::parse("1.0").unwrap();
|
||||
assert_eq!(v.debian_revision, None);
|
||||
assert_eq!(v.no_epoch(), "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_validation() {
|
||||
assert!(DebianVersion::parse("").is_err());
|
||||
assert!(DebianVersion::parse(":1.0").is_err());
|
||||
assert!(DebianVersion::parse("a:_b").is_err());
|
||||
assert!(DebianVersion::parse("1.0").is_ok());
|
||||
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user