Skip to main content

isr_dl_linux/ubuntu/
mod.rs

1//! Ubuntu archive downloader.
2
3mod artifacts;
4mod error;
5mod fetcher;
6mod index;
7mod parse;
8mod repository;
9mod request;
10
11use std::{
12    cell::OnceCell,
13    path::{Path, PathBuf},
14    time::Duration,
15};
16
17use bon::Builder;
18use isr_dl::{Error, ProgressFn};
19use reqwest::blocking::Client;
20use url::Url;
21
22pub use self::{
23    artifacts::{ArtifactRef, KernelArtifacts},
24    error::UbuntuError,
25    index::{PackageIndex, PackageQuery},
26    parse::UbuntuRepositoryEntry,
27    request::{
28        ArtifactPaths, ArtifactPolicy, FilenamePolicy, UbuntuSymbolPaths, UbuntuSymbolRequest,
29    },
30};
31use self::{fetcher::Fetcher, repository::Repository};
32use crate::{DownloaderError, UbuntuVersionSignature};
33
34/// Canonical archive hosting Ubuntu binary `.deb` packages.
35pub const DEFAULT_ARCHIVE_URL: &str = "https://archive.ubuntu.com/ubuntu/";
36
37/// Canonical archive hosting Ubuntu detached debug-symbol (`.ddeb`) packages.
38pub const DEFAULT_DDEBS_URL: &str = "https://ddebs.ubuntu.com/";
39
40/// Debian architecture string used when none is configured.
41pub const DEFAULT_ARCH: &str = "amd64";
42
43/// Ubuntu releases indexed by the downloader when none are configured.
44pub const DEFAULT_DISTS: &[&str] = &[
45    "trusty",        // 14.04
46    "xenial",        // 16.04
47    "bionic",        // 18.04
48    "focal",         // 20.04
49    "focal-updates", // 20.04
50    "jammy",         // 22.04
51    "jammy-updates", // 22.04
52    "noble",         // 24.04
53    "noble-updates", // 24.04
54    "resolute",      // 26.04
55];
56
57/// Downloads Ubuntu kernel + debug symbol `.deb` packages.
58#[derive(Builder)]
59pub struct UbuntuSymbolDownloader {
60    #[builder(field)]
61    indices: OnceCell<Vec<PackageIndex>>,
62
63    #[builder(default)]
64    client: Client,
65
66    #[builder(into, default = DEFAULT_ARCH)]
67    arch: String,
68
69    #[builder(
70        default = DEFAULT_DISTS.iter().map(ToString::to_string).collect(),
71        with = |iter: impl IntoIterator<Item = impl Into<String>>| {
72            iter.into_iter().map(Into::into).collect()
73        }
74    )]
75    dists: Vec<String>,
76
77    #[builder(
78        default = vec![
79            DEFAULT_ARCHIVE_URL.try_into().unwrap(),
80            DEFAULT_DDEBS_URL.try_into().unwrap(),
81        ],
82        with = |iter: impl IntoIterator<Item = impl Into<Url>>| {
83            iter.into_iter().map(Into::into).collect()
84        }
85    )]
86    repository_hosts: Vec<Url>,
87
88    #[builder(into)]
89    output_directory: PathBuf,
90
91    progress: Option<ProgressFn>,
92
93    /// Maximum age of cached `Packages.gz` before re-fetching.
94    /// `Duration::ZERO` always re-fetches; `Duration::MAX` always uses cache.
95    #[builder(default = Duration::from_secs(24 * 3600))]
96    index_max_age: Duration,
97}
98
99impl UbuntuSymbolDownloader {
100    /// Pure filesystem lookup. Returns `Some` only if every requested artifact
101    /// (and its extraction, if requested) is already on disk.
102    pub fn lookup(&self, request: &UbuntuSymbolRequest) -> Option<UbuntuSymbolPaths> {
103        let indices = self.load_cached_indices();
104        let artifacts = KernelArtifacts::resolve(&request.version_signature, &indices).ok()?;
105        let version_dir = self.version_dir(&request.version_signature);
106
107        Some(UbuntuSymbolPaths {
108            output_directory: version_dir.clone(),
109            linux_image: lookup_artifact(
110                artifacts.linux_image.as_ref(),
111                request.linux_image.as_ref(),
112                &version_dir,
113            )?,
114            linux_image_dbgsym: lookup_artifact(
115                artifacts.linux_image_dbgsym.as_ref(),
116                request.linux_image_dbgsym.as_ref(),
117                &version_dir,
118            )?,
119            linux_modules: lookup_artifact(
120                artifacts.linux_modules.as_ref(),
121                request.linux_modules.as_ref(),
122                &version_dir,
123            )?,
124        })
125    }
126
127    /// Fetches missing artifacts from the network. Always loads (or refreshes)
128    /// indices; never short-circuits.
129    pub fn download(&self, request: UbuntuSymbolRequest) -> Result<UbuntuSymbolPaths, Error> {
130        self.download_inner(request)
131            .map_err(|err| Error::Other(Box::new(DownloaderError::Ubuntu(err))))
132    }
133
134    fn download_inner(
135        &self,
136        request: UbuntuSymbolRequest,
137    ) -> Result<UbuntuSymbolPaths, UbuntuError> {
138        let indices = self.fetch_indices()?;
139        let artifacts = KernelArtifacts::resolve(&request.version_signature, indices)?;
140
141        let version_dir = self.version_dir(&request.version_signature);
142        std::fs::create_dir_all(&version_dir)?;
143
144        let fetcher = Fetcher::new(&self.client, self.progress.as_ref());
145
146        let linux_image = fetch_artifact(
147            &fetcher,
148            artifacts.linux_image.as_ref(),
149            request.linux_image.as_ref(),
150            &version_dir,
151        )?;
152        let linux_image_dbgsym = fetch_artifact(
153            &fetcher,
154            artifacts.linux_image_dbgsym.as_ref(),
155            request.linux_image_dbgsym.as_ref(),
156            &version_dir,
157        )?;
158        let linux_modules = fetch_artifact(
159            &fetcher,
160            artifacts.linux_modules.as_ref(),
161            request.linux_modules.as_ref(),
162            &version_dir,
163        )?;
164
165        Ok(UbuntuSymbolPaths {
166            output_directory: version_dir,
167            linux_image,
168            linux_image_dbgsym,
169            linux_modules,
170        })
171    }
172
173    fn fetch_indices(&self) -> Result<&[PackageIndex], UbuntuError> {
174        // TODO: get_or_try_init
175        let indices = match self.indices.get() {
176            Some(indices) => indices,
177            None => {
178                let mut indices = Vec::with_capacity(self.repository_hosts.len());
179                let mut last_error = None;
180
181                for host in &self.repository_hosts {
182                    let repo = Repository::new(
183                        self.client.clone(),
184                        host.clone(),
185                        self.arch.clone(),
186                        self.dists.clone(),
187                    );
188
189                    let index = match repo.fetch_index(
190                        &self.index_dir(),
191                        self.index_max_age,
192                        self.progress.clone(),
193                    ) {
194                        Ok(index) => index,
195                        Err(err) => {
196                            tracing::warn!(%err, %host, "failed to fetch index, skipping");
197                            last_error = Some(err);
198                            continue;
199                        }
200                    };
201
202                    indices.push(index);
203                }
204
205                if indices.is_empty() {
206                    return Err(last_error.unwrap_or(UbuntuError::PackageNotFound));
207                }
208
209                self.indices.get_or_init(|| indices)
210            }
211        };
212
213        Ok(indices.as_slice())
214    }
215
216    fn load_cached_indices(&self) -> Vec<PackageIndex> {
217        let mut indices = Vec::with_capacity(self.repository_hosts.len());
218        for host in &self.repository_hosts {
219            let repo = Repository::new(
220                self.client.clone(),
221                host.clone(),
222                self.arch.clone(),
223                self.dists.clone(),
224            );
225
226            let index = match repo.load_cached_index(&self.index_dir(), self.progress.clone()) {
227                Ok(index) => index,
228                Err(err) => {
229                    tracing::debug!(%host, %err, "cached index unavailable, skipping");
230                    continue;
231                }
232            };
233
234            indices.push(index);
235        }
236
237        indices
238    }
239
240    fn version_dir(&self, signature: &UbuntuVersionSignature) -> PathBuf {
241        self.output_directory.join(signature.subdirectory())
242    }
243
244    fn index_dir(&self) -> PathBuf {
245        self.output_directory.join("_index")
246    }
247}
248
249/// Resolves the on-disk paths for one artifact according to `policy`. Returns
250/// `Some` if `policy` was `Some` and (for `lookup`) all expected files exist.
251fn lookup_artifact(
252    artifact: Option<&ArtifactRef>,
253    policy: Option<&ArtifactPolicy>,
254    version_dir: &Path,
255) -> Option<Option<ArtifactPaths>> {
256    let policy = match policy {
257        Some(policy) => policy,
258        None => return Some(None),
259    };
260    let artifact = artifact?;
261
262    let deb_path = resolve_filename(&policy.deb, &artifact.deb_filename, version_dir);
263    if !deb_path.exists() {
264        return None;
265    }
266
267    let extracted = match &policy.extract {
268        Some(extracted) => {
269            let basename = artifact
270                .extract_path
271                .file_name()
272                .and_then(|filename| filename.to_str())?;
273            let path = resolve_filename(extracted, basename, version_dir);
274
275            if !path.exists() {
276                return None;
277            }
278
279            Some(path)
280        }
281        None => None,
282    };
283
284    Some(Some(ArtifactPaths {
285        deb: deb_path,
286        extracted,
287    }))
288}
289
290/// Downloads (and optionally extracts) one artifact according to `policy`.
291fn fetch_artifact(
292    fetcher: &Fetcher<'_>,
293    artifact: Option<&ArtifactRef>,
294    policy: Option<&ArtifactPolicy>,
295    version_dir: &Path,
296) -> Result<Option<ArtifactPaths>, UbuntuError> {
297    let policy = match policy {
298        Some(policy) => policy,
299        None => return Ok(None),
300    };
301    let artifact = artifact.ok_or(UbuntuError::PackageNotFound)?;
302
303    let deb_path = resolve_filename(&policy.deb, &artifact.deb_filename, version_dir);
304    fetcher.fetch_deb(artifact, &deb_path)?;
305
306    let extracted = match &policy.extract {
307        Some(extracted) => {
308            let basename = artifact
309                .extract_path
310                .file_name()
311                .and_then(|filename| filename.to_str())
312                .ok_or(UbuntuError::UrlMissingFilename)?;
313            let dest = resolve_filename(extracted, basename, version_dir);
314            fetcher.extract_deb_entry(&deb_path, &artifact.extract_path, &dest)?;
315            Some(dest)
316        }
317        None => None,
318    };
319
320    Ok(Some(ArtifactPaths {
321        deb: deb_path,
322        extracted,
323    }))
324}
325
326/// Joins `version_dir` with either the canonical `original` name or the
327/// caller-supplied `Custom` path.
328fn resolve_filename(policy: &FilenamePolicy, original: &str, version_dir: &Path) -> PathBuf {
329    match policy {
330        FilenamePolicy::Original => version_dir.join(original),
331        FilenamePolicy::Custom(custom) => version_dir.join(custom),
332    }
333}