diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff91dc110..57c7ae95a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ _Unreleased_ +- `publish` will fall back to dispatching username and repository url for Twine's keyring support. #759 + +- `publish` now includes `--skip-save-credentials`. #759 + ## 0.27.0 Released on 2024-02-26 diff --git a/rye/src/cli/publish.rs b/rye/src/cli/publish.rs index 45bc4756e2..28ac053342 100644 --- a/rye/src/cli/publish.rs +++ b/rye/src/cli/publish.rs @@ -8,7 +8,7 @@ use age::{ }; use anyhow::{bail, Context, Error}; use clap::Parser; -use toml_edit::{Item, Table}; +use toml_edit::{Document, Item, Table}; use url::Url; use crate::bootstrap::ensure_self_venv; @@ -16,14 +16,19 @@ use crate::platform::{get_credentials, write_credentials}; use crate::pyproject::PyProject; use crate::utils::{escape_string, get_venv_python_bin, tui_theme, CommandOutput}; +const DEFAULT_USERNAME: &str = "__token__"; +const DEFAULT_REPOSITORY: &str = "pypi"; +const DEFAULT_REPOSITORY_DOMAIN: &str = "upload.pypi.org"; +const DEFAULT_REPOSITORY_URL: &str = "https://upload.pypi.org/legacy/"; + /// Publish packages to a package repository. #[derive(Parser, Debug)] pub struct Args { /// The distribution files to upload to the repository (defaults to /dist/*). dist: Option>, /// The repository to publish to. - #[arg(short, long, default_value = "pypi")] - repository: String, + #[arg(short, long)] + repository: Option, /// The repository url to publish to. #[arg(long)] repository_url: Option, @@ -42,6 +47,9 @@ pub struct Args { /// Path to alternate CA bundle. #[arg(long)] cert: Option, + /// Skip saving to credentials file. + #[arg(long)] + skip_save_credentials: bool, /// Skip prompts. #[arg(short, long)] yes: bool, @@ -68,96 +76,106 @@ pub fn execute(cmd: Args) -> Result<(), Error> { None => vec![project.workspace_path().join("dist").join("*")], }; - // a. Get token from arguments and offer encryption, then store in credentials file. - // b. Get token from ~/.rye/credentials keyed by provided repository and provide decryption option. - // c. Otherwise prompt for token and provide encryption option, storing the result in credentials. - let repository = &cmd.repository; - let mut credentials = get_credentials()?; - credentials - .entry(repository) - .or_insert(Item::Table(Table::new())); - - let repository_url = match cmd.repository_url { - Some(url) => url, - None => { - let default_repository_url = Url::parse("https://upload.pypi.org/legacy/")?; - credentials - .get(repository) - .and_then(|table| table.get("repository-url")) - .map(|url| match Url::parse(&escape_string(url.to_string())) { - Ok(url) => url, - Err(_) => default_repository_url.clone(), - }) - .unwrap_or(default_repository_url) - } + let token = cmd.token.map(Secret::new); + + // Resolve credentials file + let mut credentials_file = get_credentials()?; + let entry = if let Some(key) = cmd.repository.as_ref() { + Some(credentials_file.entry(key)) + } else if cmd.repository_url.is_none() { + let default_repository = Repository::default(); + let key = default_repository + .name + .expect("default: pypi repository name"); + Some(credentials_file.entry(&key)) + } else { + // We can't key data into the credentials with only a url + None }; + let entry = entry.map(|it| it.or_insert(Item::Table(Table::new()))); + let credentials_table = entry.as_deref(); + + let mut credentials = + resolve_credentials(credentials_table, cmd.username.as_ref(), token.as_ref()); + let mut repository = resolve_repository(credentials_table, cmd.repository, cmd.repository_url)?; + + // We want to prompt decrypt any tokens from files and prompt encrypt any new inputs (cli) + let should_decrypt = credentials_table.map_or(false, |it| it.get("token").is_some()); + // Token is from cli + let mut should_encrypt = token.is_some(); + + // Fallback prompts + let mut passphrase = None; + + if !cmd.yes { + if credentials.password.is_none() { + if is_unknown_repository(&repository) || is_default_repository(&repository) { + echo!("No access token found, generate one at: https://pypi.org/manage/account/token/"); + } + credentials.password = prompt_token()?; + should_encrypt = credentials.password.is_some(); + + if should_encrypt { + passphrase = prompt_encrypt_passphrase()?; + } else if should_decrypt { + passphrase = prompt_decrypt_passphrase()?; + } + } - // If -r is pypi but the url isn't pypi then bail - if repository == "pypi" && repository_url.domain() != Some("upload.pypi.org") { - bail!("invalid pypi url {} (use -h for help)", repository_url); + if repository.url.is_none() { + repository.url = prompt_repository_url()?; + } } - let username = match cmd.username { - Some(username) => username, - None => credentials - .get(repository) - .and_then(|table| table.get("username")) - .map(|username| username.to_string()) - .map(escape_string) - .unwrap_or("__token__".to_string()), + let config = PublishConfig { + credentials, + repository, }; + let config = config.resolve_with_defaults(); - let token = if let Some(token) = cmd.token { - let secret = Secret::new(token); - let maybe_encrypted = maybe_encrypt(&secret, cmd.yes)?; - let maybe_encoded = maybe_encode(&secret, &maybe_encrypted); - credentials[repository]["token"] = Item::Value(maybe_encoded.expose_secret().into()); - write_credentials(&credentials)?; - - secret - } else if let Some(token) = credentials - .get(repository) - .and_then(|table| table.get("token")) - .map(|token| token.to_string()) - .map(escape_string) - { - let secret = Secret::new(token); - - maybe_decrypt(&secret, cmd.yes)? - } else { - echo!("No access token found, generate one at: https://pypi.org/manage/account/token/"); - let token = if !cmd.yes { - prompt_for_token()? - } else { - "".to_string() - }; - if token.is_empty() { - bail!("an access token is required") - } - let secret = Secret::new(token); - let maybe_encrypted = maybe_encrypt(&secret, cmd.yes)?; - let maybe_encoded = maybe_encode(&secret, &maybe_encrypted); - credentials[repository]["token"] = Item::Value(maybe_encoded.expose_secret().into()); - - secret - }; + if !config_is_ready(&config) { + bail!( + "failed to resolve configuration for repository '{}'", + config.repository.name.unwrap_or_default() + ); + } - credentials[repository]["repository-url"] = Item::Value(repository_url.to_string().into()); - credentials[repository]["username"] = Item::Value(username.clone().into()); - write_credentials(&credentials)?; + if !cmd.skip_save_credentials && config.repository.name.is_some() { + save_rye_credentials( + &mut credentials_file, + &config.credentials, + &config.repository, + should_encrypt, + passphrase.as_ref(), + )?; + } let mut publish_cmd = Command::new(get_venv_python_bin(&venv)); + + // Build Twine command publish_cmd .arg("-mtwine") .arg("--no-color") .arg("upload") - .args(files) - .arg("--username") - .arg(username) - .arg("--password") - .arg(token.expose_secret()) - .arg("--repository-url") - .arg(repository_url.to_string()); + .arg("--non-interactive") + .args(files); + + if let Some(usr) = config.credentials.username { + publish_cmd.arg("--username").arg(usr); + } + if let Some(pwd) = config.credentials.password.as_ref() { + publish_cmd.arg("--password"); + + if should_decrypt && passphrase.is_some() { + // Can expect passphrase due to the condition + publish_cmd.arg(decrypt(pwd, &passphrase.expect("passphrase"))?.expose_secret()); + } else { + publish_cmd.arg(pwd.expose_secret()); + } + } + if let Some(url) = config.repository.url.as_ref() { + publish_cmd.arg("--repository-url").arg(url.to_string()); + } if cmd.sign { publish_cmd.arg("--sign"); } @@ -181,30 +199,228 @@ pub fn execute(cmd: Args) -> Result<(), Error> { Ok(()) } -fn prompt_for_token() -> Result { - eprint!("Access token: "); - let token = get_trimmed_user_input().context("failed to read provided token")?; +/// We need: +/// 1. username +/// 2. password (token) +/// 4. repository url +/// +/// This can be configured with: +/// 1. credentials file +/// 2. cli +/// +/// (1) cli -> (2) credentials file -> (3) keyring +// +/// Only token ('pypi'): +/// A token is resolved from either the cli or the credentials file. +/// If a repository name, url, and a username aren't provided, we can +/// default to 'pypi' configuration and save for next time with __token__ +/// username. +/// +/// Only url (keyring): +/// Only a repository url is provided. We can default to keyring settings +/// with __token__. +/// +/// Using a repository name: +/// If a repository name is provided we would expect either sufficient +/// configuration from remaining sources or from the credentials file. +/// This includes an `is_keyring_ready` check. +struct PublishConfig { + credentials: Credentials, + repository: Repository, +} + +impl PublishConfig { + /// fallback defaults: + /// 1. username (__token__) + /// 2. repository name ('pypi') + /// 3. repository url ('pypi') + fn resolve_with_defaults(self) -> Self { + Self { + credentials: self.credentials.resolve_with_defaults(), + repository: self.repository.resolve_with_defaults(), + } + } +} - Ok(token) +fn config_is_ready(config: &PublishConfig) -> bool { + (config.credentials.username.is_some() + && config.credentials.password.is_some() + && config.repository.url.is_some()) + || config_is_keyring_ready(config) } -fn maybe_encrypt(secret: &Secret, yes: bool) -> Result>, Error> { - let phrase = if !yes { - dialoguer::Password::with_theme(tui_theme()) - .with_prompt("Encrypt with passphrase (optional)") - .allow_empty_password(true) - .report(false) - .interact() - .map(Secret::new)? - } else { - Secret::new("".to_string()) +fn config_is_keyring_ready(config: &PublishConfig) -> bool { + config.credentials.username.is_some() && config.repository.url.is_some() +} + +struct Credentials { + username: Option, + password: Option>, +} + +impl Credentials { + fn resolve_with_defaults(self) -> Self { + Self { + username: self.username.or(Some(DEFAULT_USERNAME.to_string())), + password: self.password, + } + } +} + +struct Repository { + name: Option, + url: Option, +} + +impl Default for Repository { + fn default() -> Self { + Self { + name: Some(DEFAULT_REPOSITORY.to_string()), + url: Some(default_repository_url()), + } + } +} + +impl Repository { + fn resolve_with_defaults(self) -> Self { + let name = self.name; + let url = self.url; + + if name.is_none() && url.is_none() { + return Self::default(); + } + + if url.is_none() && name.as_ref().map_or(false, |it| it == DEFAULT_REPOSITORY) { + return Self { + name, + url: Some(default_repository_url()), + }; + } + + Self { name, url } + } +} + +fn default_repository_url() -> Url { + Url::parse(DEFAULT_REPOSITORY_URL).expect("default: pypi repository url") +} + +fn is_unknown_repository(repository: &Repository) -> bool { + repository.name.is_none() && repository.url.is_none() +} + +fn is_default_repository(repository: &Repository) -> bool { + repository + .name + .as_ref() + .map_or(false, |it| it == DEFAULT_REPOSITORY) + && repository + .url + .as_ref() + .map_or(false, |it| it.domain() == Some(DEFAULT_REPOSITORY_DOMAIN)) +} + +fn resolve_credentials( + credentials_table: Option<&Item>, + username: Option<&String>, + password: Option<&Secret>, +) -> Credentials { + let mut credentials = Credentials { + username: None, + password: None, }; + if username.is_some() { + credentials.username = username.cloned(); + } else { + credentials.username = credentials_table + .as_ref() + .and_then(|it| it.get("username").map(Item::to_string).map(escape_string)); + } + + if password.is_some() { + credentials.password = password.cloned(); + } else { + credentials.password = credentials_table.as_ref().and_then(|it| { + it.get("token") + .map(Item::to_string) + .map(escape_string) + .map(Secret::new) + }); + } + + if credentials.username.is_some() && credentials.password.is_some() { + return credentials; + } + + // Rye resolves tokens from the file or the cli. If a token was resolved + // we can assume a default username of __token__. + if credentials.password.is_some() && credentials.username.is_none() { + credentials.username = Some(DEFAULT_USERNAME.to_string()) + } + + credentials +} + +fn resolve_repository( + credentials_table: Option<&Item>, + name: Option, + url: Option, +) -> Result { + let mut repository = Repository { name, url }; + + if repository.url.is_some() { + return Ok(repository); + } + + if let Some(cred_url) = credentials_table.as_ref().and_then(|it| { + it.get("repository-url") + .map(Item::to_string) + .map(escape_string) + }) { + repository.url = Some(Url::parse(&cred_url)?); + } + + if repository.url.is_none() + && repository + .name + .as_ref() + .map_or(false, |it| it == DEFAULT_REPOSITORY) + { + repository.url = Some(Url::parse(DEFAULT_REPOSITORY_URL)?); + } + + Ok(repository) +} + +fn prompt_repository_url() -> Result, Error> { + eprint!("Repository URL: "); + let url = get_trimmed_user_input().context("failed to read provided url")?; + + if url.is_empty() { + Ok(None) + } else { + Ok(Some(Url::parse(&url)?)) + } +} + +fn prompt_token() -> Result>, Error> { + eprint!("Access token: "); + let token = get_trimmed_user_input().context("failed to read provided token")?; + + if token.is_empty() { + Ok(None) + } else { + Ok(Some(Secret::new(token))) + } +} + +fn encrypt(secret: &Secret, phrase: &Secret) -> Result>, Error> { let token = if phrase.expose_secret().is_empty() { secret.expose_secret().as_bytes().to_vec() } else { // Do the encryption - let encryptor = Encryptor::with_user_passphrase(phrase); + let encryptor = Encryptor::with_user_passphrase(phrase.clone()); let mut encrypted = vec![]; let mut writer = encryptor.wrap_output(&mut encrypted)?; writer.write_all(secret.expose_secret().as_bytes())?; @@ -216,18 +432,21 @@ fn maybe_encrypt(secret: &Secret, yes: bool) -> Result>, Ok(Secret::new(token.to_vec())) } -fn maybe_decrypt(secret: &Secret, yes: bool) -> Result, Error> { - let phrase = if !yes { - dialoguer::Password::with_theme(tui_theme()) - .with_prompt("Decrypt with passphrase (optional)") - .allow_empty_password(true) - .report(false) - .interact() - .map(Secret::new)? +fn prompt_encrypt_passphrase() -> Result>, Error> { + let phrase = dialoguer::Password::with_theme(tui_theme()) + .with_prompt("Encrypt with passphrase (optional)") + .allow_empty_password(true) + .report(false) + .interact()?; + + if phrase.is_empty() { + Ok(None) } else { - Secret::new("".to_string()) - }; + Ok(Some(Secret::new(phrase))) + } +} +fn decrypt(secret: &Secret, phrase: &Secret) -> Result, Error> { if phrase.expose_secret().is_empty() { return Ok(secret.clone()); } @@ -237,7 +456,7 @@ fn maybe_decrypt(secret: &Secret, yes: bool) -> Result, E if let Decryptor::Passphrase(decryptor) = Decryptor::new(bytes.as_slice())? { // Do the decryption let mut decrypted = vec![]; - let mut reader = decryptor.decrypt(&phrase, None)?; + let mut reader = decryptor.decrypt(phrase, None)?; reader.read_to_end(&mut decrypted)?; let token = String::from_utf8(decrypted).context("failed to parse utf-8")?; @@ -249,6 +468,61 @@ fn maybe_decrypt(secret: &Secret, yes: bool) -> Result, E bail!("failed to decrypt") } +fn prompt_decrypt_passphrase() -> Result>, Error> { + let phrase = dialoguer::Password::with_theme(tui_theme()) + .with_prompt("Decrypt with passphrase (optional)") + .allow_empty_password(true) + .report(false) + .interact()?; + + if phrase.is_empty() { + Ok(None) + } else { + Ok(Some(Secret::new(phrase))) + } +} + +fn save_rye_credentials( + file: &mut Document, + credentials: &Credentials, + repository: &Repository, + should_encrypt: bool, + passphrase: Option<&Secret>, +) -> Result<(), Error> { + // We need a repository to key the credentials with + let Some(name) = repository.name.as_ref() else { + echo!("no repository found"); + echo!("skipping save credentials"); + return Ok(()); + }; + + let table = file.entry(name).or_insert(Item::Table(Table::new())); + + if let Some(it) = credentials.password.as_ref() { + let mut final_token = it.expose_secret().clone(); + if let Some(phrase) = passphrase.as_ref() { + if should_encrypt { + final_token = hex::encode(encrypt(it, phrase)?.expose_secret()); + } + } + if !final_token.is_empty() { + table["token"] = Item::Value(final_token.into()); + } + } + + if let Some(usr) = credentials.username.as_ref() { + if !usr.is_empty() { + table["username"] = Item::Value(usr.clone().into()); + } + } + + if let Some(url) = repository.url.as_ref() { + table["repository-url"] = Item::Value(url.to_string().into()); + } + + write_credentials(file) +} + fn get_trimmed_user_input() -> Result { std::io::stderr().flush()?; let mut input = String::new(); @@ -257,20 +531,6 @@ fn get_trimmed_user_input() -> Result { Ok(input.trim().to_string()) } -/// Helper function to manage potentially encoding secret data. -/// -/// If the original secret data (bytes) are not the same as the new secret's -/// then we encode, assuming the new data is encrypted data. Otherwise return -/// a new secret with the same string. -fn maybe_encode(original_secret: &Secret, new_secret: &Secret>) -> Secret { - if original_secret.expose_secret().as_bytes() != new_secret.expose_secret() { - let encoded = hex::encode(new_secret.expose_secret()); - return Secret::new(encoded); - } - - original_secret.clone() -} - fn pad_hex(s: String) -> String { if s.len() % 2 == 1 { format!("0{}", s) @@ -278,3 +538,321 @@ fn pad_hex(s: String) -> String { s } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use tempfile::tempdir; + + #[test] + fn test_config_from_cli_with_token() { + // User provides a token either from the CLI or credentials file (CLI here) + let credentials_table = Item::Table(Table::new()); + + let cli_repo = None; // no repo doesn't exclude pypi + let cli_repo_url = None; + let cli_username = None; + let cli_token = Secret::new("token".to_string()); + + let credentials = + resolve_credentials(Some(&credentials_table), cli_username, Some(&cli_token)); + let repository = + resolve_repository(Some(&credentials_table), cli_repo, cli_repo_url).unwrap(); + + let config = PublishConfig { + credentials, + repository, + }; + let config = config.resolve_with_defaults(); + + // We are 'pypi' ready with defaults (and keyring ready) + assert!(config_is_ready(&config)); + assert!(config_is_keyring_ready(&config)); + } + + #[test] + fn test_config_from_cli_with_username() { + let credentials_table = Item::Table(Table::new()); + + let cli_repo = None; // no repo doesn't exclude pypi + let cli_repo_url = None; + let cli_username = "username"; + let cli_token = None; + + let credentials = resolve_credentials( + Some(&credentials_table), + Some(&cli_username.to_string()), + cli_token, + ); + let repository = + resolve_repository(Some(&credentials_table), cli_repo, cli_repo_url).unwrap(); + + let config = PublishConfig { + credentials, + repository, + }; + let config = config.resolve_with_defaults(); + + // We can fallback to keyring with a defaulted url + assert!(config_is_ready(&config)); + assert!(config_is_keyring_ready(&config)); + } + + #[test] + fn test_config_from_cli_with_url() { + let credentials_table = Item::Table(Table::new()); + + let cli_repo = None; // no repo doesn't exclude pypi + let cli_repo_url = Url::parse("https://test.pypi.org/legacy/").unwrap(); + let cli_username = None; + let cli_token = None; + + let credentials = resolve_credentials(Some(&credentials_table), cli_username, cli_token); + let repository = + resolve_repository(Some(&credentials_table), cli_repo, Some(cli_repo_url)).unwrap(); + + let config = PublishConfig { + credentials, + repository, + }; + let config = config.resolve_with_defaults(); + + // We are ready because we can fallback to keyring + assert!(config_is_ready(&config)); + assert!(config_is_keyring_ready(&config)); + } + + #[test] + fn test_config_from_cli_with_username_token() { + let credentials_table = Item::Table(Table::new()); + + let cli_repo = None; // no repo doesn't exclude pypi + let cli_repo_url = Url::parse("https://test.pypi.org/legacy/").unwrap(); + let cli_username = "username"; + let cli_token = Secret::new("token".to_string()); + + let credentials = resolve_credentials( + Some(&credentials_table), + Some(&cli_username.to_string()), + Some(&cli_token), + ); + let repository = + resolve_repository(Some(&credentials_table), cli_repo, Some(cli_repo_url)).unwrap(); + + let config = PublishConfig { + credentials, + repository, + }; + let config = config.resolve_with_defaults(); + + // We are ready with username and password (token) + assert!(config_is_ready(&config)); + } + + #[test] + fn test_config_from_cli_with_keyring() { + let credentials_table = Item::Table(Table::new()); + + let cli_repo = None; // no repo doesn't exclude pypi + let cli_repo_url = Url::parse("https://test.pypi.org/legacy/").unwrap(); + let cli_username = "username"; + let cli_token = Secret::new("token".to_string()); + + let credentials = resolve_credentials( + Some(&credentials_table), + Some(&cli_username.to_string()), + Some(&cli_token), + ); + let repository = + resolve_repository(Some(&credentials_table), cli_repo, Some(cli_repo_url)).unwrap(); + + let config = PublishConfig { + credentials, + repository, + }; + let config = config.resolve_with_defaults(); + + // We are ready for keyring with username and url + assert!(config_is_keyring_ready(&config)); + } + + #[test] + fn test_repository_config_resolution_defaults() { + // Resolve without credentials file + let credentials_table = Item::Table(Table::new()); + + let cli_repo = "pypi".to_string(); + let cli_repo_url = None; + let cli_username = None; + let cli_token = Secret::new("token".to_string()); + + let credentials = + resolve_credentials(Some(&credentials_table), cli_username, Some(&cli_token)); + let repository = + resolve_repository(Some(&credentials_table), Some(cli_repo), cli_repo_url).unwrap(); + + assert_eq!(credentials.username.unwrap(), DEFAULT_USERNAME); + assert_eq!( + credentials.password.unwrap().expose_secret(), + cli_token.expose_secret() + ); + assert_eq!(repository.url.unwrap().to_string(), DEFAULT_REPOSITORY_URL); + } + + #[test] + fn test_repository_config_file_only() { + let mut credentials_table = Item::Table(Table::new()); + credentials_table["repository-url"] = + Item::Value("https://test.pypi.org/".to_string().into()); + credentials_table["username"] = Item::Value("username".to_string().into()); + credentials_table["token"] = Item::Value("password".to_string().into()); + + let credentials = resolve_credentials(Some(&credentials_table), None, None); + let repository = resolve_repository(Some(&credentials_table), None, None).unwrap(); + + let repository_url = Url::parse("https://test.pypi.org/").unwrap(); + let username = "username".to_string(); + let password = "password".to_string(); + + assert_eq!( + repository.url, + Some(Url::parse("https://test.pypi.org/").unwrap()) + ); + assert_eq!(credentials.username, Some(username)); + assert_eq!(*credentials.password.unwrap().expose_secret(), password); + assert_eq!(repository.url, Some(repository_url)); + } + + #[test] + fn test_repository_config_cli_only() { + // Resolve without credentials file + let credentials_table = Item::Table(Table::new()); + + let cli_repo = "pypi".to_string(); + let cli_repo_url = Url::parse("https://test.pypi.org/").unwrap(); + let cli_username = "username".to_string(); + let cli_token = Secret::new("token".to_string()); + + let credentials = resolve_credentials( + Some(&credentials_table), + Some(&cli_username), + Some(&cli_token), + ); + let repository = resolve_repository( + Some(&credentials_table), + Some(cli_repo), + Some(cli_repo_url.clone()), + ) + .unwrap(); + + assert_eq!(credentials.username, Some(cli_username)); + assert_eq!( + credentials.password.unwrap().expose_secret(), + cli_token.expose_secret() + ); + assert_eq!(repository.url, Some(cli_repo_url)); + } + + #[test] + fn test_repository_config_file_and_cli() { + let mut credentials_table = Item::Table(Table::new()); + credentials_table["repository-url"] = + Item::Value("https://test.pypi.org/".to_string().into()); + credentials_table["username"] = Item::Value("username".to_string().into()); + + let cli_repo = "pypi".to_string(); + let cli_repo_url = None; + let cli_username = None; + let cli_token = Secret::new("token".to_string()); + + let config = resolve_credentials(Some(&credentials_table), cli_username, Some(&cli_token)); + let repository = + resolve_repository(Some(&credentials_table), Some(cli_repo), cli_repo_url).unwrap(); + + assert_eq!( + config.password.unwrap().expose_secret(), + cli_token.expose_secret() + ); + assert_eq!( + repository.url.unwrap().to_string(), + "https://test.pypi.org/".to_string() + ); + } + + #[test] + fn test_repository_config_keyring_fallback() { + let credentials_table = Item::Table(Table::new()); + + let cli_repo = "pypi".to_string(); + let cli_repo_url = Url::parse("https://test.pypi.org/").unwrap(); + let cli_username = None; + let cli_token = None; + + let credentials = resolve_credentials(Some(&credentials_table), cli_username, cli_token); + let repository = resolve_repository( + Some(&credentials_table), + Some(cli_repo), + Some(cli_repo_url.clone()), + ) + .unwrap(); + + assert!(credentials.username.is_none()); + assert!(credentials.password.is_none()); + assert_eq!(repository.url.unwrap(), cli_repo_url); + } + + #[test] + fn test_save_rye_credentials_encrypt() { + let tempdir = tempdir().unwrap(); + let temp_home = tempdir.path(); + let mut credentials_table = Document::new(); + + let cli_repo = "pypi".to_string(); + + // Set the environment variable for this specific test + std::env::set_var("RYE_HOME", temp_home); + crate::platform::init().unwrap(); + + assert_eq!( + std::env::var("RYE_HOME").map(PathBuf::from), + Ok(temp_home.to_path_buf()) + ); + + save_rye_credentials( + &mut credentials_table, + &Credentials { + username: Some("username".to_string()), + password: Some("password".to_string().into()), + }, + &Repository { + name: Some(cli_repo), + url: None, + }, + true, + Some(&Secret::new("passphrase".to_string())), + ) + .unwrap(); + + let credentials = get_credentials().unwrap(); + let table = credentials.get("pypi"); + + assert_eq!( + table + .and_then(|it| it.get("username")) + .map(Item::to_string) + .map(escape_string) + .unwrap(), + "username".to_string() + ); + + let token = table + .and_then(|it| it.get("token").map(Item::to_string).map(escape_string)) + .unwrap(); + + let password = + decrypt(&Secret::new(token), &Secret::new("passphrase".to_string())).unwrap(); + + assert_eq!(password.expose_secret(), "password"); + } +} diff --git a/rye/tests/common/mod.rs b/rye/tests/common/mod.rs index 3ffbb45b9f..f1ddee67a1 100644 --- a/rye/tests/common/mod.rs +++ b/rye/tests/common/mod.rs @@ -61,6 +61,30 @@ toolchain = "cpython@3.12.2" .unwrap(); } + // write the credentials file + let credentials_file = home.join("credentials"); + if !credentials_file.is_file() { + fs::write( + credentials_file, + r#" +[pypi] +# Update pypi repository url to avoid using it during tests +repository-url = "don't use" + +[found-username-token] +username = "username" +token = "token" + +[found-token] +token = "token" + +[found-username] +username = "username" +"#, + ) + .unwrap(); + } + // fetch the most important interpreters for version in ["cpython@3.8.17", "cpython@3.11.8", "cpython@3.12.2"] { if home.join("py").join(version).is_dir() { @@ -102,6 +126,7 @@ pub fn get_bin() -> PathBuf { get_cargo_bin("rye") } +#[derive(Debug)] pub struct Space { #[allow(unused)] tempdir: TempDir, diff --git a/rye/tests/test_publish.rs b/rye/tests/test_publish.rs new file mode 100644 index 0000000000..0b29a3bc84 --- /dev/null +++ b/rye/tests/test_publish.rs @@ -0,0 +1,159 @@ +use insta_cmd::Command; + +use crate::common::{rye_cmd_snapshot, Space}; + +mod common; + +#[test] +fn test_publish() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: relative URL without a base + "###); +} + +#[test] +fn test_publish_yes() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-y")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: relative URL without a base + "###); +} + +#[test] +fn test_publish_from_credentials_missing_repo() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("missing")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Access token: Repository URL: error: failed to resolve configuration for repository 'missing' + "###); +} + +#[test] +fn test_publish_from_credentials_missing_repo_yes() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("missing").arg("-y")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: failed to resolve configuration for repository 'missing' + "###); +} + +#[test] +fn test_publish_from_credentials_found_repo_with_username() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("found-username")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Access token: Repository URL: error: failed to resolve configuration for repository 'found-username' + "###); +} + +#[test] +fn test_publish_from_credentials_found_repo_with_token() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("found-token")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Repository URL: error: failed to resolve configuration for repository 'found-token' + "###); +} + +#[test] +fn test_publish_from_credentials_found_repo_with_username_token() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("found-username-token")), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Repository URL: error: failed to resolve configuration for repository 'found-username-token' + "###); +} + +#[test] +fn test_publish_from_credentials_found_repo_with_username_yes() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("found-username")).arg("-y"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: failed to resolve configuration for repository 'found-username' + "###); +} + +#[test] +fn test_publish_from_credentials_found_repo_with_token_yes() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("found-token")).arg("-y"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: failed to resolve configuration for repository 'found-token' + "###); +} + +#[test] +fn test_publish_from_credentials_found_repo_with_username_token_yes() { + let space = Space::new(); + space.init("my-project"); + + rye_cmd_snapshot!(with_skip_save(space.rye_cmd().arg("publish").arg("-r").arg("found-username-token")).arg("-y"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: failed to resolve configuration for repository 'found-username-token' + "###); +} + +fn with_skip_save(cmd: &mut Command) -> &mut Command { + cmd.arg("--skip-save-credentials") +}