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