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