1use crate::build::utils::recursive_copy_dir;
2use crate::config::Config;
3use crate::git::url::RemoteGitUrlParseError;
4use crate::git::GitSource;
5use crate::hash::HasIntegrity;
6use crate::lockfile::RemotePackageSourceUrl;
7use crate::lua_rockspec::RockSourceSpec;
8use crate::package::PackageSpec;
9use crate::rockspec::Rockspec;
10use crate::{fs, operations};
11use auth_git2::{GitAuthenticator, Prompter};
12use bon::Builder;
13use bytes::Bytes;
14use git2::build::RepoBuilder;
15use git2::{Direction, 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;
23use std::path::PathBuf;
24use thiserror::Error;
25use tracing::span;
26
27use super::DownloadSrcRockError;
28use super::UnpackError;
29
30#[derive(Builder)]
33#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
34pub struct FetchSrc<'a, R: Rockspec> {
35 #[builder(start_fn)]
36 dest_dir: &'a Path,
37 #[builder(start_fn)]
38 rockspec: &'a R,
39 #[builder(start_fn)]
40 config: &'a Config,
41 #[builder(setters(vis = "pub(crate)"))]
42 source_url: Option<RemotePackageSourceUrl>,
43}
44
45#[derive(Debug)]
46pub(crate) struct RemotePackageSourceMetadata {
47 pub hash: Integrity,
48 pub source_url: RemotePackageSourceUrl,
49}
50
51impl<R: Rockspec, State> FetchSrcBuilder<'_, R, State>
52where
53 State: fetch_src_builder::State + fetch_src_builder::IsComplete,
54{
55 pub async fn fetch(self) -> Result<(), FetchSrcError> {
57 self.fetch_internal().await?;
58 Ok(())
59 }
60
61 pub(crate) async fn fetch_internal(self) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
64 let fetch = self._build();
65 match do_fetch_src(&fetch).await {
66 Err(err)
67 if fetch
68 .source_url
69 .is_some_and(|url| matches!(url, RemotePackageSourceUrl::File { .. })) =>
70 {
71 Err(err)
73 }
74 Err(err) => match &fetch.rockspec.source().current_platform().source_spec {
75 RockSourceSpec::Git(_) | RockSourceSpec::Url(_) => {
76 let package = PackageSpec::new(
77 fetch.rockspec.package().clone(),
78 fetch.rockspec.version().clone(),
79 );
80 let metadata = FetchSrcRock::new(&package, fetch.dest_dir, fetch.config)
81 .fetch()
82 .await?;
83 Ok(metadata)
84 }
85 RockSourceSpec::File(_) => Err(err),
86 },
87 Ok(metadata) => Ok(metadata),
88 }
89 }
90}
91
92#[derive(Error, Debug, Diagnostic)]
93#[non_exhaustive]
94pub enum FetchSrcError {
95 #[error("failed to clone rock source:\n{0}")]
96 #[diagnostic(help("check your network connection and verify the git URL is correct."))]
97 GitClone(#[from] git2::Error),
98 #[error("failed to parse git URL:\n{0}")]
99 #[diagnostic(forward(0))]
100 GitUrlParse(#[from] RemoteGitUrlParseError),
101 #[error(transparent)]
102 #[diagnostic(help("check your network connection."))]
103 Request(#[from] reqwest::Error),
104 #[error(transparent)]
105 #[diagnostic(transparent)]
106 Unpack(#[from] UnpackError),
107 #[error(transparent)]
108 #[diagnostic(transparent)]
109 FetchSrcRock(#[from] FetchSrcRockError),
110 #[error("unable to remove the '.git' directory:\n{0}")]
111 #[diagnostic(help(
112 "check that no process is using the directory and you have write permissions."
113 ))]
114 CleanGitDir(io::Error),
115 #[error("unable to compute hash:\n{0}")]
116 Hash(io::Error),
117 #[error(transparent)]
118 #[diagnostic(transparent)]
119 Fs(#[from] fs::FsError),
120}
121
122#[derive(Builder)]
125#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
126struct FetchSrcRock<'a> {
127 #[builder(start_fn)]
128 package: &'a PackageSpec,
129 #[builder(start_fn)]
130 dest_dir: &'a Path,
131 #[builder(start_fn)]
132 config: &'a Config,
133}
134
135impl<State> FetchSrcRockBuilder<'_, State>
136where
137 State: fetch_src_rock_builder::State + fetch_src_rock_builder::IsComplete,
138{
139 pub async fn fetch(self) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
140 do_fetch_src_rock(self._build()).await
141 }
142}
143
144#[derive(Error, Debug, Diagnostic)]
145#[non_exhaustive]
146#[error(transparent)]
147pub enum FetchSrcRockError {
148 DownloadSrcRock(#[from] DownloadSrcRockError),
149 Unpack(#[from] UnpackError),
150 Io(#[from] io::Error),
151}
152
153#[derive(Copy, Clone, Debug)]
155struct NullPrompter;
156
157impl Prompter for NullPrompter {
158 fn prompt_username_password(&mut self, _: &str, _: &git2::Config) -> Option<(String, String)> {
159 None
160 }
161
162 fn prompt_password(&mut self, _: &str, _: &str, _: &git2::Config) -> Option<String> {
163 None
164 }
165
166 fn prompt_ssh_key_passphrase(&mut self, _: &Path, _: &git2::Config) -> Option<String> {
167 None
168 }
169}
170
171async fn do_fetch_src<R: Rockspec>(
172 fetch: &FetchSrc<'_, R>,
173) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
174 let rockspec = fetch.rockspec;
175 let rock_source = rockspec.source().current_platform();
176 let dest_dir = fetch.dest_dir;
177 let config = fetch.config;
178 let mut source_spec = match &fetch.source_url {
180 Some(source_url) => match source_url {
181 RemotePackageSourceUrl::Git { url, checkout_ref } => RockSourceSpec::Git(GitSource {
182 url: url.parse()?,
183 checkout_ref: Some(checkout_ref.clone()),
184 }),
185 RemotePackageSourceUrl::Url { url } => RockSourceSpec::Url(url.clone()),
186 RemotePackageSourceUrl::File { path } => RockSourceSpec::File(path.clone()),
187 },
188 None => rock_source.source_spec.clone(),
189 };
190 let span = span!(
191 tracing::Level::INFO,
192 "Fetching source",
193 location = source_spec.to_string(),
194 );
195 let _enter = span.enter();
196
197 if let Some(vendor_dir) = config.vendor_dir() {
198 source_spec = match source_spec {
199 RockSourceSpec::File(_) => source_spec,
202 _ => {
203 let pkg_vendor_dir =
204 vendor_dir.join(format!("{}@{}", rockspec.package(), rockspec.version()));
205 RockSourceSpec::File(pkg_vendor_dir)
206 }
207 }
208 }
209 let metadata = match &source_spec {
210 RockSourceSpec::Git(git) => {
211 let url = git.url.to_string();
212 tracing::debug!(message = format!("Cloning {url}").as_str());
213
214 let resolved_ref = resolve_remote_ref(&url, git.checkout_ref.as_deref(), config);
215
216 if let Some(oid) = resolved_ref {
217 let cache_dir = source_cache_dir(config, &url).join(oid.to_string());
218 if fs::sync::read_dir(&cache_dir).is_ok_and(|mut entries| entries.next().is_some())
219 {
220 tracing::debug!("using cached git source");
221 recursive_copy_dir_no_ignore(&cache_dir, dest_dir).await?;
222 let hash = fetch.dest_dir.hash().await.map_err(FetchSrcError::Hash)?;
223 let checkout_ref = git.checkout_ref.clone().unwrap_or(oid.to_string());
224 return Ok(RemotePackageSourceMetadata {
225 hash,
226 source_url: RemotePackageSourceUrl::Git { url, checkout_ref },
227 });
228 }
229 }
230 tracing::debug!("fetching git source");
231
232 let checkout_ref = {
233 let auth = if config.no_prompt() {
234 GitAuthenticator::default()
235 .try_password_prompt(0)
236 .prompt_ssh_key_password(false)
237 .set_prompter(NullPrompter)
238 } else {
239 GitAuthenticator::default()
240 };
241 let git_config = git2::Config::open_default()?;
242 let mut callbacks = RemoteCallbacks::new();
243 callbacks.credentials(auth.credentials(&git_config));
244 let mut fetch_options = FetchOptions::new();
245 fetch_options.update_fetchhead(false);
246 fetch_options.remote_callbacks(callbacks);
247 if git.checkout_ref.is_none() {
248 fetch_options.depth(1);
249 };
250 let mut repo_builder = RepoBuilder::new();
251 repo_builder.fetch_options(fetch_options);
252 let repo = repo_builder.clone(&url, dest_dir)?;
253
254 match &git.checkout_ref {
255 Some(checkout_ref) => {
256 let (object, _) = repo.revparse_ext(checkout_ref)?;
257 repo.checkout_tree(&object, None)?;
258 checkout_ref.clone()
259 }
260 None => {
261 let head = repo.head()?;
262 let commit = head.peel_to_commit()?;
263 commit.id().to_string()
264 }
265 }
266 };
267 remove_dir_all(dest_dir.join(".git")).map_err(FetchSrcError::CleanGitDir)?;
269
270 if let Some(oid) = resolved_ref {
271 populate_source_cache(
272 dest_dir,
273 &source_cache_dir(config, &url).join(oid.to_string()),
274 )
275 .await;
276 }
277
278 let hash = fetch.dest_dir.hash().await.map_err(FetchSrcError::Hash)?;
279 RemotePackageSourceMetadata {
280 hash,
281 source_url: RemotePackageSourceUrl::Git { url, checkout_ref },
282 }
283 }
284 RockSourceSpec::Url(url) => {
285 tracing::debug!(message = format!("📥 Downloading {url}").as_str());
286
287 let cache_path = source_cache_dir(config, url.as_ref()).join("archive");
288 let response = match fs::tokio::read(&cache_path).await {
289 Ok(bytes) => {
290 tracing::debug!("using cached source archive");
291 Bytes::from(bytes)
292 }
293 Err(_) => {
294 tracing::debug!("fetching source archive");
295 let response = crate::reqwest::http_client(config)?
298 .get(url.clone())
299 .send()
300 .await?
301 .error_for_status()?
302 .bytes()
303 .await?;
304 write_source_cache_archive(&cache_path, &response).await;
305 response
306 }
307 };
308 let hash = response.hash().await.map_err(FetchSrcError::Hash)?;
309 let file_name = url
310 .path_segments()
311 .and_then(|mut segments| segments.next_back())
312 .and_then(|name| {
313 if name.is_empty() {
314 None
315 } else {
316 Some(name.to_string())
317 }
318 })
319 .unwrap_or(url.to_string());
320 let cursor = Cursor::new(response);
321 let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
322 operations::unpack::unpack(
323 mime_type,
324 cursor,
325 rock_source.unpack_dir.is_none(),
326 file_name,
327 dest_dir,
328 )
329 .await?;
330 RemotePackageSourceMetadata {
331 hash,
332 source_url: RemotePackageSourceUrl::Url { url: url.clone() },
333 }
334 }
335 RockSourceSpec::File(path) => {
336 tracing::debug!(message = format!("📋 Copying {}", path.display()).as_str());
337
338 let hash = if path.is_dir() {
339 recursive_copy_dir(&path.to_path_buf(), dest_dir).await?;
340 dest_dir.hash().await.map_err(FetchSrcError::Hash)?
341 } else {
342 let mut file = fs::sync::open(path)?;
343 let mut buffer = Vec::new();
344 file.read_to_end(&mut buffer)
345 .map_err(|source| fs::FsError::Read {
346 path: path.to_path_buf(),
347 source,
348 })?;
349 let mime_type = infer::get(&buffer).map(|file_type| file_type.mime_type());
350 let file_name = path
351 .file_name()
352 .map(|os_str| os_str.to_string_lossy())
353 .unwrap_or(path.to_string_lossy())
354 .to_string();
355 operations::unpack::unpack(
356 mime_type,
357 file,
358 rock_source.unpack_dir.is_none(),
359 file_name,
360 dest_dir,
361 )
362 .await?;
363 path.hash().await.map_err(FetchSrcError::Hash)?
364 };
365 RemotePackageSourceMetadata {
366 hash,
367 source_url: RemotePackageSourceUrl::File { path: path.clone() },
368 }
369 }
370 };
371 Ok(metadata)
372}
373
374fn source_cache_dir(config: &Config, url: &str) -> PathBuf {
376 config.cache_dir().join("sources").join(sanitize_url(url))
377}
378
379fn sanitize_url(url: &str) -> String {
380 url.replace(&[':', '*', '?', '"', '<', '>', '|', '/', '\\'][..], "_")
381}
382
383#[tracing::instrument(level = "trace")]
386async fn recursive_copy_dir_no_ignore(src: &Path, dest: &Path) -> Result<(), fs::FsError> {
387 let mut dirs: Vec<PathBuf> = vec![src.to_path_buf()];
388 while let Some(dir) = dirs.pop() {
389 for entry in fs::sync::read_dir(&dir)?.filter_map(Result::ok) {
390 let entry_path = entry.path();
391 let relative_path: PathBuf = pathdiff::diff_paths(&entry_path, src)
392 .unwrap_or_else(|| unreachable!("diff_path with self"));
393 let target = dest.join(relative_path);
394 let file_type = entry.file_type().map_err(|source| fs::FsError::Read {
395 path: entry_path.clone(),
396 source,
397 })?;
398 if file_type.is_dir() {
399 fs::tokio::create_dir_all(&target).await?;
400 dirs.push(entry_path);
401 } else if file_type.is_file() {
402 if let Some(parent) = target.parent() {
403 fs::tokio::create_dir_all(parent).await?;
404 }
405 fs::tokio::copy(&entry_path, &target).await?;
406 }
407 }
408 }
409 Ok(())
410}
411
412#[tracing::instrument(level = "trace")]
414async fn populate_source_cache(dest_dir: &Path, cache_dir: &Path) {
415 let Some(parent) = cache_dir.parent() else {
416 return;
417 };
418 if fs::tokio::create_dir_all(parent).await.is_err() {
419 return;
420 }
421 let temp = parent.join(format!(
422 ".tmp-{}",
423 cache_dir.file_name().unwrap_or_default().to_string_lossy()
424 ));
425 if recursive_copy_dir_no_ignore(dest_dir, &temp)
426 .await
427 .and_then(|_| fs::sync::rename(&temp, cache_dir))
428 .is_err()
429 {
430 let _ = remove_dir_all(&temp);
431 tracing::debug!("failed to populate the source cache");
432 }
433}
434
435#[tracing::instrument(level = "trace")]
437async fn write_source_cache_archive(path: &Path, contents: &Bytes) {
438 let Some(parent) = path.parent() else {
439 return;
440 };
441 if fs::tokio::create_dir_all(parent).await.is_err() {
442 return;
443 }
444 let temp = parent.join(format!(
445 ".tmp-{}",
446 path.file_name().unwrap_or_default().to_string_lossy()
447 ));
448 if fs::tokio::write(&temp, contents).await.is_err()
449 || fs::tokio::rename(&temp, path).await.is_err()
450 {
451 let _ = fs::tokio::remove_file(&temp).await;
452 tracing::debug!("failed to write the source cache");
453 }
454}
455
456#[tracing::instrument(level = "trace")]
460fn resolve_remote_ref(url: &str, checkout_ref: Option<&str>, config: &Config) -> Option<git2::Oid> {
461 if let Some(reference) = checkout_ref {
462 if let Ok(oid) = git2::Oid::from_str(reference) {
463 return Some(oid);
464 }
465 }
466 let result: Result<git2::Oid, git2::Error> = (|| {
467 let auth = if config.no_prompt() {
468 GitAuthenticator::default()
469 .try_password_prompt(0)
470 .prompt_ssh_key_password(false)
471 .set_prompter(NullPrompter)
472 } else {
473 GitAuthenticator::default()
474 };
475 let git_config = git2::Config::open_default()?;
476 let mut callbacks = RemoteCallbacks::new();
477 callbacks.credentials(auth.credentials(&git_config));
478 let tempdir = fs::tempfile::tempdir().map_err(|err| {
479 git2::Error::from_str(&format!("unable to create temporary directory: {err}"))
480 })?;
481 let repo = git2::Repository::init_bare(tempdir.path())?;
482 let mut remote = repo.remote_anonymous(url)?;
483 let connection = remote.connect_auth(Direction::Fetch, Some(callbacks), None)?;
484 let refs = connection.list()?;
485 let oid = match checkout_ref {
486 Some(reference) => {
487 let candidates = [
488 reference.to_string(),
489 format!("refs/heads/{reference}"),
490 format!("refs/tags/{reference}"),
491 ];
492 refs.iter()
493 .find(|head| candidates.iter().any(|c| c.as_str() == head.name()))
494 .map(|head| head.oid())
495 }
496 None => refs
497 .iter()
498 .find(|head| head.name() == "HEAD")
499 .map(|head| head.oid()),
500 };
501 oid.ok_or_else(|| git2::Error::from_str("no matching ref advertised"))
502 })();
503 result.ok()
504}
505
506async fn do_fetch_src_rock(
507 fetch: FetchSrcRock<'_>,
508) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
509 let package = fetch.package;
510 let span = span!(
511 tracing::Level::INFO,
512 "Fetching src.rock",
513 package = package.to_string(),
514 );
515 let _enter = span.enter();
516
517 let dest_dir = fetch.dest_dir;
518 let config = fetch.config;
519 let src_rock = operations::download_src_rock(package, config.server(), fetch.config).await?;
520 let hash = src_rock.bytes.hash().await?;
521 let cursor = Cursor::new(src_rock.bytes);
522 let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
523 operations::unpack::unpack(mime_type, cursor, true, src_rock.file_name, dest_dir).await?;
524 Ok(RemotePackageSourceMetadata {
525 hash,
526 source_url: RemotePackageSourceUrl::Url { url: src_rock.url },
527 })
528}
529
530#[cfg(test)]
531mod tests {
532 use std::io::Write;
533
534 use assert_fs::prelude::*;
535 use httptest::{matchers::request, responders::status_code, Expectation, Server};
536 use serial_test::serial;
537
538 use crate::config::ConfigBuilder;
539 use crate::lua_rockspec::RemoteLuaRockspec;
540
541 use super::*;
542
543 fn source_zip() -> Vec<u8> {
544 let mut cursor = std::io::Cursor::new(Vec::new());
545 let mut zip = zip::ZipWriter::new(&mut cursor);
546 zip.start_file("test.lua", zip::write::SimpleFileOptions::default())
547 .unwrap();
548 zip.write_all(b"return 1").unwrap();
549 zip.finish().unwrap();
550 cursor.into_inner()
551 }
552
553 fn test_config(cache_dir: std::path::PathBuf) -> Config {
554 ConfigBuilder::new()
555 .unwrap()
556 .cache_dir(Some(cache_dir))
557 .no_progress(Some(true))
558 .build()
559 .unwrap()
560 }
561
562 #[tokio::test]
563 #[serial]
564 async fn fetch_url_source_hits_cache() {
565 let cache_dir = assert_fs::TempDir::new().unwrap().to_path_buf();
566 let server = Server::run();
567 server.expect(
568 Expectation::matching(request::path("/source.zip"))
570 .times(1)
571 .respond_with(status_code(200).body(source_zip())),
572 );
573 let url = server.url_str("/source.zip");
574 let rockspec = RemoteLuaRockspec::new(&format!(
575 r#"
576rockspec_format = '3.0'
577package = 'cached-package'
578version = '1.0-1'
579description = {{ summary = 'test package' }}
580source = {{ url = '{url}', dir = 'source' }}
581build = {{ type = 'builtin', modules = {{ ['test'] = 'source/test.lua' }} }}
582"#
583 ))
584 .unwrap();
585 let config = test_config(cache_dir);
586
587 let dest_dir = assert_fs::TempDir::new().unwrap();
588 let first = FetchSrc::new(dest_dir.path(), &rockspec, &config)
589 .fetch_internal()
590 .await
591 .unwrap();
592
593 let dest_dir = assert_fs::TempDir::new().unwrap();
594 let second = FetchSrc::new(dest_dir.path(), &rockspec, &config)
595 .fetch_internal()
596 .await
597 .unwrap();
598
599 assert_eq!(first.hash, second.hash);
600 }
601
602 #[tokio::test]
603 #[serial]
604 async fn resolves_git_ref_to_oid_without_cloning() {
605 let repo_dir = assert_fs::TempDir::new().unwrap();
606 let bare_path = repo_dir.path().join("repo.git");
607 let (commit_id, default_branch) = {
608 let bare = git2::Repository::init_bare(&bare_path).unwrap();
609 let signature = git2::Signature::now("test", "test@example.com").unwrap();
610 let tree_id = {
611 let mut builder = bare.treebuilder(None).unwrap();
612 let blob = bare.blob(b"hello").unwrap();
613 builder.insert("hello.txt", blob, 0o100644).unwrap();
614 builder.write().unwrap()
615 };
616 let tree = bare.find_tree(tree_id).unwrap();
617 let commit_id = bare
618 .commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
619 .unwrap();
620 let head_ref = bare.find_reference("HEAD").unwrap();
621 let default_branch = head_ref
622 .symbolic_target()
623 .unwrap()
624 .unwrap()
625 .strip_prefix("refs/heads/")
626 .unwrap()
627 .to_string();
628 (commit_id, default_branch)
629 };
630
631 let url = format!("file://{}", bare_path.display());
632 let config = test_config(assert_fs::TempDir::new().unwrap().to_path_buf());
633
634 assert_eq!(
635 resolve_remote_ref(&url, Some(&default_branch), &config).unwrap(),
636 commit_id
637 );
638 assert_eq!(
639 resolve_remote_ref(&url, Some(&commit_id.to_string()), &config).unwrap(),
640 commit_id
641 );
642 assert_eq!(resolve_remote_ref(&url, None, &config).unwrap(), commit_id);
643 }
644
645 #[tokio::test]
646 #[serial]
647 async fn copy_dir_recursive_preserves_contents() {
648 let src = assert_fs::TempDir::new().unwrap();
649 src.child("sub").create_dir_all().unwrap();
650 src.child("file.txt").write_str("hello").unwrap();
651 src.child("sub/nested.txt").write_str("world").unwrap();
652 let dest = assert_fs::TempDir::new().unwrap();
653 let dest_path = dest.path().join("copy");
654 recursive_copy_dir_no_ignore(src.path(), &dest_path)
655 .await
656 .unwrap();
657 assert_eq!(
658 fs::sync::read_to_string(dest_path.join("file.txt")).unwrap(),
659 "hello"
660 );
661 assert_eq!(
662 fs::sync::read_to_string(dest_path.join("sub/nested.txt")).unwrap(),
663 "world"
664 );
665 }
666}