Skip to main content

lux_lib/operations/
download.rs

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