use base64::Engine; use base64::engine::general_purpose::STANDARD_NO_PAD; use rpki::data_model::resources::as_resources::Asn; use rpki::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix}; use rpki::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski}; use rpki::slurm::file::{SlurmFile, SlurmVersion}; fn sample_spki() -> Vec { vec![ 0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00, ] } fn sample_ski() -> [u8; 20] { [ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x10, 0x20, 0x30, 0x40, ] } fn sample_ski_b64() -> String { STANDARD_NO_PAD.encode(sample_ski()) } fn log_slurm_input(name: &str, json: &str) { println!("[{name}] SLURM input:\n{json}"); } fn log_slurm_ok(name: &str, slurm: &SlurmFile) { println!( "[{name}] parsed ok: version={}, prefix_filters={}, bgpsec_filters={}, aspa_filters={}, prefix_assertions={}, bgpsec_assertions={}, aspa_assertions={}", slurm.version().as_u32(), slurm.validation_output_filters().prefix_filters.len(), slurm.validation_output_filters().bgpsec_filters.len(), slurm.validation_output_filters().aspa_filters.len(), slurm.locally_added_assertions().prefix_assertions.len(), slurm.locally_added_assertions().bgpsec_assertions.len(), slurm.locally_added_assertions().aspa_assertions.len(), ); } fn log_slurm_err(name: &str, err: &impl std::fmt::Display) { println!("[{name}] rejected: {err}"); } fn log_payload_result(name: &str, payloads: &[Payload]) { println!("[{name}] payload result count={}", payloads.len()); for payload in payloads { println!("[{name}] payload: {:?}", payload); } } fn assert_invalid_slurm(json: &str, expected: &str) { log_slurm_input("invalid_slurm", json); let err = SlurmFile::from_slice(json.as_bytes()).unwrap_err(); log_slurm_err("invalid_slurm", &err); let err_text = err.to_string(); assert!( err_text.contains(expected), "expected error containing '{expected}', got '{err_text}'" ); } #[test] // Parses a baseline RFC 8416 v1 SLURM file with prefix and BGPsec entries. fn parses_rfc8416_v1_slurm() { let ski_b64 = sample_ski_b64(); let router_public_key = STANDARD_NO_PAD.encode(sample_spki()); let json = format!( r#"{{ "slurmVersion": 1, "validationOutputFilters": {{ "prefixFilters": [ {{ "prefix": "192.0.2.0/24", "asn": 64496, "comment": "drop roa" }} ], "bgpsecFilters": [ {{ "asn": 64497, "SKI": "{ski_b64}" }} ] }}, "locallyAddedAssertions": {{ "prefixAssertions": [ {{ "prefix": "198.51.100.0/24", "asn": 64500, "maxPrefixLength": 24 }} ], "bgpsecAssertions": [ {{ "asn": 64501, "SKI": "{ski_b64}", "routerPublicKey": "{router_public_key}" }} ] }} }}"# ); log_slurm_input("parses_rfc8416_v1_slurm", &json); let slurm = SlurmFile::from_slice(json.as_bytes()).unwrap(); log_slurm_ok("parses_rfc8416_v1_slurm", &slurm); assert_eq!(slurm.version(), SlurmVersion::V1); assert_eq!(slurm.validation_output_filters().prefix_filters.len(), 1); assert_eq!(slurm.validation_output_filters().bgpsec_filters.len(), 1); assert!(slurm.validation_output_filters().aspa_filters.is_empty()); assert_eq!(slurm.locally_added_assertions().prefix_assertions.len(), 1); assert_eq!(slurm.locally_added_assertions().bgpsec_assertions.len(), 1); assert!(slurm.locally_added_assertions().aspa_assertions.is_empty()); } #[test] // Parses a v2 SLURM file carrying the ASPA extension members from the draft. fn parses_v2_slurm_with_aspa_extensions() { let json = r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [ { "customerAsn": 64496 } ] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [ { "customerAsn": 64510, "providerAsns": [64511, 64512] } ] } }"#; log_slurm_input("parses_v2_slurm_with_aspa_extensions", json); let slurm = SlurmFile::from_slice(json.as_bytes()).unwrap(); log_slurm_ok("parses_v2_slurm_with_aspa_extensions", &slurm); assert_eq!(slurm.version(), SlurmVersion::V2); assert_eq!(slurm.validation_output_filters().aspa_filters.len(), 1); assert_eq!(slurm.locally_added_assertions().aspa_assertions.len(), 1); } #[test] // Rejects ASPA members in a v1 file because they are not part of RFC 8416 v1. fn rejects_v1_file_with_aspa_members() { let json = r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#; log_slurm_input("rejects_v1_file_with_aspa_members", json); let err = SlurmFile::from_slice(json.as_bytes()).unwrap_err(); log_slurm_err("rejects_v1_file_with_aspa_members", &err); assert!(err.to_string().contains("unknown field")); } #[test] // Rejects malformed v1 top-level objects and nested containers that violate RFC 8416 member rules. fn rejects_invalid_v1_file_structure() { let cases = [ ( r#"{ "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "missing field `slurmVersion`", ), ( r#"{ "slurmVersion": 3, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "unsupported slurmVersion 3", ), ( r#"{ "slurmVersion": 1, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "missing field `validationOutputFilters`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] } }"#, "missing field `locallyAddedAssertions`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] }, "extra": true }"#, "unknown field `extra`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "missing field `prefixFilters`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "missing field `bgpsecFilters`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "unknown field `aspaFilters`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "bgpsecAssertions": [] } }"#, "missing field `prefixAssertions`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [] } }"#, "missing field `bgpsecAssertions`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] } }"#, "unknown field `aspaAssertions`", ), ]; for (json, expected) in cases { assert_invalid_slurm(json, expected); } } #[test] // Rejects malformed v1 member objects that contain unknown fields or omit mandatory members. fn rejects_invalid_v1_member_structure() { let ski_b64 = sample_ski_b64(); let router_public_key = STANDARD_NO_PAD.encode(sample_spki()); let cases = vec![ ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [ { "prefix": "192.0.2.0/24", "unexpected": true } ], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"# .to_string(), "unknown field `unexpected`", ), ( format!( r#"{{ "slurmVersion": 1, "validationOutputFilters": {{ "prefixFilters": [], "bgpsecFilters": [ {{ "asn": 64496, "SKI": "{ski_b64}", "unexpected": true }} ] }}, "locallyAddedAssertions": {{ "prefixAssertions": [], "bgpsecAssertions": [] }} }}"# ), "unknown field `unexpected`", ), ( r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [ { "prefix": "198.51.100.0/24" } ], "bgpsecAssertions": [] } }"# .to_string(), "missing field `asn`", ), ( format!( r#"{{ "slurmVersion": 1, "validationOutputFilters": {{ "prefixFilters": [], "bgpsecFilters": [] }}, "locallyAddedAssertions": {{ "prefixAssertions": [], "bgpsecAssertions": [ {{ "asn": 64501, "SKI": "{ski_b64}", "routerPublicKey": "{router_public_key}", "unexpected": true }} ] }} }}"# ), "unknown field `unexpected`", ), ]; for (json, expected) in cases { assert_invalid_slurm(&json, expected); } } #[test] // Rejects non-canonical prefixes and unsorted ASPA provider lists during validation. fn rejects_non_canonical_prefixes_and_unsorted_aspa_providers() { let non_canonical = r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [ { "prefix": "192.0.2.1/24" } ], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#; log_slurm_input( "rejects_non_canonical_prefixes_and_unsorted_aspa_providers.non_canonical", non_canonical, ); let non_canonical_err = SlurmFile::from_slice(non_canonical.as_bytes()).unwrap_err(); log_slurm_err( "rejects_non_canonical_prefixes_and_unsorted_aspa_providers.non_canonical", &non_canonical_err, ); assert!(non_canonical_err.to_string().contains("not canonical")); let unsorted_aspa = r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [ { "customerAsn": 64500, "providerAsns": [64502, 64501] } ] } }"#; log_slurm_input( "rejects_non_canonical_prefixes_and_unsorted_aspa_providers.unsorted_aspa", unsorted_aspa, ); let aspa_err = SlurmFile::from_slice(unsorted_aspa.as_bytes()).unwrap_err(); log_slurm_err( "rejects_non_canonical_prefixes_and_unsorted_aspa_providers.unsorted_aspa", &aspa_err, ); assert!(aspa_err.to_string().contains("strictly increasing")); } #[test] // Rejects malformed v2 top-level objects and nested containers that violate the ASPA SLURM draft. fn rejects_invalid_v2_file_structure() { let cases = [ ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] } }"#, "missing field `aspaFilters`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#, "missing field `aspaAssertions`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [], "extra": true }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] } }"#, "unknown field `extra`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [], "extra": true } }"#, "unknown field `extra`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] }, "extra": true }"#, "unknown field `extra`", ), ]; for (json, expected) in cases { assert_invalid_slurm(json, expected); } } #[test] // Rejects malformed v2 ASPA member objects that omit required fields or contain unknown fields. fn rejects_invalid_v2_member_structure() { let cases = [ ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [ { "comment": "missing customer" } ] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] } }"#, "missing field `customerAsn`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [ { "customerAsn": 64496, "unexpected": true } ] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] } }"#, "unknown field `unexpected`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [ { "customerAsn": 64500 } ] } }"#, "missing field `providerAsns`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [ { "providerAsns": [64501] } ] } }"#, "missing field `customerAsn`", ), ( r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [ { "customerAsn": 64500, "providerAsns": [64501], "unexpected": true } ] } }"#, "unknown field `unexpected`", ), ]; for (json, expected) in cases { assert_invalid_slurm(json, expected); } } #[test] // Applies filters first, then adds assertions, while removing duplicate payloads. fn applies_filters_before_assertions_and_excludes_duplicates() { let ski = Ski::from_bytes(sample_ski()); let spki = sample_spki(); let spki_b64 = STANDARD_NO_PAD.encode(&spki); let ski_b64 = sample_ski_b64(); let json = format!( r#"{{ "slurmVersion": 2, "validationOutputFilters": {{ "prefixFilters": [ {{ "prefix": "192.0.2.0/24", "asn": 64496 }} ], "bgpsecFilters": [ {{ "SKI": "{ski_b64}" }} ], "aspaFilters": [ {{ "customerAsn": 64496 }} ] }}, "locallyAddedAssertions": {{ "prefixAssertions": [ {{ "prefix": "198.51.100.0/24", "asn": 64500, "maxPrefixLength": 24 }}, {{ "prefix": "198.51.100.0/24", "asn": 64500, "maxPrefixLength": 24 }} ], "bgpsecAssertions": [ {{ "asn": 64501, "SKI": "{ski_b64}", "routerPublicKey": "{spki_b64}" }} ], "aspaAssertions": [ {{ "customerAsn": 64510, "providerAsns": [64511, 64512] }} ] }} }}"# ); log_slurm_input("applies_filters_before_assertions_and_excludes_duplicates", &json); let slurm = SlurmFile::from_slice(json.as_bytes()).unwrap(); log_slurm_ok( "applies_filters_before_assertions_and_excludes_duplicates", &slurm, ); let input = vec![ Payload::RouteOrigin(RouteOrigin::new( IPAddressPrefix::new(IPAddress::from_ipv4("192.0.2.0".parse().unwrap()), 24), 24, Asn::from(64496u32), )), Payload::RouteOrigin(RouteOrigin::new( IPAddressPrefix::new(IPAddress::from_ipv4("203.0.113.0".parse().unwrap()), 24), 24, Asn::from(64497u32), )), Payload::RouterKey(RouterKey::new(ski, Asn::from(64497u32), spki.clone())), Payload::Aspa(Aspa::new(Asn::from(64496u32), vec![Asn::from(64498u32)])), ]; let output = slurm.apply(&input); log_payload_result( "applies_filters_before_assertions_and_excludes_duplicates", &output, ); assert_eq!(output.len(), 4); assert!(output.iter().any(|payload| matches!( payload, Payload::RouteOrigin(route_origin) if route_origin.prefix().address() == IPAddress::from_ipv4("203.0.113.0".parse().unwrap()) ))); assert!(output.iter().any(|payload| matches!( payload, Payload::RouteOrigin(route_origin) if route_origin.prefix().address() == IPAddress::from_ipv4("198.51.100.0".parse().unwrap()) ))); assert!(output.iter().any(|payload| matches!( payload, Payload::RouterKey(router_key) if router_key.asn() == Asn::from(64501u32) ))); assert!(output.iter().any(|payload| matches!( payload, Payload::Aspa(aspa) if aspa.customer_asn() == Asn::from(64510u32) ))); } #[test] // Rejects non-RFC hex SKI encoding and ASPA assertions that self-reference the customer ASN. fn rejects_hex_encoded_ski_and_aspa_customer_in_providers() { let ski_hex = hex::encode(sample_ski()); let router_public_key = STANDARD_NO_PAD.encode(sample_spki()); let invalid_ski = format!( r#"{{ "slurmVersion": 1, "validationOutputFilters": {{ "prefixFilters": [], "bgpsecFilters": [ {{ "SKI": "{ski_hex}" }} ] }}, "locallyAddedAssertions": {{ "prefixAssertions": [], "bgpsecAssertions": [ {{ "asn": 64501, "SKI": "{ski_hex}", "routerPublicKey": "{router_public_key}" }} ] }} }}"# ); log_slurm_input( "rejects_hex_encoded_ski_and_aspa_customer_in_providers.invalid_ski", &invalid_ski, ); let ski_err = SlurmFile::from_slice(invalid_ski.as_bytes()).unwrap_err(); log_slurm_err( "rejects_hex_encoded_ski_and_aspa_customer_in_providers.invalid_ski", &ski_err, ); let ski_err_text = ski_err.to_string(); assert!( ski_err_text.contains("invalid SKI base64") || ski_err_text.contains("SKI must be exactly 20 bytes") ); let invalid_aspa = r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [ { "customerAsn": 64500, "providerAsns": [64500, 64501] } ] } }"#; log_slurm_input( "rejects_hex_encoded_ski_and_aspa_customer_in_providers.invalid_aspa", invalid_aspa, ); let aspa_err = SlurmFile::from_slice(invalid_aspa.as_bytes()).unwrap_err(); log_slurm_err( "rejects_hex_encoded_ski_and_aspa_customer_in_providers.invalid_aspa", &aspa_err, ); assert!(aspa_err .to_string() .contains("providerAsns must not contain customerAsn")); } #[test] // Merges non-overlapping SLURM files and upgrades the merged policy version as needed. fn merges_multiple_slurm_files_without_conflict() { let a = r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [ { "prefix": "198.51.100.0/24", "asn": 64500, "maxPrefixLength": 24 } ], "bgpsecAssertions": [] } }"#; let b = r#"{ "slurmVersion": 2, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [], "aspaFilters": [ { "customerAsn": 64510 } ] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [], "aspaAssertions": [] } }"#; log_slurm_input("merges_multiple_slurm_files_without_conflict.a", a); log_slurm_input("merges_multiple_slurm_files_without_conflict.b", b); let merged = SlurmFile::merge_named(vec![ ( "a.slurm".to_string(), SlurmFile::from_slice(a.as_bytes()).unwrap(), ), ( "b.slurm".to_string(), SlurmFile::from_slice(b.as_bytes()).unwrap(), ), ]) .unwrap(); log_slurm_ok("merges_multiple_slurm_files_without_conflict.merged", &merged); assert_eq!(merged.version(), SlurmVersion::V2); assert_eq!(merged.locally_added_assertions().prefix_assertions.len(), 1); assert_eq!(merged.validation_output_filters().aspa_filters.len(), 1); } #[test] // Rejects multiple SLURM files whose policy scopes overlap and would conflict when merged. fn rejects_conflicting_multiple_slurm_files() { let a = r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [ { "prefix": "10.0.0.0/8", "asn": 64500, "maxPrefixLength": 24 } ], "bgpsecAssertions": [] } }"#; let b = r#"{ "slurmVersion": 1, "validationOutputFilters": { "prefixFilters": [ { "prefix": "10.0.0.0/16" } ], "bgpsecFilters": [] }, "locallyAddedAssertions": { "prefixAssertions": [], "bgpsecAssertions": [] } }"#; log_slurm_input("rejects_conflicting_multiple_slurm_files.a", a); log_slurm_input("rejects_conflicting_multiple_slurm_files.b", b); let err = SlurmFile::merge_named(vec![ ( "a.slurm".to_string(), SlurmFile::from_slice(a.as_bytes()).unwrap(), ), ( "b.slurm".to_string(), SlurmFile::from_slice(b.as_bytes()).unwrap(), ), ]) .unwrap_err(); log_slurm_err("rejects_conflicting_multiple_slurm_files", &err); assert!(err.to_string().contains("conflicting SLURM files")); }