cloudcover-cli 0.1.5

Command-line interface for analyzing cloud SDK usage and generating permission policies
use std::{
    collections::{BTreeMap, BTreeSet},
    ffi::OsString,
};

use cloudcover_aws::AwsProvider;
use cloudcover_core::{
    ApiMethod, CloudProvider, GoMethodReference, Language, MethodReference, ResolvedSdk, Sdk,
    TerraformMethodReference,
};

use crate::error::CliError;

pub(crate) fn build_policy(
    path: OsString,
    language: Language,
) -> Result<serde_json::Value, CliError> {
    let api_methods = build_api_methods(path, language)?;
    AwsProvider::new()
        .permissions_policy(&api_methods)
        .map_err(|error| CliError::Runtime(error.to_string()))
}

pub(crate) fn build_terraform_policy(
    path: OsString,
    language: Language,
) -> Result<String, CliError> {
    let api_methods = build_api_methods(path, language)?;
    AwsProvider::new()
        .permissions_policy_hcl(&api_methods)
        .map_err(|error| CliError::Runtime(error.to_string()))
}

fn build_api_methods(path: OsString, language: Language) -> Result<Vec<ApiMethod>, CliError> {
    match language {
        Language::Go => build_go_api_methods(path),
        Language::Terraform => build_terraform_api_methods(&path),
        Language::Python => Err(CliError::Usage(format!(
            "unsupported language: {language:?}"
        ))),
    }
}

fn build_go_api_methods(path: OsString) -> Result<Vec<ApiMethod>, CliError> {
    let provider = AwsProvider::new();
    let analysis = cloudcover_go::analyze_dir(path)
        .map_err(|error| CliError::Runtime(format!("failed to analyze Go code: {error}")))?;
    let mut methods_by_sdk = BTreeMap::<(String, Option<String>, String), Vec<ApiMethod>>::new();
    for sdk_name in ["aws-sdk-go-v2", "aws-sdk-go-v1"] {
        let resolved_sdk = ResolvedSdk::new(Sdk::new(sdk_name, Language::Go))
            .with_modules(analysis.modules().iter().cloned());
        for mapping in provider
            .sdk_method_mappings(&resolved_sdk)
            .map_err(|error| CliError::Runtime(error.to_string()))?
        {
            let MethodReference::Go(go_method) = mapping.method() else {
                continue;
            };
            methods_by_sdk
                .entry(go_method_key(go_method))
                .or_default()
                .extend(mapping.api_methods().iter().cloned());
        }
    }

    let mut api_methods = BTreeSet::new();
    for go_method in analysis.methods() {
        if let Some(mapped_methods) = methods_by_sdk.get(&go_method_key(go_method)) {
            api_methods.extend(mapped_methods.iter().cloned());
        }
    }
    Ok(api_methods.into_iter().collect())
}

fn build_terraform_api_methods(path: &OsString) -> Result<Vec<ApiMethod>, CliError> {
    let provider = AwsProvider::new();
    let analysis = cloudcover_terraform::analyze_dir(path)
        .map_err(|error| CliError::Runtime(format!("failed to analyze Terraform code: {error}")))?;
    let sdk = Sdk::new("terraform-provider-aws", Language::Terraform)
        .with_version(analysis.provider_version());
    let resolved_sdk = ResolvedSdk::new(sdk);
    let mut methods_by_reference = BTreeMap::<(String, String, String), Vec<ApiMethod>>::new();
    for mapping in provider
        .sdk_method_mappings(&resolved_sdk)
        .map_err(|error| CliError::Runtime(error.to_string()))?
    {
        let MethodReference::Terraform(terraform_method) = mapping.method() else {
            continue;
        };
        methods_by_reference
            .entry(terraform_method_key(terraform_method))
            .or_default()
            .extend(mapping.api_methods().iter().cloned());
    }

    let mut api_methods = BTreeSet::new();
    for method in analysis.methods() {
        let key = terraform_method_key(method);
        let Some(mapped_methods) = methods_by_reference.get(&key) else {
            if methods_by_reference.keys().any(|(kind, type_name, _)| {
                kind == method.kind() && type_name == method.type_name()
            }) {
                continue;
            }
            return Err(CliError::Runtime(format!(
                "Terraform reference is not mapped by AWS provider data: {} {} {}",
                method.kind(),
                method.type_name(),
                method.action()
            )));
        };
        if mapped_methods.is_empty() {
            continue;
        }
        api_methods.extend(mapped_methods.iter().cloned());
    }
    Ok(api_methods.into_iter().collect())
}

fn go_method_key(method: &GoMethodReference) -> (String, Option<String>, String) {
    (
        method.package().to_owned(),
        method.receiver().map(str::to_owned),
        method.name().to_owned(),
    )
}

fn terraform_method_key(method: &TerraformMethodReference) -> (String, String, String) {
    (
        method.kind().to_owned(),
        method.type_name().to_owned(),
        method.action().to_owned(),
    )
}