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}