Skip to main content

lux_lib/operations/
fetch.rs

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/// A rocks package source fetcher, providing fine-grained control
30/// over how a package should be fetched.
31#[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    /// Fetch and unpack the source into the `dest_dir`.
55    pub async fn fetch(self) -> Result<(), FetchSrcError> {
56        self.fetch_internal().await?;
57        Ok(())
58    }
59
60    /// Fetch and unpack the source into the `dest_dir`,
61    /// returning the source `Integrity`.
62    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        // prioritise lockfile source, if present
69        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                // Don't fall back to downloading .src.rock archives if a local source was specified.
89                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/// A rocks package source fetcher, providing fine-grained control
161/// over how a package should be fetched.
162#[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/// A no-prompt implementer for auth_git2's prompter
192#[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
209/// Initialises and checks out git submodules,
210/// returning the relative paths of any submodules that were checked out.
211fn 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        // The .git file/directory is not deterministic
229        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            // could be a project directory (not vendored) or a local source
256            // or a vendored dependency that we have already resolved
257            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            // The .git directory is not deterministic
334            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            // NOTE: We don't enforce HTTPS when fetching sources because some rockspecs
349            // have HTTP URLs in `source.url`.
350            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}