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