apt_sources/
lib.rs

1#![deny(missing_docs)]
2//! A library for parsing and manipulating APT source files that
3//! use the DEB822 format to hold package repositories specifications.
4//!
5//! <div class="warning">
6//!
7//! Currently only lossy _serialization_ is implemented, lossless support
8//! retaining file sequence and comments would come at later date.
9//!
10//! </div>
11//!
12//! # Examples
13//!
14//! ```rust
15//!
16//! use apt_sources::Repositories;
17//! use std::path::Path;
18//!
19//! let text = r#"Types: deb
20//! URIs: http://ports.ubuntu.com/
21//! Suites: noble
22//! Components: stable
23//! Architectures: arm64
24//! Signed-By:
25//!  -----BEGIN PGP PUBLIC KEY BLOCK-----
26//!  .
27//!  mDMEY865UxYJKwYBBAHaRw8BAQdAd7Z0srwuhlB6JKFkcf4HU4SSS/xcRfwEQWzr
28//!  crf6AEq0SURlYmlhbiBTdGFibGUgUmVsZWFzZSBLZXkgKDEyL2Jvb2t3b3JtKSA8
29//!  ZGViaWFuLXJlbGVhc2VAbGlzdHMuZGViaWFuLm9yZz6IlgQTFggAPhYhBE1k/sEZ
30//!  wgKQZ9bnkfjSWFuHg9SBBQJjzrlTAhsDBQkPCZwABQsJCAcCBhUKCQgLAgQWAgMB
31//!  Ah4BAheAAAoJEPjSWFuHg9SBSgwBAP9qpeO5z1s5m4D4z3TcqDo1wez6DNya27QW
32//!  WoG/4oBsAQCEN8Z00DXagPHbwrvsY2t9BCsT+PgnSn9biobwX7bDDg==
33//!  =5NZE
34//!  -----END PGP PUBLIC KEY BLOCK-----"#;
35//!
36//! let r = text.parse::<Repositories>().unwrap();
37//! let suites = r[0].suites();
38//! assert_eq!(suites[0], "noble");
39//! ```
40//!
41// TODO: Not supported yet:
42// See the ``lossless`` module (behind the ``lossless`` feature) for a more forgiving parser that
43// allows partial parsing, parsing files with errors and unknown fields and editing while
44// preserving formatting.
45
46use deb822_fast::{FromDeb822, FromDeb822Paragraph, ToDeb822, ToDeb822Paragraph};
47use error::{LoadError, RepositoryError};
48use signature::Signature;
49use std::path::Path;
50use std::result::Result;
51use std::{collections::HashSet, ops::Deref, str::FromStr};
52use url::Url;
53
54/// Distribution detection and utilities
55pub mod distribution;
56pub mod error;
57#[cfg(feature = "key-management")]
58/// Key management utilities for GPG keys
59pub mod key_management;
60#[cfg(feature = "key-management")]
61pub mod keyserver;
62pub mod ppa;
63pub mod signature;
64/// Module for managing APT source lists
65pub mod sources_manager;
66
67/// A representation of the repository type, by role of packages it can provide, either `Binary`
68/// (indicated by `deb`) or `Source` (indicated by `deb-src`).
69#[derive(PartialEq, Eq, Hash, Debug, Clone)]
70pub enum RepositoryType {
71    /// Repository with binary packages, indicated as `deb`
72    Binary,
73    /// Repository with source packages, indicated as `deb-src`
74    Source,
75}
76
77impl FromStr for RepositoryType {
78    type Err = RepositoryError;
79
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        match s {
82            "deb" => Ok(RepositoryType::Binary),
83            "deb-src" => Ok(RepositoryType::Source),
84            _ => Err(RepositoryError::InvalidType),
85        }
86    }
87}
88
89impl From<&RepositoryType> for String {
90    fn from(value: &RepositoryType) -> Self {
91        match value {
92            RepositoryType::Binary => "deb".to_owned(),
93            RepositoryType::Source => "deb-src".to_owned(),
94        }
95    }
96}
97
98impl std::fmt::Display for RepositoryType {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        let s = match self {
101            RepositoryType::Binary => "deb",
102            RepositoryType::Source => "deb-src",
103        };
104        write!(f, "{}", s)
105    }
106}
107
108#[derive(Debug, Clone, PartialEq)]
109/// Enumeration for fields like `By-Hash` which have third value of `force`
110pub enum YesNoForce {
111    /// True
112    Yes,
113    /// False
114    No,
115    /// Forced
116    Force,
117}
118
119impl FromStr for YesNoForce {
120    type Err = RepositoryError;
121
122    fn from_str(s: &str) -> Result<Self, Self::Err> {
123        match s {
124            "yes" => Ok(Self::Yes),
125            "no" => Ok(Self::No),
126            "force" => Ok(Self::Force),
127            _ => Err(RepositoryError::InvalidType),
128        }
129    }
130}
131
132impl From<&YesNoForce> for String {
133    fn from(value: &YesNoForce) -> Self {
134        match value {
135            YesNoForce::Yes => "yes".to_owned(),
136            YesNoForce::No => "no".to_owned(),
137            YesNoForce::Force => "force".to_owned(),
138        }
139    }
140}
141
142impl std::fmt::Display for YesNoForce {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        let s = match self {
145            YesNoForce::Yes => "yes",
146            YesNoForce::No => "no",
147            YesNoForce::Force => "force",
148        };
149        write!(f, "{}", s)
150    }
151}
152
153fn deserialize_types(text: &str) -> Result<HashSet<RepositoryType>, RepositoryError> {
154    text.split_whitespace()
155        .map(RepositoryType::from_str)
156        .collect::<Result<HashSet<RepositoryType>, RepositoryError>>()
157}
158
159fn serialize_types(files: &HashSet<RepositoryType>) -> String {
160    use std::fmt::Write;
161    let mut result = String::new();
162    for (i, rt) in files.iter().enumerate() {
163        if i > 0 {
164            result.push('\n');
165        }
166        write!(&mut result, "{}", rt).unwrap();
167    }
168    result
169}
170
171fn deserialize_uris(text: &str) -> Result<Vec<Url>, String> {
172    // TODO: bad error type
173    text.split_whitespace()
174        .map(Url::from_str)
175        .collect::<Result<Vec<Url>, _>>()
176        .map_err(|e| e.to_string()) // TODO: bad error type
177}
178
179fn serialize_uris(uris: &[Url]) -> String {
180    let mut result = String::new();
181    for (i, uri) in uris.iter().enumerate() {
182        if i > 0 {
183            result.push(' ');
184        }
185        result.push_str(uri.as_str());
186    }
187    result
188}
189
190fn deserialize_string_chain(text: &str) -> Result<Vec<String>, String> {
191    // TODO: bad error type
192    Ok(text.split_whitespace().map(|x| x.to_string()).collect())
193}
194
195fn deserialize_yesno(text: &str) -> Result<bool, String> {
196    // TODO: bad error type
197    match text {
198        "yes" => Ok(true),
199        "no" => Ok(false),
200        _ => Err("Invalid value for yes/no field".to_owned()),
201    }
202}
203
204fn serializer_yesno(value: &bool) -> String {
205    if *value {
206        "yes".to_string()
207    } else {
208        "no".to_string()
209    }
210}
211
212fn serialize_string_chain(chain: &[String]) -> String {
213    chain.join(" ")
214}
215
216/// A structure representing APT repository as declared by DEB822 source file
217///
218/// According to `sources.list(5)` man pages, only four fields are mandatory:
219/// * `Types` either `deb` or/and `deb-src`
220/// * `URIs` to repositories holding valid APT structure (unclear if multiple are allowed)
221/// * `Suites` usually being distribution codenames
222/// * `Component` most of the time `main`, but it's a section of the repository
223///
224/// The manpage specifies following optional fields
225/// * `Enabled`        is a yes/no field, default yes
226/// * `Architectures`
227/// * `Languages`
228/// * `Targets`
229/// * `PDiffs`         is a yes/no field
230/// * `By-Hash`        is a yes/no/force field
231/// * `Allow-Insecure` is a yes/no field, default no
232/// * `Allow-Weak`     is a yes/no field, default no
233/// * `Allow-Downgrade-To-Insecure` is a yes/no field, default no
234/// * `Trusted`        us a yes/no field
235/// * `Signed-By`      is either path to the key or PGP key block
236/// * `Check-Valid-Until` is a yes/no field
237/// * `Valid-Until-Min`
238/// * `Valid-Until-Max`
239/// * `Check-Date`     is a yes/no field
240/// * `Date-Max-Future`
241/// * `InRelease-Path` relative path
242/// * `Snapshot`       either `enable` or a snapshot ID
243///
244/// The unit tests of APT use:
245/// * `Description`
246///
247/// The RepoLib tool uses:
248/// * `X-Repolib-Name` identifier for own reference, meaningless for APT
249///
250/// Note: Multivalues `*-Add` & `*-Remove` semantics aren't supported.
251#[derive(FromDeb822, ToDeb822, Clone, PartialEq, /*Eq,*/ Debug, Default)]
252pub struct Repository {
253    /// If `no` (false) the repository is ignored by APT
254    #[deb822(field = "Enabled", deserialize_with = deserialize_yesno, serialize_with = serializer_yesno)]
255    // TODO: support for `default` if omitted is missing
256    pub enabled: Option<bool>,
257
258    /// The value `RepositoryType::Binary` (`deb`) or/and `RepositoryType::Source` (`deb-src`)
259    #[deb822(field = "Types", deserialize_with = deserialize_types, serialize_with = serialize_types)]
260    pub types: HashSet<RepositoryType>, // consider alternative, closed set
261    /// The address of the repository
262    #[deb822(field = "URIs", deserialize_with = deserialize_uris, serialize_with = serialize_uris)]
263    pub uris: Vec<Url>, // according to Debian that's URI, but this type is more advanced than URI from `http` crate
264    /// The distribution name as codename or suite type (like `stable` or `testing`)
265    #[deb822(field = "Suites", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
266    pub suites: Vec<String>,
267    /// (Optional) Section of the repository, usually `main`, `contrib` or `non-free`
268    /// return `None` if repository is Flat Repository Format (https://wiki.debian.org/DebianRepository/Format#Flat_Repository_Format)
269    #[deb822(field = "Components", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
270    pub components: Option<Vec<String>>,
271
272    /// (Optional) Architectures binaries from this repository run on
273    #[deb822(field = "Architectures", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
274    pub architectures: Vec<String>,
275    /// (Optional) Translations support to download
276    #[deb822(field = "Languages", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
277    pub languages: Option<Vec<String>>, // TODO: Option is redundant to empty vectors
278    /// (Optional) Download targets to acquire from this source
279    #[deb822(field = "Targets", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
280    pub targets: Option<Vec<String>>,
281    /// (Optional) Controls if APT should try PDiffs instead of downloading indexes entirely; if not set defaults to configuration option `Acquire::PDiffs`
282    #[deb822(field = "PDiffs", deserialize_with = deserialize_yesno)]
283    pub pdiffs: Option<bool>,
284    /// (Optional) Controls if APT should try to acquire indexes via a URI constructed from a hashsum of the expected file
285    #[deb822(field = "By-Hash")]
286    pub by_hash: Option<YesNoForce>,
287    /// (Optional) If yes circumvents parts of `apt-secure`, don't thread lightly
288    #[deb822(field = "Allow-Insecure")]
289    pub allow_insecure: Option<bool>, // TODO: redundant option, not present = default no
290    /// (Optional) If yes circumvents parts of `apt-secure`, don't thread lightly
291    #[deb822(field = "Allow-Weak")]
292    pub allow_weak: Option<bool>, // TODO: redundant option, not present = default no
293    /// (Optional) If yes circumvents parts of `apt-secure`, don't thread lightly
294    #[deb822(field = "Allow-Downgrade-To-Insecure")]
295    pub allow_downgrade_to_insecure: Option<bool>, // TODO: redundant option, not present = default no
296    /// (Optional) If set forces whether APT considers source as rusted or no (default not present is a third state)
297    #[deb822(field = "Trusted")]
298    pub trusted: Option<bool>,
299    /// (Optional) Contains either absolute path to GPG keyring or embedded GPG public key block, if not set APT uses all trusted keys;
300    /// I can't find example of using with fingerprints
301    #[deb822(field = "Signed-By")]
302    pub signature: Option<Signature>,
303
304    /// (Optional) Field ignored by APT but used by RepoLib to identify repositories, Ubuntu sources contain them
305    #[deb822(field = "X-Repolib-Name")]
306    pub x_repolib_name: Option<String>, // this supports RepoLib still used by PopOS, even if removed from Debian/Ubuntu
307
308    /// (Optional) Field not present in the man page, but used in APT unit tests, potentially to hold the repository description
309    #[deb822(field = "Description")]
310    pub description: Option<String>, // options: HashMap<String, String> // My original parser kept remaining optional fields in the hash map, is this right approach?
311}
312
313impl Repository {
314    /// Returns slice of strings containing suites for which this repository provides
315    pub fn suites(&self) -> &[String] {
316        self.suites.as_slice()
317    }
318
319    /// Returns the repository types (deb/deb-src)
320    pub fn types(&self) -> &HashSet<RepositoryType> {
321        &self.types
322    }
323
324    /// Returns the repository URIs
325    pub fn uris(&self) -> &[Url] {
326        &self.uris
327    }
328
329    /// Returns the repository components
330    pub fn components(&self) -> Option<&[String]> {
331        self.components.as_deref()
332    }
333
334    /// Returns the repository architectures
335    pub fn architectures(&self) -> &[String] {
336        &self.architectures
337    }
338
339    /// Generate legacy .list format lines for this repository
340    pub fn to_legacy_format(&self) -> String {
341        let mut lines = Vec::new();
342
343        // Generate deb and/or deb-src lines
344        for repo_type in &self.types {
345            let type_str = match repo_type {
346                RepositoryType::Binary => "deb",
347                RepositoryType::Source => "deb-src",
348            };
349
350            for uri in &self.uris {
351                for suite in &self.suites {
352                    let mut line = format!("{} {} {}", type_str, uri, suite);
353
354                    // Add components
355                    if let Some(components) = &self.components {
356                        for component in components {
357                            line.push(' ');
358                            line.push_str(component);
359                        }
360                    }
361
362                    lines.push(line);
363                }
364            }
365        }
366
367        lines.join("\n") + "\n"
368    }
369
370    /// Parse a legacy apt sources.list line (e.g., "deb http://example.com/debian stable main")
371    pub fn parse_legacy_line(line: &str) -> Result<Repository, String> {
372        let parts: Vec<&str> = line.split_whitespace().collect();
373
374        if parts.len() < 4 {
375            return Err("Invalid repository line format".to_string());
376        }
377
378        let repo_type = match parts[0] {
379            "deb" => RepositoryType::Binary,
380            "deb-src" => RepositoryType::Source,
381            _ => return Err("Line must start with 'deb' or 'deb-src'".to_string()),
382        };
383
384        let uri = Url::parse(parts[1]).map_err(|_| "Invalid URI".to_string())?;
385
386        let suite = parts[2].to_string();
387        let components: Vec<String> = parts[3..].iter().map(|s| s.to_string()).collect();
388
389        Ok(Repository {
390            enabled: Some(true),
391            types: HashSet::from([repo_type]),
392            uris: vec![uri],
393            suites: vec![suite],
394            components: Some(components),
395            architectures: vec![],
396            signature: None,
397            ..Default::default()
398        })
399    }
400}
401
402/// Container for multiple `Repository` specifications as single `.sources` file may contain as per specification
403#[derive(Debug)]
404pub struct Repositories(Vec<Repository>);
405
406impl Default for Repositories {
407    /// Creates a default instance by loading repositories from /etc/apt/
408    ///
409    /// Errors are logged as warnings (if the `tracing` feature is enabled) but
410    /// don't prevent loading valid repositories (like APT does)
411    fn default() -> Self {
412        let (repos, errors) = Self::load_from_directory(std::path::Path::new("/etc/apt"));
413
414        // Log any errors encountered, but continue (like APT)
415        #[cfg(feature = "tracing")]
416        for error in errors {
417            tracing::warn!("Failed to load APT source: {}", error);
418        }
419
420        // Without tracing feature, errors are silently ignored
421        #[cfg(not(feature = "tracing"))]
422        let _ = errors;
423
424        repos
425    }
426}
427
428impl Repositories {
429    /// Creates empty container of repositories
430    pub fn empty() -> Self {
431        Repositories(Vec::new())
432    }
433
434    /// Creates repositories from container consisting `Repository` instances
435    pub fn new<Container>(container: Container) -> Self
436    where
437        Container: Into<Vec<Repository>>,
438    {
439        Repositories(container.into())
440    }
441
442    /// Parse traditional sources.list format (one-line format)
443    /// Each non-empty, non-comment line is parsed as a separate repository
444    pub fn parse_legacy_format(content: &str) -> Result<Self, String> {
445        let mut repositories = Vec::new();
446
447        for line in content.lines() {
448            let trimmed = line.trim();
449
450            // Skip empty lines and comments
451            if trimmed.is_empty() || trimmed.starts_with('#') {
452                continue;
453            }
454
455            // Parse the line as a repository
456            let repo = Repository::parse_legacy_line(trimmed)?;
457            repositories.push(repo);
458        }
459
460        Ok(Repositories(repositories))
461    }
462
463    /// Load repositories from a directory (e.g., /etc/apt/)
464    ///
465    /// This will load:
466    /// - sources.list file from the directory
467    /// - All *.list files from sources.list.d/ subdirectory (in lexicographical order)
468    /// - All *.sources files from sources.list.d/ subdirectory (in lexicographical order)
469    ///
470    /// Returns a tuple of (successfully loaded repositories, errors encountered).
471    /// This method is resilient like APT - errors in individual files don't prevent
472    /// loading other valid repositories.
473    pub fn load_from_directory(path: &Path) -> (Self, Vec<LoadError>) {
474        use std::fs;
475
476        let mut all_repositories = Repositories::empty();
477        let mut errors = Vec::new();
478
479        // Process main sources.list file if it exists
480        let main_sources = path.join("sources.list");
481        if main_sources.exists() {
482            match fs::read_to_string(&main_sources) {
483                Ok(content) => match Self::parse_legacy_format(&content) {
484                    Ok(repos) => all_repositories.extend(repos.0),
485                    Err(e) => errors.push(LoadError::Parse {
486                        path: main_sources,
487                        error: e,
488                    }),
489                },
490                Err(e) => errors.push(LoadError::Io {
491                    path: main_sources,
492                    error: e,
493                }),
494            }
495        }
496
497        // Process files from sources.list.d/ directory
498        let sources_d = path.join("sources.list.d");
499        if !sources_d.is_dir() {
500            return (all_repositories, errors);
501        }
502
503        let entries = match fs::read_dir(&sources_d) {
504            Ok(entries) => entries,
505            Err(e) => {
506                errors.push(LoadError::DirectoryRead {
507                    path: sources_d,
508                    error: e,
509                });
510                return (all_repositories, errors);
511            }
512        };
513
514        // Collect and sort entries lexicographically like APT does
515        let mut entry_paths: Vec<_> = entries
516            .filter_map(|entry| entry.ok())
517            .map(|entry| entry.path())
518            .filter(|p| p.is_file())
519            .filter(|p| {
520                p.file_name()
521                    .and_then(|n| n.to_str())
522                    .map(|n| n.ends_with(".list") || n.ends_with(".sources"))
523                    .unwrap_or(false)
524            })
525            .collect();
526        entry_paths.sort();
527
528        for file_path in entry_paths {
529            let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
530
531            let content = match fs::read_to_string(&file_path) {
532                Ok(content) => content,
533                Err(e) => {
534                    errors.push(LoadError::Io {
535                        path: file_path,
536                        error: e,
537                    });
538                    continue;
539                }
540            };
541
542            let parse_result = if file_name.ends_with(".list") {
543                Self::parse_legacy_format(&content)
544                    .map(|repos| repos.0)
545                    .map_err(|e| LoadError::Parse {
546                        path: file_path,
547                        error: e,
548                    })
549            } else if file_name.ends_with(".sources") {
550                content
551                    .parse::<Repositories>()
552                    .map(|repos| repos.0)
553                    .map_err(|e| LoadError::Parse {
554                        path: file_path,
555                        error: e,
556                    })
557            } else {
558                continue;
559            };
560
561            match parse_result {
562                Ok(repos) => all_repositories.extend(repos),
563                Err(e) => errors.push(e),
564            }
565        }
566
567        (all_repositories, errors)
568    }
569
570    /// Load repositories from a directory, failing on first error
571    ///
572    /// Use this when you want strict error handling and need the loading
573    /// to stop at the first problem encountered.
574    pub fn load_from_directory_strict(path: &Path) -> Result<Self, LoadError> {
575        let (repos, errors) = Self::load_from_directory(path);
576        if let Some(error) = errors.into_iter().next() {
577            Err(error)
578        } else {
579            Ok(repos)
580        }
581    }
582
583    /// Push a new repository
584    pub fn push(&mut self, repo: Repository) {
585        self.0.push(repo);
586    }
587
588    /// Retain repositories matching a predicate
589    pub fn retain<F>(&mut self, f: F)
590    where
591        F: FnMut(&Repository) -> bool,
592    {
593        self.0.retain(f);
594    }
595
596    /// Get iterator over repositories
597    pub fn iter(&self) -> std::slice::Iter<Repository> {
598        self.0.iter()
599    }
600
601    /// Get mutable iterator over repositories
602    pub fn iter_mut(&mut self) -> std::slice::IterMut<Repository> {
603        self.0.iter_mut()
604    }
605
606    /// Extend with an iterator of repositories
607    pub fn extend<I>(&mut self, iter: I)
608    where
609        I: IntoIterator<Item = Repository>,
610    {
611        self.0.extend(iter);
612    }
613
614    /// Check if empty
615    pub fn is_empty(&self) -> bool {
616        self.0.is_empty()
617    }
618}
619
620impl std::str::FromStr for Repositories {
621    type Err = String;
622
623    fn from_str(s: &str) -> Result<Self, Self::Err> {
624        let deb822: deb822_fast::Deb822 =
625            s.parse().map_err(|e: deb822_fast::Error| e.to_string())?;
626
627        let repos = deb822
628            .iter()
629            .map(Repository::from_paragraph)
630            .collect::<Result<Vec<Repository>, Self::Err>>()?;
631        Ok(Repositories(repos))
632    }
633}
634
635impl std::fmt::Display for Repositories {
636    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
637        let result = self
638            .0
639            .iter()
640            .map(|r| {
641                let p: deb822_fast::Paragraph = r.to_paragraph();
642                p.to_string()
643            })
644            .collect::<Vec<_>>()
645            .join("\n");
646        write!(f, "{}", result)
647    }
648}
649
650impl Deref for Repositories {
651    type Target = Vec<Repository>;
652
653    fn deref(&self) -> &Self::Target {
654        &self.0
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use std::{collections::HashSet, str::FromStr};
661
662    use indoc::indoc;
663    use url::Url;
664
665    use crate::{signature::Signature, Repositories, Repository, RepositoryType};
666
667    #[test]
668    fn test_not_machine_readable() {
669        let s = indoc!(
670            r#"
671            deb [arch=arm64 signed-by=/usr/share/keyrings/docker.gpg] http://ports.ubuntu.com/ noble stable
672        "#
673        );
674        let ret = s.parse::<Repositories>();
675        assert!(ret.is_err());
676        //assert_eq!(ret.unwrap_err(), "Not machine readable".to_string());
677        assert_eq!(ret.unwrap_err(), "Unexpected token:  ".to_string());
678    }
679
680    #[test]
681    fn test_parse_flat_repo() {
682        let s = indoc! {r#"
683            Types: deb
684            URIs: http://ports.ubuntu.com/
685            Suites: ./
686            Architectures: arm64
687        "#};
688
689        let repos = s
690            .parse::<Repositories>()
691            .expect("Shall be parsed flawlessly");
692        assert!(repos[0].types.contains(&super::RepositoryType::Binary));
693    }
694
695    #[test]
696    fn test_parse_w_keyblock() {
697        let s = indoc!(
698            r#"
699            Types: deb
700            URIs: http://ports.ubuntu.com/
701            Suites: noble
702            Components: stable
703            Architectures: arm64
704            Signed-By:
705             -----BEGIN PGP PUBLIC KEY BLOCK-----
706             .
707             mDMEY865UxYJKwYBBAHaRw8BAQdAd7Z0srwuhlB6JKFkcf4HU4SSS/xcRfwEQWzr
708             crf6AEq0SURlYmlhbiBTdGFibGUgUmVsZWFzZSBLZXkgKDEyL2Jvb2t3b3JtKSA8
709             ZGViaWFuLXJlbGVhc2VAbGlzdHMuZGViaWFuLm9yZz6IlgQTFggAPhYhBE1k/sEZ
710             wgKQZ9bnkfjSWFuHg9SBBQJjzrlTAhsDBQkPCZwABQsJCAcCBhUKCQgLAgQWAgMB
711             Ah4BAheAAAoJEPjSWFuHg9SBSgwBAP9qpeO5z1s5m4D4z3TcqDo1wez6DNya27QW
712             WoG/4oBsAQCEN8Z00DXagPHbwrvsY2t9BCsT+PgnSn9biobwX7bDDg==
713             =5NZE
714             -----END PGP PUBLIC KEY BLOCK-----
715        "#
716        );
717
718        let repos = s
719            .parse::<Repositories>()
720            .expect("Shall be parsed flawlessly");
721        assert!(repos[0].types.contains(&super::RepositoryType::Binary));
722        assert!(matches!(repos[0].signature, Some(Signature::KeyBlock(_))));
723    }
724
725    #[test]
726    fn test_parse_w_keypath() {
727        let s = indoc!(
728            r#"
729            Types: deb
730            URIs: http://ports.ubuntu.com/
731            Suites: noble
732            Components: stable
733            Architectures: arm64
734            Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
735        "#
736        );
737
738        let reps = s
739            .parse::<Repositories>()
740            .expect("Shall be parsed flawlessly");
741        assert!(reps[0].types.contains(&super::RepositoryType::Binary));
742        assert!(matches!(reps[0].signature, Some(Signature::KeyPath(_))));
743    }
744
745    #[test]
746    fn test_serialize() {
747        //let repos = Repositories::empty();
748        let repos = Repositories::new([Repository {
749            enabled: Some(true), // TODO: looks odd, as only `Enabled: no` in meaningful
750            types: HashSet::from([RepositoryType::Binary]),
751            architectures: vec!["arm64".to_owned()],
752            uris: vec![Url::from_str("https://deb.debian.org/debian").unwrap()],
753            suites: vec!["jammy".to_owned()],
754            components: Some(vec!["main".to_owned()]),
755            signature: None,
756            x_repolib_name: None,
757            languages: None,
758            targets: None,
759            pdiffs: None,
760            ..Default::default()
761        }]);
762        let text = repos.to_string();
763        assert_eq!(
764            text,
765            indoc! {r#"
766            Enabled: yes
767            Types: deb
768            URIs: https://deb.debian.org/debian
769            Suites: jammy
770            Components: main
771            Architectures: arm64
772        "#}
773        );
774    }
775
776    #[test]
777    fn test_yesnoforce_to_string() {
778        let yes = crate::YesNoForce::Yes;
779        assert_eq!(yes.to_string(), "yes");
780
781        let no = crate::YesNoForce::No;
782        assert_eq!(no.to_string(), "no");
783
784        let force = crate::YesNoForce::Force;
785        assert_eq!(force.to_string(), "force");
786    }
787
788    #[test]
789    fn test_parse_legacy_line() {
790        let line = "deb http://archive.ubuntu.com/ubuntu jammy main restricted";
791        let repo = Repository::parse_legacy_line(line).unwrap();
792        assert_eq!(repo.types.len(), 1);
793        assert!(repo.types.contains(&RepositoryType::Binary));
794        assert_eq!(repo.uris.len(), 1);
795        assert_eq!(repo.uris[0].to_string(), "http://archive.ubuntu.com/ubuntu");
796        assert_eq!(repo.suites, vec!["jammy".to_string()]);
797        assert_eq!(
798            repo.components,
799            Some(vec!["main".to_string(), "restricted".to_string()])
800        );
801    }
802
803    #[test]
804    fn test_parse_legacy_line_deb_src() {
805        let line = "deb-src http://archive.ubuntu.com/ubuntu jammy main";
806        let repo = Repository::parse_legacy_line(line).unwrap();
807        assert!(repo.types.contains(&RepositoryType::Source));
808        assert!(!repo.types.contains(&RepositoryType::Binary));
809    }
810
811    #[test]
812    fn test_parse_legacy_line_minimum_components() {
813        // Test with exactly 4 parts (minimum required)
814        let line = "deb http://example.com/debian stable main";
815        let repo = Repository::parse_legacy_line(line).unwrap();
816        assert_eq!(repo.components, Some(vec!["main".to_string()]));
817    }
818
819    #[test]
820    fn test_parse_legacy_line_invalid() {
821        let line = "invalid line";
822        let result = Repository::parse_legacy_line(line);
823        assert!(result.is_err());
824    }
825
826    #[test]
827    fn test_parse_legacy_line_too_few_parts() {
828        // Test with < 4 parts
829        let line = "deb http://example.com/debian";
830        let result = Repository::parse_legacy_line(line);
831        assert!(result.is_err());
832        assert_eq!(result.unwrap_err(), "Invalid repository line format");
833    }
834
835    #[test]
836    fn test_parse_legacy_line_invalid_type() {
837        let line = "invalid-type http://example.com/debian stable main";
838        let result = Repository::parse_legacy_line(line);
839        assert!(result.is_err());
840        assert_eq!(
841            result.unwrap_err(),
842            "Line must start with 'deb' or 'deb-src'"
843        );
844    }
845
846    #[test]
847    fn test_parse_legacy_line_invalid_uri() {
848        let line = "deb not-a-valid-uri stable main";
849        let result = Repository::parse_legacy_line(line);
850        assert!(result.is_err());
851        assert_eq!(result.unwrap_err(), "Invalid URI");
852    }
853
854    #[test]
855    fn test_to_legacy_format_single_type() {
856        let repo = Repository {
857            types: HashSet::from([RepositoryType::Binary]),
858            uris: vec![Url::parse("http://example.com/debian").unwrap()],
859            suites: vec!["stable".to_string()],
860            components: Some(vec!["main".to_string()]),
861            ..Default::default()
862        };
863
864        let legacy = repo.to_legacy_format();
865        assert_eq!(legacy, "deb http://example.com/debian stable main\n");
866    }
867
868    #[test]
869    fn test_to_legacy_format_both_types() {
870        let repo = Repository {
871            types: HashSet::from([RepositoryType::Binary, RepositoryType::Source]),
872            uris: vec![Url::parse("http://example.com/debian").unwrap()],
873            suites: vec!["stable".to_string()],
874            components: Some(vec!["main".to_string(), "contrib".to_string()]),
875            ..Default::default()
876        };
877
878        let legacy = repo.to_legacy_format();
879        // Should contain both deb and deb-src lines
880        assert!(legacy.contains("deb http://example.com/debian stable main contrib"));
881        assert!(legacy.contains("deb-src http://example.com/debian stable main contrib"));
882    }
883
884    #[test]
885    fn test_to_legacy_format_multiple_uris_and_suites() {
886        let repo = Repository {
887            types: HashSet::from([RepositoryType::Binary]),
888            uris: vec![
889                Url::parse("http://example1.com/debian").unwrap(),
890                Url::parse("http://example2.com/debian").unwrap(),
891            ],
892            suites: vec!["stable".to_string(), "testing".to_string()],
893            components: Some(vec!["main".to_string()]),
894            ..Default::default()
895        };
896
897        let legacy = repo.to_legacy_format();
898        // Should generate a line for each URI/suite combination
899        assert!(legacy.contains("deb http://example1.com/debian stable main"));
900        assert!(legacy.contains("deb http://example1.com/debian testing main"));
901        assert!(legacy.contains("deb http://example2.com/debian stable main"));
902        assert!(legacy.contains("deb http://example2.com/debian testing main"));
903    }
904
905    #[test]
906    fn test_to_legacy_format_no_components() {
907        let repo = Repository {
908            types: HashSet::from([RepositoryType::Binary]),
909            uris: vec![Url::parse("http://example.com/debian").unwrap()],
910            suites: vec!["stable".to_string()],
911            components: None,
912            ..Default::default()
913        };
914
915        let legacy = repo.to_legacy_format();
916        assert_eq!(legacy, "deb http://example.com/debian stable\n");
917    }
918
919    #[test]
920    fn test_repository_type_display() {
921        assert_eq!(RepositoryType::Binary.to_string(), "deb");
922        assert_eq!(RepositoryType::Source.to_string(), "deb-src");
923    }
924
925    #[test]
926    fn test_yesnoforce_display() {
927        assert_eq!(crate::YesNoForce::Yes.to_string(), "yes");
928        assert_eq!(crate::YesNoForce::No.to_string(), "no");
929        assert_eq!(crate::YesNoForce::Force.to_string(), "force");
930    }
931
932    #[test]
933    fn test_repositories_is_empty() {
934        let empty_repos = Repositories::empty();
935        assert!(empty_repos.is_empty());
936
937        let mut repos = Repositories::empty();
938        repos.push(Repository::default());
939        assert!(!repos.is_empty());
940    }
941
942    #[test]
943    fn test_repository_getters() {
944        let repo = Repository {
945            types: HashSet::from([RepositoryType::Binary, RepositoryType::Source]),
946            uris: vec![Url::parse("http://example.com/debian").unwrap()],
947            suites: vec!["stable".to_string()],
948            components: Some(vec!["main".to_string(), "contrib".to_string()]),
949            architectures: vec!["amd64".to_string(), "arm64".to_string()],
950            ..Default::default()
951        };
952
953        // Test types getter
954        assert_eq!(
955            repo.types(),
956            &HashSet::from([RepositoryType::Binary, RepositoryType::Source])
957        );
958
959        // Test uris getter
960        assert_eq!(repo.uris().len(), 1);
961        assert_eq!(repo.uris()[0].to_string(), "http://example.com/debian");
962
963        // Test suites getter (existing)
964        assert_eq!(repo.suites(), vec!["stable"]);
965
966        // Test components getter
967        assert_eq!(
968            repo.components(),
969            Some(vec!["main".to_string(), "contrib".to_string()].as_slice())
970        );
971
972        // Test architectures getter
973        assert_eq!(repo.architectures(), vec!["amd64", "arm64"]);
974    }
975
976    #[test]
977    fn test_repositories_iter() {
978        let mut repos = Repositories::empty();
979        repos.push(Repository {
980            suites: vec!["stable".to_string()],
981            ..Default::default()
982        });
983        repos.push(Repository {
984            suites: vec!["testing".to_string()],
985            ..Default::default()
986        });
987
988        // Test iter()
989        let suites: Vec<_> = repos.iter().map(|r| r.suites()).collect();
990        assert_eq!(suites.len(), 2);
991        assert_eq!(suites[0], vec!["stable"]);
992        assert_eq!(suites[1], vec!["testing"]);
993
994        // Test iter_mut() - modifying through mutable iterator
995        for repo in repos.iter_mut() {
996            repo.enabled = Some(false);
997        }
998
999        for repo in repos.iter() {
1000            assert_eq!(repo.enabled, Some(false));
1001        }
1002    }
1003
1004    #[test]
1005    fn test_parse_legacy_format() {
1006        let content = indoc! {r#"
1007            # This is a comment
1008            deb http://archive.ubuntu.com/ubuntu jammy main restricted
1009            deb-src http://archive.ubuntu.com/ubuntu jammy main restricted
1010            
1011            # Another comment
1012            deb http://security.ubuntu.com/ubuntu jammy-security main
1013        "#};
1014
1015        let repos = Repositories::parse_legacy_format(content).unwrap();
1016        assert_eq!(repos.len(), 3);
1017
1018        // First repository
1019        assert!(repos[0].types().contains(&RepositoryType::Binary));
1020        assert_eq!(
1021            repos[0].uris()[0].to_string(),
1022            "http://archive.ubuntu.com/ubuntu"
1023        );
1024        assert_eq!(repos[0].suites(), vec!["jammy"]);
1025        assert_eq!(
1026            repos[0].components(),
1027            Some(vec!["main".to_string(), "restricted".to_string()].as_slice())
1028        );
1029
1030        // Second repository
1031        assert!(repos[1].types().contains(&RepositoryType::Source));
1032
1033        // Third repository
1034        assert_eq!(repos[2].suites(), vec!["jammy-security"]);
1035        assert_eq!(
1036            repos[2].components(),
1037            Some(vec!["main".to_string()].as_slice())
1038        );
1039    }
1040}