xbp-oci 10.46.0

OCI registry primitives and traits for XBP deploy.
Documentation
use serde::{Deserialize, Serialize};

/// Resolved registry credentials (never log `token`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OciAuth {
    pub username: Option<String>,
    pub token: Option<String>,
    pub source: String,
    pub anonymous: bool,
}

impl OciAuth {
    pub fn anonymous() -> Self {
        Self {
            username: None,
            token: None,
            source: "anonymous".into(),
            anonymous: true,
        }
    }

    pub fn basic(username: impl Into<String>, token: impl Into<String>, source: impl Into<String>) -> Self {
        Self {
            username: Some(username.into()),
            token: Some(token.into()),
            source: source.into(),
            anonymous: false,
        }
    }

    pub fn mask_token(&self) -> String {
        match &self.token {
            None => "(none)".into(),
            Some(t) if t.trim().is_empty() => "(empty)".into(),
            Some(t) if t.len() <= 8 => "****".into(),
            Some(t) => format!("{}{}", &t[..4], &t[t.len() - 4..]),
        }
    }
}

/// Inputs for auth resolution (caller supplies env/global lookups).
#[derive(Debug, Clone, Default)]
pub struct OciAuthRequest {
    pub registry: String,
    pub username: Option<String>,
    pub token: Option<String>,
    pub anonymous: bool,
    /// Hint: "github", "anonymous", provider name.
    pub auth_provider: Option<String>,
}

/// Pure resolution given explicit token candidates (no env/fs I/O).
///
/// Priority:
/// 1. anonymous flag / provider
/// 2. explicit username/token
/// 3. first non-empty candidate from `token_candidates` (label, username?, token)
/// 4. anonymous fallback
pub fn resolve_auth_from_candidates(
    request: &OciAuthRequest,
    token_candidates: &[(String, Option<String>, String)],
) -> OciAuth {
    if request.anonymous
        || request
            .auth_provider
            .as_deref()
            .map(|s| s.eq_ignore_ascii_case("anonymous"))
            .unwrap_or(false)
    {
        return OciAuth::anonymous();
    }

    if request.token.as_ref().map(|t| !t.trim().is_empty()).unwrap_or(false)
        || request.username.is_some()
    {
        return OciAuth {
            username: request.username.clone(),
            token: request.token.clone(),
            source: "cli".into(),
            anonymous: false,
        };
    }

    for (source, username, token) in token_candidates {
        if token.trim().is_empty() {
            continue;
        }
        return OciAuth {
            username: username.clone().or_else(|| Some("x-access-token".into())),
            token: Some(token.clone()),
            source: source.clone(),
            anonymous: false,
        };
    }

    OciAuth {
        username: None,
        token: None,
        source: "anonymous-fallback".into(),
        anonymous: true,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_wins() {
        let auth = resolve_auth_from_candidates(
            &OciAuthRequest {
                registry: "ghcr.io".into(),
                username: Some("u".into()),
                token: Some("t".into()),
                ..Default::default()
            },
            &[("env:GH_TOKEN".into(), None, "other".into())],
        );
        assert_eq!(auth.source, "cli");
    }

    #[test]
    fn candidate_order() {
        let auth = resolve_auth_from_candidates(
            &OciAuthRequest {
                registry: "ghcr.io".into(),
                ..Default::default()
            },
            &[
                ("env:GH_TOKEN".into(), Some("x".into()), "aaa".into()),
                ("global".into(), None, "bbb".into()),
            ],
        );
        assert_eq!(auth.source, "env:GH_TOKEN");
        assert_eq!(auth.token.as_deref(), Some("aaa"));
    }
}