diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 8357c5226..c08142a4d 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -1456,6 +1456,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .uncached_client(resource.git.repository()) .clone(), client.unmanaged.disable_ssl(resource.git.repository()), + client.unmanaged.connectivity() == Connectivity::Offline, self.build_context.cache().bucket(CacheBucket::Git), self.reporter .clone() @@ -1617,6 +1618,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .uncached_client(resource.git.repository()) .clone(), client.unmanaged.disable_ssl(resource.git.repository()), + client.unmanaged.connectivity() == Connectivity::Offline, self.build_context.cache().bucket(CacheBucket::Git), self.reporter .clone() @@ -1851,6 +1853,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { git, client.unmanaged.uncached_client(git.repository()).clone(), client.unmanaged.disable_ssl(git.repository()), + client.unmanaged.connectivity() == Connectivity::Offline, self.build_context.cache().bucket(CacheBucket::Git), self.reporter .clone() diff --git a/crates/uv-git/src/git.rs b/crates/uv-git/src/git.rs index b8f666948..d77f507ce 100644 --- a/crates/uv-git/src/git.rs +++ b/crates/uv-git/src/git.rs @@ -29,6 +29,8 @@ pub enum GitError { GitNotFound, #[error(transparent)] Other(#[from] which::Error), + #[error("Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)")] + TransportNotAllowed, } /// A global cache of the result of `which git`. @@ -230,6 +232,7 @@ impl GitRemote { locked_rev: Option, client: &ClientWithMiddleware, disable_ssl: bool, + offline: bool, ) -> Result<(GitDatabase, GitOid)> { let reference = locked_rev .map(ReferenceOrOid::Oid) @@ -237,8 +240,15 @@ impl GitRemote { let enable_lfs_fetch = env::var(EnvVars::UV_GIT_LFS).is_ok(); if let Some(mut db) = db { - fetch(&mut db.repo, &self.url, reference, client, disable_ssl) - .with_context(|| format!("failed to fetch into: {}", into.user_display()))?; + fetch( + &mut db.repo, + &self.url, + reference, + client, + disable_ssl, + offline, + ) + .with_context(|| format!("failed to fetch into: {}", into.user_display()))?; let resolved_commit_hash = match locked_rev { Some(rev) => db.contains(rev).then_some(rev), @@ -265,8 +275,15 @@ impl GitRemote { fs_err::create_dir_all(into)?; let mut repo = GitRepository::init(into)?; - fetch(&mut repo, &self.url, reference, client, disable_ssl) - .with_context(|| format!("failed to clone into: {}", into.user_display()))?; + fetch( + &mut repo, + &self.url, + reference, + client, + disable_ssl, + offline, + ) + .with_context(|| format!("failed to clone into: {}", into.user_display()))?; let rev = match locked_rev { Some(rev) => rev, None => reference.resolve(&repo)?, @@ -441,6 +458,7 @@ fn fetch( reference: ReferenceOrOid<'_>, client: &ClientWithMiddleware, disable_ssl: bool, + offline: bool, ) -> Result<()> { let oid_to_fetch = match github_fast_path(repo, remote_url, reference, client) { Ok(FastPathRev::UpToDate) => return Ok(()), @@ -516,9 +534,14 @@ fn fetch( debug!("Performing a Git fetch for: {remote_url}"); let result = match refspec_strategy { - RefspecStrategy::All => { - fetch_with_cli(repo, remote_url, refspecs.as_slice(), tags, disable_ssl) - } + RefspecStrategy::All => fetch_with_cli( + repo, + remote_url, + refspecs.as_slice(), + tags, + disable_ssl, + offline, + ), RefspecStrategy::First => { // Try each refspec let mut errors = refspecs @@ -530,6 +553,7 @@ fn fetch( std::slice::from_ref(refspec), tags, disable_ssl, + offline, ); // Stop after the first success and log failures @@ -576,6 +600,7 @@ fn fetch_with_cli( refspecs: &[String], tags: bool, disable_ssl: bool, + offline: bool, ) -> Result<()> { let mut cmd = ProcessBuilder::new(GIT.as_ref()?); // Disable interactive prompts in the terminal, as they'll be erased by the progress bar @@ -588,9 +613,13 @@ fn fetch_with_cli( cmd.arg("--tags"); } if disable_ssl { - debug!("Disabling SSL verification for Git fetch"); + debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`"); cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true"); } + if offline { + debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`"); + cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file"); + } cmd.arg("--force") // handle force pushes .arg("--update-head-ok") // see discussion in #2078 .arg(url.as_str()) @@ -611,7 +640,13 @@ fn fetch_with_cli( // We capture the output to avoid streaming it to the user's console during clones. // The required `on...line` callbacks currently do nothing. // The output appears to be included in error messages by default. - cmd.exec_with_output()?; + cmd.exec_with_output().map_err(|err| { + let msg = err.to_string(); + if msg.contains("transport '") && msg.contains("' not allowed") && offline { + return GitError::TransportNotAllowed.into(); + } + err + })?; Ok(()) } diff --git a/crates/uv-git/src/resolver.rs b/crates/uv-git/src/resolver.rs index 4bdf41443..3e909efe9 100644 --- a/crates/uv-git/src/resolver.rs +++ b/crates/uv-git/src/resolver.rs @@ -109,6 +109,7 @@ impl GitResolver { url: &GitUrl, client: ClientWithMiddleware, disable_ssl: bool, + offline: bool, cache: PathBuf, reporter: Option>, ) -> Result { @@ -138,9 +139,9 @@ impl GitResolver { // Fetch the Git repository. let source = if let Some(reporter) = reporter { - GitSource::new(url.as_ref().clone(), client, cache).with_reporter(reporter) + GitSource::new(url.as_ref().clone(), client, cache, offline).with_reporter(reporter) } else { - GitSource::new(url.as_ref().clone(), client, cache) + GitSource::new(url.as_ref().clone(), client, cache, offline) }; // If necessary, disable SSL. diff --git a/crates/uv-git/src/source.rs b/crates/uv-git/src/source.rs index 8a0bc5990..e76b44045 100644 --- a/crates/uv-git/src/source.rs +++ b/crates/uv-git/src/source.rs @@ -25,6 +25,8 @@ pub struct GitSource { client: ClientWithMiddleware, /// Whether to disable SSL verification. disable_ssl: bool, + /// Whether to operate without network connectivity. + offline: bool, /// The path to the Git source database. cache: PathBuf, /// The reporter to use for this source. @@ -37,10 +39,12 @@ impl GitSource { git: GitUrl, client: impl Into, cache: impl Into, + offline: bool, ) -> Self { Self { git, disable_ssl: false, + offline, client: client.into(), cache: cache.into(), reporter: None, @@ -110,6 +114,7 @@ impl GitSource { locked_rev, &self.client, self.disable_ssl, + self.offline, )?; (db, actual_rev, task) diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 406789893..7c63809cc 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -492,6 +492,12 @@ impl EnvVars { #[attr_hidden] pub const GIT_SSL_NO_VERIFY: &'static str = "GIT_SSL_NO_VERIFY"; + /// Sets allowed protocols for git operations. + /// + /// When uv is in "offline" mode, only the "file" protocol is allowed. + #[attr_hidden] + pub const GIT_ALLOW_PROTOCOL: &'static str = "GIT_ALLOW_PROTOCOL"; + /// Disable interactive git prompts in terminals, e.g., for credentials. Does not disable /// GUI prompts. #[attr_hidden] diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 12e2a4cc0..ac45d38d3 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -3294,6 +3294,27 @@ fn install_constraints_respects_offline_mode() { ); } +#[test] +#[cfg(feature = "git")] +fn install_git_source_respects_offline_mode() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--offline") + .arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage` + ├─▶ Git operation failed + ├─▶ failed to clone into: [CACHE_DIR]/git-v0/db/8dab139913c4b566 + ╰─▶ Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`) + " + ); +} + /// Test that constraint markers are respected when validating the current environment (i.e., we /// skip resolution entirely). #[test]