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