diff --git a/src/build/env.rs b/src/build/env.rs index 30113f8..96ef4e8 100644 --- a/src/build/env.rs +++ b/src/build/env.rs @@ -81,7 +81,7 @@ pub fn build_env( /// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as /// `dpkg-buildpackage` does. pub fn arch_env(host_arch: Option<&str>) -> Result, 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 diff --git a/src/debian/arch.rs b/src/debian/arch.rs index 9d47e1e..cb775aa 100644 --- a/src/debian/arch.rs +++ b/src/debian/arch.rs @@ -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. -pub fn list_parse(list: &str) -> Result, String> { +pub fn list_parse(list: &str) -> Result, ArchError> { let arches: Vec = list.split_whitespace().map(str::to_string).collect(); for arch in &arches { if is_invalid(arch, false) { - return Err(format!( - "'{arch}' is not a valid architecture in list '{list}'" - )); + return Err(ArchError::InvalidInList { + arch: arch.clone(), + list: list.to_string(), + }); } } Ok(arches) @@ -802,7 +833,7 @@ fn from_uname() -> Option { /// the `DEB_BUILD_*` variables): the authoritative answer comes from the /// dpkg database itself; if the `dpkg` frontend cannot be executed, the /// architecture is derived from `uname` through the same tables. -pub fn native() -> Result { +pub fn native() -> Result { if let Ok(output) = Command::new("dpkg").arg("--print-architecture").output() && output.status.success() { @@ -811,7 +842,7 @@ pub fn native() -> Result { 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 @@ -821,7 +852,7 @@ pub fn native() -> Result { /// The target architecture defaults to the host architecture, and the host /// architecture defaults to the native build architecture, exactly like /// dpkg-architecture. -pub fn arch_env(host_arch: Option<&str>) -> Result, String> { +pub fn arch_env(host_arch: Option<&str>) -> Result, ArchError> { let build_arch = native()?; let host_arch = host_arch.unwrap_or(&build_arch).to_string(); let target_arch = host_arch.clone(); @@ -832,8 +863,9 @@ pub fn arch_env(host_arch: Option<&str>) -> Result, Str ("HOST", host_arch), ("TARGET", target_arch), ] { - let tuple = debarch_to_debtuple(&arch) - .ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + let tuple = debarch_to_debtuple(&arch).ok_or_else(|| ArchError::Unknown { + arch: arch.to_string(), + })?; env.insert(format!("DEB_{role}_ARCH"), arch.clone()); env.insert(format!("DEB_{role}_ARCH_ABI"), tuple.abi.clone()); @@ -841,20 +873,26 @@ pub fn arch_env(host_arch: Option<&str>) -> Result, Str env.insert(format!("DEB_{role}_ARCH_OS"), tuple.os.clone()); env.insert(format!("DEB_{role}_ARCH_CPU"), tuple.cpu.clone()); - let (bits, endian) = - abi_attrs(&arch).ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + let (bits, endian) = abi_attrs(&arch).ok_or_else(|| ArchError::Unknown { + arch: arch.to_string(), + })?; env.insert(format!("DEB_{role}_ARCH_BITS"), bits.to_string()); env.insert(format!("DEB_{role}_ARCH_ENDIAN"), endian.to_string()); - let multi = - multiarch(&arch).ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + let multi = multiarch(&arch).ok_or_else(|| ArchError::Unknown { + arch: arch.to_string(), + })?; env.insert(format!("DEB_{role}_MULTIARCH"), multi); - let gnu_type = debarch_to_gnutriplet(&arch) - .ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; - let (gnu_cpu, gnu_system) = gnu_type - .split_once('-') - .ok_or_else(|| format!("invalid GNU triplet '{gnu_type}'"))?; + let gnu_type = debarch_to_gnutriplet(&arch).ok_or_else(|| ArchError::Unknown { + arch: arch.to_string(), + })?; + let (gnu_cpu, gnu_system) = + 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_SYSTEM"), gnu_system.to_string()); env.insert(format!("DEB_{role}_GNU_TYPE"), gnu_type); diff --git a/src/debian/checksums.rs b/src/debian/checksums.rs index be041cb..021135e 100644 --- a/src/debian/checksums.rs +++ b/src/debian/checksums.rs @@ -100,6 +100,48 @@ pub struct FileChecksums { index: HashMap, } +/// 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 { /// Create an empty registry. pub fn new() -> Self { @@ -234,7 +276,11 @@ impl FileChecksums { /// `build::parse_checksum_field` helper (which additionally accepts the /// legacy 5-column `Files` layout); the two are intentionally not unified /// across modules. - pub fn parse_field(kind: ChecksumKind, value: &str) -> Result, String> { + pub fn parse_field( + kind: ChecksumKind, + value: &str, + ) -> Result, ChecksumParseError> { + let field = kind.field_name().to_string(); let mut entries = Vec::new(); for line in value.lines() { if line.trim().is_empty() { @@ -242,30 +288,28 @@ impl FileChecksums { } let tokens: Vec<&str> = line.split_whitespace().collect(); let [digest, size, name] = tokens.as_slice() else { - return Err(format!( - "malformed '{}' line (expected 'checksum size name', got {} \ - columns): '{line}'", - kind.field_name(), - tokens.len() - )); + return Err(ChecksumParseError::MalformedLine { + field, + columns: tokens.len(), + line: line.to_string(), + }); }; - let size: u64 = size.parse().map_err(|_| { - format!( - "malformed '{}' line (size '{size}' is not a number): '{line}'", - kind.field_name() - ) + let size: u64 = size.parse().map_err(|_| ChecksumParseError::BadSize { + field: field.clone(), + size: size.to_string(), + line: line.to_string(), })?; let digest_ok = digest.len() == kind.digest_len() && digest .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); if !digest_ok { - return Err(format!( - "malformed '{}' line (digest '{digest}' is not {} lowercase \ - hex characters): '{line}'", - kind.field_name(), - kind.digest_len() - )); + return Err(ChecksumParseError::BadDigest { + field, + digest: digest.to_string(), + len: kind.digest_len(), + line: line.to_string(), + }); } let mut entry = Entry { size, @@ -401,27 +445,32 @@ mod tests { assert_eq!(entries[0].1.size, 12); // 2 columns: missing the name. - let err = - FileChecksums::parse_field(ChecksumKind::Sha512, &format!("{digest} 12")).unwrap_err(); + let err = FileChecksums::parse_field(ChecksumKind::Sha512, &format!("{digest} 12")) + .unwrap_err() + .to_string(); assert!(err.contains("Checksums-Sha512"), "{err}"); assert!(err.contains(&format!("{digest} 12")), "{err}"); // 4 columns. let err = 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("a.txt"), "{err}"); // Non-numeric size. let err = 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("twelve"), "{err}"); // 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}"); } diff --git a/src/debian/control.rs b/src/debian/control.rs index 91b4401..cf7de59 100644 --- a/src/debian/control.rs +++ b/src/debian/control.rs @@ -403,6 +403,21 @@ pub struct ControlInfo { pub binaries: Vec, } +/// 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 { /// Parse a `debian/control` file. pub fn parse(path: &Path) -> Result> { @@ -416,19 +431,17 @@ impl ControlInfo { /// Parse control content from a string. /// /// Prefer [`std::str::FromStr`] (`"...".parse::()`). - pub fn parse_content(content: &str) -> Result { + pub fn parse_content(content: &str) -> Result { 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())?; + let source = iter.next().ok_or(ControlParseError::NoParagraphs)?; if source.get("Source").is_none() { - return Err("first control paragraph has no 'Source' field".to_string()); + return Err(ControlParseError::NoSourceField); } let binaries: Vec = iter.collect(); for bin in &binaries { 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 }) @@ -451,7 +464,7 @@ impl ControlInfo { } impl std::str::FromStr for ControlInfo { - type Err = String; + type Err = ControlParseError; fn from_str(content: &str) -> Result { ControlInfo::parse_content(content) diff --git a/src/debian/deps.rs b/src/debian/deps.rs index 535f7bb..76d0f76 100644 --- a/src/debian/deps.rs +++ b/src/debian/deps.rs @@ -167,22 +167,96 @@ fn profile_name_regex() -> &'static Regex { }) } -/// Parse one simple dependency alternative. -/// -/// Fails when the text does not match the grammar, when `:native` appears 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 { +/// Failure of the dependency parsers: a syntactically invalid relation or +/// restriction formula, an invalid parse option, or an unreadable dpkg +/// status file. +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + /// 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 ` 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 { let caps = dep_regex() .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 arch_qualifier = caps.get(3).map(|m| m.as_str().to_string()); if arch_qualifier.as_deref() == Some("native") && !build_dep { - return Err(format!( - "':native' qualifier only allowed in build dependencies: '{dep}'" - )); + return Err(ParseError::NativeOutsideBuildDep { + dep: dep.to_string(), + }); } let constraint = match (caps.get(5), caps.get(6)) { @@ -197,36 +271,49 @@ pub fn parse_simple(dep: &str, build_dep: bool) -> Result { "=" => Relation::Eq, ">" | ">=" => Relation::Ge, ">>" => 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()) - .map_err(|e| format!("invalid version in dependency '{dep}': {e}"))?; + let version = DebianVersion::parse(version.as_str()).map_err(|source| { + ParseError::InvalidVersion { + dep: dep.to_string(), + source, + } + })?; Some(VersionConstraint { relation, version }) } _ => None, }; - let arches = match caps.get(8) { - Some(list) => Some( - arch::list_parse(list.as_str()) - .map_err(|e| format!("invalid architecture list in '{dep}': {e}"))?, - ), - None => None, - }; + let arches = + match caps.get(8) { + Some(list) => Some(arch::list_parse(list.as_str()).map_err(|source| { + ParseError::InvalidArchList { + dep: dep.to_string(), + source, + } + })?), + None => None, + }; let mut restrictions = Vec::new(); if let Some(formula) = caps.get(9) { for group in restriction_group_regex().captures_iter(formula.as_str()) { let terms: Vec = group[1].split_whitespace().map(str::to_string).collect(); if terms.is_empty() { - return Err(format!("empty restriction formula in '{dep}'")); + return Err(ParseError::EmptyRestrictions { + dep: dep.to_string(), + }); } for term in &terms { if !profile_name_regex().is_match(term) { - return Err(format!( - "'{}' is not a valid build profile restriction formula", - formula.as_str() - )); + return Err(ParseError::InvalidProfileFormula { + formula: formula.as_str().to_string(), + }); } } restrictions.push(terms); @@ -291,13 +378,19 @@ impl Deps { /// alternatives whose architecture or profile restrictions do not apply /// are dropped first; clauses losing all their alternatives disappear, /// and a fully reduced field yields an empty [`Deps`]. - pub fn parse(input: &str, opts: &ParseOpts) -> Result { + pub fn parse(input: &str, opts: &ParseOpts) -> Result { Self::parse_inner(input, opts, false) } - fn parse_inner(input: &str, opts: &ParseOpts, reduce_arch_only: bool) -> Result { + fn parse_inner( + input: &str, + opts: &ParseOpts, + reduce_arch_only: bool, + ) -> Result { 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. @@ -336,7 +429,7 @@ impl Deps { continue; } 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); } @@ -652,9 +745,15 @@ impl Facts { /// Load a dpkg status file (e.g. `/var/lib/dpkg/status`), binding the /// facts to the given host/build architectures. - pub fn load_status(path: &Path, host_arch: &str, build_arch: &str) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("cannot read status file '{}': {}", path.display(), e))?; + pub fn load_status( + path: &Path, + host_arch: &str, + build_arch: &str, + ) -> Result { + 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)) } @@ -936,7 +1035,10 @@ impl UnmetReport { /// /// On success the report is empty; otherwise its [`UnmetReport::message`] /// carries the dpkg-compatible diagnostics. -pub fn check_build_depends(control: &ControlInfo, opts: &CheckOpts) -> Result { +pub fn check_build_depends( + control: &ControlInfo, + opts: &CheckOpts, +) -> Result { let source = &control.source; let mut bd_parts: Vec<&str> = Vec::new(); diff --git a/src/debian/files.rs b/src/debian/files.rs index 08dda15..a855df8 100644 --- a/src/debian/files.rs +++ b/src/debian/files.rs @@ -121,6 +121,23 @@ pub struct FilesList { files: BTreeMap, } +/// 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 { /// An empty registry. pub fn new() -> Self { @@ -141,7 +158,7 @@ impl FilesList { /// Parse a `debian/files` registry from its textual content /// (`filename section priority [key=value...]` lines). - pub fn parse(content: &str) -> Result { + pub fn parse(content: &str) -> Result { let mut list = FilesList::new(); for line in content.lines() { if line.trim().is_empty() { @@ -149,10 +166,14 @@ impl FilesList { } let tokens: Vec<&str> = line.split_whitespace().collect(); 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]) - .ok_or_else(|| format!("badly formed file name: {}", tokens[0]))?; + let mut entry = + parse_filename(tokens[0]).ok_or_else(|| FilesParseError::BadFileName { + name: tokens[0].to_string(), + })?; entry.section = tokens[1].to_string(); entry.priority = tokens[2].to_string(); for attr in &tokens[3..] { diff --git a/src/debian/tests_control.rs b/src/debian/tests_control.rs index 66f6a77..a619b87 100644 --- a/src/debian/tests_control.rs +++ b/src/debian/tests_control.rs @@ -54,6 +54,30 @@ pub struct TestsControl { pub tests: Vec, } +/// 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 { /// Parse the test control file at `path`. pub fn parse(path: &Path) -> Result> { @@ -69,7 +93,7 @@ impl TestsControl { /// `Test-Command:` stanza. A file without stanzas parses to an empty /// [`TestsControl`] — whether the package declares tests at all is /// the caller's check (`Testsuite: autopkgtest`). - pub fn parse_content(content: &str) -> Result { + pub fn parse_content(content: &str) -> Result { let mut tests_control = TestsControl::default(); let mut command_index = 0usize; for (index, paragraph) in parse_paragraphs(content).iter().enumerate() { @@ -85,20 +109,14 @@ impl TestsControl { match (tests_field, command_field) { (Some(_), Some(_)) => { - return Err(format!( - "stanza {stanza}: Tests and Test-Command are mutually exclusive" - )); + return Err(TestsControlParseError::MutuallyExclusive { stanza }); } (None, None) => { - return Err(format!( - "stanza {stanza}: missing a Tests or Test-Command field" - )); + return Err(TestsControlParseError::MissingField { stanza }); } (Some(scripts), None) => { if test_name.is_some() { - return Err(format!( - "stanza {stanza}: test-name only applies to a Test-Command stanza" - )); + return Err(TestsControlParseError::TestNameOnScripts { stanza }); } for script in scripts.split_whitespace() { tests_control.tests.push(Test { @@ -259,7 +277,9 @@ mod tests { "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}"); } } diff --git a/src/debian/version.rs b/src/debian/version.rs index 47f7e95..f2a54f7 100644 --- a/src/debian/version.rs +++ b/src/debian/version.rs @@ -12,19 +12,65 @@ pub struct DebianVersion { pub debian_revision: Option, } +/// 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 { /// Parse and validate a Debian version string. - pub fn parse(raw: &str) -> Result { + pub fn parse(raw: &str) -> Result { let raw = raw.trim(); if raw.is_empty() { - return Err("empty version string".to_string()); + return Err(VersionParseError::Empty); } let (epoch, rest) = match raw.split_once(':') { Some((e, r)) => { - let epoch: u32 = e - .parse() - .map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?; + let epoch: u32 = e.parse().map_err(|_| VersionParseError::InvalidEpoch { + epoch: e.to_string(), + raw: raw.to_string(), + })?; (Some(epoch), r) } None => (None, raw), @@ -37,14 +83,19 @@ impl DebianVersion { }; 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() { if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '~' | ':') || !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 { @@ -52,14 +103,16 @@ impl DebianVersion { // dpkg rejects a trailing hyphen: "bad syntax: revision // number is empty". Native versions (no `-` at all) are // 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() { if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) { - return Err(format!( - "invalid character '{}' in revision of version '{}'", - c, raw - )); + return Err(VersionParseError::InvalidRevisionChar { + c, + raw: raw.to_string(), + }); } } } @@ -340,7 +393,7 @@ mod tests { /// the *last* hyphen (upstream `1.0-` + revision `1`). #[test] 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!(DebianVersion::parse("1.0-").is_err()); diff --git a/src/quirks.rs b/src/quirks.rs index ee9cc41..05ea07c 100644 --- a/src/quirks.rs +++ b/src/quirks.rs @@ -266,7 +266,7 @@ fn apply_rules( } 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)); } diff --git a/src/test/expand.rs b/src/test/expand.rs index 7641c6e..c3b8aac 100644 --- a/src/test/expand.rs +++ b/src/test/expand.rs @@ -114,7 +114,7 @@ fn split_top_level_commas(input: &str) -> Vec { /// stripped") and keeping everything else verbatim. fn name_only_clauses(input: &str, host_arch: &str) -> Result, String> { 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(); for clause in deps.clauses() { let kept: Vec = clause