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 rockspec::Rockspec,
27};
28
29pub struct Download<'a> {
31 package_req: &'a PackageReq,
32 package_db: Option<&'a RemotePackageDB>,
33 config: &'a Config,
34}
35
36impl<'a> Download<'a> {
37 pub fn new(package_req: &'a PackageReq, config: &'a Config) -> Self {
39 Self {
40 package_req,
41 package_db: None,
42 config,
43 }
44 }
45
46 pub fn package_db(self, package_db: &'a RemotePackageDB) -> Self {
49 Self {
50 package_db: Some(package_db),
51 ..self
52 }
53 }
54
55 pub async fn download_rockspec(self) -> Result<DownloadedRockspec, SearchAndDownloadError> {
57 match self.package_db {
58 Some(db) => download_rockspec(self.package_req, db, self.config).await,
59 None => {
60 let db = RemotePackageDB::from_config(self.config).await?;
61 download_rockspec(self.package_req, &db, self.config).await
62 }
63 }
64 }
65
66 pub async fn download_src_rock_to_file(
69 self,
70 destination_dir: Option<PathBuf>,
71 ) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
72 match self.package_db {
73 Some(db) => {
74 download_src_rock_to_file(self.package_req, destination_dir, db, self.config).await
75 }
76 None => {
77 let db = RemotePackageDB::from_config(self.config).await?;
78 download_src_rock_to_file(self.package_req, destination_dir, &db, self.config).await
79 }
80 }
81 }
82
83 pub async fn search_and_download_src_rock(
85 self,
86 ) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
87 match self.package_db {
88 Some(db) => search_and_download_src_rock(self.package_req, db, self.config).await,
89 None => {
90 let db = RemotePackageDB::from_config(self.config).await?;
91 search_and_download_src_rock(self.package_req, &db, self.config).await
92 }
93 }
94 }
95
96 pub(crate) async fn download_remote_rock(
97 self,
98 ) -> Result<RemoteRockDownload, SearchAndDownloadError> {
99 match self.package_db {
100 Some(db) => download_remote_rock(self.package_req, db, self.config).await,
101 None => {
102 let db = RemotePackageDB::from_config(self.config).await?;
103 download_remote_rock(self.package_req, &db, self.config).await
104 }
105 }
106 }
107}
108
109pub struct DownloadedPackedRockBytes {
110 pub name: PackageName,
111 pub version: PackageVersion,
112 pub bytes: Bytes,
113 pub file_name: String,
114 pub url: Url,
115}
116
117pub struct DownloadedPackedRock {
118 pub name: PackageName,
119 pub version: PackageVersion,
120 pub path: PathBuf,
121}
122
123#[derive(Clone, Debug)]
125pub struct DownloadedRockspec {
126 pub rockspec: RemoteLuaRockspec,
127 pub(crate) source: RemotePackageSource,
128 pub(crate) source_url: Option<RemotePackageSourceUrl>,
129}
130
131#[derive(Clone, Debug)]
132pub(crate) enum RemoteRockDownload {
133 RockspecOnly {
134 rockspec_download: DownloadedRockspec,
135 },
136 BinaryRock {
137 rockspec_download: DownloadedRockspec,
138 packed_rock: Bytes,
139 },
140 SrcRock {
141 rockspec_download: DownloadedRockspec,
142 src_rock: Bytes,
143 source_url: RemotePackageSourceUrl,
144 },
145}
146
147impl RemoteRockDownload {
148 pub fn rockspec(&self) -> &RemoteLuaRockspec {
149 &self.rockspec_download().rockspec
150 }
151 pub fn rockspec_download(&self) -> &DownloadedRockspec {
152 match self {
153 Self::RockspecOnly { rockspec_download }
154 | Self::BinaryRock {
155 rockspec_download, ..
156 }
157 | Self::SrcRock {
158 rockspec_download, ..
159 } => rockspec_download,
160 }
161 }
162 pub(crate) fn from_package_req_and_source_spec(
164 package_req: PackageReq,
165 source_spec: RockSourceSpec,
166 ) -> Result<Self, SearchAndDownloadError> {
167 let package_spec = package_req.try_into()?;
168 let source_url = Some(match &source_spec {
169 RockSourceSpec::Git(GitSource { url, checkout_ref }) => RemotePackageSourceUrl::Git {
170 url: url.to_string(),
171 checkout_ref: checkout_ref
172 .clone()
173 .ok_or(SearchAndDownloadError::MissingCheckoutRef(url.to_string()))?,
174 },
175 RockSourceSpec::File(path) => RemotePackageSourceUrl::File { path: path.clone() },
176 RockSourceSpec::Url(url) => RemotePackageSourceUrl::Url { url: url.clone() },
177 });
178 let rockspec = RemoteLuaRockspec::from_package_and_source_spec(package_spec, source_spec);
179 let rockspec_content = rockspec
180 .to_lua_remote_rockspec_string()
181 .expect("the infallible happened");
182 let rockspec_download = DownloadedRockspec {
183 rockspec,
184 source_url,
185 source: RemotePackageSource::RockspecContent(rockspec_content),
186 };
187 Ok(Self::RockspecOnly { rockspec_download })
188 }
189}
190
191#[derive(Error, Debug, Diagnostic)]
192pub enum DownloadRockspecError {
193 #[error("failed to download rockspec: {0}")]
194 #[diagnostic(help(
195 "check your network connection and verify the package exists on the server."
196 ))]
197 Request(#[from] reqwest::Error),
198 #[error("failed to convert rockspec response: {0}")]
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:\n{0}")]
206 #[diagnostic(forward(0))]
207 RemotePackageDB(#[from] RemotePackageDBError),
208 #[error(transparent)]
209 #[diagnostic(transparent)]
210 DownloadSrcRock(#[from] DownloadSrcRockError),
211}
212
213async fn download_rockspec(
215 package_req: &PackageReq,
216 package_db: &RemotePackageDB,
217 config: &Config,
218) -> Result<DownloadedRockspec, SearchAndDownloadError> {
219 let rockspec = match download_remote_rock(package_req, package_db, config).await? {
220 RemoteRockDownload::RockspecOnly {
221 rockspec_download: rockspec,
222 } => rockspec,
223 RemoteRockDownload::BinaryRock {
224 rockspec_download: rockspec,
225 ..
226 } => rockspec,
227 RemoteRockDownload::SrcRock {
228 rockspec_download: rockspec,
229 ..
230 } => rockspec,
231 };
232 Ok(rockspec)
233}
234
235#[tracing::instrument(
236 name = "Downloading rock",
237 level = "info",
238 skip_all,
239 fields(package = package_req.to_string(),),
240)]
241async fn download_remote_rock(
242 package_req: &PackageReq,
243 package_db: &RemotePackageDB,
244 config: &Config,
245) -> Result<RemoteRockDownload, SearchAndDownloadError> {
246 let remote_package = package_db.find(package_req, None)?;
247 match &remote_package.source {
248 RemotePackageSource::LuarocksRockspec(url) => {
249 let package = &remote_package.package;
250 let rockspec_name = format!("{}-{}.rockspec", package.name(), package.version());
251 let bytes = crate::reqwest::https_client(config)?
252 .get(format!("{}/{}", url, rockspec_name))
253 .send()
254 .await
255 .map_err(DownloadRockspecError::Request)?
256 .error_for_status()
257 .map_err(DownloadRockspecError::Request)?
258 .bytes()
259 .await
260 .map_err(DownloadRockspecError::Request)?;
261 let content = String::from_utf8(bytes.into())?;
262 let rockspec = DownloadedRockspec {
263 rockspec: RemoteLuaRockspec::new(&content)
264 .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?,
265 source: remote_package.source,
266 source_url: remote_package.source_url,
267 };
268 Ok(RemoteRockDownload::RockspecOnly {
269 rockspec_download: rockspec,
270 })
271 }
272 RemotePackageSource::RockspecContent(content) => {
273 let rockspec = DownloadedRockspec {
274 rockspec: RemoteLuaRockspec::new(content)
275 .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?,
276 source: remote_package.source,
277 source_url: remote_package.source_url,
278 };
279 Ok(RemoteRockDownload::RockspecOnly {
280 rockspec_download: rockspec,
281 })
282 }
283 RemotePackageSource::LuarocksBinaryRock(url) => {
284 let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
286 {
287 url
288 } else {
289 url
290 };
291 let rock = download_binary_rock(&remote_package.package, url, config).await?;
292 let rockspec = DownloadedRockspec {
293 rockspec: unpack_rockspec(&rock).await?,
294 source: remote_package.source,
295 source_url: remote_package.source_url,
296 };
297 Ok(RemoteRockDownload::BinaryRock {
298 rockspec_download: rockspec,
299 packed_rock: rock.bytes,
300 })
301 }
302 RemotePackageSource::LuarocksSrcRock(url) => {
303 let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
305 {
306 url.clone()
307 } else {
308 url.clone()
309 };
310 let rock = download_src_rock(&remote_package.package, &url, config).await?;
311 let rockspec = DownloadedRockspec {
312 rockspec: unpack_rockspec(&rock).await?,
313 source: remote_package.source,
314 source_url: remote_package.source_url,
315 };
316 Ok(RemoteRockDownload::SrcRock {
317 rockspec_download: rockspec,
318 src_rock: rock.bytes,
319 source_url: RemotePackageSourceUrl::Url { url },
320 })
321 }
322 RemotePackageSource::Local => Err(SearchAndDownloadError::LocalSource),
323 #[cfg(test)]
324 RemotePackageSource::Test => unimplemented!(),
325 }
326}
327
328#[derive(Error, Debug, Diagnostic)]
329pub enum SearchAndDownloadError {
330 #[error(transparent)]
331 #[diagnostic(transparent)]
332 Search(#[from] SearchError),
333 #[error(transparent)]
334 #[diagnostic(transparent)]
335 Download(#[from] DownloadSrcRockError),
336 #[error(transparent)]
337 #[diagnostic(transparent)]
338 DownloadRockspec(#[from] DownloadRockspecError),
339 #[error(transparent)]
340 #[diagnostic(transparent)]
341 Fs(#[from] fs::FsError),
342 #[error("UTF-8 conversion failed: {0}")]
343 #[diagnostic(help(
344 r#"the server returned a response that is not valid UTF-8.
345check your network connection and server configuration.
346if the issue persists, the server may be temporarily unavailable."#
347 ))]
348 Utf8(#[from] FromUtf8Error),
349 #[error(transparent)]
350 #[diagnostic(transparent)]
351 Rockspec(Box<LuaRockspecError>),
352 #[error("error initialising remote package DB:\n{0}")]
353 #[diagnostic(forward(0))]
354 RemotePackageDB(#[from] RemotePackageDBError),
355 #[error("failed to read packed rock {0}:\n{1}")]
356 #[diagnostic(help(
357 r#"the downloaded rock may be corrupted.
358check your network connection and rerun the command to re-download it."#
359 ))]
360 ZipRead(String, zip::result::ZipError),
361 #[error("failed to extract packed rock {0}:\n{1}")]
362 #[diagnostic(help(
363 r#"the downloaded rock may be corrupted.
364check your network connection and rerun the command to re-download it."#
365 ))]
366 ZipExtract(String, zip::result::ZipError),
367 #[error("{0} not found in the packed rock.")]
368 #[diagnostic(help(
369 r#"the packed rock does not contain the expected rockspec.
370check your network connection and rerun the command to re-download it."#
371 ))]
372 RockspecNotFoundInPackedRock(String),
373 #[error(transparent)]
374 #[diagnostic(transparent)]
375 PackageSpecFromPackageReq(#[from] PackageSpecFromPackageReqError),
376 #[error("git source {0} without a revision or tag.")]
377 #[diagnostic(help(
378 r#"lux requires a pinned revision or tag to ensure reproducible builds.
379without one, the same version could resolve to different code at different times.
380try a different version, or report it to the package maintainer."#
381 ))]
382 MissingCheckoutRef(String),
383 #[error("cannot download from a local rock source.")]
384 #[diagnostic(
385 help(
386 r#"lux cannot download rocks from a local path.
387for local dependencies, use `path` in your lux.toml."#
388 ),
389 url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies")
390 )]
391 LocalSource,
392 #[error("cannot download from a local rock or embedded rockspec source.")]
393 #[diagnostic(
394 help(
395 r#"lux cannot download rocks from a local path.
396for local dependencies, use `path` in your lux.toml."#
397 ),
398 url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies")
399 )]
400 NonURLSource,
401 #[error("client error:\n{0}")]
402 #[diagnostic(help(
403 "check your network connection and verify the package exists on the server."
404 ))]
405 Request(#[from] reqwest::Error),
406}
407
408async fn search_and_download_src_rock(
409 package_req: &PackageReq,
410 package_db: &RemotePackageDB,
411 config: &Config,
412) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
413 let filter = Some(RemotePackageTypeFilterSpec {
414 rockspec: false,
415 binary: false,
416 src: true,
417 });
418 let remote_package = package_db.find(package_req, filter)?;
419 let source_url = remote_package
420 .source
421 .url()
422 .ok_or(SearchAndDownloadError::NonURLSource)?;
423 Ok(download_src_rock(&remote_package.package, &source_url, config).await?)
424}
425
426#[derive(Error, Debug, Diagnostic)]
427pub enum DownloadSrcRockError {
428 #[error("failed to download source rock: {0}")]
429 #[diagnostic(help(
430 "check your network connection and verify the package exists on the server."
431 ))]
432 Request(#[from] reqwest::Error),
433 #[error("failed to parse source rock URL: {0}")]
434 Parse(#[from] ParseError),
435}
436
437#[tracing::instrument(name = "Downloading src.rock", skip_all)]
438pub(crate) async fn download_src_rock(
439 package: &PackageSpec,
440 server_url: &Url,
441 config: &Config,
442) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
443 ArchiveDownload::new()
444 .package(package)
445 .server_url(server_url)
446 .config(config)
447 .ext("src.rock")
448 .download()
449 .await
450}
451
452#[tracing::instrument(name = "Downloading binary rock", skip_all)]
453pub(crate) async fn download_binary_rock(
454 package: &PackageSpec,
455 server_url: &Url,
456 config: &Config,
457) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
458 let ext = format!("{}.rock", luarocks::current_platform_luarocks_identifier());
459 ArchiveDownload::new()
460 .package(package)
461 .server_url(server_url)
462 .config(config)
463 .ext(&ext)
464 .fallback_ext("all.rock")
465 .download()
466 .await
467}
468
469#[tracing::instrument(name = "Downloading package", skip_all)]
470async fn download_src_rock_to_file(
471 package_req: &PackageReq,
472 destination_dir: Option<PathBuf>,
473 package_db: &RemotePackageDB,
474 config: &Config,
475) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
476 let rock = search_and_download_src_rock(package_req, package_db, config).await?;
477 let full_rock_name = mk_packed_rock_name(&rock.name, &rock.version, "src.rock");
478 fs::tokio::write(
479 destination_dir
480 .map(|dest| dest.join(&full_rock_name))
481 .unwrap_or_else(|| full_rock_name.clone().into()),
482 &rock.bytes,
483 )
484 .await?;
485
486 Ok(DownloadedPackedRock {
487 name: rock.name.to_owned(),
488 version: rock.version.to_owned(),
489 path: full_rock_name.into(),
490 })
491}
492
493#[derive(Builder)]
494#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
495struct ArchiveDownload<'a> {
496 package: &'a PackageSpec,
497
498 server_url: &'a Url,
499
500 ext: &'a str,
501
502 fallback_ext: Option<&'a str>,
503
504 config: &'a Config,
505}
506
507impl<State> ArchiveDownloadBuilder<'_, State>
508where
509 State: archive_download_builder::State + archive_download_builder::IsComplete,
510{
511 async fn download(self) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
512 let args = self._build();
513 download_impl(args).await
514 }
515}
516
517#[tracing::instrument(
518 name = "Downloading",
519 level = "info",
520 skip_all,
521 fields(
522 package = args.package.name().to_string(),
523 version = args.package.version().to_string(),
524 server = args.server_url.to_string(),
525 ),
526)]
527async fn download_impl(
528 args: ArchiveDownload<'_>,
529) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
530 let package = args.package;
531 let ext = args.ext;
532 let server_url = args.server_url;
533 let full_rock_name = mk_packed_rock_name(package.name(), package.version(), ext);
534 tracing::debug!(message = format!("📥 Downloading {full_rock_name}").as_str());
535 let url = server_url.join(&full_rock_name)?;
536 let response = crate::reqwest::https_client(args.config)?
537 .get(url.clone())
538 .send()
539 .await?;
540 let bytes = if response.status().is_success() {
541 response.bytes().await
542 } else {
543 match args.fallback_ext {
544 Some(ext) => {
545 let full_rock_name = mk_packed_rock_name(package.name(), package.version(), ext);
546 let url = server_url.join(&full_rock_name)?;
547 crate::reqwest::https_client(args.config)?
548 .get(url.clone())
549 .send()
550 .await?
551 .error_for_status()?
552 .bytes()
553 .await
554 }
555 None => response.error_for_status()?.bytes().await,
556 }
557 }?;
558 Ok(DownloadedPackedRockBytes {
559 name: package.name().clone(),
560 version: package.version().clone(),
561 bytes,
562 file_name: full_rock_name,
563 url,
564 })
565}
566fn mk_packed_rock_name(name: &PackageName, version: &PackageVersion, ext: &str) -> String {
567 format!("{name}-{version}.{ext}")
568}
569
570pub(crate) async fn unpack_rockspec(
571 rock: &DownloadedPackedRockBytes,
572) -> Result<RemoteLuaRockspec, SearchAndDownloadError> {
573 let cursor = Cursor::new(&rock.bytes);
574 let rockspec_file_name = format!("{}-{}.rockspec", rock.name, rock.version);
575 let mut zip = zip::ZipArchive::new(cursor)
576 .map_err(|err| SearchAndDownloadError::ZipRead(rock.file_name.clone(), err))?;
577 let rockspec_index = (0..zip.len())
578 .find(|&i| {
579 unsafe { zip.by_index(i).unwrap_unchecked() }
580 .name()
581 .eq(&rockspec_file_name)
582 })
583 .ok_or(SearchAndDownloadError::RockspecNotFoundInPackedRock(
584 rockspec_file_name.to_string(),
585 ))?;
586 let mut rockspec_file = zip
587 .by_index(rockspec_index)
588 .map_err(|err| SearchAndDownloadError::ZipExtract(rock.file_name.clone(), err))?;
589 let mut content = String::new();
590 rockspec_file
591 .read_to_string(&mut content)
592 .map_err(|source| fs::FsError::ReadToString {
593 path: PathBuf::from(&rockspec_file_name),
594 source,
595 })?;
596 let rockspec = RemoteLuaRockspec::new(&content)
597 .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?;
598 Ok(rockspec)
599}