1use crate::value::VmDictExt;
20use std::collections::BTreeMap;
21use std::net::IpAddr;
22use std::str::FromStr;
23use std::sync::Arc;
24
25use ipnet::IpNet;
26use serde_json::json;
27use url::Url;
28
29use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
30use crate::value::{VmClosure, VmError, VmValue};
31
32pub const NET_POLICY_AUDIT_TOPIC: &str = "harness.net.policy.audit";
34
35pub const HARN_NET_POLICY_BYPASS_ENV: &str = "HARN_NET_POLICY_BYPASS";
38
39#[derive(Clone, Debug)]
41pub struct NetPolicyRule {
42 pub raw: String,
43 pub matcher: NetMatcher,
44 pub ports: Option<Vec<u16>>,
45}
46
47#[derive(Clone, Debug)]
48pub enum NetMatcher {
49 Host(String),
52 Suffix(String),
54 Ip(IpAddr),
56 Cidr(IpNet),
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum NetPolicyDefault {
63 Allow,
64 Deny,
65}
66
67impl NetPolicyDefault {
68 pub fn as_str(self) -> &'static str {
69 match self {
70 NetPolicyDefault::Allow => "allow",
71 NetPolicyDefault::Deny => "deny",
72 }
73 }
74
75 pub fn parse(raw: &str) -> Result<Self, VmError> {
76 match raw.trim().to_ascii_lowercase().as_str() {
77 "allow" => Ok(NetPolicyDefault::Allow),
78 "" | "deny" => Ok(NetPolicyDefault::Deny),
79 other => Err(vm_error(format!(
80 "NetPolicy.create: default must be `allow` or `deny`, got `{other}`"
81 ))),
82 }
83 }
84}
85
86#[derive(Clone, Debug)]
89pub enum OnViolation {
90 Error,
92 AuditOnly,
95 Quarantine,
99 Callback(Arc<VmClosure>),
103}
104
105impl OnViolation {
106 pub fn parse_str(raw: &str) -> Result<Self, VmError> {
107 match raw.trim() {
108 "error" => Ok(OnViolation::Error),
109 "audit_only" => Ok(OnViolation::AuditOnly),
110 "quarantine" => Ok(OnViolation::Quarantine),
111 other => Err(vm_error(format!(
112 "NetPolicy.create: on_violation must be one of `error`, `audit_only`, `quarantine`, or a callback, got `{other}`"
113 ))),
114 }
115 }
116}
117
118#[derive(Clone, Debug)]
120pub struct NetPolicy {
121 pub allow: Arc<Vec<NetPolicyRule>>,
122 pub deny: Arc<Vec<NetPolicyRule>>,
123 pub default: NetPolicyDefault,
124 pub on_violation: OnViolation,
125}
126
127#[derive(Clone, Debug)]
130pub enum NetPolicyDecision {
131 Allow {
134 audited: bool,
135 audit: Option<NetPolicyAudit>,
136 },
137 Deny {
140 audit: NetPolicyAudit,
141 quarantine: bool,
142 },
143}
144
145#[derive(Clone, Debug)]
147pub struct NetPolicyAudit {
148 pub method: String,
149 pub url: String,
150 pub host: String,
151 pub port: Option<u16>,
152 pub reason: String,
153 pub outcome: &'static str,
154 pub bypass: bool,
155 pub matched_rule: Option<String>,
156}
157
158impl NetPolicyAudit {
159 fn to_json(&self) -> serde_json::Value {
160 json!({
161 "method": self.method,
162 "url": self.url,
163 "host": self.host,
164 "port": self.port,
165 "reason": self.reason,
166 "outcome": self.outcome,
167 "bypass": self.bypass,
168 "matched_rule": self.matched_rule,
169 })
170 }
171}
172
173#[derive(Clone, Debug)]
174pub struct NetTarget {
175 pub host: String,
176 pub ip: Option<IpAddr>,
177 pub port: Option<u16>,
178}
179
180impl NetTarget {
181 pub fn parse(raw_url: &str) -> Result<Self, VmError> {
182 let parsed = Url::parse(raw_url)
183 .map_err(|error| vm_error(format!("harness.net: invalid URL `{raw_url}`: {error}")))?;
184 let host = parsed.host_str().ok_or_else(|| {
185 vm_error(format!(
186 "harness.net: URL `{raw_url}` does not include a host"
187 ))
188 })?;
189 let host = normalize_host(host);
190 let ip = IpAddr::from_str(&host).ok();
191 Ok(Self {
192 host,
193 ip,
194 port: parsed.port_or_known_default(),
195 })
196 }
197}
198
199impl NetPolicyRule {
200 pub fn parse_host(raw: &str, ports: Option<Vec<u16>>) -> Result<Self, VmError> {
201 let raw = raw.trim();
202 if raw.is_empty() {
203 return Err(vm_error("NetPolicy.host: empty host"));
204 }
205 let host = normalize_host(raw);
206 let matcher = if let Some(suffix) = host.strip_prefix("*.") {
207 if suffix.is_empty() {
208 return Err(vm_error(format!(
209 "NetPolicy.domain_wildcard: invalid wildcard `{raw}`"
210 )));
211 }
212 NetMatcher::Suffix(suffix.to_string())
213 } else if let Ok(ip) = IpAddr::from_str(&host) {
214 NetMatcher::Ip(ip)
215 } else {
216 NetMatcher::Host(host)
217 };
218 Ok(Self {
219 raw: raw.to_string(),
220 matcher,
221 ports,
222 })
223 }
224
225 pub fn parse_domain(raw: &str) -> Result<Self, VmError> {
226 Self::parse_host(raw, None)
227 }
228
229 pub fn parse_domain_wildcard(raw: &str) -> Result<Self, VmError> {
230 let trimmed = raw.trim();
231 if !trimmed.starts_with("*.") {
232 return Err(vm_error(format!(
233 "NetPolicy.domain_wildcard: pattern must start with `*.`, got `{raw}`"
234 )));
235 }
236 Self::parse_host(trimmed, None)
237 }
238
239 pub fn parse_cidr(raw: &str) -> Result<Self, VmError> {
240 let trimmed = raw.trim();
241 let net = IpNet::from_str(trimmed)
242 .map_err(|error| vm_error(format!("NetPolicy.cidr: invalid CIDR `{raw}`: {error}")))?;
243 Ok(Self {
244 raw: trimmed.to_string(),
245 matcher: NetMatcher::Cidr(net),
246 ports: None,
247 })
248 }
249
250 pub fn matches(&self, target: &NetTarget) -> bool {
251 if let Some(ports) = &self.ports {
252 match target.port {
253 Some(port) if ports.contains(&port) => {}
254 _ => return false,
255 }
256 }
257 match &self.matcher {
258 NetMatcher::Host(host) => target.host == *host,
259 NetMatcher::Suffix(suffix) => host_has_dns_suffix(&target.host, suffix),
260 NetMatcher::Ip(ip) => target.ip == Some(*ip),
261 NetMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
262 }
263 }
264}
265
266pub(crate) fn host_has_dns_suffix(host: &str, suffix: &str) -> bool {
271 host.len() > suffix.len()
272 && host.ends_with(suffix)
273 && host.as_bytes().get(host.len() - suffix.len() - 1) == Some(&b'.')
274}
275
276impl NetPolicy {
277 pub fn evaluate(&self, method: &str, raw_url: &str) -> Result<NetPolicyDecision, VmError> {
281 let target = NetTarget::parse(raw_url)?;
282 if let Some(rule) = self.deny.iter().find(|rule| rule.matches(&target)) {
283 return Ok(self.deny_decision(
284 method,
285 raw_url,
286 &target,
287 format!("matched deny rule `{}`", rule.raw),
288 Some(rule.raw.clone()),
289 ));
290 }
291 if let Some(rule) = self.allow.iter().find(|rule| rule.matches(&target)) {
292 return Ok(NetPolicyDecision::Allow {
293 audited: false,
294 audit: Some(NetPolicyAudit {
295 method: method.to_string(),
296 url: raw_url.to_string(),
297 host: target.host,
298 port: target.port,
299 reason: format!("matched allow rule `{}`", rule.raw),
300 outcome: "allow",
301 bypass: false,
302 matched_rule: Some(rule.raw.clone()),
303 }),
304 });
305 }
306 if self.default == NetPolicyDefault::Allow {
307 return Ok(NetPolicyDecision::Allow {
308 audited: false,
309 audit: None,
310 });
311 }
312 Ok(self.deny_decision(
313 method,
314 raw_url,
315 &target,
316 "no allow rule matched (default deny)".to_string(),
317 None,
318 ))
319 }
320
321 fn deny_decision(
322 &self,
323 method: &str,
324 raw_url: &str,
325 target: &NetTarget,
326 reason: String,
327 matched_rule: Option<String>,
328 ) -> NetPolicyDecision {
329 match &self.on_violation {
330 OnViolation::Error => NetPolicyDecision::Deny {
331 audit: NetPolicyAudit {
332 method: method.to_string(),
333 url: raw_url.to_string(),
334 host: target.host.clone(),
335 port: target.port,
336 reason,
337 outcome: "error",
338 bypass: false,
339 matched_rule,
340 },
341 quarantine: false,
342 },
343 OnViolation::AuditOnly => NetPolicyDecision::Allow {
344 audited: true,
345 audit: Some(NetPolicyAudit {
346 method: method.to_string(),
347 url: raw_url.to_string(),
348 host: target.host.clone(),
349 port: target.port,
350 reason,
351 outcome: "audit_only",
352 bypass: false,
353 matched_rule,
354 }),
355 },
356 OnViolation::Quarantine => NetPolicyDecision::Deny {
357 audit: NetPolicyAudit {
358 method: method.to_string(),
359 url: raw_url.to_string(),
360 host: target.host.clone(),
361 port: target.port,
362 reason,
363 outcome: "quarantine",
364 bypass: false,
365 matched_rule,
366 },
367 quarantine: true,
368 },
369 OnViolation::Callback(_) => NetPolicyDecision::Deny {
374 audit: NetPolicyAudit {
375 method: method.to_string(),
376 url: raw_url.to_string(),
377 host: target.host.clone(),
378 port: target.port,
379 reason,
380 outcome: "callback",
381 bypass: false,
382 matched_rule,
383 },
384 quarantine: false,
385 },
386 }
387 }
388}
389
390pub fn violation_vm_error(audit: &NetPolicyAudit) -> VmError {
394 let mut dict = BTreeMap::new();
395 dict.put_str("type", "NetPolicyViolation");
396 dict.put_str("category", "net_policy_violation");
397 dict.put_str(
398 "message",
399 format!(
400 "harness.net.{} blocked {}: {}",
401 audit.method, audit.url, audit.reason
402 ),
403 );
404 dict.put_str("method", audit.method.as_str());
405 dict.put_str("url", audit.url.as_str());
406 dict.put_str("host", audit.host.as_str());
407 dict.insert(
408 "port".to_string(),
409 audit
410 .port
411 .map(|port| VmValue::Int(port as i64))
412 .unwrap_or(VmValue::Nil),
413 );
414 dict.put_str("reason", audit.reason.as_str());
415 dict.put_str("outcome", audit.outcome);
416 dict.insert(
417 "matched_rule".to_string(),
418 audit
419 .matched_rule
420 .as_deref()
421 .map(|raw| VmValue::String(arcstr::ArcStr::from(raw)))
422 .unwrap_or(VmValue::Nil),
423 );
424 if audit.bypass {
425 dict.insert("bypass".to_string(), VmValue::Bool(true));
426 }
427 VmError::Thrown(VmValue::dict(dict))
428}
429
430pub fn violation_request_value(audit: &NetPolicyAudit) -> VmValue {
434 let mut dict = BTreeMap::new();
435 dict.put_str("method", audit.method.as_str());
436 dict.put_str("url", audit.url.as_str());
437 dict.put_str("host", audit.host.as_str());
438 dict.insert(
439 "port".to_string(),
440 audit
441 .port
442 .map(|port| VmValue::Int(port as i64))
443 .unwrap_or(VmValue::Nil),
444 );
445 dict.put_str("reason", audit.reason.as_str());
446 dict.insert(
447 "matched_rule".to_string(),
448 audit
449 .matched_rule
450 .as_deref()
451 .map(|raw| VmValue::String(arcstr::ArcStr::from(raw)))
452 .unwrap_or(VmValue::Nil),
453 );
454 VmValue::dict(dict)
455}
456
457pub async fn record_audit(audit: &NetPolicyAudit) {
461 let Some(log) = active_event_log() else {
462 return;
463 };
464 let Ok(topic) = Topic::new(NET_POLICY_AUDIT_TOPIC) else {
465 return;
466 };
467 let _ = log
468 .append(
469 &topic,
470 LogEvent::new("net.policy.evaluated", audit.to_json()),
471 )
472 .await;
473}
474
475pub fn bypass_enabled() -> bool {
479 match std::env::var(HARN_NET_POLICY_BYPASS_ENV) {
480 Ok(value) => matches!(
481 value.trim().to_ascii_lowercase().as_str(),
482 "1" | "true" | "yes" | "on"
483 ),
484 Err(_) => false,
485 }
486}
487
488fn normalize_host(host: &str) -> String {
489 host.trim()
490 .trim_end_matches('.')
491 .trim_matches('[')
492 .trim_matches(']')
493 .to_ascii_lowercase()
494}
495
496fn vm_error(message: impl Into<String>) -> VmError {
497 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
498}
499
500pub mod parse {
504 use super::*;
505
506 pub const RULE_TAG_KEY: &str = "__net_policy_rule";
508 pub const POLICY_TAG_KEY: &str = "__net_policy";
510
511 pub fn rule_from_vm(value: &VmValue) -> Result<NetPolicyRule, VmError> {
516 match value {
517 VmValue::Dict(dict) => rule_from_dict(dict),
518 VmValue::String(raw) => {
519 let raw = raw.as_str();
520 if raw.starts_with("*.") {
521 NetPolicyRule::parse_domain_wildcard(raw)
522 } else if raw.contains('/') {
523 NetPolicyRule::parse_cidr(raw)
524 } else {
525 NetPolicyRule::parse_domain(raw)
526 }
527 }
528 other => Err(vm_error(format!(
529 "NetPolicy: rule must be a tagged dict or string, got {}",
530 other.type_name()
531 ))),
532 }
533 }
534
535 fn rule_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicyRule, VmError> {
536 let tag = dict
537 .get(RULE_TAG_KEY)
538 .and_then(|v| match v {
539 VmValue::String(s) => Some(s.to_string()),
540 _ => None,
541 })
542 .ok_or_else(|| {
543 vm_error(
544 "NetPolicy: rule dict is missing the `__net_policy_rule` tag; build rules via NetPolicy.domain/.domain_wildcard/.cidr/.host",
545 )
546 })?;
547 match tag.as_str() {
548 "domain" => {
549 let host = require_string(dict, "host", "NetPolicy.domain")?;
550 NetPolicyRule::parse_domain(&host)
551 }
552 "domain_wildcard" => {
553 let pattern = require_string(dict, "pattern", "NetPolicy.domain_wildcard")?;
554 NetPolicyRule::parse_domain_wildcard(&pattern)
555 }
556 "cidr" => {
557 let range = require_string(dict, "range", "NetPolicy.cidr")?;
558 NetPolicyRule::parse_cidr(&range)
559 }
560 "host" => {
561 let host = require_string(dict, "host", "NetPolicy.host")?;
562 let ports = match dict.get("ports") {
563 Some(VmValue::List(list)) => {
564 let mut parsed = Vec::with_capacity(list.len());
565 for value in list.iter() {
566 let port = value
567 .as_int()
568 .and_then(|n| u16::try_from(n).ok())
569 .ok_or_else(|| {
570 vm_error("NetPolicy.host: ports must be a list of u16 integers")
571 })?;
572 parsed.push(port);
573 }
574 Some(parsed)
575 }
576 Some(VmValue::Nil) | None => None,
577 Some(_) => {
578 return Err(vm_error(
579 "NetPolicy.host: ports must be a list of u16 integers",
580 ))
581 }
582 };
583 NetPolicyRule::parse_host(&host, ports)
584 }
585 other => Err(vm_error(format!("NetPolicy: unknown rule kind `{other}`"))),
586 }
587 }
588
589 fn require_string(
590 dict: &crate::value::DictMap,
591 key: &str,
592 callee: &str,
593 ) -> Result<String, VmError> {
594 match dict.get(key) {
595 Some(VmValue::String(s)) => Ok(s.as_str().to_string()),
596 Some(other) => Err(vm_error(format!(
597 "{callee}: `{key}` must be a string, got {}",
598 other.type_name()
599 ))),
600 None => Err(vm_error(format!("{callee}: missing `{key}` field"))),
601 }
602 }
603
604 pub fn policy_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicy, VmError> {
607 let allow = parse_rule_list(dict.get("allow"), "allow")?;
608 let deny = parse_rule_list(dict.get("deny"), "deny")?;
609 let default = match dict.get("default") {
610 Some(VmValue::String(s)) => NetPolicyDefault::parse(s.as_str())?,
611 Some(VmValue::Nil) | None => NetPolicyDefault::Deny,
612 Some(other) => {
613 return Err(vm_error(format!(
614 "NetPolicy.create: default must be a string, got {}",
615 other.type_name()
616 )))
617 }
618 };
619 let on_violation = match dict.get("on_violation") {
620 Some(VmValue::String(s)) => OnViolation::parse_str(s.as_str())?,
621 Some(VmValue::Closure(closure)) => OnViolation::Callback(Arc::clone(closure)),
622 Some(VmValue::Nil) | None => OnViolation::Error,
623 Some(other) => {
624 return Err(vm_error(format!(
625 "NetPolicy.create: on_violation must be a string or callback, got {}",
626 other.type_name()
627 )))
628 }
629 };
630 Ok(NetPolicy {
631 allow: Arc::new(allow),
632 deny: Arc::new(deny),
633 default,
634 on_violation,
635 })
636 }
637
638 fn parse_rule_list(value: Option<&VmValue>, side: &str) -> Result<Vec<NetPolicyRule>, VmError> {
639 match value {
640 None | Some(VmValue::Nil) => Ok(Vec::new()),
641 Some(VmValue::List(items)) => items.iter().map(rule_from_vm).collect(),
642 Some(other) => Err(vm_error(format!(
643 "NetPolicy.create: `{side}` must be a list, got {}",
644 other.type_name()
645 ))),
646 }
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 fn rule(raw: &str, ports: Option<Vec<u16>>) -> NetPolicyRule {
655 NetPolicyRule::parse_host(raw, ports).expect("rule parses")
656 }
657
658 fn cidr(raw: &str) -> NetPolicyRule {
659 NetPolicyRule::parse_cidr(raw).expect("cidr parses")
660 }
661
662 fn build(
663 allow: Vec<NetPolicyRule>,
664 deny: Vec<NetPolicyRule>,
665 default: NetPolicyDefault,
666 ) -> NetPolicy {
667 NetPolicy {
668 allow: Arc::new(allow),
669 deny: Arc::new(deny),
670 default,
671 on_violation: OnViolation::Error,
672 }
673 }
674
675 #[test]
676 fn exact_host_match_allows() {
677 let policy = build(
678 vec![rule("github.com", None)],
679 Vec::new(),
680 NetPolicyDefault::Deny,
681 );
682 let decision = policy
683 .evaluate("get", "https://github.com/foo")
684 .expect("evaluates");
685 assert!(matches!(decision, NetPolicyDecision::Allow { .. }));
686 }
687
688 #[test]
689 fn wildcard_does_not_match_bare_apex() {
690 let policy = build(
691 vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
692 Vec::new(),
693 NetPolicyDefault::Deny,
694 );
695 let allow = policy.evaluate("get", "https://api.github.com/x").unwrap();
696 assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
697 let deny = policy.evaluate("get", "https://github.com/x").unwrap();
698 assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
699 }
700
701 #[test]
702 fn cidr_matches_ip_literal() {
703 let policy = build(vec![cidr("10.0.0.0/8")], Vec::new(), NetPolicyDefault::Deny);
704 let allowed = policy.evaluate("get", "http://10.5.5.5/x").unwrap();
705 assert!(matches!(allowed, NetPolicyDecision::Allow { .. }));
706 let denied = policy.evaluate("get", "http://192.168.1.1/x").unwrap();
707 assert!(matches!(denied, NetPolicyDecision::Deny { .. }));
708 }
709
710 #[test]
711 fn host_port_rule_requires_matching_port() {
712 let policy = build(
713 vec![rule("api.anthropic.com", Some(vec![443]))],
714 Vec::new(),
715 NetPolicyDefault::Deny,
716 );
717 let allow = policy
718 .evaluate("get", "https://api.anthropic.com/v1/messages")
719 .unwrap();
720 assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
721 let deny = policy
722 .evaluate("get", "http://api.anthropic.com/v1/messages")
723 .unwrap();
724 assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
725 }
726
727 #[test]
728 fn deny_overrides_allow() {
729 let policy = build(
730 vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
731 vec![rule("evil.github.com", None)],
732 NetPolicyDefault::Deny,
733 );
734 let decision = policy.evaluate("get", "https://evil.github.com/x").unwrap();
735 match decision {
736 NetPolicyDecision::Deny { audit, .. } => {
737 assert!(audit.reason.contains("deny rule"));
738 }
739 other => panic!("expected deny, got {other:?}"),
740 }
741 }
742
743 #[test]
744 fn default_allow_lets_unmatched_through() {
745 let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Allow);
746 let allow = policy.evaluate("get", "https://example.test/x").unwrap();
747 assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
748 }
749
750 #[test]
751 fn audit_only_allows_but_carries_audit() {
752 let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
753 policy.on_violation = OnViolation::AuditOnly;
754 let decision = policy
755 .evaluate("get", "https://blocked.test/x")
756 .expect("evaluates");
757 match decision {
758 NetPolicyDecision::Allow { audited, audit } => {
759 assert!(audited);
760 let audit = audit.expect("audit attached");
761 assert_eq!(audit.outcome, "audit_only");
762 assert_eq!(audit.host, "blocked.test");
763 }
764 other => panic!("expected audit_only allow, got {other:?}"),
765 }
766 }
767
768 #[test]
769 fn quarantine_denies_with_signal() {
770 let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
771 policy.on_violation = OnViolation::Quarantine;
772 match policy
773 .evaluate("get", "https://blocked.test/x")
774 .expect("evaluates")
775 {
776 NetPolicyDecision::Deny { audit, quarantine } => {
777 assert!(quarantine);
778 assert_eq!(audit.outcome, "quarantine");
779 }
780 other => panic!("expected quarantine deny, got {other:?}"),
781 }
782 }
783
784 #[test]
785 fn invalid_url_surfaces_typed_error() {
786 let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
787 let err = policy.evaluate("get", "not a url").unwrap_err();
788 match err {
789 VmError::Thrown(VmValue::String(s)) => {
790 assert!(s.contains("invalid URL"), "unexpected error: {s}");
791 }
792 other => panic!("expected Thrown, got {other:?}"),
793 }
794 }
795
796 #[test]
797 fn parse_string_rule_branches_on_shape() {
798 let domain =
799 parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("github.com"))).unwrap();
800 assert!(matches!(domain.matcher, NetMatcher::Host(_)));
801 let wildcard =
802 parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("*.github.com"))).unwrap();
803 assert!(matches!(wildcard.matcher, NetMatcher::Suffix(_)));
804 let cidr_rule =
805 parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("10.0.0.0/8"))).unwrap();
806 assert!(matches!(cidr_rule.matcher, NetMatcher::Cidr(_)));
807 }
808
809 #[test]
810 fn bypass_env_recognised() {
811 let original = std::env::var(HARN_NET_POLICY_BYPASS_ENV).ok();
812 std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "1");
813 assert!(bypass_enabled());
814 std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "0");
815 assert!(!bypass_enabled());
816 match original {
817 Some(value) => std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, value),
818 None => std::env::remove_var(HARN_NET_POLICY_BYPASS_ENV),
819 }
820 }
821}