debian: type the errors of the format primitives

Give every parser of the shared format layer a proper error enum
(thiserror): VersionParseError, ArchError, deps::ParseError (wrapping
the version and arch failures as #[source] so the context chain
survives), ControlParseError, ChecksumParseError, FilesParseError and
TestsControlParseError, each carrying its structured pieces (the
offending string, the stanza number, the field name) instead of a
pre-formatted String.

Display texts are byte-identical to the old format! strings, so
user-facing output and the message-asserting tests are unchanged; the
few tests asserting on error text now render the error first. Flow
boundaries that still take String (build::env, quirks, test::expand)
render with to_string() at the edge — typing those flows is a later
step. ControlInfo's FromStr impl follows parse_content's error type.
This commit is contained in:
2026-10-03 02:17:22 +02:00
parent 5c7627925f
commit 82a15c7426
10 changed files with 408 additions and 112 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn build_env(
/// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as /// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as
/// `dpkg-buildpackage` does. /// `dpkg-buildpackage` does.
pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> { pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> {
crate::debian::arch::arch_env(host_arch) crate::debian::arch::arch_env(host_arch).map_err(|e| e.to_string())
} }
/// Read the current vendor name from the active dpkg origins `default` file /// Read the current vendor name from the active dpkg origins `default` file
+56 -18
View File
@@ -682,14 +682,45 @@ pub fn is_invalid(arch: &str, positive: bool) -> bool {
} }
} }
/// Failure of the architecture helpers: an invalid list entry, an
/// undetectable native architecture, or an architecture unknown to dpkg's
/// tables.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ArchError {
/// An entry of the list is not a valid architecture name.
#[error("'{arch}' is not a valid architecture in list '{list}'")]
InvalidInList {
/// The offending architecture name.
arch: String,
/// The whole list.
list: String,
},
/// Neither dpkg nor uname could name the native architecture.
#[error("cannot determine native Debian architecture")]
NativeUndetermined,
/// The architecture is not in dpkg's tables.
#[error("unknown Debian architecture '{arch}'")]
Unknown {
/// The unknown architecture name.
arch: String,
},
/// A GNU triplet without the expected `cpu-system` shape.
#[error("invalid GNU triplet '{triplet}'")]
InvalidTriplet {
/// The malformed triplet.
triplet: String,
},
}
/// Parse a whitespace-separated architecture list, validating every entry. /// Parse a whitespace-separated architecture list, validating every entry.
pub fn list_parse(list: &str) -> Result<Vec<String>, String> { pub fn list_parse(list: &str) -> Result<Vec<String>, ArchError> {
let arches: Vec<String> = list.split_whitespace().map(str::to_string).collect(); let arches: Vec<String> = list.split_whitespace().map(str::to_string).collect();
for arch in &arches { for arch in &arches {
if is_invalid(arch, false) { if is_invalid(arch, false) {
return Err(format!( return Err(ArchError::InvalidInList {
"'{arch}' is not a valid architecture in list '{list}'" arch: arch.clone(),
)); list: list.to_string(),
});
} }
} }
Ok(arches) Ok(arches)
@@ -802,7 +833,7 @@ fn from_uname() -> Option<String> {
/// the `DEB_BUILD_*` variables): the authoritative answer comes from the /// the `DEB_BUILD_*` variables): the authoritative answer comes from the
/// dpkg database itself; if the `dpkg` frontend cannot be executed, the /// dpkg database itself; if the `dpkg` frontend cannot be executed, the
/// architecture is derived from `uname` through the same tables. /// architecture is derived from `uname` through the same tables.
pub fn native() -> Result<String, String> { pub fn native() -> Result<String, ArchError> {
if let Ok(output) = Command::new("dpkg").arg("--print-architecture").output() if let Ok(output) = Command::new("dpkg").arg("--print-architecture").output()
&& output.status.success() && output.status.success()
{ {
@@ -811,7 +842,7 @@ pub fn native() -> Result<String, String> {
return Ok(arch); return Ok(arch);
} }
} }
from_uname().ok_or_else(|| "cannot determine native Debian architecture".to_string()) from_uname().ok_or(ArchError::NativeUndetermined)
} }
/// Compute the complete architecture environment, the equivalent of /// Compute the complete architecture environment, the equivalent of
@@ -821,7 +852,7 @@ pub fn native() -> Result<String, String> {
/// The target architecture defaults to the host architecture, and the host /// The target architecture defaults to the host architecture, and the host
/// architecture defaults to the native build architecture, exactly like /// architecture defaults to the native build architecture, exactly like
/// dpkg-architecture. /// dpkg-architecture.
pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> { pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, ArchError> {
let build_arch = native()?; let build_arch = native()?;
let host_arch = host_arch.unwrap_or(&build_arch).to_string(); let host_arch = host_arch.unwrap_or(&build_arch).to_string();
let target_arch = host_arch.clone(); let target_arch = host_arch.clone();
@@ -832,8 +863,9 @@ pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, Str
("HOST", host_arch), ("HOST", host_arch),
("TARGET", target_arch), ("TARGET", target_arch),
] { ] {
let tuple = debarch_to_debtuple(&arch) let tuple = debarch_to_debtuple(&arch).ok_or_else(|| ArchError::Unknown {
.ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; arch: arch.to_string(),
})?;
env.insert(format!("DEB_{role}_ARCH"), arch.clone()); env.insert(format!("DEB_{role}_ARCH"), arch.clone());
env.insert(format!("DEB_{role}_ARCH_ABI"), tuple.abi.clone()); env.insert(format!("DEB_{role}_ARCH_ABI"), tuple.abi.clone());
@@ -841,20 +873,26 @@ pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, Str
env.insert(format!("DEB_{role}_ARCH_OS"), tuple.os.clone()); env.insert(format!("DEB_{role}_ARCH_OS"), tuple.os.clone());
env.insert(format!("DEB_{role}_ARCH_CPU"), tuple.cpu.clone()); env.insert(format!("DEB_{role}_ARCH_CPU"), tuple.cpu.clone());
let (bits, endian) = let (bits, endian) = abi_attrs(&arch).ok_or_else(|| ArchError::Unknown {
abi_attrs(&arch).ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; arch: arch.to_string(),
})?;
env.insert(format!("DEB_{role}_ARCH_BITS"), bits.to_string()); env.insert(format!("DEB_{role}_ARCH_BITS"), bits.to_string());
env.insert(format!("DEB_{role}_ARCH_ENDIAN"), endian.to_string()); env.insert(format!("DEB_{role}_ARCH_ENDIAN"), endian.to_string());
let multi = let multi = multiarch(&arch).ok_or_else(|| ArchError::Unknown {
multiarch(&arch).ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; arch: arch.to_string(),
})?;
env.insert(format!("DEB_{role}_MULTIARCH"), multi); env.insert(format!("DEB_{role}_MULTIARCH"), multi);
let gnu_type = debarch_to_gnutriplet(&arch) let gnu_type = debarch_to_gnutriplet(&arch).ok_or_else(|| ArchError::Unknown {
.ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; arch: arch.to_string(),
let (gnu_cpu, gnu_system) = gnu_type })?;
.split_once('-') let (gnu_cpu, gnu_system) =
.ok_or_else(|| format!("invalid GNU triplet '{gnu_type}'"))?; gnu_type
.split_once('-')
.ok_or_else(|| ArchError::InvalidTriplet {
triplet: gnu_type.clone(),
})?;
env.insert(format!("DEB_{role}_GNU_CPU"), gnu_cpu.to_string()); env.insert(format!("DEB_{role}_GNU_CPU"), gnu_cpu.to_string());
env.insert(format!("DEB_{role}_GNU_SYSTEM"), gnu_system.to_string()); env.insert(format!("DEB_{role}_GNU_SYSTEM"), gnu_system.to_string());
env.insert(format!("DEB_{role}_GNU_TYPE"), gnu_type); env.insert(format!("DEB_{role}_GNU_TYPE"), gnu_type);
+72 -23
View File
@@ -100,6 +100,48 @@ pub struct FileChecksums {
index: HashMap<String, usize>, index: HashMap<String, usize>,
} }
/// Failure of [`FileChecksums::parse_field`]: a malformed checksum field
/// line, naming the field, the offending line and what is wrong with it.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ChecksumParseError {
/// The line has other than `checksum size name` columns.
#[error(
"malformed '{field}' line (expected 'checksum size name', got {columns} columns): '{line}'"
)]
MalformedLine {
/// The checksum field the line came from (`Checksums-Sha256`, ...).
field: String,
/// The actual number of whitespace-separated columns.
columns: usize,
/// The offending line.
line: String,
},
/// The size column is not a number.
#[error("malformed '{field}' line (size '{size}' is not a number): '{line}'")]
BadSize {
/// The checksum field the line came from.
field: String,
/// The non-numeric size text.
size: String,
/// The offending line.
line: String,
},
/// The digest is not the expected number of lowercase hex characters.
#[error(
"malformed '{field}' line (digest '{digest}' is not {len} lowercase hex characters): '{line}'"
)]
BadDigest {
/// The checksum field the line came from.
field: String,
/// The malformed digest.
digest: String,
/// The expected digest length.
len: usize,
/// The offending line.
line: String,
},
}
impl FileChecksums { impl FileChecksums {
/// Create an empty registry. /// Create an empty registry.
pub fn new() -> Self { pub fn new() -> Self {
@@ -234,7 +276,11 @@ impl FileChecksums {
/// `build::parse_checksum_field` helper (which additionally accepts the /// `build::parse_checksum_field` helper (which additionally accepts the
/// legacy 5-column `Files` layout); the two are intentionally not unified /// legacy 5-column `Files` layout); the two are intentionally not unified
/// across modules. /// across modules.
pub fn parse_field(kind: ChecksumKind, value: &str) -> Result<Vec<(String, Entry)>, String> { pub fn parse_field(
kind: ChecksumKind,
value: &str,
) -> Result<Vec<(String, Entry)>, ChecksumParseError> {
let field = kind.field_name().to_string();
let mut entries = Vec::new(); let mut entries = Vec::new();
for line in value.lines() { for line in value.lines() {
if line.trim().is_empty() { if line.trim().is_empty() {
@@ -242,30 +288,28 @@ impl FileChecksums {
} }
let tokens: Vec<&str> = line.split_whitespace().collect(); let tokens: Vec<&str> = line.split_whitespace().collect();
let [digest, size, name] = tokens.as_slice() else { let [digest, size, name] = tokens.as_slice() else {
return Err(format!( return Err(ChecksumParseError::MalformedLine {
"malformed '{}' line (expected 'checksum size name', got {} \ field,
columns): '{line}'", columns: tokens.len(),
kind.field_name(), line: line.to_string(),
tokens.len() });
));
}; };
let size: u64 = size.parse().map_err(|_| { let size: u64 = size.parse().map_err(|_| ChecksumParseError::BadSize {
format!( field: field.clone(),
"malformed '{}' line (size '{size}' is not a number): '{line}'", size: size.to_string(),
kind.field_name() line: line.to_string(),
)
})?; })?;
let digest_ok = digest.len() == kind.digest_len() let digest_ok = digest.len() == kind.digest_len()
&& digest && digest
.bytes() .bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !digest_ok { if !digest_ok {
return Err(format!( return Err(ChecksumParseError::BadDigest {
"malformed '{}' line (digest '{digest}' is not {} lowercase \ field,
hex characters): '{line}'", digest: digest.to_string(),
kind.field_name(), len: kind.digest_len(),
kind.digest_len() line: line.to_string(),
)); });
} }
let mut entry = Entry { let mut entry = Entry {
size, size,
@@ -401,27 +445,32 @@ mod tests {
assert_eq!(entries[0].1.size, 12); assert_eq!(entries[0].1.size, 12);
// 2 columns: missing the name. // 2 columns: missing the name.
let err = let err = FileChecksums::parse_field(ChecksumKind::Sha512, &format!("{digest} 12"))
FileChecksums::parse_field(ChecksumKind::Sha512, &format!("{digest} 12")).unwrap_err(); .unwrap_err()
.to_string();
assert!(err.contains("Checksums-Sha512"), "{err}"); assert!(err.contains("Checksums-Sha512"), "{err}");
assert!(err.contains(&format!("{digest} 12")), "{err}"); assert!(err.contains(&format!("{digest} 12")), "{err}");
// 4 columns. // 4 columns.
let err = let err =
FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} 12 bogus a.txt")) FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} 12 bogus a.txt"))
.unwrap_err(); .unwrap_err()
.to_string();
assert!(err.contains("Checksums-Sha512"), "{err}"); assert!(err.contains("Checksums-Sha512"), "{err}");
assert!(err.contains("a.txt"), "{err}"); assert!(err.contains("a.txt"), "{err}");
// Non-numeric size. // Non-numeric size.
let err = let err =
FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} twelve a.txt")) FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} twelve a.txt"))
.unwrap_err(); .unwrap_err()
.to_string();
assert!(err.contains("not a number"), "{err}"); assert!(err.contains("not a number"), "{err}");
assert!(err.contains("twelve"), "{err}"); assert!(err.contains("twelve"), "{err}");
// Digest that is not 128 lowercase hex characters. // Digest that is not 128 lowercase hex characters.
let err = FileChecksums::parse_field(ChecksumKind::Sha512, " abc123 12 a.txt").unwrap_err(); let err = FileChecksums::parse_field(ChecksumKind::Sha512, " abc123 12 a.txt")
.unwrap_err()
.to_string();
assert!(err.contains("lowercase hex"), "{err}"); assert!(err.contains("lowercase hex"), "{err}");
} }
+20 -7
View File
@@ -403,6 +403,21 @@ pub struct ControlInfo {
pub binaries: Vec<Paragraph>, pub binaries: Vec<Paragraph>,
} }
/// Failure of [`ControlInfo::parse_content`]: the content is not shaped
/// like a `debian/control` file.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ControlParseError {
/// The content has no paragraphs at all.
#[error("control file has no paragraphs")]
NoParagraphs,
/// The first (source) stanza carries no `Source` field.
#[error("first control paragraph has no 'Source' field")]
NoSourceField,
/// A binary stanza carries no `Package` field.
#[error("binary control paragraph has no 'Package' field")]
NoBinaryPackageField,
}
impl ControlInfo { impl ControlInfo {
/// Parse a `debian/control` file. /// Parse a `debian/control` file.
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> { pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
@@ -416,19 +431,17 @@ impl ControlInfo {
/// Parse control content from a string. /// Parse control content from a string.
/// ///
/// Prefer [`std::str::FromStr`] (`"...".parse::<ControlInfo>()`). /// Prefer [`std::str::FromStr`] (`"...".parse::<ControlInfo>()`).
pub fn parse_content(content: &str) -> Result<ControlInfo, String> { pub fn parse_content(content: &str) -> Result<ControlInfo, ControlParseError> {
let paragraphs = parse_paragraphs(content); let paragraphs = parse_paragraphs(content);
let mut iter = paragraphs.into_iter(); let mut iter = paragraphs.into_iter();
let source = iter let source = iter.next().ok_or(ControlParseError::NoParagraphs)?;
.next()
.ok_or_else(|| "control file has no paragraphs".to_string())?;
if source.get("Source").is_none() { if source.get("Source").is_none() {
return Err("first control paragraph has no 'Source' field".to_string()); return Err(ControlParseError::NoSourceField);
} }
let binaries: Vec<Paragraph> = iter.collect(); let binaries: Vec<Paragraph> = iter.collect();
for bin in &binaries { for bin in &binaries {
if bin.get("Package").is_none() { if bin.get("Package").is_none() {
return Err("binary control paragraph has no 'Package' field".to_string()); return Err(ControlParseError::NoBinaryPackageField);
} }
} }
Ok(ControlInfo { source, binaries }) Ok(ControlInfo { source, binaries })
@@ -451,7 +464,7 @@ impl ControlInfo {
} }
impl std::str::FromStr for ControlInfo { impl std::str::FromStr for ControlInfo {
type Err = String; type Err = ControlParseError;
fn from_str(content: &str) -> Result<Self, Self::Err> { fn from_str(content: &str) -> Result<Self, Self::Err> {
ControlInfo::parse_content(content) ControlInfo::parse_content(content)
+135 -33
View File
@@ -167,22 +167,96 @@ fn profile_name_regex() -> &'static Regex {
}) })
} }
/// Parse one simple dependency alternative. /// Failure of the dependency parsers: a syntactically invalid relation or
/// /// restriction formula, an invalid parse option, or an unreadable dpkg
/// Fails when the text does not match the grammar, when `:native` appears in /// status file.
/// a non-build-dependency context, or when the architecture/version/profile #[derive(Debug, thiserror::Error)]
/// syntax is invalid. pub enum ParseError {
pub fn parse_simple(dep: &str, build_dep: bool) -> Result<PkgRelation, String> { /// The dependency text does not match the package relation grammar.
#[error("cannot parse dependency '{dep}'")]
Syntax {
/// The unparsable dependency text.
dep: String,
},
/// `:native` appeared outside a build-dependency context.
#[error("':native' qualifier only allowed in build dependencies: '{dep}'")]
NativeOutsideBuildDep {
/// The offending dependency text.
dep: String,
},
/// The version relation operator is not one of `<< <= = >= >>`.
#[error("invalid relation '{relation}' in '{dep}'")]
InvalidRelation {
/// The unrecognized operator.
relation: String,
/// The dependency text carrying it.
dep: String,
},
/// The constrained version does not parse.
#[error("invalid version in dependency '{dep}': {source}")]
InvalidVersion {
/// The dependency text carrying it.
dep: String,
/// The version parse failure.
source: crate::debian::version::VersionParseError,
},
/// The `[...]` architecture list does not parse.
#[error("invalid architecture list in '{dep}': {source}")]
InvalidArchList {
/// The dependency text carrying it.
dep: String,
/// The architecture list failure.
source: crate::debian::arch::ArchError,
},
/// A `[...]` restriction formula reduced to nothing.
#[error("empty restriction formula in '{dep}'")]
EmptyRestrictions {
/// The dependency text carrying it.
dep: String,
},
/// A `<...>` formula carries a non-profile term.
#[error("'{formula}' is not a valid build profile restriction formula")]
InvalidProfileFormula {
/// The offending formula.
formula: String,
},
/// [`ParseOpts::host_arch`] is empty or not a valid architecture.
#[error("invalid host_arch {host_arch}")]
InvalidHostArch {
/// The offending host architecture.
host_arch: String,
},
/// `union` mode saw a clause with several alternatives.
#[error("an union dependency can only contain simple dependencies")]
UnionNotSimple,
/// The dpkg status file could not be read.
#[error("cannot read status file '{path}': {source}")]
ReadStatus {
/// The status file path.
path: String,
/// The read failure.
source: std::io::Error,
},
}
/// Parse one simple (no alternatives) package relation: `pkg`
/// (`pkg:qualifier`, `pkg (op version)`, `pkg [arch list]`,
/// `pkg <restrictions>` combinations), validating the pieces: `:native`
/// is rejected in a non-build-dependency context, or when the
/// architecture/version/profile syntax is invalid.
pub fn parse_simple(dep: &str, build_dep: bool) -> Result<PkgRelation, ParseError> {
let caps = dep_regex() let caps = dep_regex()
.captures(dep) .captures(dep)
.ok_or_else(|| format!("cannot parse dependency '{dep}'"))?; .ok_or_else(|| ParseError::Syntax {
dep: dep.to_string(),
})?;
let package = caps[2].to_string(); let package = caps[2].to_string();
let arch_qualifier = caps.get(3).map(|m| m.as_str().to_string()); let arch_qualifier = caps.get(3).map(|m| m.as_str().to_string());
if arch_qualifier.as_deref() == Some("native") && !build_dep { if arch_qualifier.as_deref() == Some("native") && !build_dep {
return Err(format!( return Err(ParseError::NativeOutsideBuildDep {
"':native' qualifier only allowed in build dependencies: '{dep}'" dep: dep.to_string(),
)); });
} }
let constraint = match (caps.get(5), caps.get(6)) { let constraint = match (caps.get(5), caps.get(6)) {
@@ -197,36 +271,49 @@ pub fn parse_simple(dep: &str, build_dep: bool) -> Result<PkgRelation, String> {
"=" => Relation::Eq, "=" => Relation::Eq,
">" | ">=" => Relation::Ge, ">" | ">=" => Relation::Ge,
">>" => Relation::Gt, ">>" => Relation::Gt,
other => return Err(format!("invalid relation '{other}' in '{dep}'")), other => {
return Err(ParseError::InvalidRelation {
relation: other.to_string(),
dep: dep.to_string(),
});
}
}; };
let version = DebianVersion::parse(version.as_str()) let version = DebianVersion::parse(version.as_str()).map_err(|source| {
.map_err(|e| format!("invalid version in dependency '{dep}': {e}"))?; ParseError::InvalidVersion {
dep: dep.to_string(),
source,
}
})?;
Some(VersionConstraint { relation, version }) Some(VersionConstraint { relation, version })
} }
_ => None, _ => None,
}; };
let arches = match caps.get(8) { let arches =
Some(list) => Some( match caps.get(8) {
arch::list_parse(list.as_str()) Some(list) => Some(arch::list_parse(list.as_str()).map_err(|source| {
.map_err(|e| format!("invalid architecture list in '{dep}': {e}"))?, ParseError::InvalidArchList {
), dep: dep.to_string(),
None => None, source,
}; }
})?),
None => None,
};
let mut restrictions = Vec::new(); let mut restrictions = Vec::new();
if let Some(formula) = caps.get(9) { if let Some(formula) = caps.get(9) {
for group in restriction_group_regex().captures_iter(formula.as_str()) { for group in restriction_group_regex().captures_iter(formula.as_str()) {
let terms: Vec<String> = group[1].split_whitespace().map(str::to_string).collect(); let terms: Vec<String> = group[1].split_whitespace().map(str::to_string).collect();
if terms.is_empty() { if terms.is_empty() {
return Err(format!("empty restriction formula in '{dep}'")); return Err(ParseError::EmptyRestrictions {
dep: dep.to_string(),
});
} }
for term in &terms { for term in &terms {
if !profile_name_regex().is_match(term) { if !profile_name_regex().is_match(term) {
return Err(format!( return Err(ParseError::InvalidProfileFormula {
"'{}' is not a valid build profile restriction formula", formula: formula.as_str().to_string(),
formula.as_str() });
));
} }
} }
restrictions.push(terms); restrictions.push(terms);
@@ -291,13 +378,19 @@ impl Deps {
/// alternatives whose architecture or profile restrictions do not apply /// alternatives whose architecture or profile restrictions do not apply
/// are dropped first; clauses losing all their alternatives disappear, /// are dropped first; clauses losing all their alternatives disappear,
/// and a fully reduced field yields an empty [`Deps`]. /// and a fully reduced field yields an empty [`Deps`].
pub fn parse(input: &str, opts: &ParseOpts) -> Result<Deps, String> { pub fn parse(input: &str, opts: &ParseOpts) -> Result<Deps, ParseError> {
Self::parse_inner(input, opts, false) Self::parse_inner(input, opts, false)
} }
fn parse_inner(input: &str, opts: &ParseOpts, reduce_arch_only: bool) -> Result<Deps, String> { fn parse_inner(
input: &str,
opts: &ParseOpts,
reduce_arch_only: bool,
) -> Result<Deps, ParseError> {
if opts.host_arch.is_empty() || arch::is_invalid(&opts.host_arch, true) { if opts.host_arch.is_empty() || arch::is_invalid(&opts.host_arch, true) {
return Err(format!("invalid host_arch {}", opts.host_arch)); return Err(ParseError::InvalidHostArch {
host_arch: opts.host_arch.clone(),
});
} }
// Merge continuation lines and normalize whitespace. // Merge continuation lines and normalize whitespace.
@@ -336,7 +429,7 @@ impl Deps {
continue; continue;
} }
if opts.union && alternatives.len() > 1 { if opts.union && alternatives.len() > 1 {
return Err("an union dependency can only contain simple dependencies".to_string()); return Err(ParseError::UnionNotSimple);
} }
clauses.push(alternatives); clauses.push(alternatives);
} }
@@ -652,9 +745,15 @@ impl Facts {
/// Load a dpkg status file (e.g. `/var/lib/dpkg/status`), binding the /// Load a dpkg status file (e.g. `/var/lib/dpkg/status`), binding the
/// facts to the given host/build architectures. /// facts to the given host/build architectures.
pub fn load_status(path: &Path, host_arch: &str, build_arch: &str) -> Result<Facts, String> { pub fn load_status(
let content = std::fs::read_to_string(path) path: &Path,
.map_err(|e| format!("cannot read status file '{}': {}", path.display(), e))?; host_arch: &str,
build_arch: &str,
) -> Result<Facts, ParseError> {
let content = std::fs::read_to_string(path).map_err(|source| ParseError::ReadStatus {
path: path.display().to_string(),
source,
})?;
Ok(Facts::from_status(&content, host_arch, build_arch)) Ok(Facts::from_status(&content, host_arch, build_arch))
} }
@@ -936,7 +1035,10 @@ impl UnmetReport {
/// ///
/// On success the report is empty; otherwise its [`UnmetReport::message`] /// On success the report is empty; otherwise its [`UnmetReport::message`]
/// carries the dpkg-compatible diagnostics. /// carries the dpkg-compatible diagnostics.
pub fn check_build_depends(control: &ControlInfo, opts: &CheckOpts) -> Result<UnmetReport, String> { pub fn check_build_depends(
control: &ControlInfo,
opts: &CheckOpts,
) -> Result<UnmetReport, ParseError> {
let source = &control.source; let source = &control.source;
let mut bd_parts: Vec<&str> = Vec::new(); let mut bd_parts: Vec<&str> = Vec::new();
+25 -4
View File
@@ -121,6 +121,23 @@ pub struct FilesList {
files: BTreeMap<String, FilesEntry>, files: BTreeMap<String, FilesEntry>,
} }
/// Failure of [`FilesList::parse`]: a malformed `debian/files` line.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FilesParseError {
/// The line has fewer than `filename section priority` columns.
#[error("badly formed line: {line}")]
BadLine {
/// The offending line.
line: String,
},
/// The file name does not match any known artifact shape.
#[error("badly formed file name: {name}")]
BadFileName {
/// The offending file name.
name: String,
},
}
impl FilesList { impl FilesList {
/// An empty registry. /// An empty registry.
pub fn new() -> Self { pub fn new() -> Self {
@@ -141,7 +158,7 @@ impl FilesList {
/// Parse a `debian/files` registry from its textual content /// Parse a `debian/files` registry from its textual content
/// (`filename section priority [key=value...]` lines). /// (`filename section priority [key=value...]` lines).
pub fn parse(content: &str) -> Result<FilesList, String> { pub fn parse(content: &str) -> Result<FilesList, FilesParseError> {
let mut list = FilesList::new(); let mut list = FilesList::new();
for line in content.lines() { for line in content.lines() {
if line.trim().is_empty() { if line.trim().is_empty() {
@@ -149,10 +166,14 @@ impl FilesList {
} }
let tokens: Vec<&str> = line.split_whitespace().collect(); let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 { if tokens.len() < 3 {
return Err(format!("badly formed line: {line}")); return Err(FilesParseError::BadLine {
line: line.to_string(),
});
} }
let mut entry = parse_filename(tokens[0]) let mut entry =
.ok_or_else(|| format!("badly formed file name: {}", tokens[0]))?; parse_filename(tokens[0]).ok_or_else(|| FilesParseError::BadFileName {
name: tokens[0].to_string(),
})?;
entry.section = tokens[1].to_string(); entry.section = tokens[1].to_string();
entry.priority = tokens[2].to_string(); entry.priority = tokens[2].to_string();
for attr in &tokens[3..] { for attr in &tokens[3..] {
+31 -11
View File
@@ -54,6 +54,30 @@ pub struct TestsControl {
pub tests: Vec<Test>, pub tests: Vec<Test>,
} }
/// Failure of [`TestsControl::parse_content`]: a malformed stanza, naming
/// it by its 1-based position.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TestsControlParseError {
/// The stanza carries both `Tests:` and `Test-Command:`.
#[error("stanza {stanza}: Tests and Test-Command are mutually exclusive")]
MutuallyExclusive {
/// The 1-based stanza position.
stanza: usize,
},
/// The stanza carries neither `Tests:` nor `Test-Command:`.
#[error("stanza {stanza}: missing a Tests or Test-Command field")]
MissingField {
/// The 1-based stanza position.
stanza: usize,
},
/// `test-name:` appeared on a `Tests:` (script list) stanza.
#[error("stanza {stanza}: test-name only applies to a Test-Command stanza")]
TestNameOnScripts {
/// The 1-based stanza position.
stanza: usize,
},
}
impl TestsControl { impl TestsControl {
/// Parse the test control file at `path`. /// Parse the test control file at `path`.
pub fn parse(path: &Path) -> Result<TestsControl, Box<dyn std::error::Error>> { pub fn parse(path: &Path) -> Result<TestsControl, Box<dyn std::error::Error>> {
@@ -69,7 +93,7 @@ impl TestsControl {
/// `Test-Command:` stanza. A file without stanzas parses to an empty /// `Test-Command:` stanza. A file without stanzas parses to an empty
/// [`TestsControl`] — whether the package declares tests at all is /// [`TestsControl`] — whether the package declares tests at all is
/// the caller's check (`Testsuite: autopkgtest`). /// the caller's check (`Testsuite: autopkgtest`).
pub fn parse_content(content: &str) -> Result<TestsControl, String> { pub fn parse_content(content: &str) -> Result<TestsControl, TestsControlParseError> {
let mut tests_control = TestsControl::default(); let mut tests_control = TestsControl::default();
let mut command_index = 0usize; let mut command_index = 0usize;
for (index, paragraph) in parse_paragraphs(content).iter().enumerate() { for (index, paragraph) in parse_paragraphs(content).iter().enumerate() {
@@ -85,20 +109,14 @@ impl TestsControl {
match (tests_field, command_field) { match (tests_field, command_field) {
(Some(_), Some(_)) => { (Some(_), Some(_)) => {
return Err(format!( return Err(TestsControlParseError::MutuallyExclusive { stanza });
"stanza {stanza}: Tests and Test-Command are mutually exclusive"
));
} }
(None, None) => { (None, None) => {
return Err(format!( return Err(TestsControlParseError::MissingField { stanza });
"stanza {stanza}: missing a Tests or Test-Command field"
));
} }
(Some(scripts), None) => { (Some(scripts), None) => {
if test_name.is_some() { if test_name.is_some() {
return Err(format!( return Err(TestsControlParseError::TestNameOnScripts { stanza });
"stanza {stanza}: test-name only applies to a Test-Command stanza"
));
} }
for script in scripts.split_whitespace() { for script in scripts.split_whitespace() {
tests_control.tests.push(Test { tests_control.tests.push(Test {
@@ -259,7 +277,9 @@ mod tests {
"only applies", "only applies",
), ),
] { ] {
let error = TestsControl::parse_content(content).unwrap_err(); let error = TestsControl::parse_content(content)
.unwrap_err()
.to_string();
assert!(error.contains(expected), "{error}"); assert!(error.contains(expected), "{error}");
} }
} }
+66 -13
View File
@@ -12,19 +12,65 @@ pub struct DebianVersion {
pub debian_revision: Option<String>, pub debian_revision: Option<String>,
} }
/// Failure of [`DebianVersion::parse`]: the string is outside the
/// `[epoch:]upstream[-revision]` syntax.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum VersionParseError {
/// The string, once trimmed, is empty.
#[error("empty version string")]
Empty,
/// The epoch (before the first `:`) is not a number.
#[error("invalid epoch '{epoch}' in version '{raw}'")]
InvalidEpoch {
/// The non-numeric epoch text.
epoch: String,
/// The whole version string.
raw: String,
},
/// Nothing precedes the revision separator: no upstream version.
#[error("missing upstream version in '{raw}'")]
MissingUpstream {
/// The whole version string.
raw: String,
},
/// A character outside dpkg's upstream alphabet.
#[error("invalid character '{c}' in version '{raw}'")]
InvalidUpstreamChar {
/// The offending character.
c: char,
/// The whole version string.
raw: String,
},
/// A trailing hyphen: the revision number is empty (dpkg rejects it).
#[error("empty debian revision in '{raw}'")]
EmptyRevision {
/// The whole version string.
raw: String,
},
/// A character outside dpkg's revision alphabet.
#[error("invalid character '{c}' in revision of version '{raw}'")]
InvalidRevisionChar {
/// The offending character.
c: char,
/// The whole version string.
raw: String,
},
}
impl DebianVersion { impl DebianVersion {
/// Parse and validate a Debian version string. /// Parse and validate a Debian version string.
pub fn parse(raw: &str) -> Result<DebianVersion, String> { pub fn parse(raw: &str) -> Result<DebianVersion, VersionParseError> {
let raw = raw.trim(); let raw = raw.trim();
if raw.is_empty() { if raw.is_empty() {
return Err("empty version string".to_string()); return Err(VersionParseError::Empty);
} }
let (epoch, rest) = match raw.split_once(':') { let (epoch, rest) = match raw.split_once(':') {
Some((e, r)) => { Some((e, r)) => {
let epoch: u32 = e let epoch: u32 = e.parse().map_err(|_| VersionParseError::InvalidEpoch {
.parse() epoch: e.to_string(),
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?; raw: raw.to_string(),
})?;
(Some(epoch), r) (Some(epoch), r)
} }
None => (None, raw), None => (None, raw),
@@ -37,14 +83,19 @@ impl DebianVersion {
}; };
if upstream.is_empty() { if upstream.is_empty() {
return Err(format!("missing upstream version in '{}'", raw)); return Err(VersionParseError::MissingUpstream {
raw: raw.to_string(),
});
} }
for c in upstream.chars() { for c in upstream.chars() {
if !(c.is_ascii_alphanumeric() if !(c.is_ascii_alphanumeric()
|| matches!(c, '.' | '+' | '-' | '~' | ':') || matches!(c, '.' | '+' | '-' | '~' | ':')
|| !c.is_ascii()) || !c.is_ascii())
{ {
return Err(format!("invalid character '{}' in version '{}'", c, raw)); return Err(VersionParseError::InvalidUpstreamChar {
c,
raw: raw.to_string(),
});
} }
} }
if let Some(rev) = &debian_revision { if let Some(rev) = &debian_revision {
@@ -52,14 +103,16 @@ impl DebianVersion {
// dpkg rejects a trailing hyphen: "bad syntax: revision // dpkg rejects a trailing hyphen: "bad syntax: revision
// number is empty". Native versions (no `-` at all) are // number is empty". Native versions (no `-` at all) are
// handled above and stay valid. // handled above and stay valid.
return Err(format!("empty debian revision in '{}'", raw)); return Err(VersionParseError::EmptyRevision {
raw: raw.to_string(),
});
} }
for c in rev.chars() { for c in rev.chars() {
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) { if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
return Err(format!( return Err(VersionParseError::InvalidRevisionChar {
"invalid character '{}' in revision of version '{}'", c,
c, raw raw: raw.to_string(),
)); });
} }
} }
} }
@@ -340,7 +393,7 @@ mod tests {
/// the *last* hyphen (upstream `1.0-` + revision `1`). /// the *last* hyphen (upstream `1.0-` + revision `1`).
#[test] #[test]
fn version_empty_revision() { fn version_empty_revision() {
let err = DebianVersion::parse("1.0-").unwrap_err(); let err = DebianVersion::parse("1.0-").unwrap_err().to_string();
assert!(err.contains("empty"), "unexpected message: {err}"); assert!(err.contains("empty"), "unexpected message: {err}");
assert!(DebianVersion::parse("1.0-").is_err()); assert!(DebianVersion::parse("1.0-").is_err());
+1 -1
View File
@@ -266,7 +266,7 @@ fn apply_rules(
} }
for injected in &deps.inject { for injected in &deps.inject {
let parsed = Deps::parse(injected, opts)?; let parsed = Deps::parse(injected, opts).map_err(|e| e.to_string())?;
clauses.extend(parsed.clauses().map(<[PkgRelation]>::to_vec)); clauses.extend(parsed.clauses().map(<[PkgRelation]>::to_vec));
} }
+1 -1
View File
@@ -114,7 +114,7 @@ fn split_top_level_commas(input: &str) -> Vec<String> {
/// stripped") and keeping everything else verbatim. /// stripped") and keeping everything else verbatim.
fn name_only_clauses(input: &str, host_arch: &str) -> Result<Vec<String>, String> { fn name_only_clauses(input: &str, host_arch: &str) -> Result<Vec<String>, String> {
let opts = ParseOpts::build_deps(host_arch.to_string(), Vec::new()); let opts = ParseOpts::build_deps(host_arch.to_string(), Vec::new());
let deps = Deps::parse(input, &opts)?; let deps = Deps::parse(input, &opts).map_err(|e| e.to_string())?;
let mut clauses = Vec::new(); let mut clauses = Vec::new();
for clause in deps.clauses() { for clause in deps.clauses() {
let kept: Vec<String> = clause let kept: Vec<String> = clause