cloudcover-cli 0.2.1

Command-line interface for analyzing cloud SDK usage and generating permission policies
use std::{error::Error, io, path::PathBuf, process::Command};

use serde_json::Value;

fn fixture_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/aws-s3")
}

fn terraform_fixture_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/aws-terraform")
}

fn run_policy(arguments: &[&str]) -> Result<std::process::Output, Box<dyn Error>> {
    Ok(Command::new(env!("CARGO_BIN_EXE_cloudcover"))
        .args(arguments)
        .output()?)
}

fn parse_stdout_json(output: &std::process::Output) -> Result<Value, Box<dyn Error>> {
    Ok(serde_json::from_slice(&output.stdout)?)
}

fn collect_actions(policy: &Value) -> Result<Vec<String>, Box<dyn Error>> {
    let statements = policy["Statement"]
        .as_array()
        .ok_or_else(|| io::Error::other("policy statements should be an array"))?;
    let mut actions = statements
        .iter()
        .flat_map(|statement| match &statement["Action"] {
            Value::Array(actions) => actions.iter().collect::<Vec<_>>(),
            value => vec![value],
        })
        .map(|action| {
            action
                .as_str()
                .map(str::to_owned)
                .ok_or_else(|| io::Error::other("policy actions should be strings"))
        })
        .collect::<Result<Vec<_>, _>>()?;
    actions.sort();
    actions.dedup();
    Ok(actions)
}

#[test]
fn emits_expected_s3_read_policy() -> Result<(), Box<dyn Error>> {
    let fixture = fixture_dir().to_string_lossy().into_owned();
    let output = run_policy(&["policy", "--language", "go", &fixture])?;

    assert!(
        output.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        String::from_utf8_lossy(&output.stderr)
            .contains("FAS: included possible downstream requirements")
    );

    let policy = parse_stdout_json(&output)?;
    let actions = collect_actions(&policy)?;
    assert!(actions.contains(&"s3:GetObject".to_owned()));
    assert!(actions.contains(&"kms:Decrypt".to_owned()));
    assert!(actions.contains(&"kms:GenerateDataKey".to_owned()));
    assert!(
        policy["Statement"]
            .as_array()
            .ok_or("missing statements")?
            .iter()
            .any(|statement| {
                statement["Condition"]["StringEquals"]["kms:ViaService"]
                    == "s3.${Region}.amazonaws.com"
            })
    );
    assert!(actions.contains(&"s3:GetObjectLegalHold".to_owned()));
    assert!(actions.contains(&"s3:GetObjectRetention".to_owned()));
    assert!(actions.contains(&"s3:GetObjectTagging".to_owned()));
    assert!(actions.contains(&"s3:GetObjectVersion".to_owned()));
    assert!(actions.contains(&"s3-object-lambda:GetObject".to_owned()));
    assert!(!actions.contains(&"s3:PutObject".to_owned()));

    Ok(())
}

#[test]
fn shorthand_matches_explicit_language_output() -> Result<(), Box<dyn Error>> {
    let fixture = fixture_dir().to_string_lossy().into_owned();
    let explicit = run_policy(&["policy", "--language", "go", &fixture])?;
    let shorthand = run_policy(&["policy", &fixture])?;

    assert!(
        explicit.status.success(),
        "explicit stderr: {}",
        String::from_utf8_lossy(&explicit.stderr)
    );
    assert!(
        shorthand.status.success(),
        "shorthand stderr: {}",
        String::from_utf8_lossy(&shorthand.stderr)
    );
    assert_eq!(
        parse_stdout_json(&explicit)?,
        parse_stdout_json(&shorthand)?
    );

    Ok(())
}

#[test]
fn emits_full_lifecycle_policy_for_initialized_terraform_modules() -> Result<(), Box<dyn Error>> {
    let fixture = terraform_fixture_dir().to_string_lossy().into_owned();
    let output = run_policy(&["policy", "--language", "terraform", &fixture])?;

    assert!(
        output.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let actions = collect_actions(&parse_stdout_json(&output)?)?;
    for action in [
        "ecr:DescribeImages",
        "ecr:DescribeRepositories",
        "s3:CreateBucket",
        "s3:DeleteBucket",
        "s3:PutBucketPolicy",
        "bedrock-agentcore:CreateGateway",
        "cognito-idp:CreateUserPool",
        "cognito-identity:CreateIdentityPool",
        "apigateway:POST",
        "events:PutRule",
        "scheduler:CreateSchedule",
    ] {
        assert!(actions.contains(&action.to_owned()), "missing {action}");
    }
    assert!(!actions.contains(&"sts:GetCallerIdentity".to_owned()));
    assert!(
        !actions
            .iter()
            .any(|action| action == "*" || action.ends_with(":*"))
    );

    Ok(())
}

#[test]
fn emits_terraform_hcl_policy_document() -> Result<(), Box<dyn Error>> {
    let fixture = fixture_dir().to_string_lossy().into_owned();
    let output = run_policy(&[
        "policy",
        "--format",
        "terraform",
        "--language",
        "go",
        &fixture,
    ])?;

    assert!(
        output.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        String::from_utf8_lossy(&output.stderr)
            .contains("FAS: included possible downstream requirements")
    );
    let stdout = String::from_utf8(output.stdout)?;
    assert!(stdout.contains(r#"data "aws_iam_policy_document" "cloudcover""#));
    assert!(stdout.contains("statement {"));
    assert!(stdout.contains(r#""s3:GetObject""#));
    assert!(stdout.contains(r#""s3-object-lambda:GetObject""#));
    assert!(stdout.contains(r#""arn:$${Partition}:s3:::$${BucketName}/$${ObjectName}""#));

    Ok(())
}

#[test]
fn unsupported_language_is_usage_error() -> Result<(), Box<dyn Error>> {
    let fixture = fixture_dir().to_string_lossy().into_owned();
    let output = run_policy(&["policy", "--language", "python", &fixture])?;

    assert_eq!(output.status.code(), Some(2));
    Ok(())
}