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