deslicer-cli 0.1.0

Deslicer CLI — vendor-neutral CI client for planning, approving, and shipping Splunk changes via DAP.
Documentation
use base64::Engine;
use clap::Args as ClapArgs;
use serde_json::{json, Value};

use crate::ci::{self, CiPlatform, OidcError};
use crate::Ctx;

#[derive(ClapArgs)]
pub struct Args {
    #[arg(long)]
    pub environment: Option<String>,
}

pub async fn run(ctx: Ctx, args: Args) -> i32 {
    let platform = ci::detect_platform(ctx.ci_override);
    let audience = ci::AUDIENCE;

    let token_result = ci::provider_for(platform).fetch_token(audience).await;

    let (jwt_header, jwt_claims) = match &token_result {
        Ok(jwt) => decode_jwt_parts(jwt),
        Err(OidcError::MissingEnv(msg)) if platform == CiPlatform::Local => {
            eprintln!("{msg}");
            (Value::Null, Value::Null)
        }
        Err(err) => {
            eprintln!("failed to fetch OIDC token: {err}");
            (Value::Null, Value::Null)
        }
    };

    let resolved_backend = match &token_result {
        Ok(jwt) => {
            match crate::resolver::resolve(&ctx, jwt, platform, args.environment.as_deref(), None)
                .await
            {
                Ok(backend) => json!({
                    "observer_api_url": backend.observer_api_url.as_str(),
                    "resolution_path": backend.resolution_path,
                    "audience": backend.audience,
                }),
                Err(err) => json!(err.to_string()),
            }
        }
        Err(_) => Value::Null,
    };

    let audit = if std::env::var("DESLICER_DEV_TOKEN").is_ok() {
        json!("not configured")
    } else {
        Value::Null
    };

    let output = json!({
        "platform": platform.header_value(),
        "audience": audience,
        "jwt_header": jwt_header,
        "jwt_claims": jwt_claims,
        "resolved_backend": resolved_backend,
        "audit": audit,
    });

    let text = match serde_json::to_string_pretty(&output) {
        Ok(s) => s,
        Err(_) => output.to_string(),
    };
    println!("{text}");
    0
}

fn decode_jwt_parts(jwt: &str) -> (Value, Value) {
    let mut parts = jwt.split('.');
    let header = parts
        .next()
        .and_then(decode_jwt_segment)
        .unwrap_or(Value::Null);
    let mut claims = parts
        .next()
        .and_then(decode_jwt_segment)
        .unwrap_or(Value::Null);
    if !claims.is_null() {
        redact_sensitive_claims(&mut claims);
    }
    (header, claims)
}

fn decode_jwt_segment(segment: &str) -> Option<Value> {
    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(segment)
        .ok()?;
    serde_json::from_slice(&bytes).ok()
}

fn redact_sensitive_claims(value: &mut Value) {
    match value {
        Value::Object(map) => {
            for (key, val) in map.iter_mut() {
                let key_lower = key.to_ascii_lowercase();
                if key_lower.contains("token")
                    || key_lower.contains("secret")
                    || key_lower.contains("key")
                {
                    *val = Value::String("REDACTED".to_string());
                } else {
                    redact_sensitive_claims(val);
                }
            }
        }
        Value::Array(arr) => {
            for item in arr.iter_mut() {
                redact_sensitive_claims(item);
            }
        }
        _ => {}
    }
}