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