Skip to main content

lux_lib/operations/
fetch.rs

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