use spill::protocol::upnp;
// Minimal IGD descriptions: an IPv4 connection service nested in embedded
// devices (miniupnpd layout), and a PPP service with a URLBase.
const MINI: &str = "<?xml version=\"1.0\"?><root xmlns=\"urn:schemas-upnp-org:device-1-0\"><device><deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><controlURL>/ctl/IPConn</controlURL></service></serviceList></device></deviceList></device></deviceList></device></root>";
const LINKSYS: &str = "<?xml version=\"1.0\"?><root xmlns=\"urn:schemas-upnp-org:device-1-0\"><URLBase>http://192.168.1.1:49152</URLBase><device><deviceList><device><deviceList><device><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType><controlURL>/upnp/control/WANPPPConn1</controlURL></service></serviceList></device></deviceList></device></deviceList></device></root>";
const IP: &str = "urn:schemas-upnp-org:service:WANIPConnection:1";
#[test]
fn nested_and_url_base_descriptions_extract_control_and_base() {
assert_eq!(
upnp::control_url(MINI.as_bytes()),
Some((IP.into(), "/ctl/IPConn".into(), None))
);
assert_eq!(
upnp::control_url(LINKSYS.as_bytes()),
Some((
"urn:schemas-upnp-org:service:WANPPPConnection:1".into(),
"/upnp/control/WANPPPConn1".into(),
Some("http://192.168.1.1:49152".into())
))
);
let mut prefixed = MINI.replace(
"<root ",
"<root xmlns:d=\"urn:schemas-upnp-org:device-1-0\" ",
);
for tag in ["service", "serviceType", "controlURL"] {
prefixed = prefixed
.replace(&format!("<{tag}>"), &format!("<d:{tag}>\n "))
.replace(&format!("</{tag}>"), &format!(" \n</d:{tag}>"));
}
prefixed = prefixed.replace("/ctl/IPConn", "../control/ip?x=1&y=2");
assert_eq!(
upnp::control_url(prefixed.as_bytes()),
Some((IP.into(), "../control/ip?x=1&y=2".into(), None))
);
let absolute = LINKSYS.replace(
"/upnp/control/WANPPPConn1",
"http://192.168.1.2:1234/control",
);
assert_eq!(
upnp::control_url(absolute.as_bytes()).unwrap().1,
"http://192.168.1.2:1234/control"
);
}
#[test]
fn service_preference_is_independent_of_document_order() {
let service = |kind, path| {
format!(
"<service><serviceType>urn:schemas-upnp-org:service:{kind}</serviceType><controlURL>{path}</controlURL></service>"
)
};
let xml = format!(
"<root><URLBase>http://router:5000/base/</URLBase>{}{}{}</root>",
service("WANPPPConnection:1", "ppp"),
service("WANIPConnection:1", "ip1"),
service("WANIPConnection:2", "ip2")
);
assert_eq!(
upnp::control_url(xml.as_bytes()),
Some((
"urn:schemas-upnp-org:service:WANIPConnection:2".into(),
"ip2".into(),
Some("http://router:5000/base/".into())
))
);
}
#[test]
fn ssdp_searches_both_igd_versions_and_accepts_case_insensitive_headers() {
for (request, version) in upnp::search_request().iter().zip([1, 2]) {
assert!(request.starts_with("M-SEARCH * HTTP/1.1\r\n"));
assert!(request.contains("HOST: 239.255.255.250:1900\r\n"));
assert!(request.contains("MAN: \"ssdp:discover\"\r\nMX: 2\r\n"));
assert!(request.ends_with(&format!("InternetGatewayDevice:{version}\r\n\r\n")));
}
assert_eq!(upnp::search_reply(b"HTTP/1.1 200 OK\r\nCache-Control: max-age=1800\r\nmalformed header without a colon\r\nlOcAtIoN: http://192.168.1.1:5000/rootDesc.xml \r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n"),
Some("http://192.168.1.1:5000/rootDesc.xml".into()));
for bad in [
b"HTTP/1.1 404 Not Found\r\nLOCATION: http://router/\r\n\r\n".as_slice(),
b"HTTP/1.1 200 OK\r\nLOCATION: \r\n\r\n",
b"garbage",
] {
assert!(upnp::search_reply(bad).is_none());
}
}
#[test]
fn soap_success_faults_and_xml_escaping() {
let fields = upnp::response(b"<?xml version=\"1.0\"?><s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body><u:GetExternalIPAddressResponse xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"><NewExternalIPAddress>1.2.3.4</NewExternalIPAddress></u:GetExternalIPAddressResponse></s:Body></s:Envelope>").unwrap();
assert_eq!(fields["NewExternalIPAddress"], "1.2.3.4");
for (code, message) in [
(725, "OnlyPermanentLeasesSupported"),
(718, "ConflictInMappingEntry"),
] {
let xml = format!(
"<?xml version=\"1.0\"?><s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Body><s:Fault><faultcode>s:Client</faultcode><faultstring>UPnPError</faultstring><detail><UPnPError xmlns=\"urn:schemas-upnp-org:control-1-0\"><errorCode>{code}</errorCode><errorDescription>{message}</errorDescription></UPnPError></detail></s:Fault></s:Body></s:Envelope>"
);
assert_eq!(upnp::response(xml.as_bytes()), Err(code));
}
let (action, body) = upnp::request(
IP,
"AddPortMapping",
&[
("NewRemoteHost", ""),
("NewPortMappingDescription", "<&>\"'"),
],
);
assert_eq!(action, format!("\"{IP}#AddPortMapping\""));
assert!(body.contains("<NewRemoteHost></NewRemoteHost>"));
assert!(body.contains(
"<NewPortMappingDescription><&>"'</NewPortMappingDescription>"
));
let reply = b"<s:Envelope xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'><s:Body><u:TestResponse xmlns:u='urn:test'><Value><&>"'AB</Value><Empty/></u:TestResponse></s:Body></s:Envelope>";
let fields = upnp::response(reply).unwrap();
assert_eq!(fields["Value"], "<&>\"'AB");
assert_eq!(fields["Empty"], "");
assert!(
upnp::response(b"<Envelope><Body><AddPortMappingResponse/></Body></Envelope>")
.unwrap()
.is_empty()
);
for action in ["GetExternalIPAddress", "DeletePortMapping"] {
let (header, body) = upnp::request(IP, action, &[]);
assert_eq!(header, format!("\"{IP}#{action}\""));
assert!(body.contains(&format!("</u:{action}>")));
}
}
#[test]
fn malformed_xml_entities_and_faults_are_rejected_without_panicking() {
for bad in [
"<!DOCTYPE root [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><root/>",
"<root><service></root>",
"<root><!-- unfinished",
"<root x='unterminated>",
] {
assert!(upnp::control_url(bad.as_bytes()).is_none());
}
for bad in [
"<Body><TestResponse><Value>&unknown;</Value></TestResponse></Body>",
"<Body><Fault/></Body>",
"<root/>",
"<Body><TestResponse>",
] {
assert_eq!(upnp::response(bad.as_bytes()), Err(0));
}
for n in 0..MINI.len() {
let _ = upnp::control_url(&MINI.as_bytes()[..n]);
}
}