Skip to main content

lux_lib/operations/
download.rs

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