#
cargo_credential/
lib.rs

    1//! Helper library for writing Cargo credential providers.
    2//!
    3//! A credential process should have a `struct` that implements the `Credential` trait.
    4//! The `main` function should be called with an instance of that struct, such as:
    5//!
    6//! ```rust,ignore
    7//! fn main() {
    8//!     cargo_credential::main(MyCredential);
    9//! }
    10//! ```
    11//!
    12//! While in the `perform` function, stdin and stdout will be re-attached to the
    13//! active console. This allows credential providers to be interactive if necessary.
    14//!
    15//! > This crate is maintained by the Cargo team for use by the wider
    16//! > ecosystem. This crate follows semver compatibility for its APIs.
    17//!
    18//! ## Error handling
    19//! ### [`Error::UrlNotSupported`]
    20//! A credential provider may only support some registry URLs. If this is the case
    21//! and an unsupported index URL is passed to the provider, it should respond with
    22//! [`Error::UrlNotSupported`]. Other credential providers may be attempted by Cargo.
    23//!
    24//! ### [`Error::NotFound`]
    25//! When attempting an [`Action::Get`] or [`Action::Logout`], if a credential can not
    26//! be found, the provider should respond with [`Error::NotFound`]. Other credential
    27//! providers may be attempted by Cargo.
    28//!
    29//! ### [`Error::OperationNotSupported`]
    30//! A credential provider might not support all operations. For example if the provider
    31//! only supports [`Action::Get`], [`Error::OperationNotSupported`] should be returned
    32//! for all other requests.
    33//!
    34//! ### [`Error::Other`]
    35//! All other errors go here. The error will be shown to the user in Cargo, including
    36//! the full error chain using [`std::error::Error::source`].
    37//!
    38//! ## Example
    39//! ```rust,ignore
    40#![doc = include_str!("../examples/file-provider.rs")]
    41//! ```
    42
    43#![allow(clippy::print_stderr)]
    44#![allow(clippy::print_stdout)]
    45
    46use serde::{Deserialize, Serialize};
    47use std::{fmt::Display, io};
    48use time::OffsetDateTime;
    49
    50mod error;
    51mod secret;
    52mod stdio;
    53
    54pub use error::Error;
    55pub use secret::Secret;
    56use stdio::stdin_stdout_to_console;
    57
    58/// Message sent by the credential helper on startup
    59#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    60pub struct CredentialHello {
    61    // Protocol versions supported by the credential process.
    62    pub v: Vec<u32>,
    63}
    64
    65/// Credential provider that doesn't support any registries.
    66pub struct UnsupportedCredential;
    67impl Credential for UnsupportedCredential {
    68    fn perform(
    69        &self,
    70        _registry: &RegistryInfo<'_>,
    71        _action: &Action<'_>,
    72        _args: &[&str],
    73    ) -> Result<CredentialResponse, Error> {
    74        Err(Error::UrlNotSupported)
    75    }
    76}
    77
    78/// Message sent by Cargo to the credential helper after the hello
    79#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    80#[serde(rename_all = "kebab-case")]
    81pub struct CredentialRequest<'a> {
    82    // Cargo will respond with the highest common protocol supported by both.
    83    pub v: u32,
    84    #[serde(borrow)]
    85    pub registry: RegistryInfo<'a>,
    86    #[serde(borrow, flatten)]
    87    pub action: Action<'a>,
    88    /// Additional command-line arguments passed to the credential provider.
    89    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    90    pub args: Vec<&'a str>,
    91}
    92
    93#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    94#[serde(rename_all = "kebab-case")]
    95pub struct RegistryInfo<'a> {
    96    /// Registry index url
    97    pub index_url: &'a str,
    98    /// Name of the registry in configuration. May not be available.
    99    /// The crates.io registry will be `crates-io` (`CRATES_IO_REGISTRY`).
    100    #[serde(skip_serializing_if = "Option::is_none")]
    101    pub name: Option<&'a str>,
    102    /// Headers from attempting to access a registry that resulted in a HTTP 401.
    103    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    104    pub headers: Vec<String>,
    105}
    106
    107#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    108#[non_exhaustive]
    109#[serde(tag = "kind", rename_all = "kebab-case")]
    110pub enum Action<'a> {
    111    #[serde(borrow)]
    112    Get(Operation<'a>),
    113    Login(LoginOptions<'a>),
    114    Logout,
    115    #[serde(other)]
    116    Unknown,
    117}
    118
    119impl<'a> Display for Action<'a> {
    120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    121        match self {
    122            Action::Get(_) => f.write_str("get"),
    123            Action::Login(_) => f.write_str("login"),
    124            Action::Logout => f.write_str("logout"),
    125            Action::Unknown => f.write_str("<unknown>"),
    126        }
    127    }
    128}
    129
    130#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    131#[serde(rename_all = "kebab-case")]
    132pub struct LoginOptions<'a> {
    133    /// Token passed on the command line via --token or from stdin
    134    #[serde(skip_serializing_if = "Option::is_none")]
    135    pub token: Option<Secret<&'a str>>,
    136    /// Optional URL that the user can visit to log in to the registry
    137    #[serde(skip_serializing_if = "Option::is_none")]
    138    pub login_url: Option<&'a str>,
    139}
    140
    141/// A record of what kind of operation is happening that we should generate a token for.
    142#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    143#[non_exhaustive]
    144#[serde(tag = "operation", rename_all = "kebab-case")]
    145pub enum Operation<'a> {
    146    /// The user is attempting to fetch a crate.
    147    Read,
    148    /// The user is attempting to publish a crate.
    149    Publish {
    150        /// The name of the crate
    151        name: &'a str,
    152        /// The version of the crate
    153        vers: &'a str,
    154        /// The checksum of the crate file being uploaded
    155        cksum: &'a str,
    156    },
    157    /// The user is attempting to yank a crate.
    158    Yank {
    159        /// The name of the crate
    160        name: &'a str,
    161        /// The version of the crate
    162        vers: &'a str,
    163    },
    164    /// The user is attempting to unyank a crate.
    165    Unyank {
    166        /// The name of the crate
    167        name: &'a str,
    168        /// The version of the crate
    169        vers: &'a str,
    170    },
    171    /// The user is attempting to modify the owners of a crate.
    172    Owners {
    173        /// The name of the crate
    174        name: &'a str,
    175    },
    176    #[serde(other)]
    177    Unknown,
    178}
    179
    180/// Message sent by the credential helper
    181#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    182#[serde(tag = "kind", rename_all = "kebab-case")]
    183#[non_exhaustive]
    184pub enum CredentialResponse {
    185    Get {
    186        token: Secret<String>,
    187        #[serde(flatten)]
    188        cache: CacheControl,
    189        operation_independent: bool,
    190    },
    191    Login,
    192    Logout,
    193    #[serde(other)]
    194    Unknown,
    195}
    196
    197#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
    198#[serde(tag = "cache", rename_all = "kebab-case")]
    199#[non_exhaustive]
    200pub enum CacheControl {
    201    /// Do not cache this result.
    202    Never,
    203    /// Cache this result and use it for subsequent requests in the current Cargo invocation until the specified time.
    204    Expires {
    205        #[serde(with = "time::serde::timestamp")]
    206        expiration: OffsetDateTime,
    207    },
    208    /// Cache this result and use it for all subsequent requests in the current Cargo invocation.
    209    Session,
    210    #[serde(other)]
    211    Unknown,
    212}
    213
    214/// Credential process JSON protocol version.
    215///
    216/// If the protocol needs to make
    217/// a breaking change, a new protocol version should be defined (`PROTOCOL_VERSION_2`).
    218/// This library should offer support for both protocols if possible, by signaling
    219/// in the `CredentialHello` message. Cargo will then choose which protocol to use,
    220/// or it will error if there are no common protocol versions available.
    221pub const PROTOCOL_VERSION_1: u32 = 1;
    222pub trait Credential {
    223    /// Retrieves a token for the given registry.
    224    fn perform(
    225        &self,
    226        registry: &RegistryInfo<'_>,
    227        action: &Action<'_>,
    228        args: &[&str],
    229    ) -> Result<CredentialResponse, Error>;
    230}
    231
    232/// Runs the credential interaction
    233pub fn main(credential: impl Credential) {
    234    let result = doit(credential).map_err(|e| Error::Other(e));
    235    if result.is_err() {
    236        serde_json::to_writer(std::io::stdout(), &result)
    237            .expect("failed to serialize credential provider error");
    238        println!();
    239    }
    240}
    241
    242fn doit(
    243    credential: impl Credential,
    244) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    245    let hello = CredentialHello {
    246        v: vec![PROTOCOL_VERSION_1],
    247    };
    248    serde_json::to_writer(std::io::stdout(), &hello)?;
    249    println!();
    250
    251    loop {
    252        let mut buffer = String::new();
    253        let len = std::io::stdin().read_line(&mut buffer)?;
    254        if len == 0 {
    255            return Ok(());
    256        }
    257        let request = deserialize_request(&buffer)?;
    258        let response = stdin_stdout_to_console(|| {
    259            credential.perform(&request.registry, &request.action, &request.args)
    260        })?;
    261
    262        serde_json::to_writer(std::io::stdout(), &response)?;
    263        println!();
    264    }
    265}
    266
    267/// Deserialize a request from Cargo.
    268fn deserialize_request(
    269    value: &str,
    270) -> Result<CredentialRequest<'_>, Box<dyn std::error::Error + Send + Sync>> {
    271    let request: CredentialRequest<'_> = serde_json::from_str(&value)?;
    272    if request.v != PROTOCOL_VERSION_1 {
    273        return Err(format!("unsupported protocol version {}", request.v).into());
    274    }
    275    Ok(request)
    276}
    277
    278/// Read a line of text from stdin.
    279pub fn read_line() -> Result<String, io::Error> {
    280    let mut buf = String::new();
    281    io::stdin().read_line(&mut buf)?;
    282    Ok(buf.trim().to_string())
    283}
    284
    285/// Prompt the user for a token.
    286pub fn read_token(
    287    login_options: &LoginOptions<'_>,
    288    registry: &RegistryInfo<'_>,
    289) -> Result<Secret<String>, Error> {
    290    if let Some(token) = &login_options.token {
    291        return Ok(token.to_owned());
    292    }
    293
    294    if let Some(url) = login_options.login_url {
    295        eprintln!("please paste the token found on {url} below");
    296    } else if let Some(name) = registry.name {
    297        eprintln!("please paste the token for {name} below");
    298    } else {
    299        eprintln!("please paste the token for {} below", registry.index_url);
    300    }
    301
    302    Ok(Secret::from(read_line().map_err(Box::new)?))
    303}
    304
    305#[cfg(test)]
    306mod tests {
    307    use super::*;
    308
    309    #[test]
    310    fn unsupported_version() {
    311        // This shouldn't ever happen in practice, since the credential provider signals to Cargo which
    312        // protocol versions it supports, and Cargo should only attempt to use one of those.
    313        let msg = r#"{"v":999, "registry": {"index-url":""}, "args":[], "kind": "unexpected"}"#;
    314        assert_eq!(
    315            "unsupported protocol version 999",
    316            deserialize_request(msg).unwrap_err().to_string()
    317        );
    318    }
    319
    320    #[test]
    321    fn cache_control() {
    322        let cc = CacheControl::Expires {
    323            expiration: OffsetDateTime::from_unix_timestamp(1693928537).unwrap(),
    324        };
    325        let json = serde_json::to_string(&cc).unwrap();
    326        assert_eq!(json, r#"{"cache":"expires","expiration":1693928537}"#);
    327
    328        let cc = CacheControl::Session;
    329        let json = serde_json::to_string(&cc).unwrap();
    330        assert_eq!(json, r#"{"cache":"session"}"#);
    331
    332        let cc: CacheControl = serde_json::from_str(r#"{"cache":"unknown-kind"}"#).unwrap();
    333        assert_eq!(cc, CacheControl::Unknown);
    334
    335        assert_eq!(
    336            "missing field `expiration`",
    337            serde_json::from_str::<CacheControl>(r#"{"cache":"expires"}"#)
    338                .unwrap_err()
    339                .to_string()
    340        );
    341    }
    342
    343    #[test]
    344    fn credential_response() {
    345        let cr = CredentialResponse::Get {
    346            cache: CacheControl::Never,
    347            operation_independent: true,
    348            token: Secret::from("value".to_string()),
    349        };
    350        let json = serde_json::to_string(&cr).unwrap();
    351        assert_eq!(
    352            json,
    353            r#"{"kind":"get","token":"value","cache":"never","operation_independent":true}"#
    354        );
    355
    356        let cr = CredentialResponse::Login;
    357        let json = serde_json::to_string(&cr).unwrap();
    358        assert_eq!(json, r#"{"kind":"login"}"#);
    359
    360        let cr: CredentialResponse =
    361            serde_json::from_str(r#"{"kind":"unknown-kind","extra-data":true}"#).unwrap();
    362        assert_eq!(cr, CredentialResponse::Unknown);
    363
    364        let cr: CredentialResponse =
    365            serde_json::from_str(r#"{"kind":"login","extra-data":true}"#).unwrap();
    366        assert_eq!(cr, CredentialResponse::Login);
    367
    368        let cr: CredentialResponse = serde_json::from_str(r#"{"kind":"get","token":"value","cache":"never","operation_independent":true,"extra-field-ignored":123}"#).unwrap();
    369        assert_eq!(
    370            cr,
    371            CredentialResponse::Get {
    372                cache: CacheControl::Never,
    373                operation_independent: true,
    374                token: Secret::from("value".to_string())
    375            }
    376        );
    377    }
    378
    379    #[test]
    380    fn credential_request() {
    381        let get_oweners = CredentialRequest {
    382            v: PROTOCOL_VERSION_1,
    383            args: vec![],
    384            registry: RegistryInfo {
    385                index_url: "url",
    386                name: None,
    387                headers: vec![],
    388            },
    389            action: Action::Get(Operation::Owners { name: "pkg" }),
    390        };
    391
    392        let json = serde_json::to_string(&get_oweners).unwrap();
    393        assert_eq!(
    394            json,
    395            r#"{"v":1,"registry":{"index-url":"url"},"kind":"get","operation":"owners","name":"pkg"}"#
    396        );
    397
    398        let cr: CredentialRequest<'_> =
    399            serde_json::from_str(r#"{"extra-1":true,"v":1,"registry":{"index-url":"url","extra-2":true},"kind":"get","operation":"owners","name":"pkg","args":[]}"#).unwrap();
    400        assert_eq!(cr, get_oweners);
    401    }
    402
    403    #[test]
    404    fn credential_request_logout() {
    405        let unknown = CredentialRequest {
    406            v: PROTOCOL_VERSION_1,
    407            args: vec![],
    408            registry: RegistryInfo {
    409                index_url: "url",
    410                name: None,
    411                headers: vec![],
    412            },
    413            action: Action::Logout,
    414        };
    415
    416        let cr: CredentialRequest<'_> = serde_json::from_str(
    417            r#"{"v":1,"registry":{"index-url":"url"},"kind":"logout","extra-1":true,"args":[]}"#,
    418        )
    419        .unwrap();
    420        assert_eq!(cr, unknown);
    421    }
    422
    423    #[test]
    424    fn credential_request_unknown() {
    425        let unknown = CredentialRequest {
    426            v: PROTOCOL_VERSION_1,
    427            args: vec![],
    428            registry: RegistryInfo {
    429                index_url: "",
    430                name: None,
    431                headers: vec![],
    432            },
    433            action: Action::Unknown,
    434        };
    435
    436        let cr: CredentialRequest<'_> = serde_json::from_str(
    437            r#"{"v":1,"registry":{"index-url":""},"kind":"unexpected-1","extra-1":true,"args":[]}"#,
    438        )
    439        .unwrap();
    440        assert_eq!(cr, unknown);
    441    }
    442}