Skip to main content

lux_lib/operations/
download.rs

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