Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions crates/uv-distribution-types/src/requirement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ impl Display for Requirement {
subdirectory,
} => {
write!(f, " @ git+{}", git.url())?;
if let Some(reference) = git.reference().as_str() {
if let Some(reference) = git.reference().as_url_rev() {
write!(f, "@{reference}")?;
}
if let Some(subdirectory) = subdirectory {
Expand Down Expand Up @@ -771,7 +771,7 @@ impl Display for RequirementSource {
subdirectory,
} => {
write!(f, " git+{}", git.url())?;
if let Some(reference) = git.reference().as_str() {
if let Some(reference) = git.reference().as_url_rev() {
write!(f, "@{reference}")?;
}
if let Some(subdirectory) = subdirectory {
Expand Down Expand Up @@ -981,7 +981,7 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {

// Create a PEP 508-compatible URL.
let mut url = DisplaySafeUrl::parse(&format!("git+{repository}"))?;
if let Some(rev) = reference.as_str() {
if let Some(rev) = reference.as_url_rev() {
let path = format!("{}@{}", url.path(), rev);
url.set_path(&path);
}
Expand Down
1 change: 1 addition & 0 deletions crates/uv-git-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ uv-cache-key = { workspace = true }
uv-redacted = { workspace = true }
uv-static = { workspace = true }

percent-encoding = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
Expand Down
75 changes: 75 additions & 0 deletions crates/uv-git-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub use crate::reference::GitReference;
use std::cmp::Ordering;
use std::sync::LazyLock;

use percent_encoding::percent_decode_str;
use thiserror::Error;
use uv_cache_key::RepositoryUrl;
use uv_redacted::DisplaySafeUrl;
Expand Down Expand Up @@ -79,6 +80,10 @@ pub enum GitUrlParseError {
"Unsupported Git URL scheme `{0}:` in `{1}` (expected one of `https:`, `ssh:`, or `file:`)"
)]
UnsupportedGitScheme(String, DisplaySafeUrl),
#[error(
"Ambiguous Git URL `{0}`: the path contains multiple `@` characters. If the Git revision contains `@`, percent-encode it as `%40`"
)]
AmbiguousRevision(DisplaySafeUrl),
}

/// A URL reference to a Git repository.
Expand Down Expand Up @@ -234,6 +239,10 @@ impl TryFrom<DisplaySafeUrl> for GitUrl {
url.set_fragment(None);
url.set_query(None);

if url.path().matches('@').nth(1).is_some() {
return Err(GitUrlParseError::AmbiguousRevision(url));
}

// If the URL ends with a reference, like `https://git.example.com/MyProject.git@v1.0`,
// extract it.
let mut reference = GitReference::DefaultBranch;
Expand All @@ -242,6 +251,7 @@ impl TryFrom<DisplaySafeUrl> for GitUrl {
.rsplit_once('@')
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
{
let suffix = percent_decode_str(&suffix).decode_utf8_lossy().into_owned();
reference = GitReference::from_rev(suffix);
url.set_path(&prefix);
}
Expand All @@ -267,6 +277,7 @@ impl From<GitUrl> for DisplaySafeUrl {
| GitReference::BranchOrTag(rev)
| GitReference::NamedRef(rev)
| GitReference::BranchOrTagOrCommit(rev) => {
let rev = GitReference::encode_rev(&rev);
let path = format!("{}@{}", url.path(), rev);
url.set_path(&path);
}
Expand All @@ -283,3 +294,67 @@ impl std::fmt::Display for GitUrl {
write!(f, "{}", &self.url)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_percent_encoded_reference() -> Result<(), Box<dyn std::error::Error>> {
let url = DisplaySafeUrl::parse("https://example.com/pkg.git@dev%401%232")?;
let git = GitUrl::try_from(url)?;

assert_eq!(git.url().as_str(), "https://example.com/pkg.git");
assert_eq!(git.reference().as_str(), Some("dev@1#2"));

Ok(())
}

#[test]
fn parse_ssh_url_with_username_and_percent_encoded_reference()
-> Result<(), Box<dyn std::error::Error>> {
let url = DisplaySafeUrl::parse("ssh://git@github.com/example/example.git@abc%401.2.3")?;
let git = GitUrl::try_from(url)?;

assert_eq!(
git.url().as_str(),
"ssh://git@github.com/example/example.git"
);
assert_eq!(git.reference().as_str(), Some("abc@1.2.3"));

Ok(())
}

#[test]
fn reject_ambiguous_reference() -> Result<(), Box<dyn std::error::Error>> {
let url = DisplaySafeUrl::parse("https://example.com/pkg.git@dev@1.2.3")?;
let err = GitUrl::try_from(url).unwrap_err();

assert_eq!(
err.to_string(),
"Ambiguous Git URL `https://example.com/pkg.git@dev@1.2.3`: the path contains multiple `@` characters. If the Git revision contains `@`, percent-encode it as `%40`"
);

Ok(())
}

#[test]
fn display_percent_encodes_reference() -> Result<(), Box<dyn std::error::Error>> {
let git = GitUrl::from_reference(
DisplaySafeUrl::parse("https://example.com/pkg.git")?,
GitReference::from_rev("refs/pull/493/head@1#2%".to_string()),
GitLfs::Disabled,
)?;
let url = DisplaySafeUrl::from(git);

assert_eq!(
url.as_str(),
"https://example.com/pkg.git@refs/pull/493/head%401%232%25"
);

let git = GitUrl::try_from(url)?;
assert_eq!(git.reference().as_str(), Some("refs/pull/493/head@1#2%"));

Ok(())
}
}
23 changes: 23 additions & 0 deletions crates/uv-git-types/src/reference.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
use std::fmt::Display;
use std::str;

use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};

/// Percent-encode Git revisions for use after the `@` in VCS URLs.
///
/// This follows Python's `urllib.parse.quote(rev, safe="/")`.
/// See: <https://docs.python.org/3/library/urllib.parse.html#urllib.parse.quote>
const GIT_REFERENCE_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'/')
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');

/// A reference to commit or commit-ish.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum GitReference {
Expand Down Expand Up @@ -55,6 +68,16 @@ impl GitReference {
}
}

/// Converts the [`GitReference`] to a percent-encoded revision string for use in a URL.
pub fn as_url_rev(&self) -> Option<String> {
self.as_str().map(Self::encode_rev)
}

/// Percent-encode a revision string for use in a URL.
pub fn encode_rev(rev: &str) -> String {
utf8_percent_encode(rev, GIT_REFERENCE_ENCODE_SET).to_string()
}

/// Returns the kind of this reference.
pub fn kind_str(&self) -> &str {
match self {
Expand Down
109 changes: 108 additions & 1 deletion crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;

use anyhow::Result;
use anyhow::{Result, anyhow};
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use flate2::write::GzEncoder;
Expand Down Expand Up @@ -2315,6 +2315,113 @@ fn install_implicit_git_public_https() {
context.assert_installed("uv_public_pypackage", "0.1.0");
}

/// Install a package from a Git ref that contains a percent-encoded `@`.
#[test]
#[cfg(feature = "test-git")]
fn install_git_percent_encoded_ref() -> Result<()> {
let context = uv_test::test_context!(DEFAULT_PYTHON_VERSION);

let repository = context.temp_dir.child("repository");
repository
.child("packages/example/example")
.create_dir_all()?;
repository
.child("packages/example/example/__init__.py")
.write_str(r#"__version__ = "0.1.0""#)?;
repository
.child("packages/example/pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "example"
version = "0.1.0"
requires-python = ">=3.12"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#})?;

Command::new("git")
.arg("init")
.arg(repository.path())
.assert()
.success();
Command::new("git")
.arg("-C")
.arg(repository.path())
.arg("add")
.arg(".")
.assert()
.success();
Command::new("git")
.arg("-C")
.arg(repository.path())
.arg("-c")
.arg("user.name=Example")
.arg("-c")
.arg("user.email=example@example.com")
.arg("commit")
.arg("-m")
.arg("Initial commit")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.assert()
.success();
Command::new("git")
.arg("-C")
.arg(repository.path())
.arg("tag")
.arg("pkg@1.2.3")
.assert()
.success();

let repository_url = Url::from_directory_path(repository.path())
.map_err(|()| anyhow!("failed to convert repository path to file URL"))?;
let repository_url = repository_url.as_str().trim_end_matches('/');

let mut filters = context.filters();
filters.push((r"@[0-9a-f]{40}", "@[COMMIT]"));
uv_snapshot!(filters, context
.pip_install()
.arg(format!(
"example @ git+{repository_url}@pkg%401.2.3#subdirectory=packages/example"
)), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ example==0.1.0 (from git+file://[TEMP_DIR]/repository@[COMMIT]#subdirectory=packages/example)
");

context.assert_installed("example", "0.1.0");

Ok(())
}

/// Reject an ambiguous Git URL when the ref contains an unescaped `@`.
#[test]
fn install_git_unescaped_ref() {
let context = uv_test::test_context!(DEFAULT_PYTHON_VERSION);

uv_snapshot!(context.filters(), context
.pip_install()
.arg("example @ git+https://example.com/repository@pkg@1.2.3"), @"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: Failed to parse: `example @ git+https://example.com/repository@pkg@1.2.3`
Caused by: Ambiguous Git URL `https://example.com/repository@pkg@1.2.3`: the path contains multiple `@` characters. If the Git revision contains `@`, percent-encode it as `%40`
example @ git+https://example.com/repository@pkg@1.2.3
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
");
}

/// Install and update a package from a public GitHub repository
#[test]
#[cfg(feature = "test-git")]
Expand Down
Loading