Skip to main content

lux_lib/operations/
download.rs

1use std::{
2    io::{self, Cursor, Read},
3    path::PathBuf,
4    string::FromUtf8Error,
5};
6
7use bon::Builder;
8use bytes::Bytes;
9use miette::Diagnostic;
10use thiserror::Error;
11use url::{ParseError, Url};
12
13use crate::{
14    config::Config,
15    git::GitSource,
16    lockfile::RemotePackageSourceUrl,
17    lua_rockspec::{LuaRockspecError, RemoteLuaRockspec, RockSourceSpec},
18    luarocks,
19    package::{
20        PackageName, PackageReq, PackageSpec, PackageSpecFromPackageReqError, PackageVersion,
21        RemotePackageTypeFilterSpec,
22    },
23    progress::{Progress, ProgressBar},
24    remote_package_db::{RemotePackageDB, RemotePackageDBError, SearchError},
25    remote_package_source::RemotePackageSource,
26    rockspec::Rockspec,
27};
28
29/// Builder for a rock downloader.
30pub struct Download<'a> {
31    package_req: &'a PackageReq,
32    package_db: Option<&'a RemotePackageDB>,
33    config: &'a Config,
34    progress: &'a Progress<ProgressBar>,
35}
36
37impl<'a> Download<'a> {
38    /// Construct a new `.src.rock` downloader.
39    pub fn new(
40        package_req: &'a PackageReq,
41        config: &'a Config,
42        progress: &'a Progress<ProgressBar>,
43    ) -> Self {
44        Self {
45            package_req,
46            package_db: None,
47            config,
48            progress,
49        }
50    }
51
52    /// Sets the package database to use for searching for packages.
53    /// Instantiated from the config if not set.
54    pub fn package_db(self, package_db: &'a RemotePackageDB) -> Self {
55        Self {
56            package_db: Some(package_db),
57            ..self
58        }
59    }
60
61    /// Download the package's Rockspec.
62    pub async fn download_rockspec(self) -> Result<DownloadedRockspec, SearchAndDownloadError> {
63        match self.package_db {
64            Some(db) => download_rockspec(self.package_req, db, self.progress, self.config).await,
65            None => {
66                let db = RemotePackageDB::from_config(self.config, self.progress).await?;
67                download_rockspec(self.package_req, &db, self.progress, self.config).await
68            }
69        }
70    }
71
72    /// Download a `.src.rock` to a file.
73    /// `destination_dir` defaults to the current working directory if not set.
74    pub async fn download_src_rock_to_file(
75        self,
76        destination_dir: Option<PathBuf>,
77    ) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
78        match self.package_db {
79            Some(db) => {
80                download_src_rock_to_file(
81                    self.package_req,
82                    destination_dir,
83                    db,
84                    self.progress,
85                    self.config,
86                )
87                .await
88            }
89            None => {
90                let db = RemotePackageDB::from_config(self.config, self.progress).await?;
91                download_src_rock_to_file(
92                    self.package_req,
93                    destination_dir,
94                    &db,
95                    self.progress,
96                    self.config,
97                )
98                .await
99            }
100        }
101    }
102
103    /// Search for a `.src.rock` and download it to memory.
104    pub async fn search_and_download_src_rock(
105        self,
106    ) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
107        match self.package_db {
108            Some(db) => {
109                search_and_download_src_rock(self.package_req, db, self.progress, self.config).await
110            }
111            None => {
112                let db = RemotePackageDB::from_config(self.config, self.progress).await?;
113                search_and_download_src_rock(self.package_req, &db, self.progress, self.config)
114                    .await
115            }
116        }
117    }
118
119    pub(crate) async fn download_remote_rock(
120        self,
121    ) -> Result<RemoteRockDownload, SearchAndDownloadError> {
122        match self.package_db {
123            Some(db) => {
124                download_remote_rock(self.package_req, db, self.progress, self.config).await
125            }
126            None => {
127                let db = RemotePackageDB::from_config(self.config, self.progress).await?;
128                download_remote_rock(self.package_req, &db, self.progress, self.config).await
129            }
130        }
131    }
132}
133
134pub struct DownloadedPackedRockBytes {
135    pub name: PackageName,
136    pub version: PackageVersion,
137    pub bytes: Bytes,
138    pub file_name: String,
139    pub url: Url,
140}
141
142pub struct DownloadedPackedRock {
143    pub name: PackageName,
144    pub version: PackageVersion,
145    pub path: PathBuf,
146}
147
148/// Remote Lua RockSpec that has been downloaded from a remote server, along with its source metadata
149#[derive(Clone, Debug)]
150pub struct DownloadedRockspec {
151    pub rockspec: RemoteLuaRockspec,
152    pub(crate) source: RemotePackageSource,
153    pub(crate) source_url: Option<RemotePackageSourceUrl>,
154}
155
156#[derive(Clone, Debug)]
157pub(crate) enum RemoteRockDownload {
158    RockspecOnly {
159        rockspec_download: DownloadedRockspec,
160    },
161    BinaryRock {
162        rockspec_download: DownloadedRockspec,
163        packed_rock: Bytes,
164    },
165    SrcRock {
166        rockspec_download: DownloadedRockspec,
167        src_rock: Bytes,
168        source_url: RemotePackageSourceUrl,
169    },
170}
171
172impl RemoteRockDownload {
173    pub fn rockspec(&self) -> &RemoteLuaRockspec {
174        &self.rockspec_download().rockspec
175    }
176    pub fn rockspec_download(&self) -> &DownloadedRockspec {
177        match self {
178            Self::RockspecOnly { rockspec_download }
179            | Self::BinaryRock {
180                rockspec_download, ..
181            }
182            | Self::SrcRock {
183                rockspec_download, ..
184            } => rockspec_download,
185        }
186    }
187    // Instead of downloading a rockspec, generate one from a `PackageReq` and a `RockSourceSpec`.
188    pub(crate) fn from_package_req_and_source_spec(
189        package_req: PackageReq,
190        source_spec: RockSourceSpec,
191    ) -> Result<Self, SearchAndDownloadError> {
192        let package_spec = package_req.try_into()?;
193        let source_url = Some(match &source_spec {
194            RockSourceSpec::Git(GitSource { url, checkout_ref }) => RemotePackageSourceUrl::Git {
195                url: url.to_string(),
196                checkout_ref: checkout_ref
197                    .clone()
198                    .ok_or(SearchAndDownloadError::MissingCheckoutRef(url.to_string()))?,
199            },
200            RockSourceSpec::File(path) => RemotePackageSourceUrl::File { path: path.clone() },
201            RockSourceSpec::Url(url) => RemotePackageSourceUrl::Url { url: url.clone() },
202        });
203        let rockspec = RemoteLuaRockspec::from_package_and_source_spec(package_spec, source_spec);
204        let rockspec_content = rockspec
205            .to_lua_remote_rockspec_string()
206            .expect("the infallible happened");
207        let rockspec_download = DownloadedRockspec {
208            rockspec,
209            source_url,
210            source: RemotePackageSource::RockspecContent(rockspec_content),
211        };
212        Ok(Self::RockspecOnly { rockspec_download })
213    }
214}
215
216#[derive(Error, Debug, Diagnostic)]
217pub enum DownloadRockspecError {
218    #[error("failed to download rockspec: {0}")]
219    Request(#[from] reqwest::Error),
220    #[error("failed to convert rockspec response: {0}")]
221    ResponseConversion(#[from] FromUtf8Error),
222    #[error("error initialising remote package DB:\n{0}")]
223    #[diagnostic(forward(0))]
224    RemotePackageDB(#[from] RemotePackageDBError),
225    #[error(transparent)]
226    #[diagnostic(transparent)]
227    DownloadSrcRock(#[from] DownloadSrcRockError),
228}
229
230/// Find and download a rockspec for a given package requirement
231async fn download_rockspec(
232    package_req: &PackageReq,
233    package_db: &RemotePackageDB,
234    progress: &Progress<ProgressBar>,
235    config: &Config,
236) -> Result<DownloadedRockspec, SearchAndDownloadError> {
237    let rockspec = match download_remote_rock(package_req, package_db, progress, config).await? {
238        RemoteRockDownload::RockspecOnly {
239            rockspec_download: rockspec,
240        } => rockspec,
241        RemoteRockDownload::BinaryRock {
242            rockspec_download: rockspec,
243            ..
244        } => rockspec,
245        RemoteRockDownload::SrcRock {
246            rockspec_download: rockspec,
247            ..
248        } => rockspec,
249    };
250    Ok(rockspec)
251}
252
253async fn download_remote_rock(
254    package_req: &PackageReq,
255    package_db: &RemotePackageDB,
256    progress: &Progress<ProgressBar>,
257    config: &Config,
258) -> Result<RemoteRockDownload, SearchAndDownloadError> {
259    let remote_package = package_db.find(package_req, None, progress)?;
260    progress.map(|p| p.set_message(format!("📥 Downloading rockspec for {package_req}")));
261    match &remote_package.source {
262        RemotePackageSource::LuarocksRockspec(url) => {
263            let package = &remote_package.package;
264            let rockspec_name = format!("{}-{}.rockspec", package.name(), package.version());
265            let bytes = crate::reqwest::new_https_client(config)?
266                .get(format!("{}/{}", &url, rockspec_name))
267                .send()
268                .await
269                .map_err(DownloadRockspecError::Request)?
270                .error_for_status()
271                .map_err(DownloadRockspecError::Request)?
272                .bytes()
273                .await
274                .map_err(DownloadRockspecError::Request)?;
275            let content = String::from_utf8(bytes.into())?;
276            let rockspec = DownloadedRockspec {
277                rockspec: RemoteLuaRockspec::new(&content)?,
278                source: remote_package.source,
279                source_url: remote_package.source_url,
280            };
281            Ok(RemoteRockDownload::RockspecOnly {
282                rockspec_download: rockspec,
283            })
284        }
285        RemotePackageSource::RockspecContent(content) => {
286            let rockspec = DownloadedRockspec {
287                rockspec: RemoteLuaRockspec::new(content)?,
288                source: remote_package.source,
289                source_url: remote_package.source_url,
290            };
291            Ok(RemoteRockDownload::RockspecOnly {
292                rockspec_download: rockspec,
293            })
294        }
295        RemotePackageSource::LuarocksBinaryRock(url) => {
296            // prioritise lockfile source_url
297            let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
298            {
299                url
300            } else {
301                url
302            };
303            let rock = download_binary_rock(&remote_package.package, url, progress, config).await?;
304            let rockspec = DownloadedRockspec {
305                rockspec: unpack_rockspec(&rock).await?,
306                source: remote_package.source,
307                source_url: remote_package.source_url,
308            };
309            Ok(RemoteRockDownload::BinaryRock {
310                rockspec_download: rockspec,
311                packed_rock: rock.bytes,
312            })
313        }
314        RemotePackageSource::LuarocksSrcRock(url) => {
315            // prioritise lockfile source_url
316            let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
317            {
318                url.clone()
319            } else {
320                url.clone()
321            };
322            let rock = download_src_rock(&remote_package.package, &url, progress, config).await?;
323            let rockspec = DownloadedRockspec {
324                rockspec: unpack_rockspec(&rock).await?,
325                source: remote_package.source,
326                source_url: remote_package.source_url,
327            };
328            Ok(RemoteRockDownload::SrcRock {
329                rockspec_download: rockspec,
330                src_rock: rock.bytes,
331                source_url: RemotePackageSourceUrl::Url { url },
332            })
333        }
334        RemotePackageSource::Local => Err(SearchAndDownloadError::LocalSource),
335        #[cfg(test)]
336        RemotePackageSource::Test => unimplemented!(),
337    }
338}
339
340#[derive(Error, Debug, Diagnostic)]
341pub enum SearchAndDownloadError {
342    #[error(transparent)]
343    #[diagnostic(transparent)]
344    Search(#[from] SearchError),
345    #[error(transparent)]
346    #[diagnostic(transparent)]
347    Download(#[from] DownloadSrcRockError),
348    #[error(transparent)]
349    #[diagnostic(transparent)]
350    DownloadRockspec(#[from] DownloadRockspecError),
351    #[error("io operation failed: {0}")]
352    Io(#[from] io::Error),
353    #[error("UTF-8 conversion failed: {0}")]
354    Utf8(#[from] FromUtf8Error),
355    #[error(transparent)]
356    #[diagnostic(transparent)]
357    Rockspec(#[from] LuaRockspecError),
358    #[error("error initialising remote package DB:\n{0}")]
359    #[diagnostic(forward(0))]
360    RemotePackageDB(#[from] RemotePackageDBError),
361    #[error("failed to read packed rock {0}:\n{1}")]
362    ZipRead(String, zip::result::ZipError),
363    #[error("failed to extract packed rock {0}:\n{1}")]
364    ZipExtract(String, zip::result::ZipError),
365    #[error("{0} not found in the packed rock.")]
366    RockspecNotFoundInPackedRock(String),
367    #[error(transparent)]
368    #[diagnostic(transparent)]
369    PackageSpecFromPackageReq(#[from] PackageSpecFromPackageReqError),
370    #[error("git source {0} without a revision or tag.")]
371    MissingCheckoutRef(String),
372    #[error("cannot download from a local rock source.")]
373    LocalSource,
374    #[error("cannot download from a local rock or embedded rockspec source.")]
375    NonURLSource,
376    #[error("client error:\n{0}")]
377    Request(#[from] reqwest::Error),
378}
379
380async fn search_and_download_src_rock(
381    package_req: &PackageReq,
382    package_db: &RemotePackageDB,
383    progress: &Progress<ProgressBar>,
384    config: &Config,
385) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
386    let filter = Some(RemotePackageTypeFilterSpec {
387        rockspec: false,
388        binary: false,
389        src: true,
390    });
391    let remote_package = package_db.find(package_req, filter, progress)?;
392    let source_url = remote_package
393        .source
394        .url()
395        .ok_or(SearchAndDownloadError::NonURLSource)?;
396    Ok(download_src_rock(&remote_package.package, &source_url, progress, config).await?)
397}
398
399#[derive(Error, Debug, Diagnostic)]
400pub enum DownloadSrcRockError {
401    #[error("failed to download source rock: {0}")]
402    Request(#[from] reqwest::Error),
403    #[error("failed to parse source rock URL: {0}")]
404    Parse(#[from] ParseError),
405}
406
407pub(crate) async fn download_src_rock(
408    package: &PackageSpec,
409    server_url: &Url,
410    progress: &Progress<ProgressBar>,
411    config: &Config,
412) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
413    ArchiveDownload::new()
414        .package(package)
415        .server_url(server_url)
416        .config(config)
417        .progress(progress)
418        .ext("src.rock")
419        .download()
420        .await
421}
422
423pub(crate) async fn download_binary_rock(
424    package: &PackageSpec,
425    server_url: &Url,
426    progress: &Progress<ProgressBar>,
427    config: &Config,
428) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
429    let ext = format!("{}.rock", luarocks::current_platform_luarocks_identifier());
430    ArchiveDownload::new()
431        .package(package)
432        .server_url(server_url)
433        .config(config)
434        .progress(progress)
435        .ext(&ext)
436        .fallback_ext("all.rock")
437        .download()
438        .await
439}
440
441async fn download_src_rock_to_file(
442    package_req: &PackageReq,
443    destination_dir: Option<PathBuf>,
444    package_db: &RemotePackageDB,
445    progress: &Progress<ProgressBar>,
446    config: &Config,
447) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
448    progress.map(|p| p.set_message(format!("📥 Downloading {package_req}")));
449
450    let rock = search_and_download_src_rock(package_req, package_db, progress, config).await?;
451    let full_rock_name = mk_packed_rock_name(&rock.name, &rock.version, "src.rock");
452    tokio::fs::write(
453        destination_dir
454            .map(|dest| dest.join(&full_rock_name))
455            .unwrap_or_else(|| full_rock_name.clone().into()),
456        &rock.bytes,
457    )
458    .await?;
459
460    Ok(DownloadedPackedRock {
461        name: rock.name.to_owned(),
462        version: rock.version.to_owned(),
463        path: full_rock_name.into(),
464    })
465}
466
467#[derive(Builder)]
468#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
469struct ArchiveDownload<'a> {
470    package: &'a PackageSpec,
471
472    server_url: &'a Url,
473
474    ext: &'a str,
475
476    progress: &'a Progress<ProgressBar>,
477
478    fallback_ext: Option<&'a str>,
479
480    config: &'a Config,
481}
482
483impl<State> ArchiveDownloadBuilder<'_, State>
484where
485    State: archive_download_builder::State + archive_download_builder::IsComplete,
486{
487    async fn download(self) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
488        let args = self._build();
489        let progress = args.progress;
490        let package = args.package;
491        let ext = args.ext;
492        let server_url = args.server_url;
493        progress.map(|p| {
494            p.set_message(format!(
495                "📥 Downloading {}-{}.{}",
496                package.name(),
497                package.version(),
498                ext,
499            ))
500        });
501        let full_rock_name = mk_packed_rock_name(package.name(), package.version(), ext);
502        let url = server_url.join(&full_rock_name)?;
503        let response = crate::reqwest::new_https_client(args.config)?
504            .get(url.clone())
505            .send()
506            .await?;
507        let bytes = if response.status().is_success() {
508            response.bytes().await
509        } else {
510            match args.fallback_ext {
511                Some(ext) => {
512                    let full_rock_name =
513                        mk_packed_rock_name(package.name(), package.version(), ext);
514                    let url = server_url.join(&full_rock_name)?;
515                    crate::reqwest::new_https_client(args.config)?
516                        .get(url.clone())
517                        .send()
518                        .await?
519                        .error_for_status()?
520                        .bytes()
521                        .await
522                }
523                None => response.error_for_status()?.bytes().await,
524            }
525        }?;
526        Ok(DownloadedPackedRockBytes {
527            name: package.name().clone(),
528            version: package.version().clone(),
529            bytes,
530            file_name: full_rock_name,
531            url,
532        })
533    }
534}
535
536fn mk_packed_rock_name(name: &PackageName, version: &PackageVersion, ext: &str) -> String {
537    format!("{name}-{version}.{ext}")
538}
539
540pub(crate) async fn unpack_rockspec(
541    rock: &DownloadedPackedRockBytes,
542) -> Result<RemoteLuaRockspec, SearchAndDownloadError> {
543    let cursor = Cursor::new(&rock.bytes);
544    let rockspec_file_name = format!("{}-{}.rockspec", rock.name, rock.version);
545    let mut zip = zip::ZipArchive::new(cursor)
546        .map_err(|err| SearchAndDownloadError::ZipRead(rock.file_name.clone(), err))?;
547    let rockspec_index = (0..zip.len())
548        .find(|&i| {
549            unsafe { zip.by_index(i).unwrap_unchecked() }
550                .name()
551                .eq(&rockspec_file_name)
552        })
553        .ok_or(SearchAndDownloadError::RockspecNotFoundInPackedRock(
554            rockspec_file_name,
555        ))?;
556    let mut rockspec_file = zip
557        .by_index(rockspec_index)
558        .map_err(|err| SearchAndDownloadError::ZipExtract(rock.file_name.clone(), err))?;
559    let mut content = String::new();
560    rockspec_file.read_to_string(&mut content)?;
561    let rockspec = RemoteLuaRockspec::new(&content)?;
562    Ok(rockspec)
563}