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