Skip to main content

isr_dl_linux/ubuntu/
index.rs

1//! Parsed package index for one Debian-style repository.
2
3use indexmap::IndexMap;
4use url::Url;
5
6use super::{error::UbuntuError, parse::UbuntuRepositoryEntry};
7
8/// Query parameters for looking up a package across all dists in an index.
9#[derive(Debug, Clone)]
10pub struct PackageQuery {
11    /// Debian package name to match.
12    pub package: String,
13
14    /// Exact `Version:` field to match.
15    pub version: String,
16
17    /// If true, applies the dbgsym filter (no `Depends` field).
18    pub dbgsym: bool,
19
20    /// If true and the primary package name is not found, retries with the
21    /// `linux-image-unsigned-*` form (the `linux-image-` prefix is replaced
22    /// with `linux-image-unsigned-`).
23    pub unsigned_fallback: bool,
24}
25
26/// Parsed package index for one repository host across multiple dists.
27#[derive(Debug)]
28pub struct PackageIndex {
29    /// Base URL of the repository host + `Filename`.
30    host: Url,
31
32    /// `dist -> package_name -> entry`.
33    ///
34    /// Using `IndexMap` for stable iteration.
35    packages: IndexMap<String, IndexMap<String, UbuntuRepositoryEntry>>,
36}
37
38impl PackageIndex {
39    /// Creates a new `PackageIndex`.
40    pub fn new(
41        host: Url,
42        packages: IndexMap<String, IndexMap<String, UbuntuRepositoryEntry>>,
43    ) -> Self {
44        Self { host, packages }
45    }
46
47    /// Returns the repository host URL for this index.
48    pub fn host(&self) -> &Url {
49        &self.host
50    }
51
52    /// Looks up a package matching the query across all dists.
53    ///
54    /// Returns the matching entry. Errors with `PackageMultipleCandidates` if
55    /// more than one dist contains a matching entry.
56    pub fn find(
57        &self,
58        query: &PackageQuery,
59    ) -> Result<Option<&UbuntuRepositoryEntry>, UbuntuError> {
60        if let Some(entry) = self.find_inner(&query.package, &query.version, query.dbgsym)? {
61            return Ok(Some(entry));
62        }
63
64        if query.unsigned_fallback
65            && let Some(unsigned) = unsigned_variant(&query.package)
66        {
67            return self.find_inner(&unsigned, &query.version, query.dbgsym);
68        }
69
70        Ok(None)
71    }
72
73    /// Resolves a package entry to its full download URL via this index's host.
74    pub fn resolve_url(&self, entry: &UbuntuRepositoryEntry) -> Result<Url, UbuntuError> {
75        match &entry.filename {
76            Some(filename) => Ok(self.host.join(filename)?),
77            None => Err(UbuntuError::PackageMissingFilename),
78        }
79    }
80
81    fn find_inner(
82        &self,
83        package: &str,
84        version: &str,
85        dbgsym: bool,
86    ) -> Result<Option<&UbuntuRepositoryEntry>, UbuntuError> {
87        let mut candidates = Vec::new();
88
89        for (dist, packages) in &self.packages {
90            let entry = match packages.get(package) {
91                Some(entry) => entry,
92                None => continue,
93            };
94
95            let entry_version = match &entry.version {
96                Some(entry_version) => entry_version,
97                None => continue,
98            };
99
100            if entry_version != version {
101                continue;
102            }
103
104            // dbgsym packages should not have a `Depends:` field. If they do,
105            // they are wrapper packages that depend on the real dbgsym package.
106            if dbgsym && entry.depends.is_some() {
107                continue;
108            }
109
110            candidates.push((dist.as_str(), entry));
111        }
112
113        let candidate = match candidates.pop() {
114            Some(candidate) => candidate,
115            None => return Ok(None),
116        };
117
118        if !candidates.is_empty() {
119            let dists = std::iter::once(candidate.0)
120                .chain(candidates.into_iter().map(|(d, _)| d))
121                .collect::<Vec<_>>();
122
123            tracing::error!(?dists, "multiple candidates found");
124            return Err(UbuntuError::PackageMultipleCandidates);
125        }
126
127        Ok(Some(candidate.1))
128    }
129}
130
131/// Returns the `linux-image-unsigned-*` variant of a `linux-image-*` package
132/// name, or `None` if the input is not a `linux-image-` name.
133fn unsigned_variant(package: &str) -> Option<String> {
134    package
135        .strip_prefix("linux-image-")
136        .map(|rest| format!("linux-image-unsigned-{rest}"))
137}
138
139#[cfg(test)]
140mod tests {
141    use indexmap::IndexMap;
142
143    use super::*;
144    use crate::ubuntu::parse::UbuntuRepositoryEntry;
145
146    fn entry(package: &str, version: &str, filename: &str) -> UbuntuRepositoryEntry {
147        UbuntuRepositoryEntry {
148            package: Some(package.into()),
149            version: Some(version.into()),
150            filename: Some(filename.into()),
151            ..Default::default()
152        }
153    }
154
155    fn index_with(entries: Vec<(&str, Vec<UbuntuRepositoryEntry>)>) -> PackageIndex {
156        let mut packages = IndexMap::new();
157        for (dist, dist_entries) in entries {
158            let mut map = IndexMap::new();
159            for e in dist_entries {
160                map.insert(e.package.clone().unwrap(), e);
161            }
162            packages.insert(dist.into(), map);
163        }
164        PackageIndex::new("http://example.com/ubuntu/".try_into().unwrap(), packages)
165    }
166
167    #[test]
168    fn finds_signed_kernel() {
169        let idx = index_with(vec![(
170            "noble",
171            vec![entry(
172                "linux-image-6.8.0-40-generic",
173                "6.8.0-40.40",
174                "pool/x.deb",
175            )],
176        )]);
177        let query = PackageQuery {
178            package: "linux-image-6.8.0-40-generic".into(),
179            version: "6.8.0-40.40".into(),
180            dbgsym: false,
181            unsigned_fallback: true,
182        };
183        let found = idx.find(&query).unwrap().unwrap();
184        assert_eq!(found.filename.as_deref(), Some("pool/x.deb"));
185    }
186
187    #[test]
188    fn falls_back_to_unsigned_when_signed_missing() {
189        let idx = index_with(vec![(
190            "noble",
191            vec![entry(
192                "linux-image-unsigned-6.8.0-40-generic",
193                "6.8.0-40.40",
194                "pool/u.deb",
195            )],
196        )]);
197        let query = PackageQuery {
198            package: "linux-image-6.8.0-40-generic".into(),
199            version: "6.8.0-40.40".into(),
200            dbgsym: false,
201            unsigned_fallback: true,
202        };
203        let found = idx.find(&query).unwrap().unwrap();
204        assert_eq!(found.filename.as_deref(), Some("pool/u.deb"));
205    }
206
207    #[test]
208    fn unsigned_fallback_disabled_returns_none() {
209        let idx = index_with(vec![(
210            "noble",
211            vec![entry(
212                "linux-image-unsigned-6.8.0-40-generic",
213                "6.8.0-40.40",
214                "pool/u.deb",
215            )],
216        )]);
217        let query = PackageQuery {
218            package: "linux-image-6.8.0-40-generic".into(),
219            version: "6.8.0-40.40".into(),
220            dbgsym: false,
221            unsigned_fallback: false,
222        };
223        assert!(idx.find(&query).unwrap().is_none());
224    }
225
226    #[test]
227    fn dbgsym_filter_skips_packages_with_depends() {
228        let mut wrapper = entry(
229            "linux-image-6.8.0-40-generic-dbgsym",
230            "6.8.0-40.40",
231            "pool/wrapper.deb",
232        );
233        wrapper.depends = Some("linux-image-unsigned-6.8.0-40-generic-dbgsym".into());
234        let real = entry(
235            "linux-image-unsigned-6.8.0-40-generic-dbgsym",
236            "6.8.0-40.40",
237            "pool/real.deb",
238        );
239
240        let idx = index_with(vec![("noble", vec![wrapper, real])]);
241        let query = PackageQuery {
242            package: "linux-image-6.8.0-40-generic-dbgsym".into(),
243            version: "6.8.0-40.40".into(),
244            dbgsym: true,
245            unsigned_fallback: true,
246        };
247        // The signed one has Depends -> filtered out -> falls back to unsigned.
248        let found = idx.find(&query).unwrap().unwrap();
249        assert_eq!(found.filename.as_deref(), Some("pool/real.deb"));
250    }
251
252    #[test]
253    fn multiple_candidates_in_different_dists_errors() {
254        let idx = index_with(vec![
255            (
256                "noble",
257                vec![entry(
258                    "linux-image-6.8.0-40-generic",
259                    "6.8.0-40.40",
260                    "pool/a.deb",
261                )],
262            ),
263            (
264                "noble-updates",
265                vec![entry(
266                    "linux-image-6.8.0-40-generic",
267                    "6.8.0-40.40",
268                    "pool/b.deb",
269                )],
270            ),
271        ]);
272        let query = PackageQuery {
273            package: "linux-image-6.8.0-40-generic".into(),
274            version: "6.8.0-40.40".into(),
275            dbgsym: false,
276            unsigned_fallback: false,
277        };
278        assert!(matches!(
279            idx.find(&query),
280            Err(UbuntuError::PackageMultipleCandidates)
281        ));
282    }
283
284    #[test]
285    fn resolve_url_joins_host_and_filename() {
286        let idx = index_with(vec![(
287            "noble",
288            vec![entry("foo", "1.0", "pool/main/f/foo.deb")],
289        )]);
290        let entry = idx
291            .find(&PackageQuery {
292                package: "foo".into(),
293                version: "1.0".into(),
294                dbgsym: false,
295                unsigned_fallback: false,
296            })
297            .unwrap()
298            .unwrap();
299        let url = idx.resolve_url(entry).unwrap();
300        assert_eq!(
301            url.as_str(),
302            "http://example.com/ubuntu/pool/main/f/foo.deb"
303        );
304    }
305}