use flate2::read::GzDecoder; use std::collections::HashMap; use std::error::Error; use std::io::Read; use xz2::read::XzDecoder; use crate::ProgressCallback; use crate::apt::release::{self, VerifiedRelease}; use crossterm::style::Stylize; use log::{debug, warn}; /// Convert a PPA specification to a base URL /// /// # Arguments /// * user: user for the PPA /// * name: name of the PPA /// /// # Returns /// * The base URL for the PPA (e.g., "https://ppa.launchpadcontent.net/user/ppa_name/ubuntu/") pub fn ppa_to_base_url(user: &str, name: &str) -> String { format!("https://ppa.launchpadcontent.net/{}/{}/ubuntu", user, name) } fn check_launchpad_repo_sync(package: &str) -> Result, String> { let url = format!("https://git.launchpad.net/ubuntu/+source/{}", package); // Use libgit2 to check if the remote repository exists // This is more reliable than HTTP HEAD requests when CGIt is disabled match git2::Remote::create_detached(url.clone()) { Ok(mut remote) => match remote.connect(git2::Direction::Fetch) { Ok(_) => Ok(Some(url)), Err(_) => Ok(None), }, Err(_) => Ok(None), } } /// Check if a Launchpad git repository exists for the given package. /// /// This runs the blocking git2 connection check on a dedicated thread with a /// timeout so that it never stalls the tokio runtime (the git2 connect call /// has no built-in timeout and can hang for minutes on unreachable hosts). async fn check_launchpad_repo( package: &str, ) -> Result, Box> { let package_owned = package.to_string(); let package_for_err = package_owned.clone(); let result = tokio::time::timeout( std::time::Duration::from_secs(15), tokio::task::spawn_blocking(move || check_launchpad_repo_sync(&package_owned)), ) .await .map_err(|_| { format!( "Timed out checking Launchpad repository for '{}'", package_for_err ) })? .map_err(|e| format!("Launchpad check task failed: {}", e))?; result.map_err(|e| -> Box { e.into() }) } /// A File used in a source package #[derive(Debug, Clone)] pub struct FileEntry { /// Name of the file pub name: String, /// Size of the file pub size: u64, /// Checksum hash for the file pub checksum: String, /// Algorithm used for the checksum pub checksum_algo: ChecksumAlgo, } /// Checksum algorithm used for a file entry #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChecksumAlgo { /// MD5 (legacy 'Files' field) Md5, /// SHA-256 ('Checksums-Sha256' field) Sha256, /// SHA-512 ('Checksums-Sha512' field) Sha512, } impl ChecksumAlgo { /// Compute the hex-encoded digest of the given data using this algorithm pub fn hex_digest(&self, data: &[u8]) -> String { match self { ChecksumAlgo::Md5 => { use md5::{Digest, Md5}; let mut hasher = Md5::new(); hasher.update(data); hex::encode(hasher.finalize()) } ChecksumAlgo::Sha256 => { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(data); hex::encode(hasher.finalize()) } ChecksumAlgo::Sha512 => { use sha2::{Digest, Sha512}; let mut hasher = Sha512::new(); hasher.update(data); hex::encode(hasher.finalize()) } } } } /// A package 'stanza' as found is 'Sources.gz' files, containing basic information about a source package #[derive(Debug)] pub struct PackageStanza { /// Name of the package pub package: String, /// Version number for the package pub version: String, /// Directory field in the stanza pub directory: String, /// Source package format (e.g. '3.0 (quilt)') pub format: String, /// Vcs-Git field in the stanza pub vcs_git: Option, /// Vcs-Browser field in the stanza pub vcs_browser: Option, /// Files present in the source package pub files: Vec, } /// Source package information #[derive(Debug)] pub struct PackageInfo { /// Source 'stanza' for the package, containing basic information pub stanza: PackageStanza, /// Distribution for the package pub dist: String, /// Distribution series for the package pub series: String, /// Preferred VCS for the source package /// /// Should be Launchpad on Ubuntu, and Salsa on Debian pub preferred_vcs: Option, /// URL for the files of the source package pub archive_url: String, } impl PackageInfo { /// Returns true if the package is a Debian native package (no orig) pub fn is_native(&self) -> bool { self.stanza.format.contains("(native)") } } /// Decompress a package index payload, detecting the compression format from /// its magic bytes: gzip ('1f 8b'), xz ('fd 7zXZ 00'), or plain text fn decompress_index(data: &[u8]) -> Result> { let mut decoded: Box = if data.starts_with(&[0x1f, 0x8b]) { Box::new(GzDecoder::new(data)) } else if data.starts_with(&[0xfd, 0x37, b'z', b'X', b'Z', 0x00]) { Box::new(XzDecoder::new(data)) } else { return Ok(String::from_utf8(data.to_vec())?); }; let mut s = String::new(); decoded.read_to_string(&mut s)?; Ok(s) } struct DebianSources { splitted_sources: std::str::Split<'static, &'static str>, } impl DebianSources { fn new(data: &[u8]) -> Result> { // Decode the index into a string, and split it on stanzas let s = decompress_index(data)?; // Convert the string to a static lifetime by leaking it let static_str = Box::leak(s.into_boxed_str()); let splitted = static_str.split("\n\n"); Ok(DebianSources { splitted_sources: splitted, }) } } impl Iterator for DebianSources { type Item = PackageStanza; fn next(&mut self) -> Option { // Iterate over stanzas in a loop: package-less (blank) stanzas are // skipped without recursion, so that a crafted index with many // consecutive blank stanzas cannot blow the stack loop { let stanza = self.splitted_sources.next()?; // Parse stanza into a hashmap of strings, the fields let mut fields: HashMap = HashMap::new(); let mut current_key = String::new(); for line in stanza.lines() { if line.is_empty() { continue; } if line.starts_with(' ') || line.starts_with('\t') { // Continuation line if let Some(val) = fields.get_mut(¤t_key) { val.push('\n'); val.push_str(line.trim()); } } else if let Some((key, value)) = line.split_once(':') { current_key = key.trim().to_string(); fields.insert(current_key.clone(), value.trim().to_string()); } } let Some(package) = fields.get("Package") else { // Skip empty stanza continue; }; let package = package.to_string(); // A stanza without a version is malformed remote data: skip it // rather than panicking let Some(version) = fields.get("Version") else { debug!( "Skipping malformed stanza for package '{}' without a 'Version' field", package ); continue; }; let version = version.to_string(); // Parse package files. // Prefer the strongest available checksum field: Checksums-Sha256, // then Checksums-Sha512, then the legacy 'Files' (MD5) field. // Some archives (e.g. the Ubuntu development series) no longer ship // Checksums-Sha256, so falling back is required to keep working. let mut files = Vec::new(); let (checksum_field, algo) = if fields.contains_key("Checksums-Sha256") { ("Checksums-Sha256", ChecksumAlgo::Sha256) } else if fields.contains_key("Checksums-Sha512") { ("Checksums-Sha512", ChecksumAlgo::Sha512) } else { ("Files", ChecksumAlgo::Md5) }; if let Some(checksums) = fields.get(checksum_field) { for line in checksums.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 3 { files.push(FileEntry { checksum: parts[0].to_string(), size: parts[1].parse().unwrap_or(0), name: parts[2].to_string(), checksum_algo: algo, }); } } } // Parse Vcs-Git field: it may contain just a URL, or URL followed by -b // e.g., "https://salsa.debian.org/science-team/paraview.git -b debian/latest" let vcs_git = fields.get("Vcs-Git").map(|vcs| { // Split on whitespace and take the first part (the URL) // The URL should not contain spaces, so this is safe vcs.split_whitespace().next().unwrap_or(vcs).to_string() }); return Some(PackageStanza { package, version, directory: fields.get("Directory").cloned().unwrap_or_default(), format: fields .get("Format") .cloned() .unwrap_or_else(|| "1.0".to_string()), vcs_git, vcs_browser: fields.get("Vcs-Browser").cloned(), files, }); } } } /// Parse a 'Sources.gz' debian package file data, to look for a target package and /// return the data for that package stanza fn parse_sources( data: &[u8], target_package: &str, target_version: Option<&str>, ) -> Result, Box> { let mut sources = DebianSources::new(data)?; // Find the right package, with the right version if requested Ok(sources.find(|s| { s.package == target_package && (target_version.is_none() || s.version == target_version.unwrap()) })) } /// Get package information from a package, distribution series, and pocket /// /// `launchpad_vcs` is a pre-computed Launchpad VCS URL (if any), checked once /// by the caller so that the blocking git2 connection test is not repeated for /// every series/pocket combination. async fn get( package_name: &str, series: &str, pocket: &str, version: Option<&str>, base_url: Option<&str>, launchpad_vcs: Option, ) -> Result> { let dist = crate::distro_info::get_dist_from_series(series).await?; // Use the pre-computed Launchpad VCS URL (if any). // This is checked once in the caller rather than per-series to avoid // repeated blocking git2 connections. let mut preferred_vcs = launchpad_vcs; // Determine the base URL to use (either provided PPA URL or default archive) let distro_base_url = crate::distro_info::get_base_url(&dist)?; let base_url = if let Some(ppa_url) = base_url { ppa_url.to_string() } else { distro_base_url.clone() }; // If using a custom base URL (PPA), disable VCS lookup to force archive download let from_ppa = base_url != distro_base_url; // Authenticate the metadata of the suite before trusting any index // fetched from it: the signed Release file (InRelease, or Release plus // detached Release.gpg) is verified against the archive keyring (or the // PPA signing key), and each Sources index downloaded below is // checksum-checked against it before parsing. let suite = if pocket.is_empty() { series.to_string() } else { format!("{series}-{pocket}") }; let suite_url = format!("{base_url}/dists/{suite}"); let keyring_source = if from_ppa { release::KeyringSource::Ppa { base_url: base_url.clone(), } } else { release::KeyringSource::Distro { series: series.to_string(), } }; debug!("Verifying the Release file of: {}", suite_url); let verified = match release::verify_suite(&suite_url, keyring_source, true).await { Ok(release::Verification::Available(verified)) => verified, Ok(release::Verification::Unavailable { .. }) => { // The suite does not exist: same outcome as before verification // existed (probing callers simply try the next pocket/series) return Err(format!("No Release file found for suite '{suite}' at {base_url}").into()); } Ok(release::Verification::KeyringUnavailable { reason, .. }) => { // An existing suite whose keys are unavailable must not be // trusted, but this is not tampering: report and let callers // move on return Err(reason.into()); } Err(e) => return Err(e.into()), }; let components = verified.components(); if components.is_empty() { return Err(format!("Components not found in the Release file of '{suite_url}'").into()); } debug!("Found components: {:?}", components); // Collect the failures of individual fetch attempts so that, if the // package is not found, the final error explains what actually went // wrong instead of misleadingly claiming a plain 'not found' let mut fetch_errors: Vec = Vec::new(); for component in components { let url = crate::distro_info::get_sources_url(&base_url, series, pocket, &component); debug!("Fetching sources from: {}", url); let response = match crate::distro_info::http_client().get(&url).send().await { Ok(resp) => resp, Err(e) => { debug!("Failed to fetch {}: {}", url, e); fetch_errors.push(format!("{suite}/{component}: {}", e)); continue; } }; if !response.status().is_success() { debug!("Failed to fetch {}: status {}", url, response.status()); fetch_errors.push(format!("{suite}/{component}: HTTP {}", response.status())); continue; } let compressed_data = response.bytes().await?; // The index must match the checksums listed in the signed Release // file: this is what closes the 'substituted index with matching // artifact checksums' man-in-the-middle attack let suite_rel_path = format!("{component}/source/Sources.gz"); verified .verify_file(&suite_rel_path, &compressed_data) .map_err(release::VerifyError)?; debug!( "Downloaded Sources.gz for {}/{}/{}", dist, series, component ); if let Some(stanza) = parse_sources(&compressed_data, package_name, version)? { if let Some(vcs) = &stanza.vcs_git && preferred_vcs.is_none() { preferred_vcs = Some(vcs.clone()); } // If downloading from PPA, make sure we don't use a VCS if from_ppa { preferred_vcs = None; } let archive_url = format!("{base_url}/{0}", stanza.directory); return Ok(PackageInfo { dist, series: series.to_string(), stanza, preferred_vcs, archive_url, }); } } let details = if fetch_errors.is_empty() { String::new() } else { format!(" (last errors: {})", fetch_errors.join("; ")) }; Err(format!( "Package '{}' not found in {}/{}{}", package_name, dist, series, details ) .into()) } /// Try to find package information in a distribution, trying all series and pockets async fn find_package( package_name: &str, dist: &str, pocket: &str, version: Option<&str>, base_url: Option<&str>, launchpad_vcs: Option, progress: ProgressCallback<'_>, ) -> Result> { let series_list = crate::distro_info::get_ordered_series_name(dist).await?; // Collect the failures of the individual series/pocket probes so that, // if nothing is found, the final error summarizes what went wrong // (e.g. network errors, HTTP statuses) instead of a bare 'not found' let mut attempt_errors: Vec = Vec::new(); for (i, series) in series_list.iter().enumerate() { if let Some(cb) = progress { cb("", &format!("Checking {}...", series), i, series_list.len()); } let pockets = if pocket.is_empty() { crate::distro_info::get_dist_pockets(dist)? } else { vec![pocket.to_string()] }; for p in pockets { match get( package_name, series, &p, version, base_url, launchpad_vcs.clone(), ) .await { Ok(info) => { if i > 0 { let location = if p.is_empty() { format!("{}/{}", dist, series).cyan() } else { format!("{}/{}-{}", dist, series, p).cyan() }; warn!( "Package '{}' not found in development release. Found in {}.", package_name, location ); } else { let location = if p.is_empty() { format!("{}/{}", dist, series) } else { format!("{}/{}-{}", dist, series, p) }; debug!("Found package '{}' in {}", package_name, location); } return Ok(info); } Err(e) => { // A Release verification failure is a security error, // not a missing package: abort the search instead of // silently probing other series/pockets if e.downcast_ref::().is_some() { return Err(e); } // Remember the failure for the final error message, and // keep probing the other series/pockets let suite = if p.is_empty() { series.clone() } else { format!("{series}-{p}") }; attempt_errors.push(format!("{}: {}", suite, e)); continue; } } } } // Keep only the last few attempts so the message stays readable if attempt_errors.len() > 5 { let drain_to = attempt_errors.len() - 5; attempt_errors.drain(..drain_to); } let details = if attempt_errors.is_empty() { String::new() } else { format!(" (last errors: {})", attempt_errors.join("; ")) }; Err(format!("Package '{}' not found.{}", package_name, details).into()) } /// Fetch the 'Release' file at the root of a flat repository, and return its suite name /// /// Flat repositories (e.g. 'https://pkg.example.org/deb/resolute/') serve the /// Release file at their root, without any 'dists/' hierarchy. The suite is /// read from the 'Codename' field, falling back to 'Suite'. async fn get_flat_repo_series(base_url: &str) -> Result> { let url = format!("{}/Release", base_url.trim_end_matches('/')); let response = crate::distro_info::http_client().get(&url).send().await?; if !response.status().is_success() { return Err(format!( "No Release file at '{}' (HTTP {}) - is '{}' the suite URL of a flat repository? \ External repositories must be passed as their full suite URL.", url, response.status(), base_url ) .into()); } let content = response.text().await?; for field in ["Codename", "Suite"] { let prefix = format!("{field}:"); if let Some(line) = content.lines().find(|l| l.starts_with(&prefix)) { return Ok(line[prefix.len()..].trim().to_string()); } } Err(format!("No Codename or Suite field in Release file at '{url}'").into()) } /// Fetch the sources index of a flat repository /// /// Flat repositories are free to serve any compressed variant of the index /// (or an uncompressed one), so try the usual candidates in turn. When the /// repository published a Release file, each index candidate is /// checksum-verified against it (failing on mismatch, since the artifact /// hashes would come from the index itself). async fn get_flat_repo_sources( base_url: &str, verified: Option<&VerifiedRelease>, ) -> Result, Box> { let base = base_url.trim_end_matches('/'); let mut errors = Vec::new(); for name in ["Sources.xz", "Sources.gz", "Sources"] { let url = format!("{base}/{name}"); match crate::distro_info::http_client().get(&url).send().await { Ok(response) if response.status().is_success() => { let data = response.bytes().await?.to_vec(); // Some flat Release files list their entries with a './' prefix if let Some(release) = verified { let mismatch = match release.verify_file(name, &data) { Ok(()) => None, Err(first) => release .verify_file(&format!("./{name}"), &data) .err() .map(|_| first), }; if let Some(e) = mismatch { return Err( format!("Verification of the repository index failed: {e}").into() ); } } return Ok(data); } Ok(response) => errors.push(format!("{}: HTTP {}", url, response.status())), Err(e) => errors.push(format!("{}: {}", url, e)), } } Err(format!( "Could not fetch the sources index from '{}': {}", base, errors.join(", ") ) .into()) } /// Lookup package information in an external flat repository /// /// The URL points at the repository suite directory itself (e.g. /// 'https://pkg.noctalia.dev/deb/resolute/'), as flat repositories have no /// 'dists/' hierarchy. The suite name is read from the 'Release' file /// unless overridden, and the package files are fetched relative to the URL /// root, ignoring the 'Directory' field like apt does for flat repositories. /// /// Packages from external repositories are always downloaded from the /// repository itself; the 'Vcs-Git' of the stanza is never used, as it may /// point to an arbitrary source. /// /// Third-party flat repositories have no known signing key: when they /// publish a Release file its checksums are enforced on the index (with a /// hard error on mismatch), but the absence of a verifiable signature only /// produces a warning, preserving the previous behavior for such repos. pub async fn lookup_repository( package: &str, version: Option<&str>, repo_url: &str, series: Option<&str>, progress: ProgressCallback<'_>, ) -> Result> { if let Some(cb) = progress { cb( &format!("Fetching repository info from {}...", repo_url), "", 0, 0, ); } // Attempt to authenticate the repository. In non-strict mode only the // absence of a Release file (or of a way to verify it) is tolerated: // tampering evidence — an invalid or malformed signature — is a hard // error even for third-party repositories. let verified = match release::verify_suite( repo_url.trim_end_matches('/'), release::KeyringSource::None, false, ) .await { Ok(release::Verification::Available(verified)) => Some(verified), Ok(release::Verification::Unavailable { .. }) => None, Ok(release::Verification::KeyringUnavailable { reason, .. }) => { warn!("Release verification of repository {repo_url} failed: {reason}"); None } Err(e) => return Err(e.into()), }; // Prefer the suite name from the (fetched, possibly verified) Release // file; fall back to a dedicated fetch when there is none let resolved_series = if let Some(s) = series { s.to_string() } else if let Some(codename) = verified .as_ref() .and_then(|v| v.field("Codename").or_else(|| v.field("Suite"))) { codename.to_string() } else { get_flat_repo_series(repo_url).await? }; if let Some(cb) = progress { cb( &format!("Resolving package info for {}...", package), "", 0, 0, ); } let sources = get_flat_repo_sources(repo_url, verified.as_ref()).await?; let stanza = parse_sources(&sources, package, version)? .ok_or_else(|| format!("Package '{package}' not found in repository {repo_url}"))?; // The distribution is only used for naming (e.g. git branch layout); // fall back to a neutral name for suites unknown to distro-info. let dist = crate::distro_info::get_dist_from_series(&resolved_series) .await .unwrap_or_else(|_| "external".to_string()); Ok(PackageInfo { dist, series: resolved_series, stanza, preferred_vcs: None, archive_url: repo_url.trim_end_matches('/').to_string(), }) } /// Lookup package information for a source package /// /// This function obtains package information either directly from a specific series /// or by searching across all series in a distribution. /// /// # Arguments /// * `package` - The name of the package to look up /// * `version` - Optional specific version to look for /// * `series` - Optional distribution series (e.g., "noble", "bookworm") /// * `pocket` - Pocket to search in (e.g., "updates", "security", or "" for main) /// * `dist` - Optional distribution name (e.g., "ubuntu", "debian") /// * `base_url` - Optional base URL for the package archive (e.g., "https://ppa.launchpadcontent.net/user/ppa/ubuntu/") /// * `repository` - Optional URL of an external flat repository suite (e.g., "https://pkg.noctalia.dev/deb/resolute/") /// * `progress` - Optional progress callback #[allow(clippy::too_many_arguments)] pub async fn lookup( package: &str, version: Option<&str>, series: Option<&str>, pocket: &str, dist: Option<&str>, base_url: Option<&str>, repository: Option<&str>, progress: ProgressCallback<'_>, ) -> Result> { // External flat repository: the URL is the suite root, distribution // resolution and pocket logic do not apply if let Some(repo_url) = repository { return lookup_repository(package, version, repo_url, series, progress).await; } // Resolve the distribution early so we can check Launchpad once let resolved_dist = dist.unwrap_or_else(|| // Use auto-detection to see if current distro is ubuntu, or fallback to debian by default if std::process::Command::new("lsb_release").arg("-i").arg("-s").output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase()).unwrap_or_default() == "ubuntu" { "ubuntu" } else { "debian" } ); // Check Launchpad repository once for Ubuntu packages, rather than // once per series/pocket combination inside get(). let launchpad_vcs = if resolved_dist == "ubuntu" && base_url.is_none() { if let Some(cb) = progress { cb("", &format!("Checking Launchpad for {}...", package), 0, 0); } match check_launchpad_repo(package).await { Ok(Some(lp_url)) => { debug!("Found Launchpad URL: {}", lp_url); Some(lp_url) } Ok(None) => None, Err(e) => { debug!( "Launchpad check failed (will use stanza Vcs-Git if available): {}", e ); None } } } else { None }; // Obtain the package information, either directly in a series or with a search in all series let package_info = if let Some(s) = series { if let Some(cb) = progress { cb( &format!("Resolving package info for {}...", package), "", 0, 0, ); } // Get the package information from that series and pocket get(package, s, pocket, version, base_url, launchpad_vcs).await? } else { if let Some(cb) = progress { cb( &format!( "Searching for package {} in {}...", package, if base_url.is_none() { resolved_dist } else { "ppa" } ), "", 0, 0, ); } // Try to find the package in all series from that dist find_package( package, resolved_dist, pocket, version, base_url, launchpad_vcs, progress, ) .await? }; Ok(package_info) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_check_launchpad_repo() { // "hello" should exist on Launchpad for Ubuntu let url = check_launchpad_repo("hello").await.unwrap(); assert!(url.is_some()); assert_eq!( url.unwrap(), "https://git.launchpad.net/ubuntu/+source/hello" ); // "this-package-should-not-exist-12345" should not exist let url = check_launchpad_repo("this-package-should-not-exist-12345") .await .unwrap(); assert!(url.is_none()); } #[test] fn test_parse_sources() { use flate2::Compression; use flate2::write::GzEncoder; use std::io::Write; let data = "Package: hello Version: 2.10-2 Format: 3.0 (quilt) Directory: pool/main/h/hello Vcs-Git: https://salsa.debian.org/debian/hello.git Package: other Version: 1.0 "; let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(data.as_bytes()).unwrap(); let compressed = encoder.finish().unwrap(); let info = parse_sources(&compressed, "hello", None).unwrap().unwrap(); assert_eq!(info.package, "hello"); assert_eq!(info.version, "2.10-2"); assert_eq!(info.format, "3.0 (quilt)"); assert_eq!(info.directory, "pool/main/h/hello"); assert_eq!( info.vcs_git.unwrap(), "https://salsa.debian.org/debian/hello.git" ); let none = parse_sources(&compressed, "missing", None).unwrap(); assert!(none.is_none()); } #[test] fn test_parse_sources_compression_variants() { use std::io::Write; use xz2::write::XzEncoder; let data = "Package: hello Version: 1.0 Directory: pool/main/h/hello "; // xz-compressed index (e.g. the 'Sources.xz' of flat repositories) let mut encoder = XzEncoder::new(Vec::new(), 6); encoder.write_all(data.as_bytes()).unwrap(); let compressed = encoder.finish().unwrap(); let info = parse_sources(&compressed, "hello", None).unwrap().unwrap(); assert_eq!(info.version, "1.0"); // Uncompressed index let info = parse_sources(data.as_bytes(), "hello", None) .unwrap() .unwrap(); assert_eq!(info.version, "1.0"); } #[test] fn test_parse_sources_many_blank_stanzas() { // A crafted index with many consecutive package-less stanzas must be // iterated without recursion: 100k blank stanzas would overflow the // stack with the old recursive 'return self.next()' implementation let mut data = String::new(); for _ in 0..100_000 { data.push_str("Not-Really-Package: x\n\n"); } data.push_str("Package: hello\nVersion: 1.0\n"); let info = parse_sources(data.as_bytes(), "hello", None) .unwrap() .unwrap(); assert_eq!(info.package, "hello"); assert_eq!(info.version, "1.0"); } #[test] fn test_parse_sources_stanza_without_version() { // A stanza with a 'Package' but no 'Version' field is malformed // remote data: it must be skipped rather than panic let data = "Package: noversion\nDirectory: pool/main/n/noversion\n\n\ Package: hello\nVersion: 1.0\n"; let info = parse_sources(data.as_bytes(), "hello", None) .unwrap() .unwrap(); assert_eq!(info.package, "hello"); assert_eq!(info.version, "1.0"); assert!( parse_sources(data.as_bytes(), "noversion", None) .unwrap() .is_none() ); } #[tokio::test] async fn test_find_package_fallback() { // python2.7 is in bullseye but not above let info = find_package("python2.7", "debian", "", None, None, None, None) .await .unwrap(); assert_eq!(info.stanza.package, "python2.7"); assert_eq!(info.series, "bullseye") } #[tokio::test] async fn test_find_package_devel() { // hello is in sid let info = find_package("hello", "debian", "", None, None, None, None) .await .unwrap(); assert_eq!(info.stanza.package, "hello"); assert_eq!(info.series, "sid") } }