use crate::config::types::{LysConfig, ServerConfig};
use std::collections::BTreeSet;
pub(super) fn validate_outbound_links(config: &ServerConfig, errors: &mut Vec<String>) {
let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
return;
};
let Some(machine) = &lys.machine else {
return;
};
let mut seen = BTreeSet::new();
for (index, link) in machine.links.iter().enumerate() {
let field = format!("auth.lys.machine.links[{index}] link '{}'", link.link_id);
if link.link_id.is_empty()
|| link
.link_id
.chars()
.any(|c| c.is_whitespace() || c.is_control())
{
errors.push(format!("{field}.link_id: not an identifier"));
}
if !seen.insert(&link.link_id) {
errors.push(format!("{field}: duplicate link_id '{}'", link.link_id));
}
match machine.machines.get(&link.peer_holder) {
None => errors.push(format!(
"{field}: peer_holder '{}' has no recorded machine key",
link.peer_holder
)),
Some(key) => {
let identity = hex::decode(key)
.ok()
.and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
.map(crate::link_handshake::MachineIdentity::new);
if identity.is_none_or(|identity| identity.x25519_public().is_err()) {
errors.push(format!(
"{field}: peer_holder '{}' has an invalid recorded machine key",
link.peer_holder
));
}
}
}
if let Some(address) = &link.peer_address {
let valid = address.rsplit_once(':').is_some_and(|(host, port)| {
!host.is_empty()
&& !host.chars().any(|c| c.is_whitespace() || c.is_control())
&& port.parse::<u16>().is_ok_and(|port| port != 0)
});
if !valid {
errors.push(format!(
"{field}.peer_address: expected host:port with a nonzero port"
));
}
}
for prefix in &link.exports {
if prefix == "liminal" || prefix.starts_with("liminal.") {
errors.push(format!(
"{field}: exports '{prefix}' names the reserved 'liminal.' namespace"
));
continue;
}
if crate::auth_lys::SubjectBinding::new(
lys.network_id.clone(),
prefix.clone(),
prefix.clone(),
)
.is_err()
{
errors.push(format!(
"{field}: exports '{prefix}' is not a placed subject name"
));
continue;
}
let mut placed = false;
let mut durable = false;
for channel in &config.channels {
if reaches(prefix, &channel.name) {
placed = true;
if channel.durable {
durable = true;
} else {
errors.push(format!(
"{field}: exports '{prefix}' names non-durable channel '{}'",
channel.name
));
}
}
}
if durable {
continue;
}
let subjects = service_subjects_reached(lys, prefix);
if subjects.is_empty() && !placed {
errors.push(format!(
"{field}: exports '{prefix}' names no placed subject"
));
}
for subject in subjects {
errors.push(format!(
"{field}: exports '{prefix}' names service subject '{subject}', which is not a stream"
));
}
}
}
}
fn service_subjects_reached(lys: &LysConfig, prefix: &str) -> BTreeSet<String> {
let mut subjects = BTreeSet::new();
for root in lys.templates.iter().flat_map(|template| &template.subjects) {
subjects.extend(reached_family(prefix, root));
}
if lys.services.is_some() {
for kind in crate::services::ServiceKind::ALL {
subjects.extend(reached_shape(prefix, kind.subject_shape()));
}
}
if lys.broker.is_some() {
for subject in [
"net.broker.register",
"net.broker.lookup",
"net.broker.punch",
] {
if reaches(prefix, subject) {
subjects.insert(subject.to_owned());
}
}
}
if lys.relay.as_ref().is_some_and(|relay| relay.enabled) {
subjects.extend(reached_family(prefix, "net.relay"));
}
if !lys.templates.is_empty() {
for verb in ["list", "create", "deploy", "hold", "wake", "retire"] {
let subject = format!("services.{verb}");
if reaches(prefix, &subject) {
subjects.insert(subject);
}
}
}
subjects
}
fn reached_family(prefix: &str, root: &str) -> Option<String> {
if reaches(prefix, root) {
return Some(root.to_owned());
}
prefix
.strip_prefix(root)?
.strip_prefix('.')
.filter(|name| super::super::templates::is_segment(name))
.map(|_| prefix.to_owned())
}
fn reached_shape(prefix: &str, shape: &str) -> Option<String> {
let mut supplied = prefix.split('.');
let mut resolved = Vec::new();
for part in shape.split('.') {
match supplied.next() {
Some(segment)
if segment == part
|| (part.starts_with('<') && super::super::templates::is_segment(segment)) =>
{
resolved.push(segment);
}
Some(_) => return None,
None => resolved.push(part),
}
}
if supplied.next().is_some() {
return None;
}
Some(resolved.join("."))
}
fn reaches(prefix: &str, subject: &str) -> bool {
subject == prefix
|| subject
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.'))
}