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