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