use isoloom_manifest::Protocol;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ConfigPort {
pub(super) service: String,
pub(super) target: u16,
pub(super) protocol: Protocol,
pub(super) host_ip: Option<String>,
pub(super) published: Option<u16>,
}
pub(super) fn ports(config: &str) -> Option<Vec<ConfigPort>> {
let v: Value = serde_json::from_str(config).ok()?;
let mut out = Vec::new();
for (service, svc) in v.get("services")?.as_object()? {
for p in svc.get("ports").and_then(Value::as_array).into_iter().flatten() {
out.push(ConfigPort {
service: service.clone(),
target: number(p.get("target")).filter(|t| *t > 0)?,
protocol: if p.get("protocol").and_then(Value::as_str) == Some("udp") {
Protocol::Udp
} else {
Protocol::Tcp
},
host_ip: p.get("host_ip").and_then(Value::as_str).filter(|i| !i.is_empty()).map(str::to_owned),
published: number(p.get("published")).filter(|n| *n > 0),
});
}
}
Some(out)
}
fn number(v: Option<&Value>) -> Option<u16> {
v.and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|s| s.parse().ok())))
.and_then(|n| u16::try_from(n).ok())
}
pub(super) fn host_ports(config: &str) -> Vec<(u16, Protocol)> {
ports(config)
.unwrap_or_default()
.iter()
.filter_map(|p| Some((p.published?, p.protocol)))
.collect()
}
pub(super) fn networks(config: &str, project: &str) -> Vec<String> {
let v: Value = serde_json::from_str(config).unwrap_or_default();
let defined = v.get("networks").and_then(Value::as_object).into_iter().flatten();
defined
.map(|(k, n)| n.get("name").and_then(Value::as_str).map_or_else(|| format!("{project}_{k}"), str::to_owned))
.collect()
}
pub(super) fn pinned_ports(ports: &[ConfigPort], mut pick: impl FnMut(&ConfigPort) -> Option<u16>) -> Option<String> {
let mut services: Vec<&str> = Vec::new();
for p in ports.iter().filter(|p| p.published.is_none()) {
if !services.contains(&p.service.as_str()) {
services.push(&p.service);
}
}
if services.is_empty() {
return None;
}
let mut lines = vec![
"# Written by the Isothrum runner at the run's first start: its host ports, kept across resumes.".to_owned(),
"services:".to_owned(),
];
for service in services {
lines.push(format!(" {}:", yaml_str(service)));
lines.push(" ports: !override".to_owned());
for p in ports.iter().filter(|p| p.service == service) {
let host = match p.published {
Some(n) => n,
None => pick(p)?,
};
let ip = p
.host_ip
.as_deref()
.map(|i| if i.contains(':') { format!("[{i}]:") } else { format!("{i}:") })
.unwrap_or_default();
let proto = if p.protocol == Protocol::Udp { "udp" } else { "tcp" };
lines.push(format!(" - {}", yaml_str(&format!("{ip}{host}:{}/{proto}", p.target))));
}
}
Some(lines.join("\n") + "\n")
}
fn yaml_str(s: &str) -> String {
let mut out = String::new();
for c in Value::from(s).to_string().chars() {
let n = u32::from(c);
if n < 0x80 {
out.push(c);
continue;
}
let (escape, digits) = if n <= 0xFFFF { ("\\u", 4) } else { ("\\U", 8) };
out.push_str(escape);
out.extend((0..digits).rev().filter_map(|i| char::from_digit((n >> (4 * i)) & 0xF, 16)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_port_without_a_target_makes_the_config_unreadable() {
let zero = r#"{"services": {"web": {"ports": [{"target": 0, "published": "8080"}]}}}"#;
assert_eq!(ports(zero), None);
let one = r#"{"services": {"web": {"ports": [{"target": 1, "published": "0"}]}}}"#;
let read = ports(one).unwrap();
assert_eq!((read[0].target, read[0].published), (1, None), "port 0 is no published port");
}
#[test]
fn pins_ephemeral_ports_and_keeps_fixed_ones() {
let json = r#"{"services":{"web":{"ports":[{"host_ip":"127.0.0.1","target":80,"protocol":"tcp"},{"host_ip":"127.0.0.1","target":443,"published":"8443","protocol":"tcp"}]},"db":{},"dns":{"ports":[{"target":53,"published":"","protocol":"udp"}]}}}"#;
let mut next = 40000;
let yaml = pinned_ports(&ports(json).unwrap(), |_| {
next += 1;
Some(next)
})
.unwrap();
let (web, dns) = if yaml.find("\"web\"") < yaml.find("\"dns\"") {
(40001, 40002)
} else {
(40002, 40001)
};
assert!(
yaml.contains(&format!(" \"dns\":\n ports: !override\n - \"{dns}:53/udp\"\n")),
"{yaml}"
);
assert!(
yaml.contains(&format!(
" \"web\":\n ports: !override\n - \"127.0.0.1:{web}:80/tcp\"\n - \"127.0.0.1:8443:443/tcp\"\n"
)),
"{yaml}"
);
assert!(!yaml.contains("db"));
assert!(
pinned_ports(&ports(r#"{"services":{"web":{"ports":[{"target":80,"published":"8080"}]}}}"#).unwrap(), |_| {
Some(1)
})
.is_none()
);
assert!(pinned_ports(&ports(r#"{"services":{"web":{"ports":[{"target":80}]}}}"#).unwrap(), |_| None).is_none());
}
#[test]
fn unicode_line_breaks_in_names_stay_inside_their_scalar() {
for name in ["\u{85}", "a\u{2028}b", "\u{2029}", "na\u{ef}ve \u{1f600}"] {
let json = serde_json::json!({"services": {name: {"ports": [{"target": 80, "protocol": "tcp"}]}}}).to_string();
let yaml = pinned_ports(&ports(&json).unwrap(), |_| Some(40000)).unwrap();
let line = yaml.lines().nth(2).unwrap();
let quoted = line.trim().trim_end_matches(':');
assert_eq!(
serde_json::from_str::<String>("ed.replace("\\U0001f600", "\u{1f600}")).unwrap(),
name,
"{yaml}"
);
}
}
#[test]
fn ipv6_host_addresses_are_bracketed() {
let json = r#"{"services":{"a":{"ports":[{"target":80,"host_ip":"::1","protocol":"udp"},{"target":443,"published":"8443","host_ip":""}]}}}"#;
let yaml = pinned_ports(&ports(json).unwrap(), |_| Some(40000)).unwrap();
assert!(yaml.contains("- \"[::1]:40000:80/udp\"\n") && yaml.contains("- \"8443:443/tcp\"\n"), "{yaml}");
}
#[test]
fn host_ports_take_strings_and_numbers_and_drop_zero_or_out_of_range() {
let json = r#"{"services":{"a":{"ports":[{"published":"80","target":80}]},"b":{"ports":[{"published":8443,"target":8443}]},"c":{"ports":[{"published":0,"target":5000},{"published":"0","target":5001}]},"d":{"ports":[{"published":70000,"target":9}]},"e":{"ports":[{"published":"53","target":53,"protocol":"udp"}]},"init":{}}}"#;
let mut found = host_ports(json);
found.sort();
assert_eq!(found, [(53, Protocol::Udp), (80, Protocol::Tcp), (8443, Protocol::Tcp)]);
assert!(host_ports("not json").is_empty() && host_ports("{}").is_empty());
assert!(ports(r#"{"services":{"a":{"ports":[{"published":""}]}}}"#).is_none(), "a port without a target");
}
#[test]
fn networks_are_named_by_their_name_else_the_project_and_key() {
let config = r#"{"networks":{"lab":{"name":"cyberctf-x_lab"},"back":{}}}"#;
assert_eq!(networks(config, "cyberctf-x"), ["cyberctf-x_lab", "cyberctf-x_back"]);
assert_eq!(networks("garbage", "p"), Vec::<String>::new());
}
}
#[cfg(test)]
mod proptests {
use proptest::prelude::*;
use super::*;
fn unquote(quoted: &str) -> Option<String> {
let mut chars = quoted.strip_prefix('"')?.strip_suffix('"')?.chars();
let mut out = String::new();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let escaped = chars.next()?;
let hex = |n: usize, rest: &mut std::str::Chars<'_>| u32::from_str_radix(&rest.by_ref().take(n).collect::<String>(), 16).ok();
out.push(match escaped {
'u' => char::from_u32(hex(4, &mut chars)?)?,
'U' => char::from_u32(hex(8, &mut chars)?)?,
'n' => '\n',
't' => '\t',
'r' => '\r',
'b' => '\u{8}',
'f' => '\u{c}',
other => other,
});
}
Some(out)
}
proptest! {
#[test]
fn any_service_name_reads_back_from_the_override(name in "\\PC{1,24}", target in 1u16..) {
let json = serde_json::json!({"services": {&name: {"ports": [{"target": target}]}}}).to_string();
let yaml = pinned_ports(&ports(&json).unwrap(), |_| Some(40000)).unwrap();
prop_assert_eq!(yaml.lines().count(), 5, "{}", yaml);
let expected = format!(" - \"40000:{target}/tcp\"\n");
prop_assert!(yaml.ends_with(&expected));
}
#[test]
fn a_quoted_scalar_is_ascii_and_reads_back_as_itself(text in "(?s).{0,40}") {
let quoted = yaml_str(&text);
prop_assert!(quoted.is_ascii(), "{}", quoted);
prop_assert_eq!(unquote("ed), Some(text));
}
#[test]
fn reading_any_text_never_panics(text in "\\PC{0,200}") {
let _ = ports(&text);
let _ = host_ports(&text);
let _ = networks(&text, "p");
}
}
}