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
31pub 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 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 pub fn package_db(self, package_db: &'a PackageDB) -> Self {
51 Self {
52 package_db: Some(package_db),
53 ..self
54 }
55 }
56
57 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 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 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#[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 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, }
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
250async 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 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 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}