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