返回 DeepSeek-TUI-2026
network.rs
根目录 / crates / tui / src / commands / network.rs
1 //! Slash commands for the persistent network allow/deny list.
2
3 use std::fs;
4 use std::path::Path;
5
6 use anyhow::{Context, bail};
7 use toml::Value;
8
9 use super::CommandResult;
10 use crate::network_policy::host_from_url;
11 use crate::tui::app::App;
12
13 pub fn network(_app: &mut App, arg: Option<&str>) -> CommandResult {
14 match network_inner(arg) {
15 Ok(message) => CommandResult::message(message),
16 Err(err) => CommandResult::error(err.to_string()),
17 }
18 }
19
20 fn network_inner(arg: Option<&str>) -> anyhow::Result<String> {
21 let raw = arg.map(str::trim).unwrap_or("");
22 if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
23 return list_policy();
24 }
25
26 let mut parts = raw.split_whitespace();
27 let Some(command) = parts.next() else {
28 return list_policy();
29 };
30 let command = command.to_ascii_lowercase();
31
32 match command.as_str() {
33 "allow" | "deny" | "remove" | "forget" => {
34 let Some(host_arg) = parts.next() else {
35 bail!("Usage: /network {command} <host>");
36 };
37 if parts.next().is_some() {
38 bail!("Usage: /network {command} <host>");
39 }
40 let host = normalize_host_arg(host_arg)?;
41 let edit = match command.as_str() {
42 "allow" => NetworkEdit::Allow,
43 "deny" => NetworkEdit::Deny,
44 _ => NetworkEdit::Remove,
45 };
46 update_host(edit, &host)
47 }
48 "default" => {
49 let Some(value) = parts.next() else {
50 bail!("Usage: /network default <allow|deny|prompt>");
51 };
52 if parts.next().is_some() {
53 bail!("Usage: /network default <allow|deny|prompt>");
54 }
55 update_default(value)
56 }
57 _ => bail!(usage()),
58 }
59 }
60
61 fn usage() -> &'static str {
62 "Usage: /network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]"
63 }
64
65 #[derive(Clone, Copy)]
66 enum NetworkEdit {
67 Allow,
68 Deny,
69 Remove,
70 }
71
72 fn list_policy() -> anyhow::Result<String> {
73 let path = super::config::config_toml_path()?;
74 let doc = load_config_doc(&path)?;
75 let network = doc.get("network").and_then(Value::as_table);
76 let default = network
77 .and_then(|table| table.get("default"))
78 .and_then(Value::as_str)
79 .unwrap_or("prompt");
80 let allow = network
81 .map(|table| string_array(table, "allow"))
82 .unwrap_or_default();
83 let deny = network
84 .map(|table| string_array(table, "deny"))
85 .unwrap_or_default();
86
87 Ok(format!(
88 "Network policy ({})\n\
89 default = {default}\n\
90 allow = {}\n\
91 deny = {}\n\n\
92 Use `/network allow <host>` to allow a host, `/network deny <host>` to block it, or `/network remove <host>` to clear an entry.",
93 path.display(),
94 display_list(&allow),
95 display_list(&deny)
96 ))
97 }
98
99 fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result<String> {
100 let path = super::config::config_toml_path()?;
101 let mut doc = load_config_doc(&path)?;
102 let network = network_table_mut(&mut doc)?;
103
104 match edit {
105 NetworkEdit::Allow => {
106 remove_host(network, "deny", host)?;
107 add_host(network, "allow", host)?;
108 }
109 NetworkEdit::Deny => {
110 remove_host(network, "allow", host)?;
111 add_host(network, "deny", host)?;
112 }
113 NetworkEdit::Remove => {
114 remove_host(network, "allow", host)?;
115 remove_host(network, "deny", host)?;
116 }
117 }
118
119 save_config_doc(&path, &doc)?;
120 let action = match edit {
121 NetworkEdit::Allow => "allowed",
122 NetworkEdit::Deny => "denied",
123 NetworkEdit::Remove => "removed",
124 };
125 Ok(format!(
126 "Network host {action}: {host}\nSaved to {}. Retry the command now.",
127 path.display()
128 ))
129 }
130
131 fn update_default(value: &str) -> anyhow::Result<String> {
132 let normalized = match value.trim().to_ascii_lowercase().as_str() {
133 "allow" => "allow",
134 "deny" | "block" => "deny",
135 "prompt" | "ask" => "prompt",
136 _ => bail!("Usage: /network default <allow|deny|prompt>"),
137 };
138
139 let path = super::config::config_toml_path()?;
140 let mut doc = load_config_doc(&path)?;
141 let network = network_table_mut(&mut doc)?;
142 network.insert("default".to_string(), Value::String(normalized.to_string()));
143 save_config_doc(&path, &doc)?;
144
145 Ok(format!(
146 "Network default set to {normalized}\nSaved to {}.",
147 path.display()
148 ))
149 }
150
151 fn load_config_doc(path: &Path) -> anyhow::Result<Value> {
152 if !path.exists() {
153 return Ok(Value::Table(toml::value::Table::new()));
154 }
155 let raw = fs::read_to_string(path)
156 .with_context(|| format!("failed to read config at {}", path.display()))?;
157 toml::from_str(&raw).with_context(|| format!("failed to parse config at {}", path.display()))
158 }
159
160 fn save_config_doc(path: &Path, doc: &Value) -> anyhow::Result<()> {
161 if let Some(parent) = path.parent() {
162 fs::create_dir_all(parent)
163 .with_context(|| format!("failed to create config directory {}", parent.display()))?;
164 }
165 let body = toml::to_string_pretty(doc).context("failed to serialize config.toml")?;
166 fs::write(path, body).with_context(|| format!("failed to write config at {}", path.display()))
167 }
168
169 fn network_table_mut(doc: &mut Value) -> anyhow::Result<&mut toml::value::Table> {
170 let root = doc
171 .as_table_mut()
172 .context("config.toml root must be a table")?;
173 let entry = root
174 .entry("network".to_string())
175 .or_insert_with(|| Value::Table(toml::value::Table::new()));
176 let table = entry
177 .as_table_mut()
178 .context("`network` section in config.toml must be a table")?;
179 table
180 .entry("default".to_string())
181 .or_insert_with(|| Value::String("prompt".to_string()));
182 table
183 .entry("audit".to_string())
184 .or_insert_with(|| Value::Boolean(true));
185 Ok(table)
186 }
187
188 fn string_array(table: &toml::value::Table, key: &str) -> Vec<String> {
189 table
190 .get(key)
191 .and_then(Value::as_array)
192 .into_iter()
193 .flatten()
194 .filter_map(Value::as_str)
195 .map(ToString::to_string)
196 .collect()
197 }
198
199 fn string_array_mut<'a>(
200 table: &'a mut toml::value::Table,
201 key: &str,
202 ) -> anyhow::Result<&'a mut Vec<Value>> {
203 let value = table
204 .entry(key.to_string())
205 .or_insert_with(|| Value::Array(Vec::new()));
206 value
207 .as_array_mut()
208 .with_context(|| format!("`network.{key}` must be an array of strings"))
209 }
210
211 fn add_host(table: &mut toml::value::Table, key: &str, host: &str) -> anyhow::Result<()> {
212 let list = string_array_mut(table, key)?;
213 if !list
214 .iter()
215 .filter_map(Value::as_str)
216 .any(|existing| normalize_host_for_compare(existing) == host)
217 {
218 list.push(Value::String(host.to_string()));
219 }
220 Ok(())
221 }
222
223 fn remove_host(table: &mut toml::value::Table, key: &str, host: &str) -> anyhow::Result<()> {
224 let list = string_array_mut(table, key)?;
225 list.retain(|value| {
226 value
227 .as_str()
228 .is_none_or(|existing| normalize_host_for_compare(existing) != host)
229 });
230 Ok(())
231 }
232
233 fn normalize_host_arg(input: &str) -> anyhow::Result<String> {
234 let trimmed = input.trim();
235 let host = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
236 host_from_url(trimmed).context("URL must include a host")?
237 } else {
238 if trimmed.contains("://") || trimmed.contains('/') {
239 bail!("Pass a host like `github.com`, not a URL path");
240 }
241 trimmed.to_string()
242 };
243
244 let normalized = normalize_host_for_compare(&host);
245 if normalized.is_empty() {
246 bail!("host cannot be empty");
247 }
248 Ok(normalized)
249 }
250
251 fn normalize_host_for_compare(host: &str) -> String {
252 let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
253 if let Some(rest) = trimmed.strip_prefix("*.") {
254 format!(".{rest}")
255 } else {
256 trimmed
257 }
258 }
259
260 fn display_list(values: &[String]) -> String {
261 if values.is_empty() {
262 "[]".to_string()
263 } else {
264 format!("[{}]", values.join(", "))
265 }
266 }
267
268 #[cfg(test)]
269 mod tests {
270 use super::*;
271 use crate::config::Config;
272 use crate::test_support::lock_test_env;
273 use crate::tui::app::{App, TuiOptions};
274 use std::env;
275 use std::ffi::OsString;
276 use std::path::PathBuf;
277 use std::time::{SystemTime, UNIX_EPOCH};
278
279 struct EnvGuard {
280 home: Option<OsString>,
281 userprofile: Option<OsString>,
282 deepseek_config_path: Option<OsString>,
283 }
284
285 impl EnvGuard {
286 fn new(home: &Path) -> Self {
287 let config_path = home.join(".deepseek").join("config.toml");
288 let home_prev = env::var_os("HOME");
289 let userprofile_prev = env::var_os("USERPROFILE");
290 let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
291
292 // Safety: test-only environment mutation guarded by a global mutex.
293 unsafe {
294 env::set_var("HOME", home.as_os_str());
295 env::set_var("USERPROFILE", home.as_os_str());
296 env::set_var("DEEPSEEK_CONFIG_PATH", config_path.as_os_str());
297 }
298
299 Self {
300 home: home_prev,
301 userprofile: userprofile_prev,
302 deepseek_config_path: deepseek_config_prev,
303 }
304 }
305 }
306
307 impl Drop for EnvGuard {
308 fn drop(&mut self) {
309 restore_env("HOME", self.home.take());
310 restore_env("USERPROFILE", self.userprofile.take());
311 restore_env("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take());
312 }
313 }
314
315 fn restore_env(key: &str, value: Option<OsString>) {
316 // Safety: test-only environment mutation guarded by a global mutex.
317 unsafe {
318 if let Some(value) = value {
319 env::set_var(key, value);
320 } else {
321 env::remove_var(key);
322 }
323 }
324 }
325
326 fn temp_home(label: &str) -> PathBuf {
327 let nanos = SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .unwrap()
330 .as_nanos();
331 let path = env::temp_dir().join(format!(
332 "deepseek-network-{label}-{}-{nanos}",
333 std::process::id()
334 ));
335 fs::create_dir_all(&path).unwrap();
336 path
337 }
338
339 fn create_test_app(home: &Path) -> App {
340 let options = TuiOptions {
341 model: "test-model".to_string(),
342 workspace: home.to_path_buf(),
343 config_path: None,
344 config_profile: None,
345 allow_shell: false,
346 use_alt_screen: true,
347 use_mouse_capture: false,
348 use_bracketed_paste: true,
349 max_subagents: 1,
350 skills_dir: home.join("skills"),
351 memory_path: home.join("memory.md"),
352 notes_path: home.join("notes.txt"),
353 mcp_config_path: home.join("mcp.json"),
354 use_memory: false,
355 start_in_agent_mode: false,
356 skip_onboarding: true,
357 yolo: false,
358 resume_session_id: None,
359 initial_input: None,
360 };
361 App::new(options, &Config::default())
362 }
363
364 #[test]
365 fn network_allow_persists_host_and_removes_exact_deny() {
366 let _lock = lock_test_env();
367 let home = temp_home("allow");
368 let _guard = EnvGuard::new(&home);
369 let config_path = home.join(".deepseek").join("config.toml");
370 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
371 fs::write(
372 &config_path,
373 "[network]\ndefault = \"prompt\"\ndeny = [\"github.com\"]\n",
374 )
375 .unwrap();
376
377 let mut app = create_test_app(&home);
378 let result = network(&mut app, Some("allow GitHub.COM"));
379
380 assert!(!result.is_error, "{:?}", result.message);
381 let body = fs::read_to_string(config_path).unwrap();
382 assert!(body.contains("allow = [\"github.com\"]"), "{body}");
383 assert!(body.contains("deny = []"), "{body}");
384 }
385
386 #[test]
387 fn network_allow_extracts_host_from_url() {
388 let _lock = lock_test_env();
389 let home = temp_home("url");
390 let _guard = EnvGuard::new(&home);
391
392 let mut app = create_test_app(&home);
393 let result = network(&mut app, Some("allow https://github.com/obra/superpowers"));
394
395 assert!(!result.is_error, "{:?}", result.message);
396 let body = fs::read_to_string(home.join(".deepseek").join("config.toml")).unwrap();
397 assert!(body.contains("allow = [\"github.com\"]"), "{body}");
398 }
399
400 #[test]
401 fn network_default_rejects_unknown_value() {
402 let _lock = lock_test_env();
403 let home = temp_home("default");
404 let _guard = EnvGuard::new(&home);
405
406 let mut app = create_test_app(&home);
407 let result = network(&mut app, Some("default maybe"));
408
409 assert!(result.is_error);
410 assert!(
411 result
412 .message
413 .as_deref()
414 .unwrap_or_default()
415 .contains("/network default <allow|deny|prompt>")
416 );
417 }
418 }
419
419 lines RUST