返回 DeepSeek-TUI-2026
network_policy.rs
根目录 / crates / tui / src / network_policy.rs
1 // Several public helpers in this module are exposed for future slash-command
2 // wiring (`/network allow <host>`, `/network deny <host>`) and for the
3 // approval-modal hook that v0.7.x adds incrementally. Dead-code warnings
4 // would otherwise be noisy until those call sites land.
5 #![allow(dead_code)]
6
7 //! Per-domain network policy for outbound network calls (#135).
8 //!
9 //! Three small pieces:
10 //!
11 //! 1. [`Decision`] — `Allow | Deny | Prompt`.
12 //! 2. [`NetworkPolicy`] — a list of allow/deny hostnames + a default decision,
13 //! with **deny-wins precedence**: a host that matches an entry in `deny`
14 //! is denied even if it also matches `allow`.
15 //! 3. [`NetworkAuditor`] — appends one plaintext line per outbound call to
16 //! `~/.deepseek/audit.log` in the format described below.
17 //!
18 //! In addition, [`NetworkSessionCache`] holds in-process "approve once for
19 //! this session" state for the `Prompt` flow, and [`NetworkDenied`] is the
20 //! structured error surfaced to callers when a host is blocked.
21 //!
22 //! # Host-matching rules
23 //!
24 //! * **Exact match** — an entry like `api.deepseek.com` matches only the host
25 //! `api.deepseek.com` (case-insensitive).
26 //! * **Subdomain match** — an entry that **starts with a leading dot**, e.g.
27 //! `.example.com`, matches any subdomain (`api.example.com`, `a.b.example.com`)
28 //! but **not** the apex `example.com`. To match both, list both.
29 //!
30 //! Matching is case-insensitive and trims a single trailing dot from the host
31 //! (so `example.com.` and `example.com` are equivalent).
32 //!
33 //! # Audit-log format
34 //!
35 //! ```text
36 //! <RFC3339-timestamp> network <host> <tool> <Allow|Deny|Prompt-Approved|Prompt-Denied>
37 //! ```
38 //!
39 //! Plaintext, one line per call, appended to `<audit_path>` (defaults to
40 //! `~/.deepseek/audit.log`). Best-effort: write failures are logged but do
41 //! not block the call.
42
43 use std::fs::{self, OpenOptions};
44 use std::io::Write;
45 use std::path::{Path, PathBuf};
46 use std::sync::{Arc, Mutex};
47
48 use chrono::Utc;
49 use serde::{Deserialize, Serialize};
50 use thiserror::Error;
51
52 /// What the policy decided about an outbound network call.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 pub enum Decision {
55 /// Allow the call without prompting.
56 Allow,
57 /// Deny the call. Surfaced to callers as [`NetworkDenied`].
58 Deny,
59 /// Defer to the user via an approval prompt.
60 Prompt,
61 }
62
63 impl Decision {
64 /// String form used in audit-log lines.
65 #[must_use]
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::Allow => "Allow",
69 Self::Deny => "Deny",
70 Self::Prompt => "Prompt",
71 }
72 }
73
74 /// Parse a decision from a TOML string. Unknown values fall back to
75 /// `Prompt` so a typo never silently disables the policy.
76 #[must_use]
77 pub fn parse(value: &str) -> Self {
78 match value.trim().to_ascii_lowercase().as_str() {
79 "allow" => Self::Allow,
80 "deny" | "block" => Self::Deny,
81 _ => Self::Prompt,
82 }
83 }
84 }
85
86 /// Per-domain allow/deny list with a default fallback.
87 ///
88 /// See the module docs for [host-matching rules](self#host-matching-rules)
89 /// and [deny-wins precedence](self#deny-wins-precedence).
90 #[derive(Debug, Clone, Serialize, Deserialize)]
91 pub struct NetworkPolicy {
92 /// Decision for hosts that match neither `allow` nor `deny`.
93 #[serde(default = "default_decision")]
94 pub default: DecisionToml,
95 /// Hosts that should be allowed without prompting.
96 #[serde(default)]
97 pub allow: Vec<String>,
98 /// Hosts that should always be denied.
99 #[serde(default)]
100 pub deny: Vec<String>,
101 /// Whether to record one audit-log line per network call. Defaults to true.
102 #[serde(default = "default_audit")]
103 pub audit: bool,
104 }
105
106 fn default_decision() -> DecisionToml {
107 DecisionToml::Prompt
108 }
109
110 fn default_audit() -> bool {
111 true
112 }
113
114 impl Default for NetworkPolicy {
115 fn default() -> Self {
116 Self {
117 default: DecisionToml::Prompt,
118 allow: Vec::new(),
119 deny: Vec::new(),
120 audit: true,
121 }
122 }
123 }
124
125 /// Wire-format wrapper for [`Decision`] used in serde-derived TOML/JSON. The
126 /// runtime API exposes [`Decision`] directly; this type only exists so
127 /// `default = "prompt"` round-trips cleanly through TOML.
128 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129 #[serde(rename_all = "lowercase")]
130 pub enum DecisionToml {
131 Allow,
132 Deny,
133 Prompt,
134 }
135
136 impl From<DecisionToml> for Decision {
137 fn from(value: DecisionToml) -> Self {
138 match value {
139 DecisionToml::Allow => Self::Allow,
140 DecisionToml::Deny => Self::Deny,
141 DecisionToml::Prompt => Self::Prompt,
142 }
143 }
144 }
145
146 impl From<Decision> for DecisionToml {
147 fn from(value: Decision) -> Self {
148 match value {
149 Decision::Allow => Self::Allow,
150 Decision::Deny => Self::Deny,
151 Decision::Prompt => Self::Prompt,
152 }
153 }
154 }
155
156 impl NetworkPolicy {
157 /// Decide what to do for a single outbound call to `host`.
158 ///
159 /// **Deny-wins precedence**: if `host` matches any entry in `deny`, the
160 /// answer is [`Decision::Deny`] regardless of `allow`. This makes deny
161 /// lists safe to combine with broad allow rules.
162 #[must_use]
163 pub fn decide(&self, host: &str) -> Decision {
164 let normalized = normalize_host(host);
165 if normalized.is_empty() {
166 // We don't pretend we can audit a malformed host; treat it as the
167 // default (prompt or deny).
168 return self.default.into();
169 }
170 if self
171 .deny
172 .iter()
173 .any(|entry| host_matches(entry, &normalized))
174 {
175 return Decision::Deny;
176 }
177 if self
178 .allow
179 .iter()
180 .any(|entry| host_matches(entry, &normalized))
181 {
182 return Decision::Allow;
183 }
184 self.default.into()
185 }
186
187 /// Append `host` to the allow list (de-duplicated, case-insensitive).
188 /// Used by the prompt flow when the user picks "always for this host".
189 pub fn add_allow(&mut self, host: &str) {
190 let normalized = normalize_host(host);
191 if normalized.is_empty() {
192 return;
193 }
194 if !self
195 .allow
196 .iter()
197 .any(|existing| normalize_host(existing) == normalized)
198 {
199 self.allow.push(normalized);
200 }
201 }
202
203 /// Whether audit logging is enabled.
204 #[must_use]
205 pub fn audit_enabled(&self) -> bool {
206 self.audit
207 }
208 }
209
210 /// Normalize a host for matching: lowercase, trim whitespace, strip a single
211 /// trailing dot (FQDN form), and strip a leading `*.` or `.` for entries that
212 /// are written that way in config (we treat both as subdomain wildcards on
213 /// the *match* side, but on input normalization we keep the leading dot so
214 /// `host_matches` can detect the wildcard intent).
215 fn normalize_host(host: &str) -> String {
216 let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
217 if let Some(rest) = trimmed.strip_prefix("*.") {
218 format!(".{rest}")
219 } else {
220 trimmed
221 }
222 }
223
224 /// Match a single allow/deny entry against an already-normalized host.
225 fn host_matches(entry: &str, normalized_host: &str) -> bool {
226 let entry_norm = normalize_host(entry);
227 if let Some(suffix) = entry_norm.strip_prefix('.') {
228 // Wildcard subdomain rule. Match any host ending in `.suffix`, but
229 // *not* the bare `suffix` itself (per spec).
230 if suffix.is_empty() {
231 return false;
232 }
233 normalized_host.ends_with(&format!(".{suffix}"))
234 } else {
235 entry_norm == normalized_host
236 }
237 }
238
239 /// Best-effort writer for the network audit log.
240 #[derive(Debug, Clone)]
241 pub struct NetworkAuditor {
242 path: PathBuf,
243 enabled: bool,
244 }
245
246 impl NetworkAuditor {
247 /// New auditor that writes to `path`. `enabled = false` turns it into a no-op.
248 #[must_use]
249 pub fn new(path: PathBuf, enabled: bool) -> Self {
250 Self { path, enabled }
251 }
252
253 /// Auditor pointing at `~/.deepseek/audit.log`. Returns `None` if the
254 /// home directory can't be resolved.
255 #[must_use]
256 pub fn default_path(enabled: bool) -> Option<Self> {
257 let home = dirs::home_dir()?;
258 Some(Self::new(home.join(".deepseek").join("audit.log"), enabled))
259 }
260
261 /// Append one line. Best-effort: errors are logged via `eprintln!` but
262 /// never bubble back to the caller.
263 pub fn record(&self, host: &str, tool: &str, decision_label: &str) {
264 if !self.enabled {
265 return;
266 }
267 if let Err(err) = self.try_record(host, tool, decision_label) {
268 eprintln!("network audit write failed: {err}");
269 }
270 }
271
272 fn try_record(&self, host: &str, tool: &str, decision_label: &str) -> std::io::Result<()> {
273 if let Some(parent) = self.path.parent() {
274 fs::create_dir_all(parent)?;
275 }
276 let mut file = OpenOptions::new()
277 .create(true)
278 .append(true)
279 .open(&self.path)?;
280 writeln!(
281 file,
282 "{ts} network {host} {tool} {decision}",
283 ts = Utc::now().to_rfc3339(),
284 host = sanitize_field(host),
285 tool = sanitize_field(tool),
286 decision = decision_label,
287 )
288 }
289
290 /// Path the auditor would write to. Mostly useful for tests.
291 #[must_use]
292 pub fn path(&self) -> &Path {
293 &self.path
294 }
295 }
296
297 /// Replace whitespace in a token so the line stays parseable.
298 fn sanitize_field(s: &str) -> String {
299 s.chars()
300 .map(|c| if c.is_whitespace() { '_' } else { c })
301 .collect()
302 }
303
304 /// In-process cache of "approve once for this session" decisions. Keyed by
305 /// normalized host. Thread-safe.
306 #[derive(Debug, Default, Clone)]
307 pub struct NetworkSessionCache {
308 inner: Arc<Mutex<NetworkSessionCacheInner>>,
309 }
310
311 #[derive(Debug, Default)]
312 struct NetworkSessionCacheInner {
313 approved: std::collections::HashSet<String>,
314 denied: std::collections::HashSet<String>,
315 }
316
317 impl NetworkSessionCache {
318 /// New empty cache.
319 #[must_use]
320 pub fn new() -> Self {
321 Self::default()
322 }
323
324 /// `true` if the host was previously approved this session.
325 #[must_use]
326 pub fn is_approved(&self, host: &str) -> bool {
327 let normalized = normalize_host(host);
328 self.inner
329 .lock()
330 .map(|guard| guard.approved.contains(&normalized))
331 .unwrap_or(false)
332 }
333
334 /// `true` if the host was previously denied this session.
335 #[must_use]
336 pub fn is_denied(&self, host: &str) -> bool {
337 let normalized = normalize_host(host);
338 self.inner
339 .lock()
340 .map(|guard| guard.denied.contains(&normalized))
341 .unwrap_or(false)
342 }
343
344 /// Mark the host as approved for the rest of this session.
345 pub fn approve(&self, host: &str) {
346 let normalized = normalize_host(host);
347 if let Ok(mut guard) = self.inner.lock() {
348 guard.denied.remove(&normalized);
349 guard.approved.insert(normalized);
350 }
351 }
352
353 /// Mark the host as denied for the rest of this session.
354 pub fn deny(&self, host: &str) {
355 let normalized = normalize_host(host);
356 if let Ok(mut guard) = self.inner.lock() {
357 guard.approved.remove(&normalized);
358 guard.denied.insert(normalized);
359 }
360 }
361 }
362
363 /// Structured error surfaced to callers when an outbound call is blocked.
364 #[derive(Debug, Clone, Error)]
365 #[error("network call to '{0}' blocked by network policy")]
366 pub struct NetworkDenied(pub String);
367
368 impl NetworkDenied {
369 /// The host that was denied.
370 #[must_use]
371 pub fn host(&self) -> &str {
372 &self.0
373 }
374 }
375
376 /// Glue type that bundles a [`NetworkPolicy`] with a session cache and an
377 /// auditor. Tools call [`NetworkPolicyDecider::evaluate`] before any HTTP
378 /// transport is constructed; the result decides whether to proceed, deny,
379 /// or prompt the user.
380 #[derive(Debug, Clone)]
381 pub struct NetworkPolicyDecider {
382 policy: NetworkPolicy,
383 cache: NetworkSessionCache,
384 auditor: Option<NetworkAuditor>,
385 }
386
387 impl NetworkPolicyDecider {
388 /// Build a decider from a policy. The session cache starts empty.
389 #[must_use]
390 pub fn new(policy: NetworkPolicy, auditor: Option<NetworkAuditor>) -> Self {
391 Self {
392 policy,
393 cache: NetworkSessionCache::new(),
394 auditor,
395 }
396 }
397
398 /// Convenience: build a decider with default audit logging at
399 /// `~/.deepseek/audit.log`, if `policy.audit` is true.
400 #[must_use]
401 pub fn with_default_audit(policy: NetworkPolicy) -> Self {
402 let audit_enabled = policy.audit_enabled();
403 let auditor = if audit_enabled {
404 NetworkAuditor::default_path(true)
405 } else {
406 None
407 };
408 Self::new(policy, auditor)
409 }
410
411 /// Inspect the policy.
412 #[must_use]
413 pub fn policy(&self) -> &NetworkPolicy {
414 &self.policy
415 }
416
417 /// Inspect the session cache.
418 #[must_use]
419 pub fn cache(&self) -> &NetworkSessionCache {
420 &self.cache
421 }
422
423 /// Decide for `host`, consulting the session cache first.
424 ///
425 /// Audit logging happens **only** for terminal decisions (Allow / Deny).
426 /// `Prompt` is intentionally not logged here — the caller is responsible
427 /// for recording the user's eventual answer with `record_prompt_outcome`.
428 #[must_use]
429 pub fn evaluate(&self, host: &str, tool: &str) -> Decision {
430 let normalized = normalize_host(host);
431 if normalized.is_empty() {
432 return self.policy.default.into();
433 }
434 if self.cache.is_denied(&normalized) {
435 self.audit_record(&normalized, tool, "Deny");
436 return Decision::Deny;
437 }
438 if self.cache.is_approved(&normalized) {
439 self.audit_record(&normalized, tool, "Allow");
440 return Decision::Allow;
441 }
442 let decision = self.policy.decide(&normalized);
443 match decision {
444 Decision::Allow => self.audit_record(&normalized, tool, "Allow"),
445 Decision::Deny => self.audit_record(&normalized, tool, "Deny"),
446 Decision::Prompt => {}
447 }
448 decision
449 }
450
451 /// Approve `host` for the rest of the session (one-shot). Audit log gets
452 /// `Prompt-Approved`.
453 pub fn approve_session(&self, host: &str, tool: &str) {
454 self.cache.approve(host);
455 self.audit_record(host, tool, "Prompt-Approved");
456 }
457
458 /// Deny `host` for the rest of the session. Audit log gets `Prompt-Denied`.
459 pub fn deny_session(&self, host: &str, tool: &str) {
460 self.cache.deny(host);
461 self.audit_record(host, tool, "Prompt-Denied");
462 }
463
464 /// Persist `host` into the policy's allow list (so it survives the session)
465 /// **and** approve it in-session. Returns the updated policy so callers can
466 /// write it back to disk.
467 pub fn approve_persistent(&mut self, host: &str, tool: &str) -> &NetworkPolicy {
468 self.policy.add_allow(host);
469 self.cache.approve(host);
470 self.audit_record(host, tool, "Prompt-Approved");
471 &self.policy
472 }
473
474 fn audit_record(&self, host: &str, tool: &str, label: &str) {
475 if let Some(auditor) = self.auditor.as_ref() {
476 auditor.record(host, tool, label);
477 }
478 }
479 }
480
481 /// Extract the host portion of a URL, lowercased. Returns `None` if the URL
482 /// can't be parsed or has no host.
483 #[must_use]
484 pub fn host_from_url(url: &str) -> Option<String> {
485 let parsed = reqwest::Url::parse(url.trim()).ok()?;
486 parsed.host_str().map(str::to_ascii_lowercase)
487 }
488
489 #[cfg(test)]
490 mod tests {
491 use super::*;
492 use tempfile::tempdir;
493
494 fn mk(default: Decision, allow: &[&str], deny: &[&str]) -> NetworkPolicy {
495 NetworkPolicy {
496 default: default.into(),
497 allow: allow.iter().map(|s| (*s).to_string()).collect(),
498 deny: deny.iter().map(|s| (*s).to_string()).collect(),
499 audit: false,
500 }
501 }
502
503 #[test]
504 fn exact_match_in_allow_returns_allow() {
505 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
506 assert_eq!(p.decide("api.deepseek.com"), Decision::Allow);
507 }
508
509 #[test]
510 fn unknown_host_returns_default() {
511 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
512 assert_eq!(p.decide("evil.example.com"), Decision::Deny);
513
514 let p2 = mk(Decision::Prompt, &[], &[]);
515 assert_eq!(p2.decide("anything.example"), Decision::Prompt);
516 }
517
518 #[test]
519 fn deny_wins_precedence() {
520 // Acceptance criterion: a host in both allow and deny is denied.
521 let p = mk(Decision::Prompt, &["api.example.com"], &["api.example.com"]);
522 assert_eq!(p.decide("api.example.com"), Decision::Deny);
523 }
524
525 #[test]
526 fn deny_wins_with_subdomain_rules() {
527 // Deny-wins applies even when the deny is a wildcard and the allow is exact.
528 let p = mk(Decision::Allow, &["api.example.com"], &[".example.com"]);
529 assert_eq!(p.decide("api.example.com"), Decision::Deny);
530 }
531
532 #[test]
533 fn subdomain_wildcard_matches_subdomain_only() {
534 let p = mk(Decision::Deny, &[".example.com"], &[]);
535 assert_eq!(p.decide("api.example.com"), Decision::Allow);
536 assert_eq!(p.decide("a.b.example.com"), Decision::Allow);
537 // The bare apex is *not* matched by `.example.com` per the rule.
538 assert_eq!(p.decide("example.com"), Decision::Deny);
539 }
540
541 #[test]
542 fn star_dot_subdomain_alias_is_accepted() {
543 let p = mk(Decision::Deny, &["*.example.com"], &[]);
544 assert_eq!(p.decide("api.example.com"), Decision::Allow);
545 assert_eq!(p.decide("example.com"), Decision::Deny);
546 }
547
548 #[test]
549 fn host_match_is_case_insensitive() {
550 let p = mk(Decision::Deny, &["API.DeepSeek.com"], &[]);
551 assert_eq!(p.decide("api.deepseek.com"), Decision::Allow);
552 }
553
554 #[test]
555 fn trailing_dot_is_ignored() {
556 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
557 assert_eq!(p.decide("api.deepseek.com."), Decision::Allow);
558 }
559
560 #[test]
561 fn empty_host_uses_default() {
562 let p = mk(Decision::Deny, &["api.example.com"], &[]);
563 assert_eq!(p.decide(""), Decision::Deny);
564 assert_eq!(p.decide(" "), Decision::Deny);
565 }
566
567 #[test]
568 fn add_allow_dedupes_case_insensitively() {
569 let mut p = mk(Decision::Deny, &[], &[]);
570 p.add_allow("Example.COM");
571 p.add_allow("example.com");
572 assert_eq!(p.allow.len(), 1);
573 assert_eq!(p.allow[0], "example.com");
574 }
575
576 #[test]
577 fn host_from_url_extracts_host() {
578 assert_eq!(
579 host_from_url("https://api.deepseek.com/health"),
580 Some("api.deepseek.com".to_string())
581 );
582 assert_eq!(
583 host_from_url("http://Example.COM:8080/x"),
584 Some("example.com".to_string())
585 );
586 assert_eq!(host_from_url("not a url"), None);
587 }
588
589 #[test]
590 fn auditor_writes_one_line_per_call() {
591 let dir = tempdir().expect("tempdir");
592 let path = dir.path().join("audit.log");
593 let auditor = NetworkAuditor::new(path.clone(), true);
594 auditor.record("api.example.com", "fetch_url", "Allow");
595 auditor.record("evil.example.com", "fetch_url", "Deny");
596 let body = std::fs::read_to_string(&path).expect("read");
597 let lines: Vec<&str> = body.lines().collect();
598 assert_eq!(lines.len(), 2);
599 for line in &lines {
600 // <ts> network <host> <tool> <decision>
601 let parts: Vec<&str> = line.split_whitespace().collect();
602 assert!(parts.len() >= 5, "line shape: {line}");
603 assert_eq!(parts[1], "network");
604 }
605 assert!(lines[0].contains("api.example.com"));
606 assert!(lines[0].ends_with("Allow"));
607 assert!(lines[1].contains("evil.example.com"));
608 assert!(lines[1].ends_with("Deny"));
609 }
610
611 #[test]
612 fn auditor_disabled_writes_nothing() {
613 let dir = tempdir().expect("tempdir");
614 let path = dir.path().join("audit.log");
615 let auditor = NetworkAuditor::new(path.clone(), false);
616 auditor.record("api.example.com", "fetch_url", "Allow");
617 assert!(!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty());
618 }
619
620 #[test]
621 fn session_cache_short_circuits_evaluate() {
622 let policy = mk(Decision::Prompt, &[], &[]);
623 let decider = NetworkPolicyDecider::new(policy, None);
624 // First call returns Prompt.
625 assert_eq!(
626 decider.evaluate("api.example.com", "fetch_url"),
627 Decision::Prompt
628 );
629 decider.approve_session("api.example.com", "fetch_url");
630 // After approve_session, the same host returns Allow without prompting.
631 assert_eq!(
632 decider.evaluate("api.example.com", "fetch_url"),
633 Decision::Allow
634 );
635 }
636
637 #[test]
638 fn approve_persistent_writes_back_to_policy() {
639 let policy = mk(Decision::Prompt, &[], &[]);
640 let mut decider = NetworkPolicyDecider::new(policy, None);
641 decider.approve_persistent("api.example.com", "fetch_url");
642 assert!(
643 decider
644 .policy()
645 .allow
646 .iter()
647 .any(|h| h == "api.example.com")
648 );
649 // And the session cache also got updated, so fresh evaluate returns Allow.
650 assert_eq!(
651 decider.evaluate("api.example.com", "fetch_url"),
652 Decision::Allow
653 );
654 }
655
656 #[test]
657 fn deny_session_blocks_subsequent_evaluate() {
658 let policy = mk(Decision::Allow, &[], &[]);
659 let decider = NetworkPolicyDecider::new(policy, None);
660 decider.deny_session("evil.example.com", "fetch_url");
661 assert_eq!(
662 decider.evaluate("evil.example.com", "fetch_url"),
663 Decision::Deny
664 );
665 }
666
667 #[test]
668 fn audit_records_terminal_decisions_through_decider() {
669 let dir = tempdir().expect("tempdir");
670 let auditor = NetworkAuditor::new(dir.path().join("audit.log"), true);
671 let policy = mk(Decision::Deny, &["api.deepseek.com"], &[]);
672 let decider = NetworkPolicyDecider::new(policy, Some(auditor));
673
674 let allow = decider.evaluate("api.deepseek.com", "fetch_url");
675 let deny = decider.evaluate("evil.example.com", "fetch_url");
676 assert_eq!(allow, Decision::Allow);
677 assert_eq!(deny, Decision::Deny);
678
679 let body = std::fs::read_to_string(dir.path().join("audit.log")).expect("read");
680 let lines: Vec<&str> = body.lines().collect();
681 assert_eq!(lines.len(), 2);
682 assert!(lines[0].ends_with("Allow"));
683 assert!(lines[1].ends_with("Deny"));
684 }
685
686 #[test]
687 fn decision_parse_unknown_falls_back_to_prompt() {
688 assert_eq!(Decision::parse("allow"), Decision::Allow);
689 assert_eq!(Decision::parse("Deny"), Decision::Deny);
690 assert_eq!(Decision::parse("BLOCK"), Decision::Deny);
691 assert_eq!(Decision::parse("prompt"), Decision::Prompt);
692 assert_eq!(Decision::parse("garbage"), Decision::Prompt);
693 }
694
695 #[test]
696 fn network_denied_carries_host() {
697 let err = NetworkDenied("api.example.com".to_string());
698 assert_eq!(err.host(), "api.example.com");
699 assert!(format!("{err}").contains("api.example.com"));
700 }
701 }
702
702 lines RUST