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