1use std::collections::BTreeMap;
20use std::net::IpAddr;
21use std::rc::Rc;
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(Rc<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) => {
260 target.host.len() > suffix.len()
261 && target.host.ends_with(suffix)
262 && target
263 .host
264 .as_bytes()
265 .get(target.host.len() - suffix.len() - 1)
266 == Some(&b'.')
267 }
268 NetMatcher::Ip(ip) => target.ip == Some(*ip),
269 NetMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
270 }
271 }
272}
273
274impl NetPolicy {
275 pub fn evaluate(&self, method: &str, raw_url: &str) -> Result<NetPolicyDecision, VmError> {
279 let target = NetTarget::parse(raw_url)?;
280 if let Some(rule) = self.deny.iter().find(|rule| rule.matches(&target)) {
281 return Ok(self.deny_decision(
282 method,
283 raw_url,
284 &target,
285 format!("matched deny rule `{}`", rule.raw),
286 Some(rule.raw.clone()),
287 ));
288 }
289 if let Some(rule) = self.allow.iter().find(|rule| rule.matches(&target)) {
290 return Ok(NetPolicyDecision::Allow {
291 audited: false,
292 audit: Some(NetPolicyAudit {
293 method: method.to_string(),
294 url: raw_url.to_string(),
295 host: target.host,
296 port: target.port,
297 reason: format!("matched allow rule `{}`", rule.raw),
298 outcome: "allow",
299 bypass: false,
300 matched_rule: Some(rule.raw.clone()),
301 }),
302 });
303 }
304 if self.default == NetPolicyDefault::Allow {
305 return Ok(NetPolicyDecision::Allow {
306 audited: false,
307 audit: None,
308 });
309 }
310 Ok(self.deny_decision(
311 method,
312 raw_url,
313 &target,
314 "no allow rule matched (default deny)".to_string(),
315 None,
316 ))
317 }
318
319 fn deny_decision(
320 &self,
321 method: &str,
322 raw_url: &str,
323 target: &NetTarget,
324 reason: String,
325 matched_rule: Option<String>,
326 ) -> NetPolicyDecision {
327 match &self.on_violation {
328 OnViolation::Error => NetPolicyDecision::Deny {
329 audit: NetPolicyAudit {
330 method: method.to_string(),
331 url: raw_url.to_string(),
332 host: target.host.clone(),
333 port: target.port,
334 reason,
335 outcome: "error",
336 bypass: false,
337 matched_rule,
338 },
339 quarantine: false,
340 },
341 OnViolation::AuditOnly => NetPolicyDecision::Allow {
342 audited: true,
343 audit: Some(NetPolicyAudit {
344 method: method.to_string(),
345 url: raw_url.to_string(),
346 host: target.host.clone(),
347 port: target.port,
348 reason,
349 outcome: "audit_only",
350 bypass: false,
351 matched_rule,
352 }),
353 },
354 OnViolation::Quarantine => NetPolicyDecision::Deny {
355 audit: NetPolicyAudit {
356 method: method.to_string(),
357 url: raw_url.to_string(),
358 host: target.host.clone(),
359 port: target.port,
360 reason,
361 outcome: "quarantine",
362 bypass: false,
363 matched_rule,
364 },
365 quarantine: true,
366 },
367 OnViolation::Callback(_) => NetPolicyDecision::Deny {
372 audit: NetPolicyAudit {
373 method: method.to_string(),
374 url: raw_url.to_string(),
375 host: target.host.clone(),
376 port: target.port,
377 reason,
378 outcome: "callback",
379 bypass: false,
380 matched_rule,
381 },
382 quarantine: false,
383 },
384 }
385 }
386}
387
388pub fn violation_vm_error(audit: &NetPolicyAudit) -> VmError {
392 let mut dict = BTreeMap::new();
393 dict.insert(
394 "type".to_string(),
395 VmValue::String(Rc::from("NetPolicyViolation")),
396 );
397 dict.insert(
398 "category".to_string(),
399 VmValue::String(Rc::from("net_policy_violation")),
400 );
401 dict.insert(
402 "message".to_string(),
403 VmValue::String(Rc::from(format!(
404 "harness.net.{} blocked {}: {}",
405 audit.method, audit.url, audit.reason
406 ))),
407 );
408 dict.insert(
409 "method".to_string(),
410 VmValue::String(Rc::from(audit.method.as_str())),
411 );
412 dict.insert(
413 "url".to_string(),
414 VmValue::String(Rc::from(audit.url.as_str())),
415 );
416 dict.insert(
417 "host".to_string(),
418 VmValue::String(Rc::from(audit.host.as_str())),
419 );
420 dict.insert(
421 "port".to_string(),
422 audit
423 .port
424 .map(|port| VmValue::Int(port as i64))
425 .unwrap_or(VmValue::Nil),
426 );
427 dict.insert(
428 "reason".to_string(),
429 VmValue::String(Rc::from(audit.reason.as_str())),
430 );
431 dict.insert(
432 "outcome".to_string(),
433 VmValue::String(Rc::from(audit.outcome)),
434 );
435 dict.insert(
436 "matched_rule".to_string(),
437 audit
438 .matched_rule
439 .as_deref()
440 .map(|raw| VmValue::String(Rc::from(raw)))
441 .unwrap_or(VmValue::Nil),
442 );
443 if audit.bypass {
444 dict.insert("bypass".to_string(), VmValue::Bool(true));
445 }
446 VmError::Thrown(VmValue::Dict(Rc::new(dict)))
447}
448
449pub fn violation_request_value(audit: &NetPolicyAudit) -> VmValue {
453 let mut dict = BTreeMap::new();
454 dict.insert(
455 "method".to_string(),
456 VmValue::String(Rc::from(audit.method.as_str())),
457 );
458 dict.insert(
459 "url".to_string(),
460 VmValue::String(Rc::from(audit.url.as_str())),
461 );
462 dict.insert(
463 "host".to_string(),
464 VmValue::String(Rc::from(audit.host.as_str())),
465 );
466 dict.insert(
467 "port".to_string(),
468 audit
469 .port
470 .map(|port| VmValue::Int(port as i64))
471 .unwrap_or(VmValue::Nil),
472 );
473 dict.insert(
474 "reason".to_string(),
475 VmValue::String(Rc::from(audit.reason.as_str())),
476 );
477 dict.insert(
478 "matched_rule".to_string(),
479 audit
480 .matched_rule
481 .as_deref()
482 .map(|raw| VmValue::String(Rc::from(raw)))
483 .unwrap_or(VmValue::Nil),
484 );
485 VmValue::Dict(Rc::new(dict))
486}
487
488pub async fn record_audit(audit: &NetPolicyAudit) {
492 let Some(log) = active_event_log() else {
493 return;
494 };
495 let Ok(topic) = Topic::new(NET_POLICY_AUDIT_TOPIC) else {
496 return;
497 };
498 let _ = log
499 .append(
500 &topic,
501 LogEvent::new("net.policy.evaluated", audit.to_json()),
502 )
503 .await;
504}
505
506pub fn bypass_enabled() -> bool {
510 match std::env::var(HARN_NET_POLICY_BYPASS_ENV) {
511 Ok(value) => matches!(
512 value.trim().to_ascii_lowercase().as_str(),
513 "1" | "true" | "yes" | "on"
514 ),
515 Err(_) => false,
516 }
517}
518
519fn normalize_host(host: &str) -> String {
520 host.trim()
521 .trim_end_matches('.')
522 .trim_matches('[')
523 .trim_matches(']')
524 .to_ascii_lowercase()
525}
526
527fn vm_error(message: impl Into<String>) -> VmError {
528 VmError::Thrown(VmValue::String(Rc::from(message.into())))
529}
530
531pub mod parse {
535 use super::*;
536
537 pub const RULE_TAG_KEY: &str = "__net_policy_rule";
539 pub const POLICY_TAG_KEY: &str = "__net_policy";
541
542 pub fn rule_from_vm(value: &VmValue) -> Result<NetPolicyRule, VmError> {
547 match value {
548 VmValue::Dict(dict) => rule_from_dict(dict),
549 VmValue::String(raw) => {
550 let raw = raw.as_ref();
551 if raw.starts_with("*.") {
552 NetPolicyRule::parse_domain_wildcard(raw)
553 } else if raw.contains('/') {
554 NetPolicyRule::parse_cidr(raw)
555 } else {
556 NetPolicyRule::parse_domain(raw)
557 }
558 }
559 other => Err(vm_error(format!(
560 "NetPolicy: rule must be a tagged dict or string, got {}",
561 other.type_name()
562 ))),
563 }
564 }
565
566 fn rule_from_dict(dict: &BTreeMap<String, VmValue>) -> Result<NetPolicyRule, VmError> {
567 let tag = dict
568 .get(RULE_TAG_KEY)
569 .and_then(|v| match v {
570 VmValue::String(s) => Some(s.to_string()),
571 _ => None,
572 })
573 .ok_or_else(|| {
574 vm_error(
575 "NetPolicy: rule dict is missing the `__net_policy_rule` tag; build rules via NetPolicy.domain/.domain_wildcard/.cidr/.host",
576 )
577 })?;
578 match tag.as_str() {
579 "domain" => {
580 let host = require_string(dict, "host", "NetPolicy.domain")?;
581 NetPolicyRule::parse_domain(&host)
582 }
583 "domain_wildcard" => {
584 let pattern = require_string(dict, "pattern", "NetPolicy.domain_wildcard")?;
585 NetPolicyRule::parse_domain_wildcard(&pattern)
586 }
587 "cidr" => {
588 let range = require_string(dict, "range", "NetPolicy.cidr")?;
589 NetPolicyRule::parse_cidr(&range)
590 }
591 "host" => {
592 let host = require_string(dict, "host", "NetPolicy.host")?;
593 let ports = match dict.get("ports") {
594 Some(VmValue::List(list)) => {
595 let mut parsed = Vec::with_capacity(list.len());
596 for value in list.iter() {
597 let port = value
598 .as_int()
599 .and_then(|n| u16::try_from(n).ok())
600 .ok_or_else(|| {
601 vm_error("NetPolicy.host: ports must be a list of u16 integers")
602 })?;
603 parsed.push(port);
604 }
605 Some(parsed)
606 }
607 Some(VmValue::Nil) | None => None,
608 Some(_) => {
609 return Err(vm_error(
610 "NetPolicy.host: ports must be a list of u16 integers",
611 ))
612 }
613 };
614 NetPolicyRule::parse_host(&host, ports)
615 }
616 other => Err(vm_error(format!("NetPolicy: unknown rule kind `{other}`"))),
617 }
618 }
619
620 fn require_string(
621 dict: &BTreeMap<String, VmValue>,
622 key: &str,
623 callee: &str,
624 ) -> Result<String, VmError> {
625 match dict.get(key) {
626 Some(VmValue::String(s)) => Ok(s.as_ref().to_string()),
627 Some(other) => Err(vm_error(format!(
628 "{callee}: `{key}` must be a string, got {}",
629 other.type_name()
630 ))),
631 None => Err(vm_error(format!("{callee}: missing `{key}` field"))),
632 }
633 }
634
635 pub fn policy_from_dict(dict: &BTreeMap<String, VmValue>) -> Result<NetPolicy, VmError> {
638 let allow = parse_rule_list(dict.get("allow"), "allow")?;
639 let deny = parse_rule_list(dict.get("deny"), "deny")?;
640 let default = match dict.get("default") {
641 Some(VmValue::String(s)) => NetPolicyDefault::parse(s.as_ref())?,
642 Some(VmValue::Nil) | None => NetPolicyDefault::Deny,
643 Some(other) => {
644 return Err(vm_error(format!(
645 "NetPolicy.create: default must be a string, got {}",
646 other.type_name()
647 )))
648 }
649 };
650 let on_violation = match dict.get("on_violation") {
651 Some(VmValue::String(s)) => OnViolation::parse_str(s.as_ref())?,
652 Some(VmValue::Closure(closure)) => OnViolation::Callback(Rc::clone(closure)),
653 Some(VmValue::Nil) | None => OnViolation::Error,
654 Some(other) => {
655 return Err(vm_error(format!(
656 "NetPolicy.create: on_violation must be a string or callback, got {}",
657 other.type_name()
658 )))
659 }
660 };
661 Ok(NetPolicy {
662 allow: Arc::new(allow),
663 deny: Arc::new(deny),
664 default,
665 on_violation,
666 })
667 }
668
669 fn parse_rule_list(value: Option<&VmValue>, side: &str) -> Result<Vec<NetPolicyRule>, VmError> {
670 match value {
671 None | Some(VmValue::Nil) => Ok(Vec::new()),
672 Some(VmValue::List(items)) => items.iter().map(rule_from_vm).collect(),
673 Some(other) => Err(vm_error(format!(
674 "NetPolicy.create: `{side}` must be a list, got {}",
675 other.type_name()
676 ))),
677 }
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn rule(raw: &str, ports: Option<Vec<u16>>) -> NetPolicyRule {
686 NetPolicyRule::parse_host(raw, ports).expect("rule parses")
687 }
688
689 fn cidr(raw: &str) -> NetPolicyRule {
690 NetPolicyRule::parse_cidr(raw).expect("cidr parses")
691 }
692
693 fn build(
694 allow: Vec<NetPolicyRule>,
695 deny: Vec<NetPolicyRule>,
696 default: NetPolicyDefault,
697 ) -> NetPolicy {
698 NetPolicy {
699 allow: Arc::new(allow),
700 deny: Arc::new(deny),
701 default,
702 on_violation: OnViolation::Error,
703 }
704 }
705
706 #[test]
707 fn exact_host_match_allows() {
708 let policy = build(
709 vec![rule("github.com", None)],
710 Vec::new(),
711 NetPolicyDefault::Deny,
712 );
713 let decision = policy
714 .evaluate("get", "https://github.com/foo")
715 .expect("evaluates");
716 assert!(matches!(decision, NetPolicyDecision::Allow { .. }));
717 }
718
719 #[test]
720 fn wildcard_does_not_match_bare_apex() {
721 let policy = build(
722 vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
723 Vec::new(),
724 NetPolicyDefault::Deny,
725 );
726 let allow = policy.evaluate("get", "https://api.github.com/x").unwrap();
727 assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
728 let deny = policy.evaluate("get", "https://github.com/x").unwrap();
729 assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
730 }
731
732 #[test]
733 fn cidr_matches_ip_literal() {
734 let policy = build(vec![cidr("10.0.0.0/8")], Vec::new(), NetPolicyDefault::Deny);
735 let allowed = policy.evaluate("get", "http://10.5.5.5/x").unwrap();
736 assert!(matches!(allowed, NetPolicyDecision::Allow { .. }));
737 let denied = policy.evaluate("get", "http://192.168.1.1/x").unwrap();
738 assert!(matches!(denied, NetPolicyDecision::Deny { .. }));
739 }
740
741 #[test]
742 fn host_port_rule_requires_matching_port() {
743 let policy = build(
744 vec![rule("api.anthropic.com", Some(vec![443]))],
745 Vec::new(),
746 NetPolicyDefault::Deny,
747 );
748 let allow = policy
749 .evaluate("get", "https://api.anthropic.com/v1/messages")
750 .unwrap();
751 assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
752 let deny = policy
753 .evaluate("get", "http://api.anthropic.com/v1/messages")
754 .unwrap();
755 assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
756 }
757
758 #[test]
759 fn deny_overrides_allow() {
760 let policy = build(
761 vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
762 vec![rule("evil.github.com", None)],
763 NetPolicyDefault::Deny,
764 );
765 let decision = policy.evaluate("get", "https://evil.github.com/x").unwrap();
766 match decision {
767 NetPolicyDecision::Deny { audit, .. } => {
768 assert!(audit.reason.contains("deny rule"));
769 }
770 other => panic!("expected deny, got {other:?}"),
771 }
772 }
773
774 #[test]
775 fn default_allow_lets_unmatched_through() {
776 let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Allow);
777 let allow = policy.evaluate("get", "https://example.test/x").unwrap();
778 assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
779 }
780
781 #[test]
782 fn audit_only_allows_but_carries_audit() {
783 let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
784 policy.on_violation = OnViolation::AuditOnly;
785 let decision = policy
786 .evaluate("get", "https://blocked.test/x")
787 .expect("evaluates");
788 match decision {
789 NetPolicyDecision::Allow { audited, audit } => {
790 assert!(audited);
791 let audit = audit.expect("audit attached");
792 assert_eq!(audit.outcome, "audit_only");
793 assert_eq!(audit.host, "blocked.test");
794 }
795 other => panic!("expected audit_only allow, got {other:?}"),
796 }
797 }
798
799 #[test]
800 fn quarantine_denies_with_signal() {
801 let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
802 policy.on_violation = OnViolation::Quarantine;
803 match policy
804 .evaluate("get", "https://blocked.test/x")
805 .expect("evaluates")
806 {
807 NetPolicyDecision::Deny { audit, quarantine } => {
808 assert!(quarantine);
809 assert_eq!(audit.outcome, "quarantine");
810 }
811 other => panic!("expected quarantine deny, got {other:?}"),
812 }
813 }
814
815 #[test]
816 fn invalid_url_surfaces_typed_error() {
817 let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
818 let err = policy.evaluate("get", "not a url").unwrap_err();
819 match err {
820 VmError::Thrown(VmValue::String(s)) => {
821 assert!(s.contains("invalid URL"), "unexpected error: {s}");
822 }
823 other => panic!("expected Thrown, got {other:?}"),
824 }
825 }
826
827 #[test]
828 fn parse_string_rule_branches_on_shape() {
829 let domain = parse::rule_from_vm(&VmValue::String(Rc::from("github.com"))).unwrap();
830 assert!(matches!(domain.matcher, NetMatcher::Host(_)));
831 let wildcard = parse::rule_from_vm(&VmValue::String(Rc::from("*.github.com"))).unwrap();
832 assert!(matches!(wildcard.matcher, NetMatcher::Suffix(_)));
833 let cidr_rule = parse::rule_from_vm(&VmValue::String(Rc::from("10.0.0.0/8"))).unwrap();
834 assert!(matches!(cidr_rule.matcher, NetMatcher::Cidr(_)));
835 }
836
837 #[test]
838 fn bypass_env_recognised() {
839 let original = std::env::var(HARN_NET_POLICY_BYPASS_ENV).ok();
840 std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "1");
841 assert!(bypass_enabled());
842 std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "0");
843 assert!(!bypass_enabled());
844 match original {
845 Some(value) => std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, value),
846 None => std::env::remove_var(HARN_NET_POLICY_BYPASS_ENV),
847 }
848 }
849}