Files
panda-rpki/crates/panda-rpki-validator/src/ta_constraints.rs
yuyr 15c8de82ba
Some checks failed
ci / rust (push) Has been cancelled
ci / docker-runtime (push) Has been cancelled
freeze initial panda-rpki staging
2026-09-01 12:13:53 +08:00

1140 lines
39 KiB
Rust

//! Locally configured constraints for the resources carried by RPKI EE
//! certificates. The configuration format follows
//! draft-ietf-sidrops-constraining-rpki-trust-anchors.
use std::collections::{BTreeMap, BTreeSet};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use sha2::Digest;
use crate::data_model::rc::{
Afi, AsIdOrRange, AsIdentifierChoice, IpAddressChoice, IpAddressOrRange, IpResourceSet,
ResourceCertificate,
};
use crate::parallel::types::{TalInputSpec, TalSource};
const LINEAR_INTERVAL_THRESHOLD: usize = 10;
/// A normalized rule set with a small-set linear path and a large-set tree path.
///
/// The parser constructs this only after sorting and merging overlapping or
/// adjacent intervals. The tree lookup therefore only needs to inspect the
/// predecessor of a target interval; normalized intervals are disjoint and
/// sorted by their lower bound.
#[derive(Clone)]
struct IntervalIndex<I> {
rules: Vec<I>,
lookup: IntervalLookup,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum IntervalLookup {
Linear,
Tree(BTreeMap<u128, u128>),
}
impl<I: std::fmt::Debug> std::fmt::Debug for IntervalIndex<I> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Keep derived acceleration data out of Debug so diagnostics continue
// to describe canonical rules rather than their index layout.
self.rules.fmt(formatter)
}
}
impl<I: PartialEq> PartialEq for IntervalIndex<I> {
fn eq(&self, other: &Self) -> bool {
self.rules == other.rules
}
}
impl<I: Eq> Eq for IntervalIndex<I> {}
impl<I> Deref for IntervalIndex<I> {
type Target = [I];
fn deref(&self) -> &Self::Target {
&self.rules
}
}
trait IntervalValue {
fn start(&self) -> u128;
fn end(&self) -> u128;
fn overlaps(&self, other: &Self) -> bool;
}
impl<I: IntervalValue> IntervalIndex<I> {
fn new(rules: Vec<I>) -> Self {
debug_assert!(
rules
.windows(2)
.all(|window| { window[0].end().saturating_add(1) < window[1].start() })
);
let lookup = if rules.len() > LINEAR_INTERVAL_THRESHOLD {
let tree = rules
.iter()
.map(|rule| (rule.start(), rule.end()))
.collect();
IntervalLookup::Tree(tree)
} else {
IntervalLookup::Linear
};
Self { rules, lookup }
}
fn any_overlaps(&self, target: &I) -> bool {
match &self.lookup {
IntervalLookup::Linear => self.rules.iter().any(|entry| entry.overlaps(target)),
IntervalLookup::Tree(tree) => tree
.range(..=target.end())
.next_back()
.map(|(_, end)| *end >= target.start())
.unwrap_or(false),
}
}
fn fully_covers(&self, target: &I) -> bool {
match &self.lookup {
IntervalLookup::Linear => self.fully_covers_linear(target),
IntervalLookup::Tree(tree) => tree
.range(..=target.start())
.next_back()
.map(|(_, end)| *end >= target.end())
.unwrap_or(false),
}
}
fn fully_covers_linear(&self, target: &I) -> bool {
let mut cursor = target.start();
for entry in &self.rules {
if entry.end() < cursor {
continue;
}
if entry.start() > cursor {
return false;
}
if entry.end() >= target.end() {
return true;
}
cursor = entry.end().saturating_add(1);
}
false
}
#[cfg(test)]
fn uses_tree(&self) -> bool {
matches!(self.lookup, IntervalLookup::Tree(_))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaConstraintsByTal {
by_tal_id: BTreeMap<String, std::sync::Arc<TaConstraints>>,
/// Stable semantic digest of the normalized rules. This is computed once
/// while loading the run policy so cache identity checks do not serialize
/// every rule for every publication point.
fingerprint: [u8; 32],
}
impl TaConstraintsByTal {
pub fn load_for_tals(
tal_inputs: &[TalInputSpec],
explicit_specs: &[String],
) -> Result<Self, String> {
let tal_ids = tal_inputs
.iter()
.map(|input| input.tal_id.as_str())
.collect::<BTreeSet<_>>();
let mut explicit_paths = BTreeMap::<String, PathBuf>::new();
for spec in explicit_specs {
let (tal_id, path) = spec
.split_once('=')
.ok_or_else(|| format!("--ta-constraints expects <tal-id>=<path>, got '{spec}'"))?;
let tal_id = tal_id.trim();
let path = path.trim();
if tal_id.is_empty() || path.is_empty() {
return Err(format!(
"--ta-constraints expects non-empty <tal-id>=<path>, got '{spec}'"
));
}
if !tal_ids.contains(tal_id) {
return Err(format!(
"--ta-constraints references unknown TAL id '{tal_id}'"
));
}
if explicit_paths
.insert(tal_id.to_string(), PathBuf::from(path))
.is_some()
{
return Err(format!(
"--ta-constraints specifies TAL id '{tal_id}' more than once"
));
}
}
let mut by_tal_id = BTreeMap::new();
for input in tal_inputs {
let path = explicit_paths
.get(&input.tal_id)
.cloned()
.or_else(|| adjacent_constraints_path(&input.source).filter(|path| path.is_file()));
if let Some(path) = path {
let constraints = TaConstraints::from_file(&path).map_err(|error| {
format!(
"load TA constraints for '{}' from {} failed: {error}",
input.tal_id,
path.display()
)
})?;
by_tal_id.insert(input.tal_id.clone(), std::sync::Arc::new(constraints));
}
}
let fingerprint = constraints_fingerprint(&by_tal_id);
Ok(Self {
by_tal_id,
fingerprint,
})
}
pub fn for_tal(&self, tal_id: &str) -> Option<&TaConstraints> {
self.by_tal_id.get(tal_id).map(std::sync::Arc::as_ref)
}
/// Return the immutable, process-local snapshot for a TAL without cloning
/// the rule trees. Phase-2 object workers own this `Arc` in their task
/// payload, while scoped stage workers borrow the same policy map.
pub(crate) fn shared_for_tal(&self, tal_id: &str) -> Option<std::sync::Arc<TaConstraints>> {
self.by_tal_id.get(tal_id).cloned()
}
pub fn is_empty(&self) -> bool {
self.by_tal_id.is_empty()
}
pub fn configuration_warnings(&self) -> Vec<String> {
self.by_tal_id
.iter()
.flat_map(|(tal_id, constraints)| {
constraints.warnings().iter().map(move |warning| {
format!(
"TA constraints for TAL '{tal_id}' ({}): {warning}",
constraints.source().display()
)
})
})
.collect()
}
/// Return the precomputed SHA-256 digest of the normalized semantic rules.
///
/// The returned bytes are stable across source paths and derived index
/// layouts, and are intentionally borrowed so hot-path cache lookups do
/// not allocate.
pub fn fingerprint_bytes(&self) -> &[u8] {
&self.fingerprint
}
pub fn fingerprint_sha256_hex(&self) -> String {
hex::encode(self.fingerprint)
}
}
impl Default for TaConstraintsByTal {
fn default() -> Self {
let by_tal_id = BTreeMap::new();
let fingerprint = constraints_fingerprint(&by_tal_id);
Self {
by_tal_id,
fingerprint,
}
}
}
const CONSTRAINTS_FINGERPRINT_VERSION: &[u8] = b"ta-constraints-semantic-v1";
fn constraints_fingerprint(
by_tal_id: &BTreeMap<String, std::sync::Arc<TaConstraints>>,
) -> [u8; 32] {
let mut hasher = sha2::Sha256::new();
hasher.update(CONSTRAINTS_FINGERPRINT_VERSION);
hasher.update((by_tal_id.len() as u64).to_be_bytes());
for (tal_id, constraints) in by_tal_id {
update_length_prefixed(&mut hasher, tal_id.as_bytes());
update_ip_intervals(&mut hasher, b"allow-v4", &constraints.allow_v4);
update_ip_intervals(&mut hasher, b"deny-v4", &constraints.deny_v4);
update_ip_intervals(&mut hasher, b"allow-v6", &constraints.allow_v6);
update_ip_intervals(&mut hasher, b"deny-v6", &constraints.deny_v6);
update_as_intervals(&mut hasher, b"allow-asn", &constraints.allow_asn);
update_as_intervals(&mut hasher, b"deny-asn", &constraints.deny_asn);
}
let digest = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}
fn update_length_prefixed(hasher: &mut sha2::Sha256, value: &[u8]) {
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value);
}
fn update_ip_intervals(
hasher: &mut sha2::Sha256,
label: &[u8],
intervals: &IntervalIndex<IpInterval>,
) {
update_length_prefixed(hasher, label);
hasher.update((intervals.rules.len() as u64).to_be_bytes());
for interval in &intervals.rules {
hasher.update([match interval.afi {
Afi::Ipv4 => 4,
Afi::Ipv6 => 6,
}]);
hasher.update(interval.min.to_be_bytes());
hasher.update(interval.max.to_be_bytes());
}
}
fn update_as_intervals(
hasher: &mut sha2::Sha256,
label: &[u8],
intervals: &IntervalIndex<AsInterval>,
) {
update_length_prefixed(hasher, label);
hasher.update((intervals.rules.len() as u64).to_be_bytes());
for interval in &intervals.rules {
hasher.update(interval.min.to_be_bytes());
hasher.update(interval.max.to_be_bytes());
}
}
fn adjacent_constraints_path(source: &TalSource) -> Option<PathBuf> {
match source {
TalSource::FilePath(path) => Some(path.with_extension("constraints")),
TalSource::FilePathWithTa { tal_path, .. } => Some(tal_path.with_extension("constraints")),
TalSource::Url(_) | TalSource::DerBytes { .. } => None,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaConstraints {
source: PathBuf,
allow_v4: IntervalIndex<IpInterval>,
deny_v4: IntervalIndex<IpInterval>,
allow_v6: IntervalIndex<IpInterval>,
deny_v6: IntervalIndex<IpInterval>,
allow_asn: IntervalIndex<AsInterval>,
deny_asn: IntervalIndex<AsInterval>,
warnings: Vec<String>,
}
impl TaConstraints {
pub fn from_file(path: &Path) -> Result<Self, String> {
let contents = std::fs::read_to_string(path)
.map_err(|error| format!("read {}: {error}", path.display()))?;
Self::parse(path.to_path_buf(), &contents)
}
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub fn source(&self) -> &Path {
&self.source
}
pub fn validate_ee_certificate(
&self,
certificate: &ResourceCertificate,
) -> Result<(), TaConstraintsViolation> {
if let Some(ip_resources) = certificate.tbs.extensions.ip_resources.as_ref() {
self.validate_ip_resources(ip_resources)?;
}
if let Some(as_resources) = certificate.tbs.extensions.as_resources.as_ref() {
self.validate_as_choice("AS", as_resources.asnum.as_ref())?;
self.validate_as_choice("RDI", as_resources.rdi.as_ref())?;
}
Ok(())
}
fn parse(source: PathBuf, contents: &str) -> Result<Self, String> {
let mut allow_v4 = Vec::new();
let mut deny_v4 = Vec::new();
let mut allow_v6 = Vec::new();
let mut deny_v6 = Vec::new();
let mut allow_asn = Vec::new();
let mut deny_asn = Vec::new();
for (index, raw_line) in contents.lines().enumerate() {
let line_number = index + 1;
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut words = line.split_whitespace();
let action = words.next().expect("non-empty line has first word");
let resource = words.collect::<Vec<_>>().join(" ");
if resource.is_empty() {
return Err(format!(
"line {line_number}: missing resource after '{action}'"
));
}
let allow = match action {
"allow" => true,
"deny" => false,
_ => {
return Err(format!(
"line {line_number}: expected 'allow' or 'deny', got '{action}'"
));
}
};
if looks_like_ip_resource(&resource) {
let interval = parse_ip_interval(&resource).map_err(|error| {
format!("line {line_number}: invalid IP resource '{resource}': {error}")
})?;
match (interval.afi, allow) {
(Afi::Ipv4, true) => allow_v4.push(interval),
(Afi::Ipv4, false) => deny_v4.push(interval),
(Afi::Ipv6, true) => allow_v6.push(interval),
(Afi::Ipv6, false) => deny_v6.push(interval),
}
} else {
let interval = parse_as_interval(&resource).map_err(|error| {
format!("line {line_number}: invalid AS resource '{resource}': {error}")
})?;
if allow {
allow_asn.push(interval);
} else {
deny_asn.push(interval);
}
}
}
let mut warnings = Vec::new();
normalize_ip_intervals("allow IPv4", &mut allow_v4, &mut warnings);
normalize_ip_intervals("deny IPv4", &mut deny_v4, &mut warnings);
normalize_ip_intervals("allow IPv6", &mut allow_v6, &mut warnings);
normalize_ip_intervals("deny IPv6", &mut deny_v6, &mut warnings);
normalize_as_intervals("allow AS", &mut allow_asn, &mut warnings);
normalize_as_intervals("deny AS", &mut deny_asn, &mut warnings);
Ok(Self {
source,
allow_v4: IntervalIndex::new(allow_v4),
deny_v4: IntervalIndex::new(deny_v4),
allow_v6: IntervalIndex::new(allow_v6),
deny_v6: IntervalIndex::new(deny_v6),
allow_asn: IntervalIndex::new(allow_asn),
deny_asn: IntervalIndex::new(deny_asn),
warnings,
})
}
fn validate_ip_resources(
&self,
resources: &IpResourceSet,
) -> Result<(), TaConstraintsViolation> {
for family in &resources.families {
let items = match &family.choice {
// Constraints apply to explicit INR listings. EE profiles for
// constrained signed objects already reject inappropriate inherit.
IpAddressChoice::Inherit => continue,
IpAddressChoice::AddressesOrRanges(items) => items,
};
let (allow, deny) = match family.afi {
Afi::Ipv4 => (&self.allow_v4, &self.deny_v4),
Afi::Ipv6 => (&self.allow_v6, &self.deny_v6),
};
for item in items {
let interval = ip_item_to_interval(family.afi, item)?;
if deny.any_overlaps(&interval) {
return Err(TaConstraintsViolation(format!(
"{} {} intersects a deny rule in {}",
afi_name(family.afi),
interval,
self.source.display()
)));
}
if !allow.fully_covers(&interval) {
return Err(TaConstraintsViolation(format!(
"{} {} is not fully contained in allow rules in {}",
afi_name(family.afi),
interval,
self.source.display()
)));
}
}
}
Ok(())
}
fn validate_as_choice(
&self,
kind: &str,
choice: Option<&AsIdentifierChoice>,
) -> Result<(), TaConstraintsViolation> {
let Some(choice) = choice else {
return Ok(());
};
let items = match choice {
AsIdentifierChoice::Inherit => return Ok(()),
AsIdentifierChoice::AsIdsOrRanges(items) => items,
};
for item in items {
let interval = match item {
AsIdOrRange::Id(value) => AsInterval::new(*value, *value),
AsIdOrRange::Range { min, max } => AsInterval::new(*min, *max),
};
if self.deny_asn.any_overlaps(&interval) {
return Err(TaConstraintsViolation(format!(
"{kind} {interval} intersects a deny rule in {}",
self.source.display()
)));
}
if !self.allow_asn.fully_covers(&interval) {
return Err(TaConstraintsViolation(format!(
"{kind} {interval} is not fully contained in allow rules in {}",
self.source.display()
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaConstraintsViolation(pub String);
impl std::fmt::Display for TaConstraintsViolation {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
impl std::error::Error for TaConstraintsViolation {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct IpInterval {
afi: Afi,
min: u128,
max: u128,
}
impl IpInterval {
fn new(afi: Afi, min: u128, max: u128) -> Self {
Self { afi, min, max }
}
fn overlaps(&self, other: &Self) -> bool {
self.afi == other.afi && self.min <= other.max && other.min <= self.max
}
}
impl IntervalValue for IpInterval {
fn start(&self) -> u128 {
self.min
}
fn end(&self) -> u128 {
self.max
}
fn overlaps(&self, other: &Self) -> bool {
IpInterval::overlaps(self, other)
}
}
impl std::fmt::Display for IpInterval {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let min = u128_to_ip(self.afi, self.min);
let max = u128_to_ip(self.afi, self.max);
if min == max {
write!(formatter, "{min}")
} else {
write!(formatter, "{min} - {max}")
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct AsInterval {
min: u32,
max: u32,
}
impl AsInterval {
fn new(min: u32, max: u32) -> Self {
Self { min, max }
}
fn overlaps(&self, other: &Self) -> bool {
self.min <= other.max && other.min <= self.max
}
}
impl IntervalValue for AsInterval {
fn start(&self) -> u128 {
self.min.into()
}
fn end(&self) -> u128 {
self.max.into()
}
fn overlaps(&self, other: &Self) -> bool {
AsInterval::overlaps(self, other)
}
}
impl std::fmt::Display for AsInterval {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.min == self.max {
write!(formatter, "{}", self.min)
} else {
write!(formatter, "{} - {}", self.min, self.max)
}
}
}
fn looks_like_ip_resource(resource: &str) -> bool {
resource.contains('.') || resource.contains(':') || resource.contains('/')
}
fn parse_ip_interval(resource: &str) -> Result<IpInterval, String> {
if let Some((raw_min, raw_max)) = resource.split_once('-') {
let min: IpAddr = raw_min.trim().parse().map_err(|_| "invalid range start")?;
let max: IpAddr = raw_max.trim().parse().map_err(|_| "invalid range end")?;
let (afi, min) = ip_to_u128(min);
let (max_afi, max) = ip_to_u128(max);
if afi != max_afi {
return Err("range endpoints use different address families".to_string());
}
if min > max {
return Err("range start is greater than range end".to_string());
}
return Ok(IpInterval::new(afi, min, max));
}
let (raw_address, raw_prefix_len) = resource
.split_once('/')
.ok_or_else(|| "expected CIDR prefix or range".to_string())?;
let address: IpAddr = raw_address.trim().parse().map_err(|_| "invalid address")?;
let prefix_len: u16 = raw_prefix_len
.trim()
.parse()
.map_err(|_| "invalid prefix length")?;
let (afi, address) = ip_to_u128(address);
let width = match afi {
Afi::Ipv4 => 32,
Afi::Ipv6 => 128,
};
if prefix_len > width {
return Err(format!("prefix length must be <= {width}"));
}
let host_bits = width - prefix_len;
let mask = if prefix_len == 0 {
0
} else {
width_mask(width) << host_bits
};
let min = address & mask;
let max = min | (!mask & width_mask(width));
Ok(IpInterval::new(afi, min, max))
}
fn parse_as_interval(resource: &str) -> Result<AsInterval, String> {
let parse_asn = |raw: &str| -> Result<u32, String> {
raw.trim()
.strip_prefix("AS")
.or_else(|| raw.trim().strip_prefix("as"))
.unwrap_or(raw.trim())
.parse::<u32>()
.map_err(|_| "expected an ASN in the range 0..4294967295".to_string())
};
if let Some((raw_min, raw_max)) = resource.split_once('-') {
let min = parse_asn(raw_min)?;
let max = parse_asn(raw_max)?;
if min > max {
return Err("range start is greater than range end".to_string());
}
Ok(AsInterval::new(min, max))
} else {
let value = parse_asn(resource)?;
Ok(AsInterval::new(value, value))
}
}
fn normalize_ip_intervals(label: &str, entries: &mut Vec<IpInterval>, warnings: &mut Vec<String>) {
entries.sort_by_key(|entry| (entry.min, entry.max));
let mut normalized = Vec::with_capacity(entries.len());
for entry in entries.drain(..) {
let Some(last) = normalized.last_mut() else {
normalized.push(entry);
continue;
};
if entry.min <= last.max {
warnings.push(format!(
"TA constraints {label} rules overlap; normalized without blocking startup"
));
last.max = last.max.max(entry.max);
} else if entry.min == last.max.saturating_add(1) {
last.max = entry.max;
} else {
normalized.push(entry);
}
}
warnings.sort();
warnings.dedup();
*entries = normalized;
}
fn normalize_as_intervals(label: &str, entries: &mut Vec<AsInterval>, warnings: &mut Vec<String>) {
entries.sort_by_key(|entry| (entry.min, entry.max));
let mut normalized = Vec::with_capacity(entries.len());
for entry in entries.drain(..) {
let Some(last) = normalized.last_mut() else {
normalized.push(entry);
continue;
};
if entry.min <= last.max {
warnings.push(format!(
"TA constraints {label} rules overlap; normalized without blocking startup"
));
last.max = last.max.max(entry.max);
} else if entry.min == last.max.saturating_add(1) {
last.max = entry.max;
} else {
normalized.push(entry);
}
}
warnings.sort();
warnings.dedup();
*entries = normalized;
}
fn ip_item_to_interval(
afi: Afi,
item: &IpAddressOrRange,
) -> Result<IpInterval, TaConstraintsViolation> {
let (min, max) = match item {
IpAddressOrRange::Prefix(prefix) => {
let width = prefix.afi.ub();
let address = ip_bytes_to_u128(&prefix.addr);
let prefix_len = prefix.prefix_len.min(width);
let host_bits = width - prefix_len;
let mask = if prefix_len == 0 {
0
} else {
width_mask(width) << host_bits
};
let min = address & mask;
(min, min | (!mask & width_mask(width)))
}
IpAddressOrRange::Range(range) => {
(ip_bytes_to_u128(&range.min), ip_bytes_to_u128(&range.max))
}
};
if min > max {
return Err(TaConstraintsViolation(
"EE certificate carries an invalid IP range".to_string(),
));
}
Ok(IpInterval::new(afi, min, max))
}
fn ip_to_u128(address: IpAddr) -> (Afi, u128) {
match address {
IpAddr::V4(address) => (Afi::Ipv4, u32::from(address) as u128),
IpAddr::V6(address) => (Afi::Ipv6, u128::from(address)),
}
}
fn ip_bytes_to_u128(bytes: &[u8]) -> u128 {
bytes
.iter()
.fold(0u128, |value, byte| (value << 8) | u128::from(*byte))
}
fn width_mask(width: u16) -> u128 {
if width == 128 {
u128::MAX
} else {
(1u128 << width) - 1
}
}
fn u128_to_ip(afi: Afi, value: u128) -> IpAddr {
match afi {
Afi::Ipv4 => IpAddr::V4(Ipv4Addr::from(value as u32)),
Afi::Ipv6 => IpAddr::V6(Ipv6Addr::from(value)),
}
}
fn afi_name(afi: Afi) -> &'static str {
match afi {
Afi::Ipv4 => "IPv4",
Afi::Ipv6 => "IPv6",
}
}
#[cfg(test)]
mod tests {
use super::{IntervalIndex, IntervalLookup, TaConstraints, TaConstraintsByTal};
fn parse(body: &str) -> TaConstraints {
TaConstraints::parse("test.constraints".into(), body).expect("parse constraints")
}
fn indexed_ipv4_rules(action: &str, count: usize) -> String {
(0..count)
.map(|index| format!("{action} 10.{}.0.0/16\n", index * 2))
.collect()
}
fn indexed_ipv6_rules(action: &str, count: usize) -> String {
(0..count)
.map(|index| format!("{action} 2001:db8:{}::/48\n", index * 2))
.collect()
}
fn indexed_asn_rules(action: &str, count: usize) -> String {
(0..count)
.map(|index| format!("{action} {}\n", 65000 + index * 2))
.collect()
}
#[test]
fn deny_has_precedence_and_unlisted_resources_are_denied() {
let constraints = parse("allow 192.0.2.0/24\ndeny 192.0.2.128/25\n");
assert!(
constraints
.allow_v4
.fully_covers(&super::parse_ip_interval("192.0.2.0/25").unwrap())
);
assert!(
constraints
.deny_v4
.iter()
.any(|entry| entry.overlaps(&super::parse_ip_interval("192.0.2.128/25").unwrap()))
);
assert!(
!constraints
.allow_v4
.fully_covers(&super::parse_ip_interval("198.51.100.0/24").unwrap())
);
}
#[test]
fn same_list_overlap_warns_and_normalizes() {
let constraints = parse("allow 10.0.0.0/8\nallow 10.1.0.0/16\n");
assert_eq!(constraints.allow_v4.len(), 1);
assert_eq!(constraints.warnings.len(), 1);
assert!(constraints.warnings[0].contains("overlap"));
}
#[test]
fn adjacent_allow_ranges_cover_one_interval() {
let constraints = parse("allow 64496 - 64500\nallow 64501 - 64511\n");
assert!(
constraints
.allow_asn
.fully_covers(&super::parse_as_interval("64496 - 64511").unwrap())
);
}
#[test]
fn interval_index_uses_linear_for_at_most_ten_and_tree_above_ten() {
for count in [0, 1, 9, 10] {
let constraints = parse(&indexed_ipv4_rules("deny", count));
assert_eq!(constraints.deny_v4.len(), count);
assert!(!constraints.deny_v4.uses_tree(), "count={count}");
}
let constraints = parse(&indexed_ipv4_rules("deny", 11));
assert_eq!(constraints.deny_v4.len(), 11);
assert!(constraints.deny_v4.uses_tree());
}
#[test]
fn tree_ip_queries_match_linear_reference() {
let body = format!(
"{}{}",
indexed_ipv4_rules("allow", 11),
indexed_ipv4_rules("deny", 11)
);
let constraints = parse(&body);
assert!(constraints.allow_v4.uses_tree());
assert!(constraints.deny_v4.uses_tree());
let linear_allow = IntervalIndex {
rules: constraints.allow_v4.rules.clone(),
lookup: IntervalLookup::Linear,
};
let linear_deny = IntervalIndex {
rules: constraints.deny_v4.rules.clone(),
lookup: IntervalLookup::Linear,
};
let targets = [
super::parse_ip_interval("10.0.0.0/16").unwrap(),
super::parse_ip_interval("10.0.1.0/24").unwrap(),
super::parse_ip_interval("10.1.0.0/16").unwrap(),
super::parse_ip_interval("10.20.0.0/16").unwrap(),
super::parse_ip_interval("10.21.0.0/16").unwrap(),
super::parse_ip_interval("10.0.0.0 - 10.2.255.255").unwrap(),
super::parse_ip_interval("9.0.0.0/8").unwrap(),
super::parse_ip_interval("11.0.0.0/8").unwrap(),
];
for target in targets {
assert_eq!(
constraints.allow_v4.any_overlaps(&target),
linear_allow.any_overlaps(&target),
"allow overlap for {target}"
);
assert_eq!(
constraints.allow_v4.fully_covers(&target),
linear_allow.fully_covers(&target),
"allow coverage for {target}"
);
assert_eq!(
constraints.deny_v4.any_overlaps(&target),
linear_deny.any_overlaps(&target),
"deny overlap for {target}"
);
}
}
#[test]
fn tree_ipv6_queries_match_linear_reference() {
let body = format!(
"{}{}",
indexed_ipv6_rules("allow", 11),
indexed_ipv6_rules("deny", 11)
);
let constraints = parse(&body);
assert!(constraints.allow_v6.uses_tree());
assert!(constraints.deny_v6.uses_tree());
let linear_allow = IntervalIndex {
rules: constraints.allow_v6.rules.clone(),
lookup: IntervalLookup::Linear,
};
let linear_deny = IntervalIndex {
rules: constraints.deny_v6.rules.clone(),
lookup: IntervalLookup::Linear,
};
let targets = [
super::parse_ip_interval("2001:db8:0::/48").unwrap(),
super::parse_ip_interval("2001:db8:1::/64").unwrap(),
super::parse_ip_interval("2001:db8:2::/48").unwrap(),
super::parse_ip_interval("2001:db8:20::/48").unwrap(),
super::parse_ip_interval("2001:db8:21::/48").unwrap(),
super::parse_ip_interval("2001:db8:0:: - 2001:db8:2:ffff:ffff:ffff:ffff:ffff").unwrap(),
super::parse_ip_interval("2001:db7::/32").unwrap(),
super::parse_ip_interval("2001:db9::/32").unwrap(),
];
for target in targets {
assert_eq!(
constraints.allow_v6.any_overlaps(&target),
linear_allow.any_overlaps(&target),
"allow overlap for {target}"
);
assert_eq!(
constraints.allow_v6.fully_covers(&target),
linear_allow.fully_covers(&target),
"allow coverage for {target}"
);
assert_eq!(
constraints.deny_v6.any_overlaps(&target),
linear_deny.any_overlaps(&target),
"deny overlap for {target}"
);
}
}
#[test]
fn tree_asn_queries_match_linear_reference() {
let body = format!(
"{}{}",
indexed_asn_rules("allow", 11),
indexed_asn_rules("deny", 11)
);
let constraints = parse(&body);
assert!(constraints.allow_asn.uses_tree());
assert!(constraints.deny_asn.uses_tree());
let linear_allow = IntervalIndex {
rules: constraints.allow_asn.rules.clone(),
lookup: IntervalLookup::Linear,
};
let linear_deny = IntervalIndex {
rules: constraints.deny_asn.rules.clone(),
lookup: IntervalLookup::Linear,
};
let targets = [
super::AsInterval::new(65000, 65000),
super::AsInterval::new(65001, 65001),
super::AsInterval::new(65000, 65002),
super::AsInterval::new(65020, 65020),
super::AsInterval::new(65021, 65021),
super::AsInterval::new(64999, 65000),
];
for target in targets {
assert_eq!(
constraints.allow_asn.any_overlaps(&target),
linear_allow.any_overlaps(&target),
"allow overlap for {target}"
);
assert_eq!(
constraints.allow_asn.fully_covers(&target),
linear_allow.fully_covers(&target),
"allow coverage for {target}"
);
assert_eq!(
constraints.deny_asn.any_overlaps(&target),
linear_deny.any_overlaps(&target),
"deny overlap for {target}"
);
}
}
#[test]
fn index_equality_and_debug_ignore_derived_lookup() {
let constraints = parse(&indexed_ipv4_rules("deny", 11));
let tree = constraints.deny_v4.clone();
let linear = IntervalIndex {
rules: tree.rules.clone(),
lookup: IntervalLookup::Linear,
};
assert_eq!(tree, linear);
assert_eq!(format!("{tree:?}"), format!("{linear:?}"));
}
#[test]
fn adjacent_file_is_discovered_by_tal_stem() {
let dir = tempfile::tempdir().expect("tmpdir");
let tal_path = dir.path().join("example.tal");
std::fs::write(&tal_path, "placeholder").expect("write TAL");
std::fs::write(
tal_path.with_extension("constraints"),
"allow 192.0.2.0/24\n",
)
.expect("write constraints");
let inputs = vec![crate::parallel::types::TalInputSpec::from_file_path(
tal_path,
)];
let loaded = TaConstraintsByTal::load_for_tals(&inputs, &[]).expect("load constraints");
assert!(loaded.for_tal("example").is_some());
let first = loaded
.shared_for_tal("example")
.expect("shared constraints snapshot");
let second = loaded
.shared_for_tal("example")
.expect("shared constraints snapshot");
assert!(std::sync::Arc::ptr_eq(&first, &second));
}
#[test]
fn shared_snapshots_remain_tal_specific() {
let dir = tempfile::tempdir().expect("tmpdir");
let tal_a = dir.path().join("alpha.tal");
let tal_b = dir.path().join("bravo.tal");
std::fs::write(&tal_a, "placeholder").expect("write alpha TAL");
std::fs::write(&tal_b, "placeholder").expect("write bravo TAL");
std::fs::write(tal_a.with_extension("constraints"), "allow 192.0.2.0/24\n")
.expect("write alpha constraints");
std::fs::write(
tal_b.with_extension("constraints"),
"allow 198.51.100.0/24\n",
)
.expect("write bravo constraints");
let inputs = vec![
crate::parallel::types::TalInputSpec::from_file_path(tal_a),
crate::parallel::types::TalInputSpec::from_file_path(tal_b),
];
let loaded = TaConstraintsByTal::load_for_tals(&inputs, &[]).expect("load constraints");
let alpha = loaded.shared_for_tal("alpha").expect("alpha snapshot");
let bravo = loaded.shared_for_tal("bravo").expect("bravo snapshot");
assert!(!std::sync::Arc::ptr_eq(&alpha, &bravo));
assert_ne!(alpha.as_ref(), bravo.as_ref());
}
#[test]
fn semantic_fingerprint_is_cached_and_ignores_source_path() {
let first_dir = tempfile::tempdir().expect("first tmpdir");
let second_dir = tempfile::tempdir().expect("second tmpdir");
let first_tal = first_dir.path().join("example.tal");
let second_tal = second_dir.path().join("example.tal");
for tal_path in [&first_tal, &second_tal] {
std::fs::write(tal_path, "placeholder").expect("write TAL");
std::fs::write(
tal_path.with_extension("constraints"),
"allow 192.0.2.0/24\ndeny 192.0.2.128/25\n",
)
.expect("write constraints");
}
let first = TaConstraintsByTal::load_for_tals(
&[crate::parallel::types::TalInputSpec::from_file_path(
first_tal,
)],
&[],
)
.expect("load first constraints");
let second = TaConstraintsByTal::load_for_tals(
&[crate::parallel::types::TalInputSpec::from_file_path(
second_tal,
)],
&[],
)
.expect("load second constraints");
assert_eq!(first.fingerprint_bytes().len(), 32);
assert_eq!(first.fingerprint_bytes(), second.fingerprint_bytes());
assert_eq!(first.fingerprint_sha256_hex().len(), 64);
}
#[test]
fn full_afrinic_ipv4_fixture_is_parseable() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints");
let raw = std::fs::read_to_string(&path).expect("read full AFRINIC fixture");
let source_ipv4_rules = raw
.lines()
.filter(|line| line.trim_start().starts_with("deny "))
.count();
assert_eq!(source_ipv4_rules, 383);
let constraints = TaConstraints::from_file(&path).expect("parse full AFRINIC fixture");
assert_eq!(constraints.allow_v4.len(), 1);
assert_eq!(constraints.allow_v6.len(), 1);
assert_eq!(constraints.allow_asn.len(), 1);
assert!(!constraints.deny_v4.is_empty());
}
#[test]
fn current_afrinic_ipv4_fixture_is_parseable_and_canonical() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints");
let raw = std::fs::read_to_string(&path).expect("read current AFRINIC fixture");
let source_ipv4_rules = raw
.lines()
.filter(|line| line.trim_start().starts_with("deny "))
.count();
assert_eq!(source_ipv4_rules, 1825);
let constraints = TaConstraints::from_file(&path).expect("parse current AFRINIC fixture");
assert_eq!(constraints.allow_v4.len(), 1);
assert_eq!(constraints.allow_v6.len(), 1);
assert_eq!(constraints.allow_asn.len(), 1);
assert_eq!(constraints.deny_v4.len(), 815);
assert!(constraints.deny_v4.uses_tree());
assert!(!constraints.allow_v4.uses_tree());
assert!(!constraints.allow_v6.uses_tree());
assert!(!constraints.allow_asn.uses_tree());
assert!(constraints.warnings.is_empty());
}
}