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