1use crate::config::Config;
2use crate::git::url::RemoteGitUrlParseError;
3use crate::git::{GitRef, GitSource};
4use crate::hash::HasIntegrity;
5use crate::lockfile::RemotePackageSourceUrl;
6use crate::lua_rockspec::{RemoteRockSource, RockSourceSpec};
7use crate::package;
8use crate::project;
9use crate::reqwest::{RequestBuilderExt, RequestError};
10use crate::rockspec::Rockspec;
11use crate::wally;
12use crate::{fs, operations};
13use auth_git2::{GitAuthenticator, Prompter};
14use bon::Builder;
15use git2::build::RepoBuilder;
16use git2::{FetchOptions, RemoteCallbacks};
17use miette::Diagnostic;
18use remove_dir_all::remove_dir_all;
19use ssri::Integrity;
20use std::io;
21use std::io::Cursor;
22use std::io::Read;
23use std::path::{Path, PathBuf};
24use thiserror::Error;
25
26use super::DownloadSrcRockError;
27use super::UnpackError;
28
29#[derive(Builder)]
32#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
33pub struct FetchSrc<'a, R: Rockspec> {
34 #[builder(start_fn)]
35 dest_dir: &'a Path,
36 #[builder(start_fn)]
37 rockspec: &'a R,
38 #[builder(start_fn)]
39 config: &'a Config,
40 #[builder(setters(vis = "pub(crate)"))]
41 source_url: Option<RemotePackageSourceUrl>,
42}
43
44#[derive(Debug)]
45pub(crate) struct RemotePackageSourceMetadata {
46 pub hash: Integrity,
47 pub source_url: RemotePackageSourceUrl,
48}
49
50impl<R: Rockspec, State> FetchSrcBuilder<'_, R, State>
51where
52 State: fetch_src_builder::State + fetch_src_builder::IsComplete,
53{
54 pub async fn fetch(self) -> Result<(), FetchSrcError> {
56 self.fetch_internal().await?;
57 Ok(())
58 }
59
60 pub(crate) async fn fetch_internal(self) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
63 let fetch = self._build();
64 let rockspec = fetch.rockspec;
65 let rock_source = rockspec.source().current_platform();
66 let dest_dir = fetch.dest_dir;
67 let config = fetch.config;
68 let source_spec = match &fetch.source_url {
70 Some(source_url) => match source_url {
71 RemotePackageSourceUrl::Git {
72 url, checkout_ref, ..
73 } => RockSourceSpec::Git(GitSource {
74 url: url.parse()?,
75 git_ref: Some(GitRef::Tag(checkout_ref.clone())),
76 }),
77 RemotePackageSourceUrl::Url { url } => RockSourceSpec::Url(url.clone()),
78 RemotePackageSourceUrl::File { path } => RockSourceSpec::File(path.clone()),
79 },
80 None => rock_source.source_spec.clone(),
81 };
82 match fetch_src_impl(source_spec, rockspec, rock_source, dest_dir, config).await {
83 Err(err)
84 if fetch
85 .source_url
86 .is_some_and(|url| matches!(url, RemotePackageSourceUrl::File { .. })) =>
87 {
88 Err(err)
90 }
91 Err(err) => match &fetch.rockspec.source().current_platform().source_spec {
92 RockSourceSpec::Git(_) | RockSourceSpec::Url(_) => {
93 let package = package::Spec::new(
94 fetch.rockspec.package().clone(),
95 fetch.rockspec.version().clone(),
96 );
97 match FetchSrcRock::new(&package, fetch.dest_dir, fetch.config)
98 .fetch()
99 .await
100 {
101 Ok(metadata) => Ok(metadata),
102 Err(fallback_err) => {
103 tracing::error!("fallback .src.rock download failed: {fallback_err:?}");
104 Err(err)
105 }
106 }
107 }
108 RockSourceSpec::File(_) | RockSourceSpec::Wally(_) => Err(err),
109 },
110 Ok(metadata) => Ok(metadata),
111 }
112 }
113}
114
115#[derive(Error, Debug, Diagnostic)]
116#[non_exhaustive]
117pub enum FetchSrcError {
118 #[error("failed to clone rock source")]
119 #[diagnostic(help("check your network connection and verify the git URL is correct."))]
120 GitClone(#[from] git2::Error),
121 #[error("git operation failed")]
122 #[diagnostic(help("this is a bug, please report it."))]
123 GitJoin(#[from] tokio::task::JoinError),
124 #[error("failed to parse git URL")]
125 #[diagnostic(forward(0))]
126 GitUrlParse(#[from] RemoteGitUrlParseError),
127 #[error(transparent)]
128 #[diagnostic(transparent)]
129 Request(#[from] RequestError),
130 #[error(transparent)]
131 #[diagnostic(transparent)]
132 Unpack(#[from] UnpackError),
133 #[error(transparent)]
134 #[diagnostic(transparent)]
135 FetchSrcRock(#[from] FetchSrcRockError),
136 #[error("unable to remove the '.git' directory")]
137 #[diagnostic(help(
138 "check that no process is using the directory and you have write permissions."
139 ))]
140 CleanGitDir(#[source] io::Error),
141 #[error("failed to resolve wally package '{0}'")]
142 #[diagnostic(
143 code(lux_lib::wally::unresolved_source),
144 help("this is a bug, please report it.")
145 )]
146 WallySource(String),
147 #[error("unable to compute hash")]
148 Hash(#[source] io::Error),
149 #[error(transparent)]
150 #[diagnostic(transparent)]
151 Fs(#[from] fs::Error),
152}
153
154impl From<reqwest::Error> for FetchSrcError {
155 fn from(err: reqwest::Error) -> Self {
156 Self::Request(err.into())
157 }
158}
159
160#[derive(Builder)]
163#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
164struct FetchSrcRock<'a> {
165 #[builder(start_fn)]
166 package: &'a package::Spec,
167 #[builder(start_fn)]
168 dest_dir: &'a Path,
169 #[builder(start_fn)]
170 config: &'a Config,
171}
172
173impl<State> FetchSrcRockBuilder<'_, State>
174where
175 State: fetch_src_rock_builder::State + fetch_src_rock_builder::IsComplete,
176{
177 pub async fn fetch(self) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
178 do_fetch_src_rock(self._build()).await
179 }
180}
181
182#[derive(Error, Debug, Diagnostic)]
183#[non_exhaustive]
184#[error(transparent)]
185pub enum FetchSrcRockError {
186 DownloadSrcRock(#[from] DownloadSrcRockError),
187 Unpack(#[from] UnpackError),
188 Io(#[from] io::Error),
189}
190
191#[derive(Copy, Clone, Debug)]
193struct NullPrompter;
194
195impl Prompter for NullPrompter {
196 fn prompt_username_password(&mut self, _: &str, _: &git2::Config) -> Option<(String, String)> {
197 None
198 }
199
200 fn prompt_password(&mut self, _: &str, _: &str, _: &git2::Config) -> Option<String> {
201 None
202 }
203
204 fn prompt_ssh_key_passphrase(&mut self, _: &Path, _: &git2::Config) -> Option<String> {
205 None
206 }
207}
208
209fn init_submodules(
212 repo: &git2::Repository,
213 auth: &GitAuthenticator,
214 git_config: &git2::Config,
215) -> Result<Vec<PathBuf>, FetchSrcError> {
216 let workdir = repo
217 .workdir()
218 .ok_or_else(|| git2::Error::from_str("cloned repository has no working directory"))?;
219 let mut submodule_paths = Vec::new();
220 for mut submodule in repo.submodules()? {
221 let mut callbacks = RemoteCallbacks::new();
222 callbacks.credentials(auth.credentials(git_config));
223 let mut fetch_options = FetchOptions::new();
224 fetch_options.remote_callbacks(callbacks);
225 let mut options = git2::SubmoduleUpdateOptions::new();
226 options.fetch(fetch_options);
227 submodule.update(true, Some(&mut options))?;
228 let git_file = workdir.join(submodule.path()).join(".git");
230 if git_file.is_dir() {
231 fs::sync::remove_dir_all(&git_file)?;
232 } else {
233 fs::sync::remove_file(&git_file)?;
234 }
235 submodule_paths.push(submodule.path().to_path_buf());
236 }
237 Ok(submodule_paths)
238}
239
240#[tracing::instrument(
241 name = "Fetching source",
242 level = "info",
243 skip_all,
244 fields(location = source_spec.to_string()),
245)]
246async fn fetch_src_impl<R: Rockspec>(
247 mut source_spec: RockSourceSpec,
248 rockspec: &R,
249 rock_source: &RemoteRockSource,
250 dest_dir: &Path,
251 config: &Config,
252) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
253 if let Some(vendor_dir) = config.vendor_dir() {
254 source_spec = match source_spec {
255 RockSourceSpec::File(_) => source_spec,
258 _ => {
259 let pkg_vendor_dir =
260 vendor_dir.join(format!("{}@{}", rockspec.package(), rockspec.version()));
261 RockSourceSpec::File(pkg_vendor_dir)
262 }
263 }
264 }
265 let metadata = match &source_spec {
266 RockSourceSpec::Git(git) => {
267 let url = git.url.to_string();
268 tracing::debug!(message = format!("Cloning {url}").as_str());
269
270 let clone_url = url.clone();
271 let clone_dir = dest_dir.to_path_buf();
272 let host = git.url.host().to_string();
273 let no_prompt = config.no_prompt();
274 let access_token = config.access_token(&host);
275 let git_ref = git.git_ref.clone();
276
277 let (checkout_ref, has_submodules) = tokio::task::spawn_blocking(move || {
278 let mut auth = if no_prompt {
279 GitAuthenticator::default()
280 .try_password_prompt(0)
281 .prompt_ssh_key_password(false)
282 .set_prompter(NullPrompter)
283 } else {
284 GitAuthenticator::default()
285 };
286 if let Some(access_token) = access_token {
287 auth = auth.add_plaintext_credentials(&host, access_token.username(), unsafe {
288 access_token.password()
289 });
290 }
291 let git_config = git2::Config::open_default()?;
292 let mut callbacks = RemoteCallbacks::new();
293 callbacks.credentials(auth.credentials(&git_config));
294 let mut fetch_options = FetchOptions::new();
295 fetch_options.update_fetchhead(false);
296 fetch_options.remote_callbacks(callbacks);
297 let checkout_ref = match &git_ref {
298 Some(GitRef::Tag(tag)) => Some(tag.as_str()),
299 Some(GitRef::Branch(branch)) => Some(branch.as_str()),
300 None => None,
301 };
302 if checkout_ref.is_none() {
303 fetch_options.depth(1);
304 };
305 let mut repo_builder = RepoBuilder::new();
306 repo_builder.fetch_options(fetch_options);
307 let repo = repo_builder.clone(&clone_url, &clone_dir)?;
308
309 let checkout_ref = match checkout_ref {
310 Some(checkout_ref) => {
311 let ref_name = if let Some(GitRef::Branch(branch)) = &git_ref {
312 format!("origin/{branch}")
313 } else {
314 checkout_ref.to_string()
315 };
316 let (object, _) = repo.revparse_ext(&ref_name)?;
317 repo.checkout_tree(&object, None)?;
318 checkout_ref.to_string()
319 }
320 None => {
321 let head = repo.head()?;
322 let commit = head.peel_to_commit()?;
323 commit.id().to_string()
324 }
325 };
326
327 let submodule_paths = init_submodules(&repo, &auth, &git_config)?;
328
329 Ok::<_, FetchSrcError>((checkout_ref, !submodule_paths.is_empty()))
330 })
331 .await??;
332
333 remove_dir_all(dest_dir.join(".git")).map_err(FetchSrcError::CleanGitDir)?;
335 let hash = dest_dir.hash().await.map_err(FetchSrcError::Hash)?;
336 RemotePackageSourceMetadata {
337 hash,
338 source_url: RemotePackageSourceUrl::Git {
339 url,
340 checkout_ref,
341 submodules: has_submodules,
342 },
343 }
344 }
345 RockSourceSpec::Url(url) => {
346 tracing::debug!(message = format!("Downloading {url}").as_str());
347
348 let request = crate::reqwest::http_client(config)?
351 .get(url.clone())
352 .apply_access_token(config, url);
353 let request = wally::apply_content_headers(request, url);
354 let response = request.send().await?.error_for_status()?.bytes().await?;
355 let hash = response.hash().await.map_err(FetchSrcError::Hash)?;
356 let file_name = url
357 .path_segments()
358 .and_then(|mut segments| segments.next_back())
359 .and_then(|name| {
360 if name.is_empty() {
361 None
362 } else {
363 Some(name.to_string())
364 }
365 })
366 .unwrap_or(url.to_string());
367 let cursor = Cursor::new(response);
368 let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
369 operations::unpack::unpack(
370 mime_type,
371 cursor,
372 rock_source.unpack_dir.is_none(),
373 file_name,
374 dest_dir,
375 )
376 .await?;
377 RemotePackageSourceMetadata {
378 hash,
379 source_url: RemotePackageSourceUrl::Url { url: url.clone() },
380 }
381 }
382 RockSourceSpec::Wally(_) => {
383 return Err(FetchSrcError::WallySource(rockspec.package().to_string()))
384 }
385 RockSourceSpec::File(path) => {
386 tracing::debug!(message = format!("Copying {}", path.display()).as_str());
387
388 let hash = if path.is_dir() {
389 let hash = path.hash().await.map_err(FetchSrcError::Hash)?;
390 project::copy_dir(path, dest_dir).await?;
391 hash
392 } else {
393 let mut file = fs::sync::open(path)?;
394 let mut buffer = Vec::new();
395 file.read_to_end(&mut buffer)
396 .map_err(|source| fs::Error::Read {
397 path: path.to_path_buf(),
398 source,
399 })?;
400 let mime_type = infer::get(&buffer).map(|file_type| file_type.mime_type());
401 let file_name = path
402 .file_name()
403 .map(|os_str| os_str.to_string_lossy())
404 .unwrap_or(path.to_string_lossy())
405 .to_string();
406 operations::unpack::unpack(
407 mime_type,
408 file,
409 rock_source.unpack_dir.is_none(),
410 file_name,
411 dest_dir,
412 )
413 .await?;
414 path.hash().await.map_err(FetchSrcError::Hash)?
415 };
416 RemotePackageSourceMetadata {
417 hash,
418 source_url: RemotePackageSourceUrl::File { path: path.clone() },
419 }
420 }
421 };
422 Ok(metadata)
423}
424
425#[tracing::instrument(
426 name = "Fetching src.rock",
427 level = "info",
428 skip_all,
429 fields(package = fetch.package.to_string(),),
430)]
431async fn do_fetch_src_rock(
432 fetch: FetchSrcRock<'_>,
433) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
434 let package = fetch.package;
435 let dest_dir = fetch.dest_dir;
436 let config = fetch.config;
437 let src_rock = operations::download_src_rock(package, config.server(), fetch.config).await?;
438 let hash = src_rock.bytes.hash().await?;
439 let cursor = Cursor::new(src_rock.bytes);
440 let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
441 operations::unpack::unpack(mime_type, cursor, true, src_rock.file_name, dest_dir).await?;
442 Ok(RemotePackageSourceMetadata {
443 hash,
444 source_url: RemotePackageSourceUrl::Url { url: src_rock.url },
445 })
446}