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::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, checkout_ref }) => RemotePackageSourceUrl::Git {
171                url: url.to_string(),
172                checkout_ref: checkout_ref
173                    .clone()
174                    .ok_or(SearchAndDownloadError::MissingCheckoutRef(url.to_string()))?,
175                submodules: false, // unknown until the source is fetched
176            },
177            RockSourceSpec::File(path) => RemotePackageSourceUrl::File { path: path.clone() },
178            RockSourceSpec::Url(url) => RemotePackageSourceUrl::Url { url: url.clone() },
179        });
180        let rockspec = RemoteLuaRockspec::from_package_and_source_spec(package_spec, source_spec);
181        let rockspec_content = rockspec
182            .to_lua_remote_rockspec_string()
183            .expect("the infallible happened");
184        let rockspec_download = DownloadedRockspec {
185            rockspec,
186            source_url,
187            source: RemotePackageSource::RockspecContent(rockspec_content),
188        };
189        Ok(Self::RockspecOnly { rockspec_download })
190    }
191}
192
193#[derive(Error, Debug, Diagnostic)]
194pub enum DownloadRockspecError {
195    #[error("failed to download rockspec")]
196    #[diagnostic(transparent)]
197    Request(#[from] RequestError),
198    #[error("failed to convert rockspec response")]
199    #[diagnostic(help(
200        r#"the server returned a response that is not valid UTF-8.
201check your network connection and server configuration.
202if the issue persists, the server may be temporarily unavailable."#
203    ))]
204    ResponseConversion(#[from] FromUtf8Error),
205    #[error("error initialising remote package DB")]
206    #[diagnostic(forward(0))]
207    RemotePackageDB(#[from] RemotePackageDBError),
208    #[error(transparent)]
209    #[diagnostic(transparent)]
210    DownloadSrcRock(#[from] DownloadSrcRockError),
211}
212
213impl From<reqwest::Error> for DownloadRockspecError {
214    fn from(err: reqwest::Error) -> Self {
215        Self::Request(err.into())
216    }
217}
218
219/// Find and download a rockspec for a given package requirement
220async fn download_rockspec(
221    package_req: &PackageReq,
222    package_db: &RemotePackageDB,
223    config: &Config,
224) -> Result<DownloadedRockspec, SearchAndDownloadError> {
225    let rockspec = match download_remote_rock(package_req, package_db, config).await? {
226        RemoteRockDownload::RockspecOnly {
227            rockspec_download: rockspec,
228        } => rockspec,
229        RemoteRockDownload::BinaryRock {
230            rockspec_download: rockspec,
231            ..
232        } => rockspec,
233        RemoteRockDownload::SrcRock {
234            rockspec_download: rockspec,
235            ..
236        } => rockspec,
237    };
238    Ok(rockspec)
239}
240
241#[tracing::instrument(
242    name = "Downloading rock",
243    level = "info",
244    skip_all,
245    fields(package = package_req.to_string(),),
246)]
247async fn download_remote_rock(
248    package_req: &PackageReq,
249    package_db: &RemotePackageDB,
250    config: &Config,
251) -> Result<RemoteRockDownload, SearchAndDownloadError> {
252    let remote_package = package_db.find(package_req, None)?;
253    match &remote_package.source {
254        RemotePackageSource::LuarocksRockspec(url) => {
255            let package = &remote_package.package;
256            let rockspec_name = format!("{}-{}.rockspec", package.name(), package.version());
257            let url = url
258                .join(&rockspec_name)
259                .map_err(|source| SearchAndDownloadError::Parse {
260                    source,
261                    url: format!("{}/{}", url, rockspec_name),
262                })?;
263            let bytes = crate::reqwest::https_client(config)?
264                .get(url.clone())
265                .apply_access_token(config, &url)
266                .send()
267                .await?
268                .error_for_status()?
269                .bytes()
270                .await?;
271            let content = String::from_utf8(bytes.into())?;
272            let rockspec = DownloadedRockspec {
273                rockspec: RemoteLuaRockspec::new(&content)
274                    .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?,
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                    .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?,
286                source: remote_package.source,
287                source_url: remote_package.source_url,
288            };
289            Ok(RemoteRockDownload::RockspecOnly {
290                rockspec_download: rockspec,
291            })
292        }
293        RemotePackageSource::LuarocksBinaryRock(url) => {
294            // prioritise lockfile source_url
295            let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
296            {
297                url
298            } else {
299                url
300            };
301            let rock = download_binary_rock(&remote_package.package, url, config).await?;
302            let rockspec = DownloadedRockspec {
303                rockspec: unpack_rockspec(&rock).await?,
304                source: remote_package.source,
305                source_url: remote_package.source_url,
306            };
307            Ok(RemoteRockDownload::BinaryRock {
308                rockspec_download: rockspec,
309                packed_rock: rock.bytes,
310            })
311        }
312        RemotePackageSource::LuarocksSrcRock(url) => {
313            // prioritise lockfile source_url
314            let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
315            {
316                url.clone()
317            } else {
318                url.clone()
319            };
320            let rock = download_src_rock(&remote_package.package, &url, config).await?;
321            let rockspec = DownloadedRockspec {
322                rockspec: unpack_rockspec(&rock).await?,
323                source: remote_package.source,
324                source_url: remote_package.source_url,
325            };
326            Ok(RemoteRockDownload::SrcRock {
327                rockspec_download: rockspec,
328                src_rock: rock.bytes,
329                source_url: RemotePackageSourceUrl::Url { url },
330            })
331        }
332        RemotePackageSource::Local => Err(SearchAndDownloadError::LocalSource),
333        #[cfg(test)]
334        RemotePackageSource::Test => unimplemented!(),
335    }
336}
337
338#[derive(Error, Debug, Diagnostic)]
339pub enum SearchAndDownloadError {
340    #[error("failed to parse rock URL '{url}'")]
341    Parse {
342        source: url::ParseError,
343        url: String,
344    },
345    #[error(transparent)]
346    #[diagnostic(transparent)]
347    Search(#[from] SearchError),
348    #[error(transparent)]
349    #[diagnostic(transparent)]
350    Download(#[from] DownloadSrcRockError),
351    #[error(transparent)]
352    #[diagnostic(transparent)]
353    DownloadRockspec(#[from] DownloadRockspecError),
354    #[error(transparent)]
355    #[diagnostic(transparent)]
356    Fs(#[from] fs::FsError),
357    #[error("UTF-8 conversion failed")]
358    #[diagnostic(help(
359        r#"the server returned a response that is not valid UTF-8.
360check your network connection and server configuration.
361if the issue persists, the server may be temporarily unavailable."#
362    ))]
363    Utf8(#[from] FromUtf8Error),
364    #[error(transparent)]
365    #[diagnostic(transparent)]
366    Rockspec(Box<LuaRockspecError>),
367    #[error("error initialising remote package DB")]
368    #[diagnostic(forward(0))]
369    RemotePackageDB(#[from] RemotePackageDBError),
370    #[error("failed to read packed rock {0}:\n{1}")]
371    #[diagnostic(help(
372        r#"the downloaded rock may be corrupted.
373check your network connection and rerun the command to re-download it."#
374    ))]
375    ZipRead(String, zip::result::ZipError),
376    #[error("failed to extract packed rock {0}:\n{1}")]
377    #[diagnostic(help(
378        r#"the downloaded rock may be corrupted.
379check your network connection and rerun the command to re-download it."#
380    ))]
381    ZipExtract(String, zip::result::ZipError),
382    #[error("{0} not found in the packed rock.")]
383    #[diagnostic(help(
384        r#"the packed rock does not contain the expected rockspec.
385check your network connection and rerun the command to re-download it."#
386    ))]
387    RockspecNotFoundInPackedRock(String),
388    #[error(transparent)]
389    #[diagnostic(transparent)]
390    PackageSpecFromPackageReq(#[from] PackageSpecFromPackageReqError),
391    #[error("git source {0} without a revision or tag.")]
392    #[diagnostic(help(
393        r#"lux requires a pinned revision or tag to ensure reproducible builds.
394without one, the same version could resolve to different code at different times.
395try a different version, or report it to the package maintainer."#
396    ))]
397    MissingCheckoutRef(String),
398    #[error("cannot download from a local rock source.")]
399    #[diagnostic(
400        help(
401            r#"lux cannot download rocks from a local path.
402for local dependencies, use `path` in your lux.toml."#
403        ),
404        url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies")
405    )]
406    LocalSource,
407    #[error("cannot download from a local rock or embedded rockspec source.")]
408    #[diagnostic(
409        help(
410            r#"lux cannot download rocks from a local path.
411for local dependencies, use `path` in your lux.toml."#
412        ),
413        url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies")
414    )]
415    NonURLSource,
416    #[error("client error")]
417    #[diagnostic(transparent)]
418    Request(#[from] RequestError),
419}
420
421impl From<reqwest::Error> for SearchAndDownloadError {
422    fn from(err: reqwest::Error) -> Self {
423        Self::Request(err.into())
424    }
425}
426
427async fn search_and_download_src_rock(
428    package_req: &PackageReq,
429    package_db: &RemotePackageDB,
430    config: &Config,
431) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
432    let filter = Some(RemotePackageTypeFilterSpec {
433        rockspec: false,
434        binary: false,
435        src: true,
436    });
437    let remote_package = package_db.find(package_req, filter)?;
438    let source_url = remote_package
439        .source
440        .url()
441        .ok_or(SearchAndDownloadError::NonURLSource)?;
442    Ok(download_src_rock(&remote_package.package, &source_url, config).await?)
443}
444
445#[derive(Error, Debug, Diagnostic)]
446pub enum DownloadSrcRockError {
447    #[error("failed to download source rock")]
448    #[diagnostic(transparent)]
449    Request(#[from] RequestError),
450    #[error("failed to parse source rock URL")]
451    Parse(#[from] ParseError),
452}
453
454impl From<reqwest::Error> for DownloadSrcRockError {
455    fn from(err: reqwest::Error) -> Self {
456        Self::Request(err.into())
457    }
458}
459
460#[tracing::instrument(name = "Downloading src.rock", skip_all)]
461pub(crate) async fn download_src_rock(
462    package: &PackageSpec,
463    server_url: &Url,
464    config: &Config,
465) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
466    ArchiveDownload::new()
467        .package(package)
468        .server_url(server_url)
469        .config(config)
470        .ext("src.rock")
471        .download()
472        .await
473}
474
475#[tracing::instrument(name = "Downloading binary rock", skip_all)]
476pub(crate) async fn download_binary_rock(
477    package: &PackageSpec,
478    server_url: &Url,
479    config: &Config,
480) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
481    let ext = format!("{}.rock", luarocks::current_platform_luarocks_identifier());
482    ArchiveDownload::new()
483        .package(package)
484        .server_url(server_url)
485        .config(config)
486        .ext(&ext)
487        .fallback_ext("all.rock")
488        .download()
489        .await
490}
491
492#[tracing::instrument(name = "Downloading package", skip_all)]
493async fn download_src_rock_to_file(
494    package_req: &PackageReq,
495    destination_dir: Option<PathBuf>,
496    package_db: &RemotePackageDB,
497    config: &Config,
498) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
499    let rock = search_and_download_src_rock(package_req, package_db, config).await?;
500    let full_rock_name = mk_packed_rock_name(&rock.name, &rock.version, "src.rock");
501    fs::tokio::write(
502        destination_dir
503            .map(|dest| dest.join(&full_rock_name))
504            .unwrap_or_else(|| full_rock_name.clone().into()),
505        &rock.bytes,
506    )
507    .await?;
508
509    Ok(DownloadedPackedRock {
510        name: rock.name.to_owned(),
511        version: rock.version.to_owned(),
512        path: full_rock_name.into(),
513    })
514}
515
516#[derive(Builder)]
517#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
518struct ArchiveDownload<'a> {
519    package: &'a PackageSpec,
520
521    server_url: &'a Url,
522
523    ext: &'a str,
524
525    fallback_ext: Option<&'a str>,
526
527    config: &'a Config,
528}
529
530impl<State> ArchiveDownloadBuilder<'_, State>
531where
532    State: archive_download_builder::State + archive_download_builder::IsComplete,
533{
534    async fn download(self) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
535        let args = self._build();
536        download_impl(args).await
537    }
538}
539
540#[tracing::instrument(
541    name = "Downloading",
542    level = "info",
543    skip_all,
544    fields(
545        package = args.package.name().to_string(),
546        version = args.package.version().to_string(),
547        server = args.server_url.to_string(),
548    ),
549)]
550async fn download_impl(
551    args: ArchiveDownload<'_>,
552) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
553    let package = args.package;
554    let ext = args.ext;
555    let server_url = args.server_url;
556    let full_rock_name = mk_packed_rock_name(package.name(), package.version(), ext);
557    tracing::debug!(message = format!("📥 Downloading {full_rock_name}").as_str());
558    let url = server_url.join(&full_rock_name)?;
559    let response = crate::reqwest::https_client(args.config)?
560        .get(url.clone())
561        .apply_access_token(args.config, &url)
562        .send()
563        .await?;
564    let bytes = if response.status().is_success() {
565        response.bytes().await
566    } else {
567        match args.fallback_ext {
568            Some(ext) => {
569                let full_rock_name = mk_packed_rock_name(package.name(), package.version(), ext);
570                let url = server_url.join(&full_rock_name)?;
571                crate::reqwest::https_client(args.config)?
572                    .get(url.clone())
573                    .apply_access_token(args.config, &url)
574                    .send()
575                    .await?
576                    .error_for_status()?
577                    .bytes()
578                    .await
579            }
580            None => response.error_for_status()?.bytes().await,
581        }
582    }?;
583    Ok(DownloadedPackedRockBytes {
584        name: package.name().clone(),
585        version: package.version().clone(),
586        bytes,
587        file_name: full_rock_name,
588        url,
589    })
590}
591fn mk_packed_rock_name(name: &PackageName, version: &PackageVersion, ext: &str) -> String {
592    format!("{name}-{version}.{ext}")
593}
594
595pub(crate) async fn unpack_rockspec(
596    rock: &DownloadedPackedRockBytes,
597) -> Result<RemoteLuaRockspec, SearchAndDownloadError> {
598    let cursor = Cursor::new(&rock.bytes);
599    let rockspec_file_name = format!("{}-{}.rockspec", rock.name, rock.version);
600    let mut zip = zip::ZipArchive::new(cursor)
601        .map_err(|err| SearchAndDownloadError::ZipRead(rock.file_name.clone(), err))?;
602    let rockspec_index = (0..zip.len())
603        .find(|&i| {
604            unsafe { zip.by_index(i).unwrap_unchecked() }
605                .name()
606                .eq(&rockspec_file_name)
607        })
608        .ok_or(SearchAndDownloadError::RockspecNotFoundInPackedRock(
609            rockspec_file_name.to_string(),
610        ))?;
611    let mut rockspec_file = zip
612        .by_index(rockspec_index)
613        .map_err(|err| SearchAndDownloadError::ZipExtract(rock.file_name.clone(), err))?;
614    let mut content = String::new();
615    rockspec_file
616        .read_to_string(&mut content)
617        .map_err(|source| fs::FsError::ReadToString {
618            path: PathBuf::from(&rockspec_file_name),
619            source,
620        })?;
621    let rockspec = RemoteLuaRockspec::new(&content)
622        .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?;
623    Ok(rockspec)
624}