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_lossless::{FromDeb822, FromDeb822Paragraph, ToDeb822, ToDeb822Paragraph};
47use signature::Signature;
48use std::{collections::HashSet, ops::Deref, str::FromStr};
49use url::Url;
50use std::result::Result;
51use error::RepositoryError;
52
53pub mod error;
54pub mod signature;
55
56/// A representation of the repository type, by role of packages it can provide, either `Binary`
57/// (indicated by `deb`) or `Source` (indicated by `deb-src`).
58#[derive(PartialEq, Eq, Hash, Debug, Clone)]
59pub enum RepositoryType {
60    /// Repository with binary packages, indicated as `deb`
61    Binary,
62    /// Repository with source packages, indicated as `deb-src`
63    Source
64}
65
66impl FromStr for RepositoryType {
67    type Err = RepositoryError;
68
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        match s {
71            "deb" => Ok(RepositoryType::Binary),
72            "deb-src" => Ok(RepositoryType::Source),
73            _ => Err(RepositoryError::InvalidType)
74        }
75    }
76}
77
78impl From<&RepositoryType> for String {
79    fn from(value: &RepositoryType) -> Self {
80        match value {
81            RepositoryType::Binary => "deb".to_owned(),
82            RepositoryType::Source => "deb-src".to_owned(),
83        }
84    }
85}
86
87impl ToString for RepositoryType {
88    fn to_string(&self) -> String {
89        self.into()
90    }
91}
92
93#[derive(Debug, Clone, PartialEq)]
94/// Enumeration for fields like `By-Hash` which have third value of `force`
95pub enum YesNoForce {
96    /// True
97    Yes,
98    /// False
99    No,
100    /// Forced
101    Force
102}
103
104
105impl FromStr for YesNoForce {
106    type Err = RepositoryError;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        match s {
110            "yes" => Ok(Self::Yes),
111            "no" => Ok(Self::No),
112            "force" => Ok(Self::Force),
113            _ => Err(RepositoryError::InvalidType)
114        }
115    }
116}
117
118impl From<&YesNoForce> for String {
119    fn from(value: &YesNoForce) -> Self {
120        match value {
121            YesNoForce::Yes => "yes".to_owned(),
122            YesNoForce::No => "no".to_owned(),
123            YesNoForce::Force => "force".to_owned()
124        }
125    }
126}
127
128impl ToString for &YesNoForce {
129    fn to_string(&self) -> String {
130        self.to_owned().into()
131    }
132}
133
134fn deserialize_types(text: &str) -> Result<HashSet<RepositoryType>, RepositoryError> {
135    text.split_whitespace()
136        .map(|t| RepositoryType::from_str(t))
137        .collect::<Result<HashSet<RepositoryType>, RepositoryError>>()
138}
139
140fn serialize_types(files: &HashSet<RepositoryType>) -> String {
141    files.into_iter().map(|rt| rt.to_string()).collect::<Vec<String>>().join("\n")
142}
143
144fn deserialize_uris(text: &str) -> Result<Vec<Url>, String> { // TODO: bad error type
145    text.split_whitespace()
146        .map(|u| Url::from_str(u))
147        .collect::<Result<Vec<Url>, _>>()
148        .map_err(|e| e.to_string()) // TODO: bad error type
149}
150
151fn serialize_uris(uris: &[Url]) -> String {
152    uris.into_iter().map(|u| u.as_str()).collect::<Vec<&str>>().join(" ")
153}
154
155fn deserialize_string_chain(text: &str) -> Result<Vec<String>, String> { // TODO: bad error type
156    Ok(text.split_whitespace()
157        .map(|x| x.to_string())
158        .collect())
159}
160
161fn deserialize_yesno(text: &str) -> Result<bool, String> { // TODO: bad error type
162    match text {
163        "yes" => Ok(true),
164        "no" => Ok(false),
165        _ => Err("Invalid value for yes/no field".to_owned())
166    }
167}
168
169fn serializer_yesno(value: &bool) -> String {
170    if *value {
171        "yes".to_owned()
172    } else {
173        "no".to_owned()
174    }
175}
176
177fn serialize_string_chain(chain: &[String]) -> String {
178    chain.join(" ")
179}
180
181/// A structure representing APT repository as declared by DEB822 source file
182/// 
183/// According to `sources.list(5)` man pages, only four fields are mandatory:
184/// * `Types` either `deb` or/and `deb-src`
185/// * `URIs` to repositories holding valid APT structure (unclear if multiple are allowed)
186/// * `Suites` usually being distribution codenames
187/// * `Component` most of the time `main`, but it's a section of the repository
188/// 
189/// The manpage specifies following optional fields
190/// * `Enabled`        is a yes/no field, default yes
191/// * `Architectures`
192/// * `Languages`
193/// * `Targets`
194/// * `PDiffs`         is a yes/no field
195/// * `By-Hash`        is a yes/no/force field
196/// * `Allow-Insecure` is a yes/no field, default no
197/// * `Allow-Weak`     is a yes/no field, default no
198/// * `Allow-Downgrade-To-Insecure` is a yes/no field, default no
199/// * `Trusted`        us a yes/no field
200/// * `Signed-By`      is either path to the key or PGP key block
201/// * `Check-Valid-Until` is a yes/no field
202/// * `Valid-Until-Min`
203/// * `Valid-Until-Max`
204/// * `Check-Date`     is a yes/no field
205/// * `Date-Max-Future`
206/// * `InRelease-Path` relative path
207/// * `Snapshot`       either `enable` or a snapshot ID
208/// 
209/// The unit tests of APT use:
210/// * `Description`
211/// 
212/// The RepoLib tool uses:
213/// * `X-Repolib-Name` identifier for own reference, meaningless for APT
214/// 
215/// Note: Multivalues `*-Add` & `*-Remove` semantics aren't supported.
216#[derive(FromDeb822, ToDeb822, Clone, PartialEq, /*Eq,*/ Debug, Default)]
217pub struct Repository {
218    /// If `no` (false) the repository is ignored by APT
219    #[deb822(field = "Enabled", deserialize_with = deserialize_yesno, serialize_with = serializer_yesno)] // TODO: support for `default` if omitted is missing
220    enabled: Option<bool>,
221
222    /// The value `RepositoryType::Binary` (`deb`) or/and `RepositoryType::Source` (`deb-src`)
223    #[deb822(field = "Types", deserialize_with = deserialize_types, serialize_with = serialize_types)]
224    types: HashSet<RepositoryType>, // consider alternative, closed set
225    /// The address of the repository
226    #[deb822(field = "URIs", deserialize_with = deserialize_uris, serialize_with = serialize_uris)]
227    uris: Vec<Url>, // according to Debian that's URI, but this type is more advanced than URI from `http` crate
228    /// The distribution name as codename or suite type (like `stable` or `testing`)
229    #[deb822(field = "Suites", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
230    suites: Vec<String>,
231    /// Section of the repository, usually `main`, `contrib` or `non-free`
232    #[deb822(field = "Components", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
233    components: Vec<String>,
234
235    /// (Optional) Architectures binaries from this repository run on
236    #[deb822(field = "Architectures", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
237    architectures: Vec<String>,
238    /// (Optional) Translations support to download
239    #[deb822(field = "Languages", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
240    languages: Option<Vec<String>>, // TODO: Option is redundant to empty vectors
241    /// (Optional) Download targets to acquire from this source
242    #[deb822(field = "Targets", deserialize_with = deserialize_string_chain, serialize_with = serialize_string_chain)]
243    targets: Option<Vec<String>>,
244    /// (Optional) Controls if APT should try PDiffs instead of downloading indexes entirely; if not set defaults to configuration option `Acquire::PDiffs`
245    #[deb822(field = "PDiffs", deserialize_with = deserialize_yesno)]
246    pdiffs: Option<bool>,
247    /// (Optional) Controls if APT should try to acquire indexes via a URI constructed from a hashsum of the expected file
248    #[deb822(field = "By-Hash")]
249    by_hash: Option<YesNoForce>,
250    /// (Optional) If yes circumvents parts of `apt-secure`, don't thread lightly
251    #[deb822(field = "Allow-Insecure")]
252    allow_insecure: Option<bool>, // TODO: redundant option, not present = default no
253    /// (Optional) If yes circumvents parts of `apt-secure`, don't thread lightly
254    #[deb822(field = "Allow-Weak")]
255    allow_weak: Option<bool>, // TODO: redundant option, not present = default no
256    /// (Optional) If yes circumvents parts of `apt-secure`, don't thread lightly
257    #[deb822(field = "Allow-Downgrade-To-Insecure")]
258    allow_downgrade_to_insecure: Option<bool>, // TODO: redundant option, not present = default no
259    /// (Optional) If set forces whether APT considers source as rusted or no (default not present is a third state)
260    #[deb822(field = "Trusted")]
261    trusted: Option<bool>,
262    /// (Optional) Contains either absolute path to GPG keyring or embedded GPG public key block, if not set APT uses all trusted keys;
263    /// I can't find example of using with fingerprints
264    #[deb822(field = "Signed-By")]
265    signature: Option<Signature>,
266
267    /// (Optional) Field ignored by APT but used by RepoLib to identify repositories, Ubuntu sources contain them
268    #[deb822(field = "X-Repolib-Name")]
269    x_repolib_name: Option<String>, // this supports RepoLib still used by PopOS, even if removed from Debian/Ubuntu
270
271    /// (Optional) Field not present in the man page, but used in APT unit tests, potentially to hold the repository description
272    #[deb822(field = "Description")]
273    description: Option<String>
274
275    // options: HashMap<String, String> // My original parser kept remaining optional fields in the hash map, is this right approach?
276}
277
278impl Repository {
279    /// Returns slice of strings containing suites for which this repository provides
280    pub fn suites(&self) -> &[String] {
281        self.suites.as_slice()
282    }
283    
284}
285
286/// Container for multiple `Repository` specifications as single `.sources` file may contain as per specification
287#[derive(Debug)]
288pub struct Repositories(Vec<Repository>);
289
290impl Repositories {
291    /// Creates empty container of repositories
292    pub fn empty() -> Self {
293        Repositories(Vec::new())
294    }
295    
296    /// Creates repositories from container consisting `Repository` instances
297    pub fn new<Container>(container: Container) -> Self
298    where
299        Container: Into<Vec<Repository>>
300    {
301        Repositories(container.into())
302    }
303}
304
305impl std::str::FromStr for Repositories {
306    type Err = String;
307
308    fn from_str(s: &str) -> Result<Self, Self::Err> {
309        let deb822: deb822_lossless::Deb822 = s
310            .parse()
311            .map_err(|e: deb822_lossless::ParseError| e.to_string())?;
312
313        let repos = deb822.paragraphs().map(|p| Repository::from_paragraph(&p)).collect::<Result<Vec<Repository>, Self::Err>>()?;
314        Ok(Repositories(repos))
315    }
316}
317
318impl ToString for Repositories {
319    fn to_string(&self) -> String {
320        self.0.iter()
321            .map(|r| { let p: deb822_lossless::lossy::Paragraph = r.to_paragraph(); p.to_string() })
322            .collect::<Vec<_>>()
323            .join("\n")
324    }
325}
326
327impl Deref for Repositories {
328    type Target = Vec<Repository>;
329    
330    fn deref(&self) -> &Self::Target {
331        &self.0
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use std::{collections::HashSet, str::FromStr};
338
339    use indoc::indoc;
340    use url::Url;
341
342    use crate::{signature::Signature, Repositories, Repository, RepositoryType};
343
344    #[test]
345    fn test_not_machine_readable() {
346        let s = indoc!(r#"
347            deb [arch=arm64 signed-by=/usr/share/keyrings/docker.gpg] http://ports.ubuntu.com/ noble stable
348        "#);
349        let ret = s.parse::<Repositories>();
350        assert!(ret.is_err());
351        //assert_eq!(ret.unwrap_err(), "Not machine readable".to_string());
352        assert_eq!(ret.unwrap_err(), "expected ':', got Some(NEWLINE)\n".to_owned());
353    }
354
355    #[test]
356    fn test_parse_w_keyblock() {
357        let s = indoc!(r#"
358            Types: deb
359            URIs: http://ports.ubuntu.com/
360            Suites: noble
361            Components: stable
362            Architectures: arm64
363            Signed-By:
364             -----BEGIN PGP PUBLIC KEY BLOCK-----
365             .
366             mDMEY865UxYJKwYBBAHaRw8BAQdAd7Z0srwuhlB6JKFkcf4HU4SSS/xcRfwEQWzr
367             crf6AEq0SURlYmlhbiBTdGFibGUgUmVsZWFzZSBLZXkgKDEyL2Jvb2t3b3JtKSA8
368             ZGViaWFuLXJlbGVhc2VAbGlzdHMuZGViaWFuLm9yZz6IlgQTFggAPhYhBE1k/sEZ
369             wgKQZ9bnkfjSWFuHg9SBBQJjzrlTAhsDBQkPCZwABQsJCAcCBhUKCQgLAgQWAgMB
370             Ah4BAheAAAoJEPjSWFuHg9SBSgwBAP9qpeO5z1s5m4D4z3TcqDo1wez6DNya27QW
371             WoG/4oBsAQCEN8Z00DXagPHbwrvsY2t9BCsT+PgnSn9biobwX7bDDg==
372             =5NZE
373             -----END PGP PUBLIC KEY BLOCK-----
374        "#);
375
376        let repos = s.parse::<Repositories>().expect("Shall be parsed flawlessly");
377        assert!(repos[0].types.contains(&super::RepositoryType::Binary));
378        assert!(matches!(repos[0].signature, Some(Signature::KeyBlock(_))));
379    }
380
381    #[test]
382    fn test_parse_w_keypath() {
383        let s = indoc!(r#"
384            Types: deb
385            URIs: http://ports.ubuntu.com/
386            Suites: noble
387            Components: stable
388            Architectures: arm64
389            Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
390        "#);
391
392        let reps = s.parse::<Repositories>().expect("Shall be parsed flawlessly");
393        assert!(reps[0].types.contains(&super::RepositoryType::Binary));
394        assert!(matches!(reps[0].signature, Some(Signature::KeyPath(_))));
395    }
396
397    #[test]
398    fn test_serialize() {
399        //let repos = Repositories::empty();
400        let repos = Repositories::new([
401            Repository {
402                enabled: Some(true), // TODO: looks odd, as only `Enabled: no` in meaningful
403                types: HashSet::from([RepositoryType::Binary]),
404                architectures: vec!["arm64".to_owned()],
405                uris: vec![Url::from_str("https://deb.debian.org/debian").unwrap()],
406                suites: vec!["jammy".to_owned()],
407                components: vec!["main". to_owned()],
408                signature: None,
409                x_repolib_name: None,
410                languages: None,
411                targets: None,
412                pdiffs: None,
413                ..Default::default()
414            }
415        ]);
416        let text = repos.to_string();
417        assert_eq!(text, indoc! {r#"
418            Enabled: yes
419            Types: deb
420            URIs: https://deb.debian.org/debian
421            Suites: jammy
422            Components: main
423            Architectures: arm64
424        "#});
425    }
426}