rusticity-core 0.1.10

Core AWS SDK integration for Rusticity
Documentation
use super::*;
use crate::AwsConfig;
use aws_smithy_http_client::test_util::infallible_client_fn;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};

type Requests = Arc<Mutex<Vec<Value>>>;
fn client(pages: Vec<(u16, Value)>) -> (EcrClient, Requests) {
    let requests = Arc::new(Mutex::new(vec![]));
    let captured = requests.clone();
    let pages = Arc::new(Mutex::new(pages.into_iter()));
    let sdk = aws_config::SdkConfig::builder()
        .behavior_version(aws_config::BehaviorVersion::latest())
        .region(aws_config::Region::new("us-east-1"))
        .credentials_provider(aws_sdk_ecr::config::SharedCredentialsProvider::new(
            aws_sdk_ecr::config::Credentials::new("test", "test", None, None, "test"),
        ))
        .retry_config(aws_config::retry::RetryConfig::disabled())
        .http_client(infallible_client_fn(move |request| {
            assert_eq!(
                request.headers()["x-amz-target"],
                "AmazonEC2ContainerRegistry_V20150921.DescribeImageScanFindings"
            );
            captured
                .lock()
                .unwrap()
                .push(serde_json::from_slice(request.body().bytes().unwrap()).unwrap());
            let (status, response) = pages.lock().unwrap().next().expect("unexpected request");
            http::Response::builder()
                .status(status)
                .header("content-type", "application/x-amz-json-1.1")
                .body(response.to_string().into_bytes())
                .unwrap()
        }))
        .build();
    (
        EcrClient::new(AwsConfig::from_sdk_config(sdk, "us-east-1", "123456789012")),
        requests,
    )
}
fn page(status: &str, findings: Value, token: Option<&str>) -> Value {
    json!({"imageScanStatus":{"status":status},"imageScanFindings":{"findings":findings},"nextToken":token})
}

#[tokio::test]
async fn scan_pages_basic_findings_by_digest_and_sorts_severity() {
    let first = json!({
        "imageScanStatus":{"status":"COMPLETE"},"nextToken":"next",
        "imageScanFindings":{
            "imageScanCompletedAt":1700000000,
            "vulnerabilitySourceUpdatedAt":1700000001,
            "findingSeverityCounts":{"LOW":1,"CRITICAL":1},
            "findings":[{"name":"CVE-low","severity":"LOW","attributes":[{"key":"package_name","value":"openssl"}]}]
        }
    });
    let (client, requests) = client(vec![
        (200, first),
        (
            200,
            page(
                "COMPLETE",
                json!([
                    {"name":"CVE-critical","severity":"CRITICAL","uri":"https://example.invalid/cve"}
                ]),
                None,
            ),
        ),
    ]);
    let report = client
        .image_scan_findings("nested/repo", "sha256:immutable")
        .await
        .unwrap();
    assert!(report.complete());
    assert_eq!(report.findings.len(), 2);
    assert_eq!(report.findings[0].name, "CVE-critical");
    assert!(report.findings[1]
        .details
        .contains(&("package_name".into(), "openssl".into())));
    assert_eq!(report.severity_counts["LOW"], 1);
    assert!(report.completed_at.starts_with("2023-11-14"));
    let requests = requests.lock().unwrap();
    assert_eq!(requests.len(), 2);
    for request in requests.iter() {
        assert_eq!(request["repositoryName"], "nested/repo");
        assert_eq!(request["imageId"]["imageDigest"], "sha256:immutable");
        assert!(request["imageId"].get("imageTag").is_none());
        assert_eq!(request["maxResults"], 100);
    }
    assert_eq!(requests[1]["nextToken"], "next");
}

#[tokio::test]
async fn scan_enhanced_preserves_all_packages_and_missing_fix_information() {
    let (client, _) = client(vec![(
        200,
        json!({
            "imageScanStatus":{"status":"ACTIVE"},
            "imageScanFindings":{"enhancedFindings":[{
                "severity":"HIGH","status":"ACTIVE","title":"title","fixAvailable":"PARTIAL",
                "packageVulnerabilityDetails":{"vulnerabilityId":"CVE-123","vulnerablePackages":[
                    {"name":"first","version":"1.0","fixedInVersion":"1.1","epoch":0,"release":"2","filePath":"/usr/lib/first","arch":"x86_64"},
                    {"name":"second","version":"2.0"}
                ]}
            }]}
        }),
    )]);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert!(report.complete());
    let finding = &report.findings[0];
    assert_eq!(finding.name, "CVE-123");
    for pair in [
        ("Package", "first"),
        ("Package", "second"),
        ("Fixed in", "1.1"),
        ("Fixed in", "Not reported"),
        ("Epoch", "0"),
        ("Fix available", "PARTIAL"),
    ] {
        assert!(
            finding.details.contains(&(pair.0.into(), pair.1.into())),
            "missing {pair:?}"
        );
    }
}

#[tokio::test]
async fn scan_absent_pending_failed_and_unknown_are_not_complete() {
    for status in [
        "IN_PROGRESS",
        "PENDING",
        "FAILED",
        "UNSUPPORTED_IMAGE",
        "SCAN_ELIGIBILITY_EXPIRED",
        "FINDINGS_UNAVAILABLE",
        "FUTURE_STATUS",
    ] {
        let (client, _) = client(vec![(200, page(status, json!([]), None))]);
        let report = client.image_scan_findings("repo", "digest").await.unwrap();
        assert_eq!(report.status, status);
        assert!(!report.complete());
    }
    let (client, _) = client(vec![(
        200,
        json!({"imageScanStatus":{"status":"COMPLETE"}}),
    )]);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert!(!report.complete());
    assert!(report.warning.unwrap().contains("payload unavailable"));
}

#[tokio::test]
async fn scan_unavailable_and_denied_reads_are_errors_not_empty_success() {
    for code in [
        "ScanNotFoundException",
        "AccessDeniedException",
        "ImageNotFoundException",
    ] {
        let (client, _) = client(vec![(
            400,
            json!({"__type":code,"message":"not available"}),
        )]);
        let error = client
            .image_scan_findings("repo", "digest")
            .await
            .unwrap_err();
        assert!(error.to_string().contains("unavailable"));
        assert!(format!("{error:#}").contains(code));
    }
}

#[tokio::test]
async fn scan_preserves_partial_results_on_later_page_failure() {
    let (client, _) = client(vec![
        (
            200,
            page(
                "COMPLETE",
                json!([{"name":"CVE-first","severity":"HIGH"}]),
                Some("next"),
            ),
        ),
        (
            400,
            json!({"__type":"AccessDeniedException","message":"denied"}),
        ),
    ]);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert_eq!(report.findings.len(), 1);
    assert!(!report.complete());
    assert!(report.warning.unwrap().contains("Partial results"));
}

#[tokio::test]
async fn scan_repeated_token_and_empty_pages_are_bounded() {
    let (client, requests) = client(vec![
        (200, page("COMPLETE", json!([]), Some("same"))),
        (200, page("COMPLETE", json!([]), Some("same"))),
    ]);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert!(!report.complete());
    assert!(report.warning.unwrap().contains("repeated"));
    assert_eq!(requests.lock().unwrap().len(), 2);
}

#[tokio::test]
async fn scan_cap_distinguishes_exactly_full_from_truncated() {
    for (count, next, limited) in [
        (FINDING_LIMIT, None, false),
        (FINDING_LIMIT + 1, None, true),
        (FINDING_LIMIT, Some("more"), true),
    ] {
        let rows: Vec<_> = (0..count)
            .map(|i| json!({"name":format!("CVE-{i}"),"severity":"LOW"}))
            .collect();
        let (client, requests) = client(vec![(200, page("COMPLETE", json!(rows), next))]);
        let report = client.image_scan_findings("repo", "digest").await.unwrap();
        assert_eq!(report.findings.len(), FINDING_LIMIT);
        assert_eq!(report.warning.is_some(), limited);
        assert_eq!(requests.lock().unwrap().len(), 1);
    }
}

#[tokio::test]
async fn scan_page_limit_handles_unique_tokens_without_findings() {
    let pages = (0..PAGE_LIMIT)
        .map(|i| (200, page("COMPLETE", json!([]), Some(&i.to_string()))))
        .collect();
    let (client, requests) = client(pages);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert!(!report.complete());
    assert!(report.warning.unwrap().contains("100 pages"));
    assert_eq!(requests.lock().unwrap().len(), PAGE_LIMIT);
}

#[tokio::test]
async fn scan_missing_digest_never_makes_a_request() {
    let (client, requests) = client(vec![]);
    assert!(client.image_scan_findings("repo", "").await.is_err());
    assert!(client.image_scan_findings("", "digest").await.is_err());
    assert!(requests.lock().unwrap().is_empty());
}

#[tokio::test]
async fn scan_final_page_at_page_bound_is_complete() {
    let pages = (0..PAGE_LIMIT)
        .map(|i| {
            let token = (i + 1 < PAGE_LIMIT).then(|| format!("page-{i}"));
            (200, page("COMPLETE", json!([]), token.as_deref()))
        })
        .collect();
    let (client, requests) = client(pages);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert_eq!(requests.lock().unwrap().len(), PAGE_LIMIT);
    assert!(report.complete());
    assert!(report.warning.is_none());
}

#[tokio::test]
async fn scan_longer_token_cycle_keeps_rows_with_original_warning() {
    let pages = ["a", "b", "a"]
        .into_iter()
        .map(|token| {
            (
                200,
                page(
                    "COMPLETE",
                    json!([{"name":"CVE-test","severity":"HIGH"}]),
                    Some(token),
                ),
            )
        })
        .collect();
    let (client, requests) = client(pages);
    let report = client.image_scan_findings("repo", "digest").await.unwrap();
    assert_eq!(requests.lock().unwrap().len(), 3);
    assert_eq!(report.findings.len(), 3);
    assert_eq!(
        report.warning.as_deref(),
        Some("Partial results: repeated pagination token")
    );
    assert!(!report.complete());
}