diff --git a/crates/crates-io/lib.rs b/crates/crates-io/lib.rs index a2541ce5d75..3aea1ff6ea8 100644 --- a/crates/crates-io/lib.rs +++ b/crates/crates-io/lib.rs @@ -145,6 +145,50 @@ struct Crates { meta: TotalCrates, } +#[derive(Deserialize)] +pub struct GitHubConfig { + pub id: u32, + #[serde(rename = "crate")] + pub krate: String, + pub repository_owner: String, + pub repository_owner_id: Option, + pub repository_name: String, + pub workflow_filename: String, + pub environment: Option, + pub created_at: Option, +} +#[derive(Deserialize)] +struct GitHubConfigs { + github_configs: Vec, +} +#[derive(Deserialize)] +struct GitHubConfigResponse { + github_config: GitHubConfig, +} +#[derive(Serialize)] +struct NewGitHubConfig<'a> { + #[serde(rename = "crate")] + krate: &'a str, + repository_owner: &'a str, + repository_name: &'a str, + workflow_filename: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + environment: Option<&'a str>, +} +#[derive(Serialize)] +struct NewGitHubConfigReq<'a> { + github_config: NewGitHubConfig<'a>, +} +#[derive(Serialize)] +struct CrateUpdate { + trustpub_only: bool, +} +#[derive(Serialize)] +struct CrateUpdateReq { + #[serde(rename = "crate")] + krate: CrateUpdate, +} + /// Error returned when interacting with a registry. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -274,6 +318,56 @@ impl Registry { Ok(serde_json::from_str::(&body)?.users) } + pub fn list_github_trustpub_configs( + &mut self, + krate: &str, + ) -> RegistryResult, T::Error> { + let krate = percent_encode(krate.as_bytes(), NON_ALPHANUMERIC); + let body = self.get(&format!( + "/trusted_publishing/github_configs?crate={}", + krate + ))?; + Ok(serde_json::from_str::(&body)?.github_configs) + } + + pub fn add_github_trustpub_config( + &mut self, + krate: &str, + repository_owner: &str, + repository_name: &str, + workflow_filename: &str, + environment: Option<&str>, + ) -> RegistryResult { + let body = serde_json::to_string(&NewGitHubConfigReq { + github_config: NewGitHubConfig { + krate, + repository_owner, + repository_name, + workflow_filename, + environment, + }, + })?; + let body = self.post("/trusted_publishing/github_configs", Some(body.as_bytes()))?; + Ok(serde_json::from_str::(&body)?.github_config) + } + + pub fn remove_github_trustpub_config(&mut self, id: u32) -> RegistryResult<(), T::Error> { + self.delete(&format!("/trusted_publishing/github_configs/{}", id), None)?; + Ok(()) + } + + pub fn set_trustpub_only( + &mut self, + krate: &str, + trustpub_only: bool, + ) -> RegistryResult<(), T::Error> { + let body = serde_json::to_string(&CrateUpdateReq { + krate: CrateUpdate { trustpub_only }, + })?; + self.patch(&format!("/crates/{}", krate), Some(body.as_bytes()))?; + Ok(()) + } + pub fn publish( &mut self, krate: &NewCrate, @@ -390,6 +484,14 @@ impl Registry { self.req(Method::PUT, path, b, Auth::Authorized) } + fn post(&mut self, path: &str, b: Option<&[u8]>) -> RegistryResult { + self.req(Method::POST, path, b, Auth::Authorized) + } + + fn patch(&mut self, path: &str, b: Option<&[u8]>) -> RegistryResult { + self.req(Method::PATCH, path, b, Auth::Authorized) + } + fn get(&mut self, path: &str) -> RegistryResult { self.req(Method::GET, path, None, Auth::Authorized) } diff --git a/src/bin/cargo/commands/mod.rs b/src/bin/cargo/commands/mod.rs index b507226f3a9..b59cf653883 100644 --- a/src/bin/cargo/commands/mod.rs +++ b/src/bin/cargo/commands/mod.rs @@ -35,6 +35,7 @@ pub fn builtin() -> Vec { search::cli(), test::cli(), tree::cli(), + trustpub::cli(), uninstall::cli(), update::cli(), vendor::cli(), @@ -81,6 +82,7 @@ pub fn builtin_exec(cmd: &str) -> Option { "search" => search::exec, "test" => test::exec, "tree" => tree::exec, + "trustpub" => trustpub::exec, "uninstall" => uninstall::exec, "update" => update::exec, "vendor" => vendor::exec, @@ -125,6 +127,7 @@ pub mod rustdoc; pub mod search; pub mod test; pub mod tree; +pub mod trustpub; pub mod uninstall; pub mod update; pub mod vendor; diff --git a/src/bin/cargo/commands/trustpub.rs b/src/bin/cargo/commands/trustpub.rs new file mode 100644 index 00000000000..bd65bb27a93 --- /dev/null +++ b/src/bin/cargo/commands/trustpub.rs @@ -0,0 +1,103 @@ +use crate::command_prelude::*; + +use cargo::ops::{self, TrustpubCommand, TrustpubOptions}; +use cargo_credential::Secret; + +pub fn cli() -> Command { + subcommand("trustpub") + .about("Manage Trusted Publishing configuration for a crate on the registry") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + opt("crate", "Crate to operate on") + .value_name("CRATE") + .global(true), + ) + .arg( + opt("token", "API token to use when authenticating") + .value_name("TOKEN") + .global(true), + ) + .arg_silent_suggestion() + .subcommand(subcommand("list").about("List the Trusted Publishing configs for a crate")) + .subcommand( + subcommand("add") + .about("Add a GitHub Actions Trusted Publishing config to a crate") + .arg( + opt("owner", "GitHub repository owner (user or organization)") + .value_name("OWNER") + .required(true), + ) + .arg( + opt("repo", "GitHub repository name") + .value_name("REPO") + .required(true), + ) + .arg( + opt( + "pipeline", + "GitHub Actions workflow filename (e.g. `ci.yml`)", + ) + .value_name("PIPELINE") + .required(true), + ) + .arg( + opt("env", "GitHub Actions environment the workflow must run in") + .value_name("ENV"), + ), + ) + .subcommand( + subcommand("remove") + .about("Remove a Trusted Publishing config from a crate") + .arg( + opt( + "id", + "Id of the config to remove (see `cargo trustpub list`)", + ) + .value_name("ID") + .value_parser(value_parser!(u32)) + .required(true), + ), + ) + .subcommand( + subcommand("set") + .about("Control whether new versions must be published via Trusted Publishing") + .arg( + opt( + "trustpub-only", + "Require Trusted Publishing for new versions of the crate", + ) + .value_name("BOOL") + .value_parser(value_parser!(bool)) + .required(true), + ), + ) +} + +pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { + let command = match args.subcommand() { + Some(("list", _)) => TrustpubCommand::List, + Some(("add", sub)) => TrustpubCommand::Add { + repository_owner: sub.get_one::("owner").cloned().unwrap(), + repository_name: sub.get_one::("repo").cloned().unwrap(), + workflow_filename: sub.get_one::("pipeline").cloned().unwrap(), + environment: sub.get_one::("env").cloned(), + }, + Some(("remove", sub)) => TrustpubCommand::Remove { + id: *sub.get_one::("id").unwrap(), + }, + Some(("set", sub)) => TrustpubCommand::Set { + trustpub_only: *sub.get_one::("trustpub-only").unwrap(), + }, + Some((cmd, _)) => unreachable!("unexpected command {}", cmd), + None => unreachable!("unexpected command"), + }; + + let opts = TrustpubOptions { + krate: args.get_one::("crate").cloned(), + token: args.get_one::("token").cloned().map(Secret::from), + command, + }; + ops::trusted_publish(gctx, &opts)?; + Ok(()) +} diff --git a/src/cargo/ops/mod.rs b/src/cargo/ops/mod.rs index 04de1164244..ba8567e66e3 100644 --- a/src/cargo/ops/mod.rs +++ b/src/cargo/ops/mod.rs @@ -41,12 +41,15 @@ pub use self::registry::OwnersOptions; pub use self::registry::PublishOpts; pub use self::registry::RegistryCredentialConfig; pub use self::registry::RegistryOrIndex; +pub use self::registry::TrustpubCommand; +pub use self::registry::TrustpubOptions; pub use self::registry::info; pub use self::registry::modify_owners; pub use self::registry::publish; pub use self::registry::registry_login; pub use self::registry::registry_logout; pub use self::registry::search; +pub use self::registry::trusted_publish; pub use self::registry::yank; pub use self::resolve::{ WorkspaceResolve, add_overrides, get_resolved_packages, resolve_with_previous, resolve_ws, diff --git a/src/cargo/ops/registry/mod.rs b/src/cargo/ops/registry/mod.rs index cc63efb2df7..850d770c567 100644 --- a/src/cargo/ops/registry/mod.rs +++ b/src/cargo/ops/registry/mod.rs @@ -8,6 +8,7 @@ mod logout; mod owner; mod publish; mod search; +mod trustpub; mod yank; use std::collections::HashSet; @@ -35,6 +36,9 @@ pub use self::owner::modify_owners; pub use self::publish::PublishOpts; pub use self::publish::publish; pub use self::search::search; +pub use self::trustpub::TrustpubCommand; +pub use self::trustpub::TrustpubOptions; +pub use self::trustpub::trusted_publish; pub use self::yank::yank; pub(crate) use self::publish::prepare_transmit; diff --git a/src/cargo/ops/registry/trustpub.rs b/src/cargo/ops/registry/trustpub.rs new file mode 100644 index 00000000000..4be81e67c44 --- /dev/null +++ b/src/cargo/ops/registry/trustpub.rs @@ -0,0 +1,158 @@ +use anyhow::Context as _; +use cargo_credential::Operation; +use cargo_credential::Secret; + +use crate::CargoResult; +use crate::GlobalContext; +use crate::core::Workspace; +use crate::drop_print; +use crate::drop_println; +use crate::util::important_paths::find_root_manifest_for_wd; + +pub enum TrustpubCommand { + List, + Add { + repository_owner: String, + repository_name: String, + workflow_filename: String, + environment: Option, + }, + Remove { + id: u32, + }, + Set { + trustpub_only: bool, + }, +} + +pub struct TrustpubOptions { + pub krate: Option, + pub token: Option>, + pub command: TrustpubCommand, +} + +pub fn trusted_publish(gctx: &GlobalContext, opts: &TrustpubOptions) -> CargoResult<()> { + let name = match opts.krate { + Some(ref name) => name.clone(), + None => { + let manifest_path = find_root_manifest_for_wd(gctx.cwd())?; + let ws = Workspace::new(&manifest_path, gctx)?; + ws.current()?.package_id().name().to_string() + } + }; + + let operation = Operation::Owners { name: &name }; + let source_ids = super::get_source_id(gctx, None)?; + let (mut registry, _) = super::registry( + gctx, + &source_ids, + opts.token.as_ref().map(Secret::as_deref), + None, + true, + Some(operation), + )?; + + match &opts.command { + TrustpubCommand::List => { + let configs = registry.list_github_trustpub_configs(&name).with_context(|| { + format!( + "failed to list trusted publishing configs for crate `{}` on registry at {}", + name, + registry.host() + ) + })?; + if configs.is_empty() { + drop_println!( + gctx, + "no trusted publishing configs found for crate `{}`", + name + ); + } + for config in configs.iter() { + drop_print!( + gctx, + "{}: github {}/{} workflow={}", + config.id, + config.repository_owner, + config.repository_name, + config.workflow_filename, + ); + match config.environment.as_ref() { + Some(env) => drop_println!(gctx, " environment={}", env), + None => drop_println!(gctx), + } + } + } + TrustpubCommand::Add { + repository_owner, + repository_name, + workflow_filename, + environment, + } => { + let config = registry + .add_github_trustpub_config( + &name, + repository_owner, + repository_name, + workflow_filename, + environment.as_deref(), + ) + .with_context(|| { + format!( + "failed to add trusted publishing config to crate `{}` on registry at {}", + name, + registry.host() + ) + })?; + let environment = match config.environment.as_ref() { + Some(env) => format!(" environment={}", env), + None => String::new(), + }; + gctx.shell().status( + "Added", + format!( + "trusted publishing config {} ({}/{} workflow={}{}) for crate `{}`", + config.id, + config.repository_owner, + config.repository_name, + config.workflow_filename, + environment, + name, + ), + )?; + } + TrustpubCommand::Remove { id } => { + registry + .remove_github_trustpub_config(*id) + .with_context(|| { + format!( + "failed to remove trusted publishing config {} from crate `{}` on registry at {}", + id, + name, + registry.host() + ) + })?; + gctx.shell().status( + "Removed", + format!("trusted publishing config {} for crate `{}`", id, name), + )?; + } + TrustpubCommand::Set { trustpub_only } => { + registry + .set_trustpub_only(&name, *trustpub_only) + .with_context(|| { + format!( + "failed to update `trustpub_only` for crate `{}` on registry at {}", + name, + registry.host() + ) + })?; + gctx.shell().status( + "Updated", + format!("`trustpub_only` for crate `{}` to {}", name, trustpub_only), + )?; + } + } + + Ok(()) +}