Skip to main content

cargo/sources/git/
oxide.rs

1//! This module contains all code sporting `gitoxide` for operations on `git` repositories and it mirrors
2//! `utils` closely for now. One day it can be renamed into `utils` once `git2` isn't required anymore.
3
4use crate::context::ConfigRelativePathResolveExt;
5use crate::util::HumanBytes;
6use crate::util::network::http::HttpTimeout;
7use crate::util::{MetricsCounter, Progress, network};
8use crate::{CargoResult, GlobalContext};
9use cargo_util::paths;
10use gix::bstr::{BString, ByteSlice};
11use std::cell::RefCell;
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Weak};
15use std::time::{Duration, Instant};
16use tracing::debug;
17
18/// For the time being, `repo_path` makes it easy to instantiate a gitoxide repo just for fetching.
19/// In future this may change to be the gitoxide repository itself.
20pub fn with_retry_and_progress(
21    repo_path: &std::path::Path,
22    gctx: &GlobalContext,
23    repo_remote_url: &str,
24    cb: &(
25         dyn Fn(
26        &std::path::Path,
27        &AtomicBool,
28        &mut gix::progress::tree::Item,
29        &mut dyn FnMut(&gix::bstr::BStr),
30    ) -> Result<(), crate::sources::git::fetch::Error>
31             + Send
32             + Sync
33     ),
34) -> CargoResult<()> {
35    std::thread::scope(|s| {
36        let mut progress_bar = Progress::new("Fetch", gctx);
37        let is_shallow = gctx.cli_unstable().git.map_or(false, |features| {
38            features.shallow_deps || features.shallow_index
39        });
40        network::retry::with_retry(gctx, || {
41            let progress_root: Arc<gix::progress::tree::Root> =
42                gix::progress::tree::root::Options {
43                    initial_capacity: 10,
44                    message_buffer_capacity: 10,
45                }
46                .into();
47            let root = Arc::downgrade(&progress_root);
48            let thread = s.spawn(move || {
49                let mut progress = progress_root.add_child("operation");
50                let mut urls = RefCell::new(Default::default());
51                let res = cb(
52                    &repo_path,
53                    &AtomicBool::default(),
54                    &mut progress,
55                    &mut |url| {
56                        *urls.borrow_mut() = Some(url.to_owned());
57                    },
58                );
59                amend_authentication_hints(res, repo_remote_url, urls.get_mut().take())
60            });
61            translate_progress_to_bar(&mut progress_bar, root, is_shallow)?;
62            thread.join().expect("no panic in scoped thread")
63        })
64    })
65}
66
67fn translate_progress_to_bar(
68    progress_bar: &mut Progress<'_>,
69    root: Weak<gix::progress::tree::Root>,
70    is_shallow: bool,
71) -> CargoResult<()> {
72    let remote_progress: gix::progress::Id = gix::remote::fetch::ProgressId::RemoteProgress.into();
73    let read_pack_bytes: gix::progress::Id =
74        gix::odb::pack::bundle::write::ProgressId::ReadPackBytes.into();
75    let delta_index_objects: gix::progress::Id =
76        gix::odb::pack::index::write::ProgressId::IndexObjects.into();
77    let resolve_objects: gix::progress::Id =
78        gix::odb::pack::index::write::ProgressId::ResolveObjects.into();
79
80    // We choose `N=10` here to make a `300ms * 10slots ~= 3000ms`
81    // sliding window for tracking the data transfer rate (in bytes/s).
82    let mut last_percentage_update = Instant::now();
83    let mut last_fast_update = Instant::now();
84    let mut counter = MetricsCounter::<10>::new(0, last_percentage_update);
85
86    let mut tasks = Vec::with_capacity(10);
87    let slow_check_interval = std::time::Duration::from_millis(300);
88    let fast_check_interval = Duration::from_millis(50);
89    let sleep_interval = Duration::from_millis(10);
90    debug_assert_eq!(
91        slow_check_interval.as_millis() % fast_check_interval.as_millis(),
92        0,
93        "progress should be smoother by keeping these as multiples of each other"
94    );
95    debug_assert_eq!(
96        fast_check_interval.as_millis() % sleep_interval.as_millis(),
97        0,
98        "progress should be smoother by keeping these as multiples of each other"
99    );
100
101    let num_phases = if is_shallow { 3 } else { 2 }; // indexing + delta-resolution, both with same amount of objects to handle
102    while let Some(root) = root.upgrade() {
103        std::thread::sleep(sleep_interval);
104        let needs_update = last_fast_update.elapsed() >= fast_check_interval;
105        if !needs_update {
106            continue;
107        }
108        let now = Instant::now();
109        last_fast_update = now;
110
111        root.sorted_snapshot(&mut tasks);
112
113        fn progress_by_id(
114            id: gix::progress::Id,
115            task: &gix::progress::Task,
116        ) -> Option<(&str, &gix::progress::Value)> {
117            (task.id == id)
118                .then(|| task.progress.as_ref())
119                .flatten()
120                .map(|value| (task.name.as_str(), value))
121        }
122        fn find_in<K>(
123            tasks: &[(K, gix::progress::Task)],
124            cb: impl Fn(&gix::progress::Task) -> Option<(&str, &gix::progress::Value)>,
125        ) -> Option<(&str, &gix::progress::Value)> {
126            tasks.iter().find_map(|(_, t)| cb(t))
127        }
128
129        if let Some((_, objs)) = find_in(&tasks, |t| progress_by_id(resolve_objects, t)) {
130            // Phase 3: Resolving deltas.
131            let objects = objs.step.load(Ordering::Relaxed);
132            let total_objects = objs.done_at.expect("known amount of objects");
133            let msg = format!(", ({objects}/{total_objects}) resolving deltas");
134
135            progress_bar.tick(
136                (total_objects * (num_phases - 1)) + objects,
137                total_objects * num_phases,
138                &msg,
139            )?;
140        } else if let Some((objs, read_pack)) =
141            find_in(&tasks, |t| progress_by_id(read_pack_bytes, t)).and_then(|read| {
142                find_in(&tasks, |t| progress_by_id(delta_index_objects, t))
143                    .map(|delta| (delta.1, read.1))
144            })
145        {
146            // Phase 2: Receiving objects.
147            let objects = objs.step.load(Ordering::Relaxed);
148            let total_objects = objs.done_at.expect("known amount of objects");
149            let received_bytes = read_pack.step.load(Ordering::Relaxed);
150
151            let needs_percentage_update = last_percentage_update.elapsed() >= slow_check_interval;
152            if needs_percentage_update {
153                counter.add(received_bytes, now);
154                last_percentage_update = now;
155            }
156            let rate = HumanBytes(counter.rate() as u64);
157            let msg = format!(", {rate:.2}/s");
158
159            progress_bar.tick(
160                (total_objects * (num_phases - 2)) + objects,
161                total_objects * num_phases,
162                &msg,
163            )?;
164        } else if let Some((action, remote)) =
165            find_in(&tasks, |t| progress_by_id(remote_progress, t))
166        {
167            if !is_shallow {
168                continue;
169            }
170            // phase 1: work on the remote side
171
172            // Resolving deltas.
173            let objects = remote.step.load(Ordering::Relaxed);
174            if let Some(total_objects) = remote.done_at {
175                let msg = format!(", ({objects}/{total_objects}) {action}");
176                progress_bar.tick(objects, total_objects * num_phases, &msg)?;
177            }
178        }
179    }
180    Ok(())
181}
182
183#[derive(Debug, thiserror::Error)]
184#[error("{0}")]
185pub(super) struct CredentialError(#[source] pub gix::Error);
186
187fn amend_authentication_hints(
188    res: Result<(), crate::sources::git::fetch::Error>,
189    remote_url: &str,
190    last_url_for_authentication: Option<gix::bstr::BString>,
191) -> CargoResult<()> {
192    let Err(err) = res else { return Ok(()) };
193    let auth_message = if err.downcast_any_ref::<CredentialError>().is_some()
194        || err.is_unauthenticated()
195    {
196        if err
197            .downcast_any_ref::<gix::protocol::transport::client::AuthenticationRequired>()
198            .is_none()
199        {
200            "\n* attempted to find username/password via \
201                     git's `credential.helper` support, but failed"
202                .into()
203        } else {
204            "\n* attempted to find username/password via \
205                     `credential.helper`, but maybe the found \
206                     credentials were incorrect"
207                .into()
208        }
209    } else {
210        if err
211            .downcast_any_ref::<gix::protocol::transport::client::Error>()
212            .is_some()
213        {
214            let msg = format!(
215                concat!(
216                    "network failure seems to have happened\n",
217                    "if a proxy or similar is necessary `net.git-fetch-with-cli` may help here\n",
218                    "https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli",
219                    "{}"
220                ),
221                super::utils::note_github_pull_request(remote_url).unwrap_or_default()
222            );
223            return Err(anyhow::Error::from(err).context(msg));
224        }
225        None
226    };
227    if let Some(auth_message) = auth_message {
228        let mut msg = "failed to authenticate when downloading \
229                       repository"
230            .to_string();
231        if let Some(url) = last_url_for_authentication {
232            msg.push_str(": ");
233            msg.push_str(url.to_str_lossy().as_ref());
234        }
235        msg.push('\n');
236        msg.push_str(auth_message);
237        msg.push_str("\n\n");
238        msg.push_str("if the git CLI succeeds then `net.git-fetch-with-cli` may help here\n");
239        msg.push_str("https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli");
240        return Err(anyhow::Error::from(err).context(msg));
241    }
242    Err(err.into())
243}
244
245/// The reason we are opening a git repository.
246///
247/// This can affect the way we open it and the cost associated with it.
248pub enum OpenMode {
249    /// We need `git_binary` configuration as well for being able to see credential helpers
250    /// that are configured with the `git` installation itself.
251    /// However, this is slow on windows (~150ms) and most people won't need it as they use the
252    /// standard index which won't ever need authentication, so we only enable this when needed.
253    ForFetch,
254}
255
256impl OpenMode {
257    /// Sometimes we don't need to pay for figuring out the system's git installation, and this tells
258    /// us if that is the case.
259    pub fn needs_git_binary_config(&self) -> bool {
260        match self {
261            OpenMode::ForFetch => true,
262        }
263    }
264}
265
266/// Produce a repository with everything pre-configured according to `config`. Most notably this includes
267/// transport configuration. Knowing its `purpose` helps to optimize the way we open the repository.
268/// Use `config_overrides` to configure the new repository.
269pub fn open_repo(
270    repo_path: &std::path::Path,
271    config_overrides: Vec<BString>,
272    purpose: OpenMode,
273) -> gix::Result<gix::Repository> {
274    gix::open_opts(repo_path, {
275        let mut opts = gix::open::Options::default();
276        opts.permissions.config = gix::open::permissions::Config::all();
277        opts.permissions.config.git_binary = purpose.needs_git_binary_config();
278        opts.with(gix::sec::Trust::Full)
279            .config_overrides(config_overrides)
280    })
281}
282
283/// Convert `git` related cargo configuration into the respective `git` configuration which can be
284/// used when opening new repositories.
285pub fn cargo_config_to_gitoxide_overrides(gctx: &GlobalContext) -> CargoResult<Vec<BString>> {
286    use gix::config::tree::{Core, Http, Key, gitoxide};
287    let timeout = HttpTimeout::new(gctx)?;
288    let http = gctx.http_config()?;
289
290    let mut values = vec![
291        gitoxide::Http::CONNECT_TIMEOUT.validated_assignment_fmt(&timeout.dur.as_millis())?,
292        Http::LOW_SPEED_LIMIT.validated_assignment_fmt(&timeout.low_speed_limit)?,
293        Http::LOW_SPEED_TIME.validated_assignment_fmt(&timeout.dur.as_secs())?,
294        // Assure we are not depending on committer information when updating refs after cloning.
295        Core::LOG_ALL_REF_UPDATES.validated_assignment_fmt(&false)?,
296    ];
297    if let Some(proxy) = &http.proxy {
298        values.push(Http::PROXY.validated_assignment_fmt(proxy)?);
299    }
300    if let Some(check_revoke) = http.check_revoke {
301        values.push(Http::SCHANNEL_CHECK_REVOKE.validated_assignment_fmt(&check_revoke)?);
302    }
303    if let Some(cainfo) = &http.cainfo {
304        values.push(
305            Http::SSL_CA_INFO.validated_assignment_fmt(&cainfo.resolve_path(gctx).display())?,
306        );
307    }
308
309    values.push(if let Some(user_agent) = &http.user_agent {
310        Http::USER_AGENT.validated_assignment_fmt(user_agent)
311    } else {
312        Http::USER_AGENT.validated_assignment_fmt(&format!("cargo {}", crate::version()))
313    }?);
314    if let Some(ssl_version) = &http.ssl_version {
315        use crate::context::SslVersionConfig;
316        match ssl_version {
317            SslVersionConfig::Single(version) => {
318                values.push(Http::SSL_VERSION.validated_assignment_fmt(&version)?);
319            }
320            SslVersionConfig::Range(range) => {
321                values.push(
322                    gitoxide::Http::SSL_VERSION_MIN
323                        .validated_assignment_fmt(&range.min.as_deref().unwrap_or("default"))?,
324                );
325                values.push(
326                    gitoxide::Http::SSL_VERSION_MAX
327                        .validated_assignment_fmt(&range.max.as_deref().unwrap_or("default"))?,
328                );
329            }
330        }
331    } else if cfg!(windows) {
332        // This text is copied from https://github.com/rust-lang/cargo/blob/39c13e67a5962466cc7253d41bc1099bbcb224c3/src/ops/registry.rs#L658-L674 .
333        // This is a temporary workaround for some bugs with libcurl and
334        // schannel and TLS 1.3.
335        //
336        // Our libcurl on Windows is usually built with schannel.
337        // On Windows 11 (or Windows Server 2022), libcurl recently (late
338        // 2022) gained support for TLS 1.3 with schannel, and it now defaults
339        // to 1.3. Unfortunately there have been some bugs with this.
340        // https://github.com/curl/curl/issues/9431 is the most recent. Once
341        // that has been fixed, and some time has passed where we can be more
342        // confident that the 1.3 support won't cause issues, this can be
343        // removed.
344        //
345        // Windows 10 is unaffected. libcurl does not support TLS 1.3 on
346        // Windows 10. (Windows 10 sorta had support, but it required enabling
347        // an advanced option in the registry which was buggy, and libcurl
348        // does runtime checks to prevent it.)
349        values.push(gitoxide::Http::SSL_VERSION_MIN.validated_assignment_fmt(&"default")?);
350        values.push(gitoxide::Http::SSL_VERSION_MAX.validated_assignment_fmt(&"tlsv1.2")?);
351    }
352    if let Some(debug) = http.debug {
353        values.push(gitoxide::Http::VERBOSE.validated_assignment_fmt(&debug)?);
354    }
355    if let Some(multiplexing) = http.multiplexing {
356        let http_version = multiplexing.then(|| "HTTP/2").unwrap_or("HTTP/1.1");
357        // Note that failing to set the HTTP version in `gix-transport` isn't fatal,
358        // which is why we don't have to try to figure out if HTTP V2 is supported in the
359        // currently linked version (see `try_old_curl!()`)
360        values.push(Http::VERSION.validated_assignment_fmt(&http_version)?);
361    }
362
363    Ok(values)
364}
365
366/// Reinitializes a given Git repository. This is useful when a Git repository
367/// seems corrupted, and we want to start over.
368pub fn reinitialize(git_dir: &Path) -> CargoResult<()> {
369    fn init(path: &Path, bare: bool) -> CargoResult<()> {
370        let mut opts = git2::RepositoryInitOptions::new();
371        // Skip anything related to templates, they just call all sorts of issues as
372        // we really don't want to use them yet they insist on being used. See #6240
373        // for an example issue that comes up.
374        opts.external_template(false);
375        opts.bare(bare);
376        git2::Repository::init_opts(&path, &opts)?;
377        Ok(())
378    }
379    // Here we want to drop the current repository object pointed to by `repo`,
380    // so we initialize temporary repository in a sub-folder, blow away the
381    // existing git folder, and then recreate the git repo. Finally we blow away
382    // the `tmp` folder we allocated.
383    debug!("reinitializing git repo at {:?}", git_dir);
384    let tmp = git_dir.join("tmp");
385    let bare = !git_dir.ends_with(".git");
386    init(&tmp, false)?;
387    for entry in git_dir.read_dir()? {
388        let entry = entry?;
389        if entry.file_name().to_str() == Some("tmp") {
390            continue;
391        }
392        let path = entry.path();
393        drop(paths::remove_file(&path).or_else(|_| paths::remove_dir_all(&path)));
394    }
395    init(git_dir, bare)?;
396    paths::remove_dir_all(&tmp)?;
397    Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402    use super::{CredentialError, amend_authentication_hints};
403    use gix::error::ErrorExt;
404    use gix::protocol::transport::client::{AuthenticationRequired, Error};
405
406    #[test]
407    fn authentication_hints_preserve_causes_and_distinguish_network_errors() {
408        let missing = gix::error::unauthenticated("missing identity").raise();
409        let helper =
410            CredentialError(gix::error::message("Terminal prompts are disabled").raise()).raise();
411        let rejected = std::io::Error::new(
412            std::io::ErrorKind::PermissionDenied,
413            AuthenticationRequired::default(),
414        )
415        .raise();
416        let network = Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)).raise();
417        for (err, hint) in [
418            (missing, "git's `credential.helper` support, but failed"),
419            (helper, "git's `credential.helper` support, but failed"),
420            (rejected, "credentials were incorrect"),
421            (network, "network failure seems to have happened"),
422        ] {
423            let err = amend_authentication_hints(
424                Err(err),
425                "https://example.com/repo",
426                Some("https://example.com/auth".into()),
427            )
428            .unwrap_err();
429            assert!(err.to_string().contains(hint));
430            assert!(err.downcast_ref::<gix::Error>().is_some());
431        }
432        let cancelled = CredentialError(
433            std::io::Error::from(std::io::ErrorKind::ConnectionReset)
434                .and_raise(gix::error::cancelled("cancelled")),
435        )
436        .raise();
437        assert!(cancelled.is_cancelled());
438        assert!(!cancelled.can_retry_lenient());
439    }
440}