Skip to content

Commit

Permalink
Add redundant check
Browse files Browse the repository at this point in the history
Update crates/uv-settings/src/Implement trusted publishing

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
  • Loading branch information
konstin and charliermarsh committed Sep 24, 2024
1 parent 1995d20 commit a1636c6
Show file tree
Hide file tree
Showing 22 changed files with 482 additions and 39 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,9 @@ jobs:
env:
# No dbus in GitHub Actions
PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring
permissions:
# For trusted publishing
id-token: write
steps:
- uses: actions/checkout@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

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

10 changes: 9 additions & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use url::Url;
use uv_cache::CacheArgs;
use uv_configuration::{
ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier,
TargetTriple, TrustedHost,
TargetTriple, TrustedHost, TrustedPublishing,
};
use uv_normalize::{ExtraName, PackageName};
use uv_python::{PythonDownloads, PythonPreference, PythonVersion};
Expand Down Expand Up @@ -4347,6 +4347,14 @@ pub struct PublishArgs {
)]
pub token: Option<String>,

/// Configure using trusted publishing through GitHub Actions.
///
/// By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it
/// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request
/// from a fork).
#[arg(long)]
pub trusted_publishing: Option<TrustedPublishing>,

/// Attempt to use `keyring` for authentication for remote requirements files.
///
/// At present, only `--keyring-provider subprocess` is supported, which configures uv to
Expand Down
78 changes: 65 additions & 13 deletions crates/uv-client/src/base_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ use crate::middleware::OfflineMiddleware;
use crate::tls::read_identity;
use crate::Connectivity;

/// Selectively skip parts or the entire auth middleware.
#[derive(Debug, Clone, Copy, Default)]
pub enum AuthIntegration {
/// Run the full auth middleware, including sending an unauthenticated request first.
#[default]
Default,
/// Send only an authenticated request without cloning and sending an unauthenticated request
/// first. Errors if no credentials were found.
OnlyAuthenticated,
/// Skip the auth middleware entirely. The caller is responsible for managing authentication.
NoAuthMiddleware,
}

/// A builder for an [`BaseClient`].
#[derive(Debug, Clone)]
pub struct BaseClientBuilder<'a> {
Expand All @@ -36,7 +49,7 @@ pub struct BaseClientBuilder<'a> {
client: Option<Client>,
markers: Option<&'a MarkerEnvironment>,
platform: Option<&'a Platform>,
only_authenticated: bool,
auth_integration: AuthIntegration,
}

impl Default for BaseClientBuilder<'_> {
Expand All @@ -56,7 +69,7 @@ impl BaseClientBuilder<'_> {
client: None,
markers: None,
platform: None,
only_authenticated: false,
auth_integration: AuthIntegration::default(),
}
}
}
Expand Down Expand Up @@ -111,8 +124,8 @@ impl<'a> BaseClientBuilder<'a> {
}

#[must_use]
pub fn only_authenticated(mut self, only_authenticated: bool) -> Self {
self.only_authenticated = only_authenticated;
pub fn auth_integration(mut self, auth_integration: AuthIntegration) -> Self {
self.auth_integration = auth_integration;
self
}

Expand Down Expand Up @@ -162,34 +175,53 @@ impl<'a> BaseClientBuilder<'a> {
debug!("Using request timeout of {timeout}s");

// Create a secure client that validates certificates.
let client = self.create_client(
let raw_client = self.create_client(
&user_agent_string,
timeout,
ssl_cert_file_exists,
Security::Secure,
);

// Create an insecure client that accepts invalid certificates.
let dangerous_client = self.create_client(
let raw_dangerous_client = self.create_client(
&user_agent_string,
timeout,
ssl_cert_file_exists,
Security::Insecure,
);

// Wrap in any relevant middleware and handle connectivity.
let client = self.apply_middleware(client);
let dangerous_client = self.apply_middleware(dangerous_client);
let client = self.apply_middleware(raw_client.clone());
let dangerous_client = self.apply_middleware(raw_dangerous_client.clone());

BaseClient {
connectivity: self.connectivity,
allow_insecure_host: self.allow_insecure_host.clone(),
client,
raw_client,
dangerous_client,
raw_dangerous_client,
timeout,
}
}

/// Share the underlying client between two different middleware configurations.
pub fn wrap_existing(&self, existing: &BaseClient) -> BaseClient {
// Wrap in any relevant middleware and handle connectivity.
let client = self.apply_middleware(existing.raw_client.clone());
let dangerous_client = self.apply_middleware(existing.raw_dangerous_client.clone());

BaseClient {
connectivity: self.connectivity,
allow_insecure_host: self.allow_insecure_host.clone(),
client,
dangerous_client,
raw_client: existing.raw_client.clone(),
raw_dangerous_client: existing.raw_dangerous_client.clone(),
timeout: existing.timeout,
}
}

fn create_client(
&self,
user_agent: &str,
Expand Down Expand Up @@ -253,11 +285,22 @@ impl<'a> BaseClientBuilder<'a> {
}

// Initialize the authentication middleware to set headers.
client = client.with(
AuthMiddleware::new()
.with_keyring(self.keyring.to_provider())
.with_only_authenticated(self.only_authenticated),
);
match self.auth_integration {
AuthIntegration::Default => {
client = client
.with(AuthMiddleware::new().with_keyring(self.keyring.to_provider()));
}
AuthIntegration::OnlyAuthenticated => {
client = client.with(
AuthMiddleware::new()
.with_keyring(self.keyring.to_provider())
.with_only_authenticated(true),
);
}
AuthIntegration::NoAuthMiddleware => {
// The downstream code uses custom auth logic.
}
}

client.build()
}
Expand All @@ -275,6 +318,10 @@ pub struct BaseClient {
client: ClientWithMiddleware,
/// The underlying HTTP client that accepts invalid certificates.
dangerous_client: ClientWithMiddleware,
/// The HTTP client without middleware.
raw_client: Client,
/// The HTTP client that accepts invalid certificates without middleware.
raw_dangerous_client: Client,
/// The connectivity mode to use.
connectivity: Connectivity,
/// Configured client timeout, in seconds.
Expand All @@ -297,6 +344,11 @@ impl BaseClient {
self.client.clone()
}

/// The underlying [`Client`] without middleware.
pub fn raw_client(&self) -> Client {
self.raw_client.clone()
}

/// Selects the appropriate client based on the host's trustworthiness.
pub fn for_host(&self, url: &Url) -> &ClientWithMiddleware {
if self
Expand Down
2 changes: 1 addition & 1 deletion crates/uv-client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub use base_client::{BaseClient, BaseClientBuilder};
pub use base_client::{AuthIntegration, BaseClient, BaseClientBuilder};
pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy};
pub use error::{Error, ErrorKind, WrappedReqwestError};
pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexError};
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-configuration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub use preview::*;
pub use sources::*;
pub use target_triple::*;
pub use trusted_host::*;
pub use trusted_publishing::*;

mod authentication;
mod build_options;
Expand All @@ -35,3 +36,4 @@ mod preview;
mod sources;
mod target_triple;
mod trusted_host;
mod trusted_publishing;
15 changes: 15 additions & 0 deletions crates/uv-configuration/src/trusted_publishing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum TrustedPublishing {
/// Try trusted publishing when we're already in GitHub Actions, continue if that fails.
#[default]
Automatic,
// Force trusted publishing.
Always,
// Never try to get a trusted publishing token.
Never,
}
2 changes: 2 additions & 0 deletions crates/uv-publish/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ license.workspace = true
distribution-filename = { workspace = true }
pypi-types = { workspace = true }
uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-fs = { workspace = true }
uv-metadata = { workspace = true }
uv-warnings = { workspace = true }

async-compression = { workspace = true }
base64 = { workspace = true }
Expand Down
74 changes: 65 additions & 9 deletions crates/uv-publish/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
mod trusted_publishing;

use crate::trusted_publishing::TrustedPublishingError;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
Expand All @@ -9,24 +12,25 @@ use pypi_types::{Metadata23, MetadataError};
use reqwest::header::AUTHORIZATION;
use reqwest::multipart::Part;
use reqwest::{Body, Response, StatusCode};
use reqwest_middleware::RequestBuilder;
use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
use rustc_hash::FxHashSet;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::{fmt, io};
use std::{env, fmt, io};
use thiserror::Error;
use tokio::io::AsyncReadExt;
use tracing::{debug, enabled, trace, Level};
use url::Url;
use uv_client::BaseClient;
use uv_configuration::{KeyringProviderType, TrustedPublishing};
use uv_fs::Simplified;
use uv_metadata::read_metadata_async_seek;
use uv_warnings::warn_user_once;

#[derive(Error, Debug)]
pub enum PublishError {
#[error("Invalid publish path: `{0}`")]
#[error("The publish path is not a valid glob pattern: `{0}`")]
Pattern(String, #[source] PatternError),
/// [`GlobError`] is a wrapped io error.
#[error(transparent)]
Expand All @@ -41,6 +45,8 @@ pub enum PublishError {
PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>),
#[error("Failed to publish `{}` to {}", _0.user_display(), _1)]
PublishSend(PathBuf, Url, #[source] PublishSendError),
#[error("Failed to obtain token for trusted publishing")]
TrustedPublishing(#[from] TrustedPublishingError),
}

/// Failure to get the metadata for a specific file.
Expand Down Expand Up @@ -202,14 +208,65 @@ pub fn files_for_publishing(
Ok(files)
}

/// If applicable, attempt obtaining a token for trusted publishing.
pub async fn check_trusted_publishing(
username: Option<&str>,
password: Option<&str>,
keyring_provider: KeyringProviderType,
trusted_publishing: TrustedPublishing,
registry: &Url,
client: &ClientWithMiddleware,
) -> Result<Option<String>, PublishError> {
match trusted_publishing {
TrustedPublishing::Automatic => {
// If the user provided credentials, use those.
if username.is_some()
|| password.is_some()
|| keyring_provider != KeyringProviderType::Disabled
{
return Ok(None);
}
// If we aren't in GitHub Actions, we can't use trusted publishing.
if env::var("GITHUB_ACTIONS") != Ok("true".to_string()) {
return Ok(None);
}
// We could check for credentials from the keyring or netrc the auth middleware first, but
// given that we are in GitHub Actions we check for trusted publishing first.
debug!("Running on GitHub Actions without explicit credentials, checking for trusted publishing");
match trusted_publishing::get_token(registry, client).await {
Ok(token) => Ok(Some(token)),
Err(err) => {
// TODO(konsti): It would be useful if we could differentiate between actual errors
// such as connection errors and warn for them while ignoring errors from trusted
// publishing not being configured.
debug!("Could not obtain trusted publishing credentials, skipping: {err}");
Ok(None)
}
}
}
TrustedPublishing::Always => {
debug!("Using trusted publishing for GitHub Actions");
if env::var("GITHUB_ACTIONS") != Ok("true".to_string()) {
warn_user_once!(
"Trusted publishing was requested, but you're not in GitHub Actions."
);
}

let token = trusted_publishing::get_token(registry, client).await?;
Ok(Some(token))
}
TrustedPublishing::Never => Ok(None),
}
}

/// Upload a file to a registry.
///
/// Returns `true` if the file was newly uploaded and `false` if it already existed.
pub async fn upload(
file: &Path,
filename: &DistFilename,
registry: &Url,
client: &BaseClient,
client: &ClientWithMiddleware,
username: Option<&str>,
password: Option<&str>,
) -> Result<bool, PublishError> {
Expand Down Expand Up @@ -392,7 +449,7 @@ async fn build_request(
file: &Path,
filename: &DistFilename,
registry: &Url,
client: &BaseClient,
client: &ClientWithMiddleware,
username: Option<&str>,
password: Option<&str>,
form_metadata: Vec<(&'static str, String)>,
Expand Down Expand Up @@ -425,7 +482,6 @@ async fn build_request(
};

let mut request = client
.client()
.post(url)
.multipart(form)
// Ask PyPI for a structured error messages instead of HTML-markup error messages.
Expand Down Expand Up @@ -598,7 +654,7 @@ mod tests {
&file,
&filename,
&Url::parse("https://example.org/upload").unwrap(),
&BaseClientBuilder::new().build(),
&BaseClientBuilder::new().build().client(),
Some("ferris"),
Some("F3RR!S"),
form_metadata,
Expand Down Expand Up @@ -740,7 +796,7 @@ mod tests {
&file,
&filename,
&Url::parse("https://example.org/upload").unwrap(),
&BaseClientBuilder::new().build(),
&BaseClientBuilder::new().build().client(),
Some("ferris"),
Some("F3RR!S"),
form_metadata,
Expand Down
Loading

0 comments on commit a1636c6

Please sign in to comment.