use crate::command::requests::{PortSpec, TrafficFilterSpec};
use crate::core_error::CoreError;
fn parse_port(value: &str) -> Result<u16, CoreError> {
value
.parse::<u16>()
.map_err(|_| CoreError::ValidationFailed {
message: format!("invalid port number {value:?} (expected 0-65535)"),
})
}
fn build_port_item_json(p: &str) -> Result<serde_json::Value, CoreError> {
if p.contains('-') {
let mut parts = p.splitn(2, '-');
let start_str = parts.next().unwrap_or("");
let end_str = parts.next().unwrap_or("");
let start = parse_port(start_str)?;
let end = parse_port(end_str)?;
Ok(serde_json::json!({
"type": "PORT_NUMBER_RANGE",
"startPort": start,
"endPort": end,
}))
} else {
let port = parse_port(p)?;
Ok(serde_json::json!({ "type": "PORT_NUMBER", "value": port }))
}
}
fn build_port_filter_json(spec: &PortSpec) -> Result<serde_json::Value, CoreError> {
match spec {
PortSpec::Values {
items,
match_opposite,
} => {
let json_items: Vec<serde_json::Value> = items
.iter()
.map(|p| build_port_item_json(p))
.collect::<Result<_, _>>()?;
Ok(serde_json::json!({
"type": "PORTS",
"items": json_items,
"matchOpposite": match_opposite,
}))
}
PortSpec::MatchingList {
list_id,
match_opposite,
} => Ok(serde_json::json!({
"type": "TRAFFIC_MATCHING_LIST",
"trafficMatchingListId": list_id,
"matchOpposite": match_opposite,
})),
}
}
pub(in super::super) fn build_endpoint_json(
zone_id: &str,
filter: Option<&TrafficFilterSpec>,
) -> Result<serde_json::Value, CoreError> {
let mut obj = serde_json::json!({ "zoneId": zone_id });
if let Some(spec) = filter {
let traffic_filter = match spec {
TrafficFilterSpec::Network {
network_ids,
match_opposite,
ports,
} => {
let mut v = serde_json::json!({
"type": "NETWORK",
"networkFilter": {
"networkIds": network_ids,
"matchOpposite": match_opposite,
}
});
if let Some(ports) = ports {
v.as_object_mut()
.expect("json! produces object")
.insert("portFilter".into(), build_port_filter_json(ports)?);
}
v
}
TrafficFilterSpec::IpAddress {
addresses,
match_opposite,
ports,
} => {
let items: Vec<serde_json::Value> = addresses
.iter()
.map(|addr| {
if addr.contains('/') {
serde_json::json!({ "type": "SUBNET", "value": addr })
} else if addr.contains('-') {
let parts: Vec<&str> = addr.splitn(2, '-').collect();
serde_json::json!({ "type": "RANGE", "start": parts[0], "stop": parts.get(1).unwrap_or(&"") })
} else {
serde_json::json!({ "type": "IP_ADDRESS", "value": addr })
}
})
.collect();
let mut v = serde_json::json!({
"type": "IP_ADDRESS",
"ipAddressFilter": {
"type": "IP_ADDRESSES",
"items": items,
"matchOpposite": match_opposite,
}
});
if let Some(ports) = ports {
v.as_object_mut()
.expect("json! produces object")
.insert("portFilter".into(), build_port_filter_json(ports)?);
}
v
}
TrafficFilterSpec::Port { ports } => serde_json::json!({
"type": "PORT",
"portFilter": build_port_filter_json(ports)?,
}),
TrafficFilterSpec::IpMatchingList {
list_id,
match_opposite,
ports,
} => {
let mut v = serde_json::json!({
"type": "IP_ADDRESS",
"ipAddressFilter": {
"type": "TRAFFIC_MATCHING_LIST",
"trafficMatchingListId": list_id,
"matchOpposite": match_opposite,
}
});
if let Some(port_spec) = ports {
v.as_object_mut()
.expect("json! produces object")
.insert("portFilter".into(), build_port_filter_json(port_spec)?);
}
v
}
};
obj.as_object_mut()
.expect("json! produces object")
.insert("trafficFilter".into(), traffic_filter);
}
Ok(obj)
}
pub(in super::super) fn traffic_matching_list_items(
entries: &[String],
raw_items: Option<&[serde_json::Value]>,
) -> Vec<serde_json::Value> {
raw_items.map_or_else(
|| {
entries
.iter()
.cloned()
.map(serde_json::Value::String)
.collect()
},
<[serde_json::Value]>::to_vec,
)
}
#[cfg(test)]
mod tests {
use super::{build_endpoint_json, traffic_matching_list_items};
use crate::command::requests::{PortSpec, TrafficFilterSpec};
use crate::core_error::CoreError;
use serde_json::json;
#[test]
fn traffic_matching_list_items_prefer_raw_payloads() {
let raw_items = [json!({"type": "PORT_NUMBER", "value": 443})];
let items = traffic_matching_list_items(&["80".into()], Some(&raw_items));
assert_eq!(items, vec![json!({"type": "PORT_NUMBER", "value": 443})]);
}
#[test]
fn build_endpoint_json_ip_with_port_nests_port_filter() {
let spec = TrafficFilterSpec::IpAddress {
addresses: vec!["10.0.40.10".into()],
match_opposite: false,
ports: Some(PortSpec::Values {
items: vec!["80".into()],
match_opposite: false,
}),
};
let result = build_endpoint_json("zone-uuid", Some(&spec)).expect("build endpoint json");
let tf = result.get("trafficFilter").expect("trafficFilter present");
assert_eq!(tf.get("type").and_then(|v| v.as_str()), Some("IP_ADDRESS"));
let ip_filter = tf.get("ipAddressFilter").expect("ipAddressFilter present");
assert_eq!(
ip_filter
.get("items")
.and_then(|v| v.as_array())
.map(Vec::len),
Some(1)
);
let port_filter = tf.get("portFilter").expect("portFilter present");
assert_eq!(
port_filter
.get("items")
.and_then(|v| v.as_array())
.map(Vec::len),
Some(1)
);
let port_item = &port_filter["items"][0];
assert_eq!(port_item["type"].as_str(), Some("PORT_NUMBER"));
assert_eq!(port_item["value"].as_u64(), Some(80));
}
#[test]
fn build_endpoint_json_port_matching_list() {
let spec = TrafficFilterSpec::Port {
ports: PortSpec::MatchingList {
list_id: "24740a56-9cb9-4890-a5ac-589d30914a55".into(),
match_opposite: false,
},
};
let result = build_endpoint_json("zone-uuid", Some(&spec)).expect("build endpoint json");
let tf = result.get("trafficFilter").expect("trafficFilter present");
assert_eq!(tf.get("type").and_then(|v| v.as_str()), Some("PORT"));
let pf = tf.get("portFilter").expect("portFilter present");
assert_eq!(
pf.get("type").and_then(|v| v.as_str()),
Some("TRAFFIC_MATCHING_LIST")
);
assert_eq!(
pf.get("trafficMatchingListId").and_then(|v| v.as_str()),
Some("24740a56-9cb9-4890-a5ac-589d30914a55")
);
assert_eq!(
pf.get("matchOpposite").and_then(serde_json::Value::as_bool),
Some(false)
);
}
#[test]
fn build_endpoint_json_ip_matching_list() {
let spec = TrafficFilterSpec::IpMatchingList {
list_id: "b777b27c-410c-4b40-8489-a61bf1a536d4".into(),
match_opposite: false,
ports: None,
};
let result = build_endpoint_json("zone-uuid", Some(&spec)).expect("build endpoint json");
let tf = result.get("trafficFilter").expect("trafficFilter present");
assert_eq!(tf.get("type").and_then(|v| v.as_str()), Some("IP_ADDRESS"));
let ipf = tf.get("ipAddressFilter").expect("ipAddressFilter present");
assert_eq!(
ipf.get("type").and_then(|v| v.as_str()),
Some("TRAFFIC_MATCHING_LIST")
);
assert_eq!(
ipf.get("trafficMatchingListId").and_then(|v| v.as_str()),
Some("b777b27c-410c-4b40-8489-a61bf1a536d4")
);
}
#[test]
fn build_endpoint_json_ip_without_port_omits_port_filter() {
let spec = TrafficFilterSpec::IpAddress {
addresses: vec!["10.0.0.1".into()],
match_opposite: false,
ports: None,
};
let result = build_endpoint_json("zone-uuid", Some(&spec)).expect("build endpoint json");
let tf = result.get("trafficFilter").expect("trafficFilter present");
assert!(tf.get("portFilter").is_none());
}
#[test]
fn build_port_filter_json_rejects_invalid_port_number() {
let spec = PortSpec::Values {
items: vec!["abc".into()],
match_opposite: false,
};
let err = super::build_port_filter_json(&spec).expect_err("invalid port should error");
assert!(
matches!(err, CoreError::ValidationFailed { .. }),
"expected ValidationFailed, got {err:?}",
);
}
#[test]
fn build_port_filter_json_rejects_invalid_port_range() {
let spec = PortSpec::Values {
items: vec!["80-abc".into()],
match_opposite: false,
};
let err = super::build_port_filter_json(&spec).expect_err("invalid range end should error");
assert!(matches!(err, CoreError::ValidationFailed { .. }));
}
#[test]
fn full_roundtrip_ip_address_with_port_matching_list() {
use crate::command::requests::CreateFirewallPolicyRequest;
let mut req: CreateFirewallPolicyRequest = serde_json::from_value(json!({
"name": "Apple Companion Link",
"action": "Allow",
"source_zone_id": "d2864b8e-56fb-4945-b69f-6d424fa5b248",
"destination_zone_id": "5888bc93-aaae-4242-ae2f-2050d76211fd",
"destination_filter": {
"type": "ip_address",
"addresses": ["10.0.10.2"],
"ports": {
"type": "matching_list",
"list_id": "companion-link-ports-uuid"
}
}
}))
.expect("deserialize");
req.resolve_filters().expect("resolve");
let wire = build_endpoint_json(
"5888bc93-aaae-4242-ae2f-2050d76211fd",
req.destination_filter.as_ref(),
)
.expect("build endpoint json");
eprintln!(
"wire payload:\n{}",
serde_json::to_string_pretty(&wire).expect("serializing test wire payload"),
);
assert_eq!(wire["trafficFilter"]["type"].as_str(), Some("IP_ADDRESS"));
assert_eq!(
wire["trafficFilter"]["portFilter"]["type"].as_str(),
Some("TRAFFIC_MATCHING_LIST"),
);
assert_eq!(
wire["trafficFilter"]["portFilter"]["trafficMatchingListId"].as_str(),
Some("companion-link-ports-uuid"),
);
}
#[test]
fn build_endpoint_json_ip_with_port_matching_list_emits_traffic_matching_list() {
let spec = TrafficFilterSpec::IpAddress {
addresses: vec!["10.0.0.5".into()],
match_opposite: false,
ports: Some(PortSpec::MatchingList {
list_id: "24740a56-9cb9-4890-a5ac-589d30914a55".into(),
match_opposite: false,
}),
};
let result = build_endpoint_json("zone-uuid", Some(&spec)).expect("build endpoint json");
let port_filter = result["trafficFilter"]
.get("portFilter")
.expect("portFilter present");
assert_eq!(port_filter["type"].as_str(), Some("TRAFFIC_MATCHING_LIST"));
assert_eq!(
port_filter["trafficMatchingListId"].as_str(),
Some("24740a56-9cb9-4890-a5ac-589d30914a55")
);
}
}