Skip to main content

lux_lib/operations/
download.rs

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