Skip to main content

lux_lib/operations/
download.rs

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