1use indexmap::IndexMap;
4use url::Url;
5
6use super::{error::UbuntuError, parse::UbuntuRepositoryEntry};
7
8#[derive(Debug, Clone)]
10pub struct PackageQuery {
11 pub package: String,
13
14 pub version: String,
16
17 pub dbgsym: bool,
19
20 pub unsigned_fallback: bool,
24}
25
26#[derive(Debug)]
28pub struct PackageIndex {
29 host: Url,
31
32 packages: IndexMap<String, IndexMap<String, UbuntuRepositoryEntry>>,
36}
37
38impl PackageIndex {
39 pub fn new(
41 host: Url,
42 packages: IndexMap<String, IndexMap<String, UbuntuRepositoryEntry>>,
43 ) -> Self {
44 Self { host, packages }
45 }
46
47 pub fn host(&self) -> &Url {
49 &self.host
50 }
51
52 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 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 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
131fn 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 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}