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