assay-cli 3.35.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use super::constants::{EVENT_SOURCE, EVENT_TYPE, SOURCE_SURFACE, SOURCE_SYSTEM};
use super::*;
use assay_evidence::bundle::BundleReader;
use std::fs;

#[test]
fn import_writes_verifiable_model_component_bundle_without_bodies() {
    let dir = tempfile::tempdir().unwrap();
    let input = dir.path().join("bom.cdx.json");
    let output = dir.path().join("cyclonedx-model.tar.gz");
    fs::write(&input, fixture_with_one_model()).unwrap();

    let code = cmd_cyclonedx_mlbom_model(CycloneDxMlBomModelArgs {
        input: input.clone(),
        bundle_out: output.clone(),
        bom_ref: None,
        source_artifact_ref: Some("bom.cdx.json".to_string()),
        run_id: "cyclonedx_test".to_string(),
        import_time: Some("2026-04-28T12:00:00Z".to_string()),
    })
    .unwrap();
    assert_eq!(code, exit_codes::OK);

    let reader = BundleReader::open(File::open(output).unwrap()).unwrap();
    assert_eq!(reader.manifest().event_count, 1);
    let events = reader.events().collect::<Result<Vec<_>>>().unwrap();
    let payload = &events[0].payload;
    assert_eq!(events[0].type_, EVENT_TYPE);
    assert_eq!(events[0].source, EVENT_SOURCE);
    assert_eq!(payload["source_system"], SOURCE_SYSTEM);
    assert_eq!(payload["source_surface"], SOURCE_SURFACE);
    assert_eq!(
        payload["model_component"]["bom_ref"],
        "pkg:huggingface/example/model@abc123"
    );
    assert_eq!(payload["model_component"]["name"], "example-model");
    assert_eq!(payload["model_component"]["version"], "1.0.0");
    assert_eq!(payload["model_component"]["publisher"], "Example Inc.");
    assert_eq!(
        payload["model_component"]["purl"],
        "pkg:huggingface/example/model@abc123"
    );
    assert_eq!(
        payload["model_component"]["dataset_refs"][0],
        "component-training-data"
    );
    assert_eq!(
        payload["model_component"]["model_card_refs"][0],
        "model-card-example-model"
    );

    let serialized = serde_json::to_string(payload).unwrap();
    assert!(!serialized.contains("quantitativeAnalysis"));
    assert!(!serialized.contains("ethicalConsiderations"));
    assert!(!serialized.contains("Speech Training Data"));
    assert!(!serialized.contains("licenses"));
    assert!(!serialized.contains("vulnerabilities"));
    assert!(!serialized.contains("pedigree"));
}

#[test]
fn import_requires_bom_ref_when_multiple_model_components_exist() {
    let dir = tempfile::tempdir().unwrap();
    let input = dir.path().join("bom.cdx.json");
    let output = dir.path().join("cyclonedx-model.tar.gz");
    fs::write(&input, fixture_with_two_models()).unwrap();

    let err = cmd_cyclonedx_mlbom_model(CycloneDxMlBomModelArgs {
        input,
        bundle_out: output,
        bom_ref: None,
        source_artifact_ref: None,
        run_id: "cyclonedx_test".to_string(),
        import_time: Some("2026-04-28T12:00:00Z".to_string()),
    })
    .unwrap_err();
    assert!(err
        .to_string()
        .contains("multiple machine-learning-model components"));
}

#[test]
fn import_selects_model_component_by_bom_ref() {
    let dir = tempfile::tempdir().unwrap();
    let input = dir.path().join("bom.cdx.json");
    let output = dir.path().join("cyclonedx-model.tar.gz");
    fs::write(&input, fixture_with_two_models()).unwrap();

    cmd_cyclonedx_mlbom_model(CycloneDxMlBomModelArgs {
        input,
        bundle_out: output.clone(),
        bom_ref: Some("component-secondary-model".to_string()),
        source_artifact_ref: None,
        run_id: "cyclonedx_test".to_string(),
        import_time: Some("2026-04-28T12:00:00Z".to_string()),
    })
    .unwrap();

    let reader = BundleReader::open(File::open(output).unwrap()).unwrap();
    let events = reader.events().collect::<Result<Vec<_>>>().unwrap();
    assert_eq!(
        events[0].payload["model_component"]["bom_ref"],
        "component-secondary-model"
    );
    assert_eq!(
        events[0].payload["model_component"]["name"],
        "secondary-model"
    );
}

#[test]
fn import_rejects_missing_or_non_model_bom_ref() {
    let dir = tempfile::tempdir().unwrap();
    let input = dir.path().join("bom.cdx.json");
    let output = dir.path().join("cyclonedx-model.tar.gz");
    fs::write(&input, fixture_with_one_model()).unwrap();

    let err = cmd_cyclonedx_mlbom_model(CycloneDxMlBomModelArgs {
        input,
        bundle_out: output,
        bom_ref: Some("component-training-data".to_string()),
        source_artifact_ref: None,
        run_id: "cyclonedx_test".to_string(),
        import_time: Some("2026-04-28T12:00:00Z".to_string()),
    })
    .unwrap_err();
    assert!(err
        .to_string()
        .contains("did not match a components[] machine-learning-model entry"));
}

#[test]
fn import_rejects_bom_without_model_components() {
    let dir = tempfile::tempdir().unwrap();
    let input = dir.path().join("bom.cdx.json");
    let output = dir.path().join("cyclonedx-model.tar.gz");
    fs::write(
        &input,
        r#"{"bomFormat":"CycloneDX","specVersion":"1.7","components":[{"bom-ref":"app","type":"application","name":"app"}]}"#,
    )
    .unwrap();

    let err = cmd_cyclonedx_mlbom_model(CycloneDxMlBomModelArgs {
        input,
        bundle_out: output,
        bom_ref: None,
        source_artifact_ref: None,
        run_id: "cyclonedx_test".to_string(),
        import_time: Some("2026-04-28T12:00:00Z".to_string()),
    })
    .unwrap_err();
    assert!(err.to_string().contains("no components[] entries"));
}

fn fixture_with_one_model() -> &'static str {
    r#"{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "components": [
    {
      "bom-ref": "pkg:huggingface/example/model@abc123",
      "type": "machine-learning-model",
      "publisher": "Example Inc.",
      "name": "example-model",
      "version": "1.0.0",
      "purl": "pkg:huggingface/example/model@abc123",
      "description": "This long description is intentionally not imported.",
      "pedigree": { "ancestors": [{ "name": "base-model" }] },
      "licenses": [{ "license": { "id": "Apache-2.0" } }],
      "modelCard": {
        "bom-ref": "model-card-example-model",
        "modelParameters": {
          "datasets": [{ "ref": "component-training-data" }]
        },
        "quantitativeAnalysis": {
          "performanceMetrics": [{ "type": "accuracy", "value": "0.9" }]
        },
        "considerations": {
          "ethicalConsiderations": [{ "name": "not imported" }]
        }
      }
    },
    {
      "bom-ref": "component-training-data",
      "type": "data",
      "publisher": "Example Inc.",
      "name": "Speech Training Data",
      "data": [{ "type": "dataset", "classification": "public" }]
    }
  ],
  "vulnerabilities": [{ "id": "CVE-0000-0000" }]
}"#
}

fn fixture_with_two_models() -> &'static str {
    r#"{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "components": [
    {
      "bom-ref": "component-primary-model",
      "type": "machine-learning-model",
      "name": "primary-model"
    },
    {
      "bom-ref": "component-secondary-model",
      "type": "machine-learning-model",
      "name": "secondary-model"
    }
  ]
}"#
}