use crate::util::hash_file;
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use bytesize::ByteSize;
use configparser::ini::Ini;
use fs_err as fs;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
#[cfg(any(feature = "native-tls", feature = "rustls"))]
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use thiserror::Error;
use tracing::debug;
#[derive(Debug, clap::Parser)]
pub struct PublishOpt {
#[arg(short = 'r', long, env = "MATURIN_REPOSITORY", default_value = "pypi")]
repository: String,
#[arg(long, env = "MATURIN_REPOSITORY_URL", overrides_with = "repository")]
repository_url: Option<String>,
#[arg(short, long, env = "MATURIN_USERNAME")]
username: Option<String>,
#[arg(short, long, env = "MATURIN_PASSWORD", hide_env_values = true)]
password: Option<String>,
#[arg(long)]
skip_existing: bool,
#[arg(long, env = "MATURIN_NON_INTERACTIVE")]
non_interactive: bool,
}
impl PublishOpt {
const DEFAULT_REPOSITORY_URL: &'static str = "https://upload.pypi.org/legacy";
const TEST_REPOSITORY_URL: &'static str = "https://test.pypi.org/legacy";
pub fn non_interactive_on_ci(&mut self) {
let msg = "⚠️ Warning: The maturin upload and publish commands are deprecated and will be removed in the future. For more information see: https://github.com/PyO3/maturin/issues/2334";
eprintln!("{msg}");
if env::var("GITHUB_ACTIONS")
.map(|v| v == "true")
.unwrap_or_default()
{
println!("::warning::{msg}");
}
if !self.non_interactive && env::var("CI").map(|v| v == "true").unwrap_or_default() {
eprintln!("🎛️ Running in non-interactive mode on CI");
self.non_interactive = true;
}
}
}
#[derive(Error, Debug)]
#[error("Uploading to the registry failed")]
pub enum UploadError {
#[error("Http error")]
UreqError(#[source] Box<ureq::Error>),
#[error("Username or password are incorrect")]
AuthenticationError(String),
#[error("IO Error")]
IoError(#[source] io::Error),
#[error("Failed to upload the wheel with status {0}: {1}")]
StatusCodeError(String, String),
#[error("File already exists: {0}")]
FileExistsError(String),
#[error("Could not read the metadata from the package at {0}")]
PkgInfoError(PathBuf, #[source] python_pkginfo::Error),
#[cfg(feature = "native-tls")]
#[error("TLS Error")]
TlsError(#[source] native_tls::Error),
}
impl From<io::Error> for UploadError {
fn from(error: io::Error) -> Self {
UploadError::IoError(error)
}
}
impl From<ureq::Error> for UploadError {
fn from(error: ureq::Error) -> Self {
UploadError::UreqError(Box::new(error))
}
}
#[cfg(feature = "native-tls")]
impl From<native_tls::Error> for UploadError {
fn from(error: native_tls::Error) -> Self {
UploadError::TlsError(error)
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Registry {
pub username: String,
pub password: String,
pub url: String,
}
impl Registry {
pub fn new(username: String, password: String, url: String) -> Registry {
Registry {
username,
password,
url,
}
}
}
fn get_password(_username: &str) -> String {
#[cfg(feature = "keyring")]
{
let service = env!("CARGO_PKG_NAME");
let keyring = keyring::Entry::new(service, _username);
if let Ok(password) = keyring.and_then(|keyring| keyring.get_password()) {
return password;
};
}
dialoguer::Password::new()
.with_prompt("Please enter your password")
.interact()
.unwrap_or_else(|_| {
let mut password = String::new();
io::stdin()
.read_line(&mut password)
.expect("Failed to read line");
password.trim().to_string()
})
}
fn get_username() -> String {
eprintln!("Please enter your username:");
let mut line = String::new();
io::stdin().read_line(&mut line).unwrap();
line.trim().to_string()
}
fn load_pypirc() -> Ini {
let mut config = Ini::new();
if let Some(mut config_path) = dirs::home_dir() {
config_path.push(".pypirc");
if let Ok(pypirc) = fs::read_to_string(config_path.as_path()) {
let _ = config.read(pypirc);
}
}
config
}
fn load_pypi_cred_from_config(config: &Ini, registry_name: &str) -> Option<(String, String)> {
if let (Some(username), Some(password)) = (
config.get(registry_name, "username"),
config.get(registry_name, "password"),
) {
return Some((username, password));
}
None
}
fn resolve_pypi_cred(
opt: &PublishOpt,
config: &Ini,
registry_name: Option<&str>,
registry_url: &str,
) -> Result<(String, String)> {
if let Ok(token) = env::var("MATURIN_PYPI_TOKEN") {
return Ok(("__token__".to_string(), token));
}
match resolve_pypi_token_via_oidc(registry_url) {
Ok(Some(token)) => {
eprintln!("🔐 Using trusted publisher for upload");
return Ok(("__token__".to_string(), token));
}
Ok(None) => {}
Err(e) => eprintln!("⚠️ Warning: Failed to resolve PyPI token via OIDC: {e}"),
}
if let Some((username, password)) =
registry_name.and_then(|name| load_pypi_cred_from_config(config, name))
{
eprintln!("🔐 Using credential in pypirc for upload");
return Ok((username, password));
}
if opt.non_interactive && (opt.username.is_none() || opt.password.is_none()) {
bail!("Credentials not found and non-interactive mode is enabled");
}
let username = opt.username.clone().unwrap_or_else(get_username);
let password = opt
.password
.clone()
.unwrap_or_else(|| get_password(&username));
Ok((username, password))
}
#[derive(Debug, Deserialize)]
struct OidcAudienceResponse {
audience: String,
}
#[derive(Debug, Deserialize)]
struct OidcTokenResponse {
value: String,
}
#[derive(Debug, Deserialize)]
struct MintTokenResponse {
token: String,
}
fn resolve_pypi_token_via_oidc(registry_url: &str) -> Result<Option<String>> {
if env::var_os("GITHUB_ACTIONS").is_none() {
return Ok(None);
}
if let (Ok(req_token), Ok(req_url)) = (
env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
env::var("ACTIONS_ID_TOKEN_REQUEST_URL"),
) {
let registry_url = url::Url::parse(registry_url)?;
let mut audience_url = registry_url.clone();
audience_url.set_path("_/oidc/audience");
debug!("Requesting OIDC audience from {}", audience_url);
let agent: ureq::Agent = ureq::Agent::config_builder()
.proxy(ureq::Proxy::try_from_env())
.timeout_global(Some(Duration::from_secs(30)))
.build()
.into();
let mut audience_res = agent.get(audience_url.as_str()).call()?;
if audience_res.status() == 404 {
return Ok(None);
}
let audience = audience_res
.body_mut()
.read_json::<OidcAudienceResponse>()?
.audience;
debug!("Requesting OIDC token for {} from {}", audience, req_url);
let request_token_res: OidcTokenResponse = agent
.get(&req_url)
.query("audience", &audience)
.header("Authorization", &format!("bearer {req_token}"))
.call()?
.body_mut()
.read_json()?;
let oidc_token = request_token_res.value;
let mut mint_token_url = registry_url;
mint_token_url.set_path("_/oidc/github/mint-token");
debug!("Requesting API token from {}", mint_token_url);
let mut mint_token_req = HashMap::new();
mint_token_req.insert("token", oidc_token);
let mint_token_res = agent
.post(mint_token_url.as_str())
.send_json(mint_token_req)?
.body_mut()
.read_json::<MintTokenResponse>()?;
return Ok(Some(mint_token_res.token));
}
Ok(None)
}
fn complete_registry(opt: &PublishOpt) -> Result<Registry> {
let pypirc = load_pypirc();
let (registry_name, registry_url) = if let Some(repository_url) = opt.repository_url.as_deref()
{
match repository_url.trim_end_matches('/') {
PublishOpt::DEFAULT_REPOSITORY_URL => (
Some("pypi"),
format!("{}/", PublishOpt::DEFAULT_REPOSITORY_URL),
),
PublishOpt::TEST_REPOSITORY_URL => (
Some("testpypi"),
format!("{}/", PublishOpt::TEST_REPOSITORY_URL),
),
_ => (None, repository_url.to_string()),
}
} else if let Some(url) = pypirc.get(&opt.repository, "repository") {
(Some(opt.repository.as_str()), url)
} else if opt.repository == "pypi" {
(
Some("pypi"),
format!("{}/", PublishOpt::DEFAULT_REPOSITORY_URL),
)
} else if opt.repository == "testpypi" {
(
Some("testpypi"),
format!("{}/", PublishOpt::TEST_REPOSITORY_URL),
)
} else {
bail!(
"Failed to get registry {} in .pypirc. \
Note: Your index didn't start with http:// or https://, \
which is required for non-pypirc indices.",
opt.repository
);
};
let (username, password) = resolve_pypi_cred(opt, &pypirc, registry_name, ®istry_url)?;
let registry = Registry::new(username, password, registry_url);
Ok(registry)
}
fn canonicalize_name(name: &str) -> String {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new("[-_.]+").unwrap());
RE.replace_all(name, "-").to_lowercase()
}
#[cfg(any(feature = "native-tls", feature = "rustls"))]
fn tls_ca_bundle() -> Option<OsString> {
env::var_os("MATURIN_CA_BUNDLE")
.or_else(|| env::var_os("REQUESTS_CA_BUNDLE"))
.or_else(|| env::var_os("CURL_CA_BUNDLE"))
}
#[cfg(any(feature = "native-tls", feature = "rustls"))]
fn load_ca_certs(
ca_bundle: impl AsRef<std::path::Path>,
) -> Result<Vec<ureq::tls::Certificate<'static>>, UploadError> {
use rustls_pki_types::pem::PemObject;
let certs = rustls_pki_types::CertificateDer::pem_file_iter(ca_bundle)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(certs
.into_iter()
.map(|cert| {
let owned = cert.into_owned();
ureq::tls::Certificate::from_der(owned.as_ref()).to_owned()
})
.collect())
}
#[cfg(all(feature = "native-tls", not(feature = "rustls")))]
fn http_agent() -> Result<ureq::Agent, UploadError> {
let mut tls_config =
ureq::tls::TlsConfig::builder().provider(ureq::tls::TlsProvider::NativeTls);
if let Some(ca_bundle) = tls_ca_bundle() {
let certs = load_ca_certs(ca_bundle)?;
tls_config = tls_config.root_certs(ureq::tls::RootCerts::new_with_certs(&certs));
}
Ok(ureq::Agent::config_builder()
.proxy(ureq::Proxy::try_from_env())
.http_status_as_error(false)
.tls_config(tls_config.build())
.build()
.into())
}
#[cfg(feature = "rustls")]
fn http_agent() -> Result<ureq::Agent, UploadError> {
let mut builder = ureq::Agent::config_builder()
.proxy(ureq::Proxy::try_from_env())
.http_status_as_error(false);
if let Some(ca_bundle) = tls_ca_bundle() {
let certs = load_ca_certs(ca_bundle)?;
let tls_config = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::new_with_certs(&certs))
.build();
builder = builder.tls_config(tls_config);
}
Ok(builder.build().into())
}
#[cfg(not(any(feature = "native-tls", feature = "rustls")))]
fn http_agent() -> Result<ureq::Agent, UploadError> {
Ok(ureq::Agent::config_builder()
.proxy(ureq::Proxy::try_from_env())
.http_status_as_error(false)
.build()
.into())
}
pub fn upload(registry: &Registry, wheel_path: &Path) -> Result<(), UploadError> {
let hash_hex = hash_file(wheel_path)?;
let dist = python_pkginfo::Distribution::new(wheel_path)
.map_err(|err| UploadError::PkgInfoError(wheel_path.to_owned(), err))?;
let metadata = dist.metadata();
let mut api_metadata = vec![
(":action", "file_upload".to_string()),
("sha256_digest", hash_hex),
("protocol_version", "1".to_string()),
("metadata_version", metadata.metadata_version.clone()),
("name", canonicalize_name(&metadata.name)),
("version", metadata.version.clone()),
("pyversion", dist.python_version().to_string()),
("filetype", dist.r#type().to_string()),
];
let mut add_option = |name, value: &Option<String>| {
if let Some(some) = value.clone() {
api_metadata.push((name, some));
}
};
add_option("summary", &metadata.summary);
add_option("description", &metadata.description);
add_option(
"description_content_type",
&metadata.description_content_type,
);
add_option("author", &metadata.author);
add_option("author_email", &metadata.author_email);
add_option("maintainer", &metadata.maintainer);
add_option("maintainer_email", &metadata.maintainer_email);
add_option("license", &metadata.license);
add_option("keywords", &metadata.keywords);
add_option("home_page", &metadata.home_page);
add_option("download_url", &metadata.download_url);
add_option("requires_python", &metadata.requires_python);
if metadata.requires_python.is_none() {
api_metadata.push(("requires_python", "".to_string()));
}
let mut add_vec = |name, values: &[String]| {
for i in values {
api_metadata.push((name, i.clone()));
}
};
add_vec("classifiers", &metadata.classifiers);
add_vec("platform", &metadata.platforms);
add_vec("requires_dist", &metadata.requires_dist);
add_vec("provides_dist", &metadata.provides_dist);
add_vec("obsoletes_dist", &metadata.obsoletes_dist);
add_vec("requires_external", &metadata.requires_external);
add_vec("project_urls", &metadata.project_urls);
let wheel_name = wheel_path
.file_name()
.expect("Wheel path has a file name")
.to_string_lossy();
let mut form = ureq::unversioned::multipart::Form::new();
for (key, value) in &api_metadata {
form = form.text(key, value.as_str());
}
form = form.part(
"content",
ureq::unversioned::multipart::Part::file(wheel_path)?.file_name(&wheel_name),
);
let encoded = STANDARD.encode(format!("{}:{}", registry.username, registry.password));
let agent = http_agent()?;
let mut response = agent
.post(registry.url.as_str())
.header(
"User-Agent",
&format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
)
.header("Authorization", &format!("Basic {encoded}"))
.send(form)?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let err_text = response.body_mut().read_to_string().unwrap_or_else(|e| {
format!(
"The registry should return some text, \
even in case of an error, but didn't ({e})"
)
});
debug!("Upload error response: {}", err_text);
handle_upload_error(status.as_u16(), err_text)
}
fn handle_upload_error(status_code: u16, err_text: String) -> Result<(), UploadError> {
match status_code {
403 => {
if err_text.contains("overwrite artifact") {
Err(UploadError::FileExistsError(err_text))
} else {
Err(UploadError::AuthenticationError(err_text))
}
}
409 => {
Err(UploadError::FileExistsError(err_text))
}
400 if is_file_exists_error(&err_text) => Err(UploadError::FileExistsError(err_text)),
_ => Err(UploadError::StatusCodeError(
status_code.to_string(),
err_text,
)),
}
}
fn is_file_exists_error(err_text: &str) -> bool {
err_text.contains("already exists") || err_text.contains("updating asset") || err_text.contains("already been taken") }
pub fn upload_ui(items: &[PathBuf], publish: &PublishOpt) -> Result<()> {
let registry = complete_registry(publish)?;
eprintln!("🚀 Uploading {} packages", items.len());
static TITLE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"<title>(.+?)</title>").unwrap());
for i in items {
let upload_result = upload(®istry, i);
match upload_result {
Ok(()) => (),
Err(UploadError::AuthenticationError(msg)) => {
let title = TITLE_RE
.captures(&msg)
.and_then(|c| c.get(1))
.map(|m| m.as_str());
match title {
Some(title) => {
eprintln!("⛔ {title}");
}
None => eprintln!("⛔ Username and/or password are wrong"),
}
#[cfg(feature = "keyring")]
{
let old_username = registry.username;
match keyring::Entry::new(env!("CARGO_PKG_NAME"), &old_username)
.and_then(|keyring| keyring.delete_password())
{
Ok(()) => {
eprintln!("🔑 Removed wrong password from keyring")
}
Err(keyring::Error::NoEntry)
| Err(keyring::Error::NoStorageAccess(_))
| Err(keyring::Error::PlatformFailure(_)) => {}
Err(err) => {
eprintln!("⚠️ Warning: Failed to remove password from keyring: {err}")
}
}
}
bail!("Username and/or password are possibly wrong");
}
Err(err) => {
let filename = i.file_name().unwrap_or(i.as_os_str());
if let UploadError::FileExistsError(_) = err
&& publish.skip_existing
{
eprintln!("⚠️ Note: Skipping {filename:?} because it appears to already exist");
continue;
}
let filesize = fs::metadata(i)
.map(|x| ByteSize(x.len()).to_string())
.unwrap_or_else(|e| format!("Failed to get the filesize of {:?}: {}", &i, e));
return Err(err).context(format!("💥 Failed to upload {filename:?} ({filesize})"));
}
}
}
eprintln!("✨ Packages uploaded successfully");
#[cfg(feature = "keyring")]
{
let username = registry.username.clone();
let password = registry.password;
match keyring::Entry::new(env!("CARGO_PKG_NAME"), &username)
.and_then(|keyring| keyring.set_password(&password))
{
Ok(())
| Err(keyring::Error::NoStorageAccess(_))
| Err(keyring::Error::PlatformFailure(_)) => {}
Err(err) => {
eprintln!("⚠️ Warning: Failed to store the password in the keyring: {err:?}");
}
}
}
Ok(())
}