isr_dl_linux/ubuntu/
mod.rs

1mod error;
2pub mod repository;
3mod repository_cache;
4
5use std::{
6    fs::File,
7    path::{Path, PathBuf},
8};
9
10use debpkg::DebPkg;
11use url::Url;
12
13pub use self::{
14    error::Error, repository::UbuntuRepositoryEntry, repository_cache::UbuntuPackageCache,
15};
16use crate::{LinuxBanner, LinuxVersionSignature, UbuntuVersionSignature};
17
18pub const DEFAULT_DDEBS_URL: &str = "http://ddebs.ubuntu.com";
19pub const DEFAULT_ARCHIVE_URL: &str = "http://cz.archive.ubuntu.com/ubuntu";
20pub const DEFAULT_ARCH: &str = "amd64";
21pub const DEFAULT_DISTS: &[&str] = &[
22    "trusty",        // 14.04
23    "xenial",        // 16.04
24    "bionic",        // 18.04
25    "focal",         // 20.04
26    "focal-updates", // 20.04
27    "jammy",         // 22.04
28    "jammy-updates", // 22.04
29    "noble",         // 24.04
30    "noble-updates", // 24.04
31];
32
33enum Filename {
34    Original,
35    Custom(PathBuf),
36}
37
38pub struct UbuntuDownloader {
39    arch: String,
40    dists: Vec<String>,
41
42    release: String,
43    version: String,
44
45    archive_url: Url,
46    ddebs_url: Url,
47
48    output_directory: Option<PathBuf>,
49    subdirectory: String,
50    skip_existing: bool,
51
52    linux_image_deb: Option<Filename>,
53    linux_image_dbgsym_deb: Option<Filename>,
54    linux_modules_deb: Option<Filename>,
55    extract_linux_image: Option<Filename>,
56    extract_linux_image_dbgsym: Option<Filename>,
57    extract_systemmap: Option<Filename>,
58}
59
60#[derive(Debug, Default)]
61pub struct UbuntuPaths {
62    pub output_directory: PathBuf,
63    pub linux_image_deb: Option<PathBuf>,
64    pub linux_image_dbgsym_deb: Option<PathBuf>,
65    pub linux_modules_deb: Option<PathBuf>,
66    pub linux_image: Option<PathBuf>,
67    pub linux_image_dbgsym: Option<PathBuf>,
68    pub systemmap: Option<PathBuf>,
69}
70
71impl UbuntuDownloader {
72    pub fn new(release: &str, revision: &str, variant: &str) -> Self {
73        //
74        // Build the Ubuntu kernel package name and version string.
75        // Example:
76        //     Ubuntu {
77        //         release: "6.8.0",
78        //         revision: "40.40~22.04.3",
79        //         kernel_flavour: "generic",
80        //         mainline_kernel_version: "6.8.12",
81        //     }
82        //
83        // ... results in:
84        //     release: "6.8.0-40-generic"
85        //     version: "6.8.0-40.40~22.04.3"
86        //
87        // See https://ubuntu.com/kernel for more information.
88
89        let revision_short = match revision.split_once('.') {
90            Some((revision_short, _)) => revision_short,
91            None => revision,
92        };
93
94        let kernel_release = format!("{release}-{revision_short}-{variant}");
95        let kernel_version = format!("{release}-{revision}");
96        let subdirectory = format!("{kernel_version}-{variant}");
97
98        Self {
99            arch: DEFAULT_ARCH.into(),
100            dists: DEFAULT_DISTS.iter().map(ToString::to_string).collect(),
101            release: kernel_release,
102            version: kernel_version,
103            archive_url: DEFAULT_ARCHIVE_URL.try_into().unwrap(),
104            ddebs_url: DEFAULT_DDEBS_URL.try_into().unwrap(),
105            output_directory: None,
106            subdirectory,
107            skip_existing: false,
108            linux_image_deb: None,
109            linux_image_dbgsym_deb: None,
110            linux_modules_deb: None,
111            extract_linux_image: None,
112            extract_linux_image_dbgsym: None,
113            extract_systemmap: None,
114        }
115    }
116
117    pub fn from_banner(banner: &LinuxBanner) -> Result<Self, Error> {
118        match &banner.version_signature {
119            Some(LinuxVersionSignature::Ubuntu(UbuntuVersionSignature {
120                release,
121                revision,
122                kernel_flavour,
123                ..
124            })) => Ok(Self::new(release, revision, kernel_flavour)),
125            _ => Err(Error::InvalidBanner),
126        }
127    }
128
129    pub fn destination_path(&self) -> PathBuf {
130        match &self.output_directory {
131            Some(output_directory) => PathBuf::from(output_directory).join(&self.subdirectory),
132            None => PathBuf::from(&self.subdirectory),
133        }
134    }
135
136    pub fn with_arch(self, arch: impl Into<String>) -> Self {
137        Self {
138            arch: arch.into(),
139            ..self
140        }
141    }
142
143    pub fn with_dists(self, dists: impl IntoIterator<Item = impl Into<String>>) -> Self {
144        Self {
145            dists: dists.into_iter().map(Into::into).collect(),
146            ..self
147        }
148    }
149
150    pub fn with_archive_url(self, archive_url: Url) -> Self {
151        Self {
152            archive_url,
153            ..self
154        }
155    }
156
157    pub fn with_ddebs_url(self, ddebs_url: Url) -> Self {
158        Self { ddebs_url, ..self }
159    }
160
161    pub fn with_output_directory(self, directory: impl Into<PathBuf>) -> Self {
162        Self {
163            output_directory: Some(directory.into()),
164            ..self
165        }
166    }
167
168    pub fn skip_existing(self) -> Self {
169        Self {
170            skip_existing: true,
171            ..self
172        }
173    }
174
175    pub fn download_linux_image(self) -> Self {
176        Self {
177            linux_image_deb: Some(Filename::Original),
178            ..self
179        }
180    }
181
182    pub fn download_linux_image_as(self, filename: impl Into<PathBuf>) -> Self {
183        Self {
184            linux_image_deb: Some(Filename::Custom(filename.into())),
185            ..self
186        }
187    }
188
189    pub fn download_linux_image_dbgsym(self) -> Self {
190        Self {
191            linux_image_dbgsym_deb: Some(Filename::Original),
192            ..self
193        }
194    }
195
196    pub fn download_linux_image_dbgsym_as(self, filename: impl Into<PathBuf>) -> Self {
197        Self {
198            linux_image_dbgsym_deb: Some(Filename::Custom(filename.into())),
199            ..self
200        }
201    }
202
203    pub fn download_linux_modules(self) -> Self {
204        Self {
205            linux_modules_deb: Some(Filename::Original),
206            ..self
207        }
208    }
209
210    pub fn download_linux_modules_as(self, filename: impl Into<PathBuf>) -> Self {
211        Self {
212            linux_modules_deb: Some(Filename::Custom(filename.into())),
213            ..self
214        }
215    }
216
217    pub fn extract_linux_image(self) -> Self {
218        Self {
219            extract_linux_image: Some(Filename::Original),
220            ..self
221        }
222    }
223
224    pub fn extract_linux_image_as(self, filename: impl Into<PathBuf>) -> Self {
225        Self {
226            extract_linux_image: Some(Filename::Custom(filename.into())),
227            ..self
228        }
229    }
230
231    pub fn extract_linux_image_dbgsym(self) -> Self {
232        Self {
233            extract_linux_image_dbgsym: Some(Filename::Original),
234            ..self
235        }
236    }
237
238    pub fn extract_linux_image_dbgsym_as(self, filename: impl Into<PathBuf>) -> Self {
239        Self {
240            extract_linux_image_dbgsym: Some(Filename::Custom(filename.into())),
241            ..self
242        }
243    }
244
245    pub fn extract_systemmap(self) -> Self {
246        Self {
247            extract_systemmap: Some(Filename::Original),
248            ..self
249        }
250    }
251
252    pub fn extract_systemmap_as(self, filename: impl Into<PathBuf>) -> Self {
253        Self {
254            extract_systemmap: Some(Filename::Custom(filename.into())),
255            ..self
256        }
257    }
258
259    pub fn download(self) -> Result<UbuntuPaths, Error> {
260        //
261        // Validate options.
262        //
263
264        if self.extract_linux_image.is_some() && self.linux_image_deb.is_none() {
265            tracing::error!("extract_linux_image requires download_linux_image");
266            return Err(Error::InvalidOptions);
267        }
268
269        if self.extract_linux_image_dbgsym.is_some() && self.linux_image_dbgsym_deb.is_none() {
270            tracing::error!("extract_linux_image_dbgsym requires download_linux_image_dbgsym");
271            return Err(Error::InvalidOptions);
272        }
273
274        if self.extract_systemmap.is_some() && self.linux_modules_deb.is_none() {
275            tracing::error!("extract_systemmap requires download_linux_modules");
276            return Err(Error::InvalidOptions);
277        }
278
279        if self.linux_image_deb.is_none()
280            && self.linux_image_dbgsym_deb.is_none()
281            && self.linux_modules_deb.is_none()
282        {
283            tracing::warn!("no download options specified");
284            return Err(Error::InvalidOptions);
285        }
286
287        let destination_path = self.destination_path();
288        std::fs::create_dir_all(&destination_path)?;
289
290        let mut result = UbuntuPaths {
291            output_directory: destination_path.clone(),
292            ..Default::default()
293        };
294
295        if self.linux_image_deb.is_some() || self.linux_modules_deb.is_some() {
296            let packages = UbuntuPackageCache::fetch(self.archive_url, &self.arch, &self.dists)?;
297
298            (result.linux_image_deb, result.linux_image) = find_and_download_and_extract(
299                &packages,
300                &self.release,
301                &self.version,
302                &destination_path,
303                self.skip_existing,
304                find_linux_image_url,
305                &format!("./boot/vmlinuz-{}", self.release),
306                self.linux_image_deb,
307                self.extract_linux_image,
308            )?;
309
310            (result.linux_modules_deb, result.systemmap) = find_and_download_and_extract(
311                &packages,
312                &self.release,
313                &self.version,
314                &destination_path,
315                self.skip_existing,
316                find_linux_modules_url,
317                &format!("./boot/System.map-{}", self.release),
318                self.linux_modules_deb,
319                self.extract_systemmap,
320            )?;
321        }
322
323        if self.linux_image_dbgsym_deb.is_some() {
324            let packages = UbuntuPackageCache::fetch(self.ddebs_url, &self.arch, &self.dists)?;
325
326            (result.linux_image_dbgsym_deb, result.linux_image_dbgsym) =
327                find_and_download_and_extract(
328                    &packages,
329                    &self.release,
330                    &self.version,
331                    &destination_path,
332                    self.skip_existing,
333                    find_linux_image_dbgsym_url,
334                    &format!("./usr/lib/debug/boot/vmlinux-{}", self.release),
335                    self.linux_image_dbgsym_deb,
336                    self.extract_linux_image_dbgsym,
337                )?;
338        }
339
340        Ok(result)
341    }
342}
343
344#[expect(clippy::too_many_arguments)]
345fn find_and_download_and_extract(
346    packages: &UbuntuPackageCache,
347    release: &str,
348    version: &str,
349    output_directory: &Path,
350    skip_existing: bool,
351    find_package_fn: impl Fn(&UbuntuPackageCache, &str, &str) -> Result<Url, Error>,
352    deb_entry: &str,
353    deb_filename: Option<Filename>,
354    extract_filename: Option<Filename>,
355) -> Result<(Option<PathBuf>, Option<PathBuf>), Error> {
356    let deb_filename = match deb_filename {
357        Some(deb_filename) => deb_filename,
358        None => return Ok((None, None)),
359    };
360
361    let url = find_package_fn(packages, release, version)?;
362    let deb_path = path_from_url(&url, output_directory, deb_filename)?;
363
364    if !deb_path.exists() || !skip_existing {
365        download(url, &deb_path)?;
366    }
367    else {
368        tracing::info!(path = %deb_path.display(), "skipping download");
369    }
370
371    let extract_filename = match extract_filename {
372        Some(extract_filename) => extract_filename,
373        None => return Ok((Some(deb_path), None)),
374    };
375
376    let path = path_from_deb_entry(deb_entry, output_directory, extract_filename)?;
377
378    if !path.exists() || !skip_existing {
379        unpack_deb_entry(&deb_path, deb_entry, &path)?;
380    }
381    else {
382        tracing::info!(path = %path.display(), "skipping extraction");
383    }
384
385    Ok((Some(deb_path), Some(path)))
386}
387
388fn find_linux_image_url(
389    packages: &UbuntuPackageCache,
390    release: &str,
391    version: &str,
392) -> Result<Url, Error> {
393    let package = format!("linux-image-{release}");
394    if let Some(candidate) = packages.find_package(&package, version)? {
395        return packages.package_url(candidate);
396    }
397
398    let package = format!("linux-image-unsigned-{release}");
399    if let Some(candidate) = packages.find_package(&package, version)? {
400        return packages.package_url(candidate);
401    }
402
403    Err(Error::PackageNotFound)
404}
405
406fn find_linux_image_dbgsym_url(
407    packages: &UbuntuPackageCache,
408    release: &str,
409    version: &str,
410) -> Result<Url, Error> {
411    let package = format!("linux-image-{release}-dbgsym");
412    if let Some(candidate) = packages.find_dbgsym_package(&package, version)? {
413        return packages.package_url(candidate);
414    }
415
416    let package = format!("linux-image-unsigned-{release}-dbgsym");
417    if let Some(candidate) = packages.find_dbgsym_package(&package, version)? {
418        return packages.package_url(candidate);
419    }
420
421    Err(Error::PackageNotFound)
422}
423
424fn find_linux_modules_url(
425    packages: &UbuntuPackageCache,
426    release: &str,
427    version: &str,
428) -> Result<Url, Error> {
429    let package = format!("linux-modules-{release}");
430    if let Some(candidate) = packages.find_package(&package, version)? {
431        return packages.package_url(candidate);
432    }
433
434    Err(Error::PackageNotFound)
435}
436
437fn path_from_url(
438    url: &Url,
439    destination_directory: &Path,
440    filename: Filename,
441) -> Result<PathBuf, Error> {
442    fn extract_file_name_from_url(url: &Url) -> Option<String> {
443        url.path_segments()?.next_back().map(ToString::to_string)
444    }
445
446    match filename {
447        Filename::Original => match extract_file_name_from_url(url) {
448            Some(filename) => Ok(destination_directory.join(filename)),
449            None => {
450                tracing::error!("failed to extract filename from URL");
451                Err(Error::UrlDoesNotContainFilename)
452            }
453        },
454        Filename::Custom(path) => Ok(destination_directory.join(path)),
455    }
456}
457
458fn download(url: Url, destination_path: impl AsRef<Path>) -> Result<(), Error> {
459    let destination_path = destination_path.as_ref();
460
461    tracing::info!(%url, "downloading");
462    let mut response = reqwest::blocking::get(url)?.error_for_status()?;
463    let mut file = File::create(destination_path)?;
464    response.copy_to(&mut file)?;
465
466    Ok(())
467}
468
469fn path_from_deb_entry(
470    deb_entry_path: impl AsRef<Path>,
471    destination_directory: &Path,
472    filename: Filename,
473) -> Result<PathBuf, Error> {
474    match filename {
475        Filename::Original => match deb_entry_path.as_ref().file_name() {
476            Some(filename) => Ok(destination_directory.join(filename)),
477            None => {
478                tracing::error!("failed to extract filename from deb entry path");
479                Err(Error::UrlDoesNotContainFilename)
480            }
481        },
482        Filename::Custom(path) => Ok(destination_directory.join(path)),
483    }
484}
485
486fn unpack_deb_entry(
487    deb_path: impl AsRef<Path>,
488    deb_entry_path: impl AsRef<Path>,
489    destination_path: impl AsRef<Path>,
490) -> Result<(), Error> {
491    let deb_path = deb_path.as_ref();
492    let deb_entry_path = deb_entry_path.as_ref();
493    let destination_path = destination_path.as_ref();
494
495    let file = File::open(deb_path)?;
496    let mut pkg = DebPkg::parse(file)?;
497
498    let mut data = pkg.data()?;
499    for entry in data.entries()? {
500        let mut entry = entry?;
501
502        if entry.header().path()? == deb_entry_path {
503            tracing::info!(path = %deb_entry_path.display(), "unpacking");
504            entry.unpack(destination_path)?;
505            return Ok(());
506        }
507    }
508
509    Err(Error::DebEntryNotFound)
510}