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::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;
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 { url, checkout_ref } => {
71                    RockSourceSpec::Git(GitSource {
72                        url: url.parse()?,
73                        checkout_ref: Some(checkout_ref.clone()),
74                    })
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("failed to parse git URL")]
121    #[diagnostic(forward(0))]
122    GitUrlParse(#[from] RemoteGitUrlParseError),
123    #[error(transparent)]
124    #[diagnostic(transparent)]
125    Request(#[from] RequestError),
126    #[error(transparent)]
127    #[diagnostic(transparent)]
128    Unpack(#[from] UnpackError),
129    #[error(transparent)]
130    #[diagnostic(transparent)]
131    FetchSrcRock(#[from] FetchSrcRockError),
132    #[error("unable to remove the '.git' directory")]
133    #[diagnostic(help(
134        "check that no process is using the directory and you have write permissions."
135    ))]
136    CleanGitDir(#[source] io::Error),
137    #[error("unable to compute hash")]
138    Hash(#[source] io::Error),
139    #[error(transparent)]
140    #[diagnostic(transparent)]
141    Fs(#[from] fs::FsError),
142}
143
144impl From<reqwest::Error> for FetchSrcError {
145    fn from(err: reqwest::Error) -> Self {
146        Self::Request(err.into())
147    }
148}
149
150/// A rocks package source fetcher, providing fine-grained control
151/// over how a package should be fetched.
152#[derive(Builder)]
153#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
154struct FetchSrcRock<'a> {
155    #[builder(start_fn)]
156    package: &'a PackageSpec,
157    #[builder(start_fn)]
158    dest_dir: &'a Path,
159    #[builder(start_fn)]
160    config: &'a Config,
161}
162
163impl<State> FetchSrcRockBuilder<'_, State>
164where
165    State: fetch_src_rock_builder::State + fetch_src_rock_builder::IsComplete,
166{
167    pub async fn fetch(self) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
168        do_fetch_src_rock(self._build()).await
169    }
170}
171
172#[derive(Error, Debug, Diagnostic)]
173#[non_exhaustive]
174#[error(transparent)]
175pub enum FetchSrcRockError {
176    DownloadSrcRock(#[from] DownloadSrcRockError),
177    Unpack(#[from] UnpackError),
178    Io(#[from] io::Error),
179}
180
181/// A no-prompt implementer for auth_git2's prompter
182#[derive(Copy, Clone, Debug)]
183struct NullPrompter;
184
185impl Prompter for NullPrompter {
186    fn prompt_username_password(&mut self, _: &str, _: &git2::Config) -> Option<(String, String)> {
187        None
188    }
189
190    fn prompt_password(&mut self, _: &str, _: &str, _: &git2::Config) -> Option<String> {
191        None
192    }
193
194    fn prompt_ssh_key_passphrase(&mut self, _: &Path, _: &git2::Config) -> Option<String> {
195        None
196    }
197}
198
199#[tracing::instrument(
200    name = "Fetching source",
201    level = "info",
202    skip_all,
203    fields(location = source_spec.to_string()),
204)]
205async fn fetch_src_impl<R: Rockspec>(
206    mut source_spec: RockSourceSpec,
207    rockspec: &R,
208    rock_source: &RemoteRockSource,
209    dest_dir: &Path,
210    config: &Config,
211) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
212    if let Some(vendor_dir) = config.vendor_dir() {
213        source_spec = match source_spec {
214            // could be a project directory (not vendored) or a local source
215            // or a vendored dependency that we have already resolved
216            RockSourceSpec::File(_) => source_spec,
217            _ => {
218                let pkg_vendor_dir =
219                    vendor_dir.join(format!("{}@{}", rockspec.package(), rockspec.version()));
220                RockSourceSpec::File(pkg_vendor_dir)
221            }
222        }
223    }
224    let metadata = match &source_spec {
225        RockSourceSpec::Git(git) => {
226            let url = git.url.to_string();
227            tracing::debug!(message = format!("Cloning {url}").as_str());
228
229            let checkout_ref = {
230                let mut auth = if config.no_prompt() {
231                    GitAuthenticator::default()
232                        .try_password_prompt(0)
233                        .prompt_ssh_key_password(false)
234                        .set_prompter(NullPrompter)
235                } else {
236                    GitAuthenticator::default()
237                };
238                if let Some(access_token) = config.access_token(git.url.host()) {
239                    auth = auth.add_plaintext_credentials(
240                        git.url.host(),
241                        access_token.username(),
242                        unsafe { access_token.password() },
243                    );
244                }
245                let git_config = git2::Config::open_default()?;
246                let mut callbacks = RemoteCallbacks::new();
247                callbacks.credentials(auth.credentials(&git_config));
248                let mut fetch_options = FetchOptions::new();
249                fetch_options.update_fetchhead(false);
250                fetch_options.remote_callbacks(callbacks);
251                if git.checkout_ref.is_none() {
252                    fetch_options.depth(1);
253                };
254                let mut repo_builder = RepoBuilder::new();
255                repo_builder.fetch_options(fetch_options);
256                let repo = repo_builder.clone(&url, dest_dir)?;
257
258                match &git.checkout_ref {
259                    Some(checkout_ref) => {
260                        let (object, _) = repo.revparse_ext(checkout_ref)?;
261                        repo.checkout_tree(&object, None)?;
262                        checkout_ref.clone()
263                    }
264                    None => {
265                        let head = repo.head()?;
266                        let commit = head.peel_to_commit()?;
267                        commit.id().to_string()
268                    }
269                }
270            };
271            // The .git directory is not deterministic
272            remove_dir_all(dest_dir.join(".git")).map_err(FetchSrcError::CleanGitDir)?;
273            let hash = dest_dir.hash().await.map_err(FetchSrcError::Hash)?;
274            RemotePackageSourceMetadata {
275                hash,
276                source_url: RemotePackageSourceUrl::Git { url, checkout_ref },
277            }
278        }
279        RockSourceSpec::Url(url) => {
280            tracing::debug!(message = format!("📥 Downloading {url}").as_str());
281
282            // NOTE: We don't enforce HTTPS when fetching sources because some rockspecs
283            // have HTTP URLs in `source.url`.
284            let response = crate::reqwest::http_client(config)?
285                .get(url.clone())
286                .apply_access_token(config, url)
287                .send()
288                .await?
289                .error_for_status()?
290                .bytes()
291                .await?;
292            let hash = response.hash().await.map_err(FetchSrcError::Hash)?;
293            let file_name = url
294                .path_segments()
295                .and_then(|mut segments| segments.next_back())
296                .and_then(|name| {
297                    if name.is_empty() {
298                        None
299                    } else {
300                        Some(name.to_string())
301                    }
302                })
303                .unwrap_or(url.to_string());
304            let cursor = Cursor::new(response);
305            let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
306            operations::unpack::unpack(
307                mime_type,
308                cursor,
309                rock_source.unpack_dir.is_none(),
310                file_name,
311                dest_dir,
312            )
313            .await?;
314            RemotePackageSourceMetadata {
315                hash,
316                source_url: RemotePackageSourceUrl::Url { url: url.clone() },
317            }
318        }
319        RockSourceSpec::File(path) => {
320            tracing::debug!(message = format!("📋 Copying {}", path.display()).as_str());
321
322            let hash = if path.is_dir() {
323                recursive_copy_dir(&path.to_path_buf(), dest_dir).await?;
324                dest_dir.hash().await.map_err(FetchSrcError::Hash)?
325            } else {
326                let mut file = fs::sync::open(path)?;
327                let mut buffer = Vec::new();
328                file.read_to_end(&mut buffer)
329                    .map_err(|source| fs::FsError::Read {
330                        path: path.to_path_buf(),
331                        source,
332                    })?;
333                let mime_type = infer::get(&buffer).map(|file_type| file_type.mime_type());
334                let file_name = path
335                    .file_name()
336                    .map(|os_str| os_str.to_string_lossy())
337                    .unwrap_or(path.to_string_lossy())
338                    .to_string();
339                operations::unpack::unpack(
340                    mime_type,
341                    file,
342                    rock_source.unpack_dir.is_none(),
343                    file_name,
344                    dest_dir,
345                )
346                .await?;
347                path.hash().await.map_err(FetchSrcError::Hash)?
348            };
349            RemotePackageSourceMetadata {
350                hash,
351                source_url: RemotePackageSourceUrl::File { path: path.clone() },
352            }
353        }
354    };
355    Ok(metadata)
356}
357
358#[tracing::instrument(
359    name = "Fetching src.rock",
360    level = "info",
361    skip_all,
362    fields(package = fetch.package.to_string(),),
363)]
364async fn do_fetch_src_rock(
365    fetch: FetchSrcRock<'_>,
366) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
367    let package = fetch.package;
368    let dest_dir = fetch.dest_dir;
369    let config = fetch.config;
370    let src_rock = operations::download_src_rock(package, config.server(), fetch.config).await?;
371    let hash = src_rock.bytes.hash().await?;
372    let cursor = Cursor::new(src_rock.bytes);
373    let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
374    operations::unpack::unpack(mime_type, cursor, true, src_rock.file_name, dest_dir).await?;
375    Ok(RemotePackageSourceMetadata {
376        hash,
377        source_url: RemotePackageSourceUrl::Url { url: src_rock.url },
378    })
379}