use std::collections::BTreeSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fs, io};
use serde::{Deserialize, Serialize};
use crate::config::env_registry::{self, EnvVar};
pub const DISCOVERY_POLICY_SCHEMA_V1: &str = "ee.mesh.discovery_policy.v1";
pub const EE_MESH_SERVICE_TAG: &str = "tag:ee-mesh";
pub const NODE_KEY_LIST_MAX_BYTES: usize = 1024 * 1024;
pub const DISCOVERY_POLICY_NO_EE_MESH_TAG_CODE: &str = "discovery_policy_no_ee_mesh_tag";
pub const DISCOVERY_POLICY_EMPTY_ALLOWLIST_CODE: &str = "discovery_policy_empty_allowlist";
pub const DISCOVERY_ALLOWLIST_FILE: &str = "discovery_allowlist.toml";
pub const DISCOVERY_DENYLIST_FILE: &str = "discovery_denylist.toml";
pub const RESPOND_ALLOWLIST_FILE: &str = "respond_allowlist.toml";
const NODE_KEY_PREFIX: &str = "nodekey:";
const NODE_KEY_HEX_LEN: usize = 64;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryMode {
ServiceTag,
AutoAdmit,
Allowlist,
}
impl DiscoveryMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ServiceTag => "service_tag",
Self::AutoAdmit => "auto_admit",
Self::Allowlist => "allowlist",
}
}
#[must_use]
pub const fn default_mode() -> Self {
Self::ServiceTag
}
#[must_use]
pub fn from_env_discovery<F>(on_unknown: F) -> Self
where
F: FnOnce(&str),
{
Self::from_env_var(EnvVar::TailscaleDiscoveryMode, on_unknown)
}
#[must_use]
pub fn from_env_respond<F>(on_unknown: F) -> Self
where
F: FnOnce(&str),
{
Self::from_env_var(EnvVar::TailscaleRespondMode, on_unknown)
}
fn from_env_var<F>(var: EnvVar, on_unknown: F) -> Self
where
F: FnOnce(&str),
{
Self::from_raw_with_default(env_registry::read(var).as_deref(), on_unknown)
}
#[must_use]
pub(crate) fn from_raw_with_default<F>(raw: Option<&str>, on_unknown: F) -> Self
where
F: FnOnce(&str),
{
let Some(raw) = raw else {
return Self::default_mode();
};
match Self::from_str(raw) {
Ok(mode) => mode,
Err(_) => {
on_unknown(raw);
Self::default_mode()
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseDiscoveryModeError {
pub raw: String,
}
impl std::fmt::Display for ParseDiscoveryModeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unknown discovery mode {:?}; expected one of service_tag, auto_admit, allowlist",
self.raw
)
}
}
impl std::error::Error for ParseDiscoveryModeError {}
impl FromStr for DiscoveryMode {
type Err = ParseDiscoveryModeError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
"service_tag" => Ok(Self::ServiceTag),
"auto_admit" => Ok(Self::AutoAdmit),
"allowlist" => Ok(Self::Allowlist),
other => Err(ParseDiscoveryModeError {
raw: other.to_owned(),
}),
}
}
}
impl Default for DiscoveryMode {
fn default() -> Self {
Self::default_mode()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryDecision {
Probe,
Skip,
}
impl DiscoveryDecision {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Probe => "probe",
Self::Skip => "skip",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryConsent {
Granted,
Denied,
}
impl DiscoveryConsent {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Granted => "granted",
Self::Denied => "denied",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryReason {
AutoAdmit,
ServiceTagMatch,
Allowlisted,
SkipNoTag,
SkipNotAllowlisted,
SkipDenylisted,
SkipSelf,
}
impl DiscoveryReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::AutoAdmit => "auto_admit",
Self::ServiceTagMatch => "service_tag_match",
Self::Allowlisted => "allowlisted",
Self::SkipNoTag => "skip_no_tag",
Self::SkipNotAllowlisted => "skip_not_allowlisted",
Self::SkipDenylisted => "skip_denylisted",
Self::SkipSelf => "skip_self",
}
}
#[must_use]
pub fn autodiscovery_policy_decision(self) -> Option<&'static str> {
match self {
Self::AutoAdmit => Some("auto_admit"),
Self::ServiceTagMatch => Some("service_tag_match"),
Self::Allowlisted => Some("allowlisted"),
Self::SkipNoTag | Self::SkipNotAllowlisted | Self::SkipDenylisted | Self::SkipSelf => {
None
}
}
}
#[must_use]
pub fn autodiscovery_skip_reason(self) -> Option<&'static str> {
match self {
Self::AutoAdmit | Self::ServiceTagMatch | Self::Allowlisted => None,
Self::SkipNoTag | Self::SkipNotAllowlisted => Some("no_discovery_consent"),
Self::SkipDenylisted | Self::SkipSelf => Some("denied_by_policy"),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct DiscoveryDecisionInput<'a> {
pub mode: DiscoveryMode,
pub peer_node_key: &'a str,
pub peer_advertised_tags: &'a [String],
pub self_node_key: &'a str,
pub allowlist: &'a BTreeSet<String>,
pub denylist: &'a BTreeSet<String>,
}
#[derive(Clone, Copy, Debug)]
pub struct RespondDecisionInput<'a> {
pub mode: DiscoveryMode,
pub requester_node_key: &'a str,
pub requester_advertised_tags: &'a [String],
pub self_advertised_tags: &'a [String],
pub respond_allowlist: &'a BTreeSet<String>,
pub denylist: &'a BTreeSet<String>,
}
#[must_use]
pub fn decide_discovery(
input: &DiscoveryDecisionInput<'_>,
) -> (DiscoveryDecision, DiscoveryReason) {
if input.peer_node_key == input.self_node_key {
return (DiscoveryDecision::Skip, DiscoveryReason::SkipSelf);
}
if input.denylist.contains(input.peer_node_key) {
return (DiscoveryDecision::Skip, DiscoveryReason::SkipDenylisted);
}
match input.mode {
DiscoveryMode::AutoAdmit => (DiscoveryDecision::Probe, DiscoveryReason::AutoAdmit),
DiscoveryMode::ServiceTag => {
if input
.peer_advertised_tags
.iter()
.any(|tag| tag == EE_MESH_SERVICE_TAG)
{
(DiscoveryDecision::Probe, DiscoveryReason::ServiceTagMatch)
} else {
(DiscoveryDecision::Skip, DiscoveryReason::SkipNoTag)
}
}
DiscoveryMode::Allowlist => {
if input.allowlist.contains(input.peer_node_key) {
(DiscoveryDecision::Probe, DiscoveryReason::Allowlisted)
} else {
(DiscoveryDecision::Skip, DiscoveryReason::SkipNotAllowlisted)
}
}
}
}
#[must_use]
pub fn decide_respond(input: &RespondDecisionInput<'_>) -> (DiscoveryConsent, DiscoveryReason) {
if input.denylist.contains(input.requester_node_key) {
return (DiscoveryConsent::Denied, DiscoveryReason::SkipDenylisted);
}
let _ = input.requester_advertised_tags; match input.mode {
DiscoveryMode::AutoAdmit => (DiscoveryConsent::Granted, DiscoveryReason::AutoAdmit),
DiscoveryMode::ServiceTag => {
if input
.self_advertised_tags
.iter()
.any(|tag| tag == EE_MESH_SERVICE_TAG)
{
(DiscoveryConsent::Granted, DiscoveryReason::ServiceTagMatch)
} else {
(DiscoveryConsent::Denied, DiscoveryReason::SkipNoTag)
}
}
DiscoveryMode::Allowlist => {
if input.respond_allowlist.contains(input.requester_node_key) {
(DiscoveryConsent::Granted, DiscoveryReason::Allowlisted)
} else {
(
DiscoveryConsent::Denied,
DiscoveryReason::SkipNotAllowlisted,
)
}
}
}
}
#[derive(Debug)]
pub enum LoadListError {
Read(std::io::Error),
Parse(toml_edit::TomlError),
InvalidShape(String),
}
impl std::fmt::Display for LoadListError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read(error) => write!(f, "failed to read discovery list file: {error}"),
Self::Parse(error) => write!(f, "failed to parse discovery list TOML: {error}"),
Self::InvalidShape(detail) => {
write!(f, "invalid discovery list shape: {detail}")
}
}
}
}
impl std::error::Error for LoadListError {}
pub fn load_node_key_list(path: &Path) -> Result<BTreeSet<String>, LoadListError> {
if !node_key_list_path_is_regular(path)? {
return Ok(BTreeSet::new());
}
let body = match read_node_key_list_bounded(path) {
Ok(body) => body,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(error) => return Err(LoadListError::Read(error)),
};
let document = body
.parse::<toml_edit::DocumentMut>()
.map_err(LoadListError::Parse)?;
let Some(node_keys_item) = document.get("node_keys") else {
return Ok(BTreeSet::new());
};
let Some(array) = node_keys_item.as_array() else {
return Err(LoadListError::InvalidShape(
"expected `node_keys` to be a TOML array of strings".to_owned(),
));
};
let mut out = BTreeSet::new();
for (index, value) in array.iter().enumerate() {
let Some(s) = value.as_str() else {
return Err(LoadListError::InvalidShape(format!(
"expected `node_keys[{index}]` to be a string, got `{value:?}`",
)));
};
let trimmed = s.trim();
if !trimmed.is_empty() {
validate_node_key_list_entry(index, trimmed)?;
out.insert(trimmed.to_owned());
}
}
Ok(out)
}
pub fn validate_node_key(value: &str) -> Result<(), LoadListError> {
validate_node_key_list_entry(0, value)
}
fn validate_node_key_list_entry(index: usize, value: &str) -> Result<(), LoadListError> {
if is_valid_node_key(value) {
return Ok(());
}
Err(LoadListError::InvalidShape(format!(
"expected `node_keys[{index}]` to be a Tailscale node key formatted as `{NODE_KEY_PREFIX}` plus {NODE_KEY_HEX_LEN} lowercase hex characters",
)))
}
fn is_valid_node_key(value: &str) -> bool {
let Some(body) = value.strip_prefix(NODE_KEY_PREFIX) else {
return false;
};
body.len() == NODE_KEY_HEX_LEN
&& body
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
fn read_node_key_list_bounded(path: &Path) -> io::Result<String> {
let read_limit = NODE_KEY_LIST_MAX_BYTES.checked_add(1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"node-key list read cap overflowed usize",
)
})?;
let file = fs::File::open(path)?;
let mut bytes = Vec::new();
file.take(read_limit as u64).read_to_end(&mut bytes)?;
if bytes.len() > NODE_KEY_LIST_MAX_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Discovery list '{}' exceeds the {NODE_KEY_LIST_MAX_BYTES}-byte cap; refusing to read",
path.display()
),
));
}
String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn node_key_list_path_is_regular(path: &Path) -> Result<bool, LoadListError> {
if let Some(symlink_path) = node_key_list_symlink_component(path)? {
return Err(node_key_list_invalid_path_error(format!(
"refusing to read discovery list {} because it traverses symbolic link {}",
discovery_list_path_ref(path),
discovery_list_path_ref(&symlink_path),
)));
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(true),
Ok(_) => Err(node_key_list_invalid_path_error(format!(
"refusing to read discovery list {} because it is not a regular file",
discovery_list_path_ref(path),
))),
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
Ok(false)
}
Err(error) => Err(LoadListError::Read(error)),
}
}
fn node_key_list_symlink_component(path: &Path) -> Result<Option<PathBuf>, LoadListError> {
crate::core::path_safety::first_existing_symlink_component(path).map_err(LoadListError::Read)
}
fn node_key_list_invalid_path_error(message: String) -> LoadListError {
LoadListError::Read(io::Error::new(io::ErrorKind::InvalidInput, message))
}
fn discovery_list_path_ref(path: &Path) -> String {
let path = path.to_string_lossy();
let hash = blake3::hash(path.as_bytes()).to_hex();
format!("discovery_list_path_{}", &hash[..10])
}
pub fn load_workspace_lists(workspace_path: &Path) -> Result<WorkspaceLists, LoadListError> {
let ee_dir = workspace_path.join(".ee");
let allowlist = load_node_key_list(&ee_dir.join(DISCOVERY_ALLOWLIST_FILE))?;
let denylist = load_node_key_list(&ee_dir.join(DISCOVERY_DENYLIST_FILE))?;
let respond_allowlist = load_node_key_list(&ee_dir.join(RESPOND_ALLOWLIST_FILE))?;
Ok(WorkspaceLists {
allowlist,
denylist,
respond_allowlist,
})
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct WorkspaceLists {
pub allowlist: BTreeSet<String>,
pub denylist: BTreeSet<String>,
pub respond_allowlist: BTreeSet<String>,
}
#[must_use]
pub fn evaluate_policy_degradations(
discovery_mode: DiscoveryMode,
respond_mode: DiscoveryMode,
self_advertised_tags: &[String],
discovery_allowlist: &BTreeSet<String>,
) -> Vec<PolicyDegradation> {
let mut out = Vec::new();
if respond_mode == DiscoveryMode::ServiceTag
&& !self_advertised_tags
.iter()
.any(|tag| tag == EE_MESH_SERVICE_TAG)
{
out.push(PolicyDegradation {
code: DISCOVERY_POLICY_NO_EE_MESH_TAG_CODE,
severity: "info",
message: format!(
"respondMode is {} but this host does not advertise {EE_MESH_SERVICE_TAG}; peers will see decline responses",
DiscoveryMode::ServiceTag.as_str(),
),
repair: "tailscale up --advertise-tags=tag:ee-mesh",
});
}
if discovery_mode == DiscoveryMode::Allowlist && discovery_allowlist.is_empty() {
out.push(PolicyDegradation {
code: DISCOVERY_POLICY_EMPTY_ALLOWLIST_CODE,
severity: "info",
message: format!(
"discoveryMode is {} but the allowlist is empty; no peers will be probed",
DiscoveryMode::Allowlist.as_str(),
),
repair: "ee mesh discovery-policy allow --help",
});
}
out
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PolicyDegradation {
pub code: &'static str,
pub severity: &'static str,
pub message: String,
pub repair: &'static str,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn load_node_key_list_refuses_oversized_payload() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("oversized_allowlist.toml");
std::fs::write(&path, vec![b'x'; NODE_KEY_LIST_MAX_BYTES + 1]).expect("write oversized");
let error = load_node_key_list(&path).expect_err("oversized read must error");
match error {
LoadListError::Read(io_error) => {
assert_eq!(io_error.kind(), io::ErrorKind::InvalidData);
let message = format!("{io_error}");
assert!(
message.contains("exceeds") && message.contains("byte cap"),
"expected over-cap diagnostic; got {message}"
);
}
other => panic!("expected LoadListError::Read with InvalidData; got {other:?}"),
}
}
#[test]
fn load_node_key_list_passes_payload_at_cap() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("at_cap_allowlist.toml");
let mut bytes = b"# at-cap fixture\n".to_vec();
bytes.resize(NODE_KEY_LIST_MAX_BYTES, b' ');
assert_eq!(bytes.len(), NODE_KEY_LIST_MAX_BYTES);
std::fs::write(&path, &bytes).expect("write at-cap");
let set = load_node_key_list(&path).expect("at-cap read must succeed");
assert!(
set.is_empty(),
"fixture omits node_keys array; expected empty set"
);
}
const NODE_KEY_A: &str =
"nodekey:0000000000000000000000000000000000000000000000000000000000000001";
const NODE_KEY_B: &str =
"nodekey:0000000000000000000000000000000000000000000000000000000000000002";
const NODE_KEY_C: &str =
"nodekey:0000000000000000000000000000000000000000000000000000000000000003";
fn empty_set() -> BTreeSet<String> {
BTreeSet::new()
}
fn set_of(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn discovery_mode_default_is_service_tag() {
assert_eq!(DiscoveryMode::default(), DiscoveryMode::ServiceTag);
assert_eq!(DiscoveryMode::default_mode(), DiscoveryMode::ServiceTag);
assert_eq!(DiscoveryMode::default().as_str(), "service_tag");
}
#[test]
fn discovery_mode_from_str_accepts_three_canonical_tokens() {
assert_eq!(
DiscoveryMode::from_str("service_tag").expect("service_tag parses"),
DiscoveryMode::ServiceTag
);
assert_eq!(
DiscoveryMode::from_str("auto_admit").expect("auto_admit parses"),
DiscoveryMode::AutoAdmit
);
assert_eq!(
DiscoveryMode::from_str("allowlist").expect("allowlist parses"),
DiscoveryMode::Allowlist
);
}
#[test]
fn discovery_mode_from_str_trims_surrounding_whitespace() {
assert_eq!(
DiscoveryMode::from_str(" auto_admit\n").expect("trim parses"),
DiscoveryMode::AutoAdmit
);
}
#[test]
fn discovery_mode_from_str_rejects_unknown_token() {
let err = DiscoveryMode::from_str("AUTO_ADMIT").expect_err("uppercase rejected");
assert_eq!(err.raw, "AUTO_ADMIT");
let err = DiscoveryMode::from_str("anything").expect_err("unknown rejected");
assert_eq!(err.raw, "anything");
let display = format!("{err}");
assert!(display.contains("service_tag"), "{display}");
assert!(display.contains("auto_admit"), "{display}");
assert!(display.contains("allowlist"), "{display}");
}
#[test]
fn discovery_mode_from_raw_missing_falls_back_to_default() {
let mut unknown_seen = String::new();
let mode = DiscoveryMode::from_raw_with_default(None, |raw| unknown_seen.push_str(raw));
assert_eq!(mode, DiscoveryMode::ServiceTag);
assert!(unknown_seen.is_empty(), "no on_unknown when value unset");
}
#[test]
fn discovery_mode_from_raw_valid_returns_parsed_without_calling_on_unknown() {
let mut unknown_seen = String::new();
let mode = DiscoveryMode::from_raw_with_default(Some("allowlist"), |raw| {
unknown_seen.push_str(raw);
});
assert_eq!(mode, DiscoveryMode::Allowlist);
assert!(unknown_seen.is_empty(), "on_unknown not called on valid");
}
#[test]
fn discovery_mode_from_raw_invalid_calls_on_unknown_and_falls_back() {
let mut unknown_seen = String::new();
let mode = DiscoveryMode::from_raw_with_default(Some("AUTO_ADMIT"), |raw| {
unknown_seen.push_str(raw);
});
assert_eq!(mode, DiscoveryMode::ServiceTag);
assert_eq!(unknown_seen, "AUTO_ADMIT");
}
#[test]
fn discovery_mode_round_trips_through_serde_snake_case() {
for mode in [
DiscoveryMode::ServiceTag,
DiscoveryMode::AutoAdmit,
DiscoveryMode::Allowlist,
] {
let serialized = serde_json::to_string(&mode).expect("serialize");
let deserialized: DiscoveryMode =
serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(deserialized, mode);
assert!(serialized.contains(mode.as_str()));
}
}
#[test]
fn discovery_reason_str_matches_snake_case_serde() {
for reason in [
DiscoveryReason::AutoAdmit,
DiscoveryReason::ServiceTagMatch,
DiscoveryReason::Allowlisted,
DiscoveryReason::SkipNoTag,
DiscoveryReason::SkipNotAllowlisted,
DiscoveryReason::SkipDenylisted,
DiscoveryReason::SkipSelf,
] {
let serialized = serde_json::to_string(&reason).expect("serialize");
assert!(serialized.contains(reason.as_str()));
}
}
#[test]
fn caller_skips_self_node_key_in_every_mode() {
let tags = vec![EE_MESH_SERVICE_TAG.to_owned()];
let allow = set_of(&["nodekey:self"]);
let deny = empty_set();
for mode in [
DiscoveryMode::ServiceTag,
DiscoveryMode::AutoAdmit,
DiscoveryMode::Allowlist,
] {
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode,
peer_node_key: "nodekey:self",
peer_advertised_tags: &tags,
self_node_key: "nodekey:self",
allowlist: &allow,
denylist: &deny,
});
assert_eq!(decision, DiscoveryDecision::Skip);
assert_eq!(reason, DiscoveryReason::SkipSelf);
}
}
#[test]
fn caller_denylist_overrides_auto_admit() {
let deny = set_of(&["nodekey:bad"]);
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::AutoAdmit,
peer_node_key: "nodekey:bad",
peer_advertised_tags: &[],
self_node_key: "nodekey:self",
allowlist: &empty_set(),
denylist: &deny,
});
assert_eq!(decision, DiscoveryDecision::Skip);
assert_eq!(reason, DiscoveryReason::SkipDenylisted);
}
#[test]
fn caller_denylist_overrides_allowlist() {
let allow = set_of(&["nodekey:peer"]);
let deny = set_of(&["nodekey:peer"]);
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::Allowlist,
peer_node_key: "nodekey:peer",
peer_advertised_tags: &[],
self_node_key: "nodekey:self",
allowlist: &allow,
denylist: &deny,
});
assert_eq!(decision, DiscoveryDecision::Skip);
assert_eq!(reason, DiscoveryReason::SkipDenylisted);
}
#[test]
fn caller_auto_admit_probes_peer_without_tag() {
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::AutoAdmit,
peer_node_key: "nodekey:peer",
peer_advertised_tags: &[],
self_node_key: "nodekey:self",
allowlist: &empty_set(),
denylist: &empty_set(),
});
assert_eq!(decision, DiscoveryDecision::Probe);
assert_eq!(reason, DiscoveryReason::AutoAdmit);
}
#[test]
fn caller_service_tag_probes_peer_with_ee_mesh_tag() {
let tags = vec![EE_MESH_SERVICE_TAG.to_owned()];
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::ServiceTag,
peer_node_key: "nodekey:peer",
peer_advertised_tags: &tags,
self_node_key: "nodekey:self",
allowlist: &empty_set(),
denylist: &empty_set(),
});
assert_eq!(decision, DiscoveryDecision::Probe);
assert_eq!(reason, DiscoveryReason::ServiceTagMatch);
}
#[test]
fn caller_service_tag_skips_peer_without_ee_mesh_tag() {
let tags = vec!["tag:something-else".to_owned()];
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::ServiceTag,
peer_node_key: "nodekey:peer",
peer_advertised_tags: &tags,
self_node_key: "nodekey:self",
allowlist: &empty_set(),
denylist: &empty_set(),
});
assert_eq!(decision, DiscoveryDecision::Skip);
assert_eq!(reason, DiscoveryReason::SkipNoTag);
}
#[test]
fn caller_allowlist_probes_listed_node_key() {
let allow = set_of(&["nodekey:friend"]);
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::Allowlist,
peer_node_key: "nodekey:friend",
peer_advertised_tags: &[],
self_node_key: "nodekey:self",
allowlist: &allow,
denylist: &empty_set(),
});
assert_eq!(decision, DiscoveryDecision::Probe);
assert_eq!(reason, DiscoveryReason::Allowlisted);
}
#[test]
fn caller_allowlist_skips_unlisted_node_key() {
let allow = set_of(&["nodekey:friend"]);
let (decision, reason) = decide_discovery(&DiscoveryDecisionInput {
mode: DiscoveryMode::Allowlist,
peer_node_key: "nodekey:stranger",
peer_advertised_tags: &[],
self_node_key: "nodekey:self",
allowlist: &allow,
denylist: &empty_set(),
});
assert_eq!(decision, DiscoveryDecision::Skip);
assert_eq!(reason, DiscoveryReason::SkipNotAllowlisted);
}
#[test]
fn caller_probe_reasons_map_to_autodiscovery_policy_decisions() {
for (reason, expected) in [
(DiscoveryReason::AutoAdmit, "auto_admit"),
(DiscoveryReason::ServiceTagMatch, "service_tag_match"),
(DiscoveryReason::Allowlisted, "allowlisted"),
] {
assert_eq!(reason.autodiscovery_policy_decision(), Some(expected));
assert_eq!(reason.autodiscovery_skip_reason(), None);
}
}
#[test]
fn caller_skip_reasons_map_to_autodiscovery_skip_vocabulary() {
for (reason, expected) in [
(DiscoveryReason::SkipNoTag, "no_discovery_consent"),
(DiscoveryReason::SkipNotAllowlisted, "no_discovery_consent"),
(DiscoveryReason::SkipDenylisted, "denied_by_policy"),
(DiscoveryReason::SkipSelf, "denied_by_policy"),
] {
assert_eq!(reason.autodiscovery_policy_decision(), None);
assert_eq!(reason.autodiscovery_skip_reason(), Some(expected));
}
}
#[test]
fn responder_denylist_overrides_auto_admit() {
let deny = set_of(&["nodekey:bad"]);
let (consent, reason) = decide_respond(&RespondDecisionInput {
mode: DiscoveryMode::AutoAdmit,
requester_node_key: "nodekey:bad",
requester_advertised_tags: &[],
self_advertised_tags: &[],
respond_allowlist: &empty_set(),
denylist: &deny,
});
assert_eq!(consent, DiscoveryConsent::Denied);
assert_eq!(reason, DiscoveryReason::SkipDenylisted);
}
#[test]
fn responder_service_tag_grants_when_self_advertises_tag() {
let self_tags = vec![EE_MESH_SERVICE_TAG.to_owned()];
let (consent, reason) = decide_respond(&RespondDecisionInput {
mode: DiscoveryMode::ServiceTag,
requester_node_key: "nodekey:peer",
requester_advertised_tags: &[],
self_advertised_tags: &self_tags,
respond_allowlist: &empty_set(),
denylist: &empty_set(),
});
assert_eq!(consent, DiscoveryConsent::Granted);
assert_eq!(reason, DiscoveryReason::ServiceTagMatch);
}
#[test]
fn responder_service_tag_denies_when_self_does_not_advertise_tag() {
let (consent, reason) = decide_respond(&RespondDecisionInput {
mode: DiscoveryMode::ServiceTag,
requester_node_key: "nodekey:peer",
requester_advertised_tags: &[EE_MESH_SERVICE_TAG.to_owned()],
self_advertised_tags: &[],
respond_allowlist: &empty_set(),
denylist: &empty_set(),
});
assert_eq!(consent, DiscoveryConsent::Denied);
assert_eq!(reason, DiscoveryReason::SkipNoTag);
}
#[test]
fn responder_allowlist_grants_listed_requester() {
let allow = set_of(&["nodekey:peer"]);
let (consent, reason) = decide_respond(&RespondDecisionInput {
mode: DiscoveryMode::Allowlist,
requester_node_key: "nodekey:peer",
requester_advertised_tags: &[],
self_advertised_tags: &[],
respond_allowlist: &allow,
denylist: &empty_set(),
});
assert_eq!(consent, DiscoveryConsent::Granted);
assert_eq!(reason, DiscoveryReason::Allowlisted);
}
#[test]
fn responder_allowlist_denies_unlisted_requester() {
let allow = set_of(&["nodekey:peer"]);
let (consent, reason) = decide_respond(&RespondDecisionInput {
mode: DiscoveryMode::Allowlist,
requester_node_key: "nodekey:stranger",
requester_advertised_tags: &[],
self_advertised_tags: &[],
respond_allowlist: &allow,
denylist: &empty_set(),
});
assert_eq!(consent, DiscoveryConsent::Denied);
assert_eq!(reason, DiscoveryReason::SkipNotAllowlisted);
}
#[test]
fn load_node_key_list_returns_empty_set_when_file_missing() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("missing.toml");
let result = load_node_key_list(&path).expect("missing file is ok");
assert!(result.is_empty());
}
#[test]
fn load_node_key_list_parses_simple_toml() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("list.toml");
let mut file = std::fs::File::create(&path).expect("create");
writeln!(file, "node_keys = [\"{NODE_KEY_A}\", \"{NODE_KEY_B}\"]").expect("write");
drop(file);
let result = load_node_key_list(&path).expect("parse");
assert_eq!(result.len(), 2);
assert!(result.contains(NODE_KEY_A));
assert!(result.contains(NODE_KEY_B));
}
#[cfg(unix)]
#[test]
fn load_node_key_list_rejects_symlinked_list_file() {
use std::os::unix::fs::symlink;
let tempdir = tempfile::tempdir().expect("tempdir");
let real_path = tempdir.path().join("real.toml");
std::fs::write(&real_path, "node_keys = [\"nodekey:outside\"]\n").expect("write");
let linked_path = tempdir.path().join("linked.toml");
symlink(&real_path, &linked_path).expect("symlink");
let result = load_node_key_list(&linked_path);
let Err(LoadListError::Read(error)) = result else {
panic!("expected symlinked list path to fail closed, got {result:?}");
};
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
let message = error.to_string();
assert!(message.contains("symbolic link"));
assert!(message.contains("discovery_list_path_"));
assert!(!message.contains("linked.toml"));
assert!(!message.contains("real.toml"));
assert!(!message.contains(tempdir.path().to_string_lossy().as_ref()));
}
#[test]
fn load_node_key_list_rejects_non_regular_list_path() {
let tempdir = tempfile::tempdir().expect("tempdir");
let directory_path = tempdir.path().join("list.toml");
std::fs::create_dir(&directory_path).expect("mkdir");
let result = load_node_key_list(&directory_path);
let Err(LoadListError::Read(error)) = result else {
panic!("expected non-regular list path to fail closed, got {result:?}");
};
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
let message = error.to_string();
assert!(message.contains("not a regular file"));
assert!(message.contains("discovery_list_path_"));
assert!(!message.contains("list.toml"));
assert!(!message.contains(tempdir.path().to_string_lossy().as_ref()));
}
#[test]
fn load_node_key_list_deduplicates_and_trims() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("list.toml");
let mut file = std::fs::File::create(&path).expect("create");
writeln!(
file,
"node_keys = [\"{NODE_KEY_A}\", \" {NODE_KEY_A} \", \"\", \" \"]"
)
.expect("write");
drop(file);
let result = load_node_key_list(&path).expect("parse");
assert_eq!(
result.len(),
1,
"expected dedup + empty drop, got {result:?}"
);
assert!(result.contains(NODE_KEY_A));
}
#[test]
fn load_node_key_list_rejects_non_node_key_entries() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("list.toml");
std::fs::write(&path, "node_keys = [\"machinekey:alpha\"]\n").expect("write");
let result = load_node_key_list(&path);
let Err(LoadListError::InvalidShape(message)) = result else {
panic!("expected malformed node key to fail closed, got {result:?}");
};
assert!(message.contains("node_keys[0]"));
assert!(message.contains("nodekey:"));
}
#[test]
fn load_node_key_list_rejects_path_like_node_key_entries() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("list.toml");
std::fs::write(&path, "node_keys = [\"nodekey:../../secret\"]\n").expect("write");
let result = load_node_key_list(&path);
let Err(LoadListError::InvalidShape(message)) = result else {
panic!("expected path-like node key to fail closed, got {result:?}");
};
assert!(message.contains("node_keys[0]"));
assert!(message.contains("lowercase hex"));
}
#[test]
fn load_node_key_list_rejects_invalid_toml() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("list.toml");
let mut file = std::fs::File::create(&path).expect("create");
writeln!(file, "node_keys = this is not toml [").expect("write");
drop(file);
let result = load_node_key_list(&path);
assert!(matches!(result, Err(LoadListError::Parse(_))));
}
#[test]
fn load_workspace_lists_returns_default_when_ee_dir_missing() {
let tempdir = tempfile::tempdir().expect("tempdir");
let lists = load_workspace_lists(tempdir.path()).expect("ok");
assert!(lists.allowlist.is_empty());
assert!(lists.denylist.is_empty());
assert!(lists.respond_allowlist.is_empty());
}
#[test]
fn load_workspace_lists_reads_all_three_files_when_present() {
let tempdir = tempfile::tempdir().expect("tempdir");
let ee_dir = tempdir.path().join(".ee");
std::fs::create_dir(&ee_dir).expect("mkdir");
for (name, key) in [
(DISCOVERY_ALLOWLIST_FILE, NODE_KEY_A),
(DISCOVERY_DENYLIST_FILE, NODE_KEY_B),
(RESPOND_ALLOWLIST_FILE, NODE_KEY_C),
] {
let path = ee_dir.join(name);
std::fs::write(&path, format!("node_keys = [\"{key}\"]\n")).expect("write");
}
let lists = load_workspace_lists(tempdir.path()).expect("ok");
assert!(lists.allowlist.contains(NODE_KEY_A));
assert!(lists.denylist.contains(NODE_KEY_B));
assert!(lists.respond_allowlist.contains(NODE_KEY_C));
}
#[test]
fn evaluate_policy_degradations_flags_missing_service_tag() {
let degradations = evaluate_policy_degradations(
DiscoveryMode::AutoAdmit,
DiscoveryMode::ServiceTag,
&[],
&empty_set(),
);
assert!(
degradations
.iter()
.any(|d| d.code == DISCOVERY_POLICY_NO_EE_MESH_TAG_CODE)
);
}
#[test]
fn evaluate_policy_degradations_does_not_flag_when_tag_advertised() {
let tags = vec![EE_MESH_SERVICE_TAG.to_owned()];
let degradations = evaluate_policy_degradations(
DiscoveryMode::AutoAdmit,
DiscoveryMode::ServiceTag,
&tags,
&empty_set(),
);
assert!(
degradations
.iter()
.all(|d| d.code != DISCOVERY_POLICY_NO_EE_MESH_TAG_CODE)
);
}
#[test]
fn evaluate_policy_degradations_flags_empty_allowlist() {
let degradations = evaluate_policy_degradations(
DiscoveryMode::Allowlist,
DiscoveryMode::AutoAdmit,
&[],
&empty_set(),
);
assert!(
degradations
.iter()
.any(|d| d.code == DISCOVERY_POLICY_EMPTY_ALLOWLIST_CODE)
);
let empty_allowlist = degradations
.iter()
.find(|d| d.code == DISCOVERY_POLICY_EMPTY_ALLOWLIST_CODE)
.expect("empty allowlist degradation");
assert_eq!(
empty_allowlist.repair,
"ee mesh discovery-policy allow --help"
);
assert!(
!empty_allowlist.repair.contains('<') && !empty_allowlist.repair.contains('>'),
"repair hint must not expose an unresolved metavariable: {}",
empty_allowlist.repair
);
}
#[test]
fn evaluate_policy_degradations_returns_empty_when_well_formed() {
let tags = vec![EE_MESH_SERVICE_TAG.to_owned()];
let allow = set_of(&["nodekey:friend"]);
let degradations = evaluate_policy_degradations(
DiscoveryMode::Allowlist,
DiscoveryMode::ServiceTag,
&tags,
&allow,
);
assert!(degradations.is_empty());
}
#[test]
fn evaluate_policy_degradations_can_return_both_codes_simultaneously() {
let degradations = evaluate_policy_degradations(
DiscoveryMode::Allowlist,
DiscoveryMode::ServiceTag,
&[],
&empty_set(),
);
assert_eq!(degradations.len(), 2);
}
}