1use std::fmt;
2use std::path::PathBuf;
3
4use crate::command_runner::CommandRunner;
5use crate::inspection::{detect_platform, inspect_system_proxy_with_runner, SystemProxySettings};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CompatibilityProxyKind {
9 Http,
10 Socks5,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18pub struct Command {
19 pub program: String,
20 pub args: Vec<String>,
21}
22
23impl Command {
24 pub fn new(program: impl Into<String>, args: Vec<String>) -> Self {
25 Self {
26 program: program.into(),
27 args,
28 }
29 }
30}
31
32impl fmt::Display for Command {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 write!(f, "{}", self.program)?;
35 for arg in &self.args {
36 write!(f, " {}", arg)?;
37 }
38 Ok(())
39 }
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct ApplyPlan {
45 pub platform: String,
47 pub service: Option<String>,
49 pub http_proxy: Option<String>,
51 pub https_proxy: Option<String>,
53 pub socks_proxy: Option<String>,
55 pub no_proxy: Option<String>,
57 pub commands: Vec<Command>,
59 pub previous_settings: Option<SystemProxySettings>,
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct RollbackState {
66 pub timestamp: String,
68 pub platform: String,
70 pub service: Option<String>,
72 pub http_proxy: Option<String>,
74 pub https_proxy: Option<String>,
76 pub socks_proxy: Option<String>,
78 pub no_proxy: Option<String>,
80 #[serde(default)]
82 pub proxy_enabled: Option<bool>,
83}
84
85pub struct AppliedProxy {
87 rollback: Option<RollbackState>,
88}
89
90impl AppliedProxy {
91 pub fn restore(&mut self) -> Result<(), String> {
92 let Some(rollback) = self.rollback.take() else {
93 return Ok(());
94 };
95 let runner = crate::command_runner::RealCommandRunner;
96 restore_with_runner(&rollback, &runner)
97 }
98
99 pub fn restore_with_runner(&mut self, runner: &dyn CommandRunner) -> Result<(), String> {
100 let Some(rollback) = self.rollback.take() else {
101 return Ok(());
102 };
103 restore_with_runner(&rollback, runner)
104 }
105}
106
107impl Drop for AppliedProxy {
108 fn drop(&mut self) {
109 let _ = self.restore();
110 }
111}
112
113pub fn apply_compatibility_proxy(
117 kind: CompatibilityProxyKind,
118 address: std::net::SocketAddr,
119) -> Result<AppliedProxy, String> {
120 let runner = crate::command_runner::RealCommandRunner;
121 apply_compatibility_proxy_with_runner(kind, address, &runner)
122}
123
124pub fn apply_compatibility_proxy_with_runner(
125 kind: CompatibilityProxyKind,
126 address: std::net::SocketAddr,
127 runner: &dyn CommandRunner,
128) -> Result<AppliedProxy, String> {
129 let platform = detect_platform();
135 let inspection = inspect_system_proxy_with_runner(runner);
136 if !inspection.apply_supported {
137 return Err(format!(
138 "system proxy apply is unavailable on platform '{platform}'"
139 ));
140 }
141 let settings = inspection
142 .settings
143 .ok_or_else(|| "unable to inspect current system proxy settings".to_string())?;
144 let endpoint = address.to_string();
145 let (http, https, socks) = match kind {
146 CompatibilityProxyKind::Http => (Some(endpoint.clone()), Some(endpoint), None),
147 CompatibilityProxyKind::Socks5 => (None, None, Some(endpoint)),
148 };
149 let plan = plan_apply(
150 &platform,
151 None,
152 http.as_deref(),
153 https.as_deref(),
154 socks.as_deref(),
155 None,
156 Some(&settings),
157 );
158 let rollback = create_rollback(&platform, plan.service.as_deref(), &settings);
159 if let Err(error) = execute_apply(&plan, runner) {
160 let rollback_error = restore_with_runner(&rollback, runner).err();
161 return Err(match rollback_error {
162 Some(rollback_error) => format!("{error}; rollback failed: {rollback_error}"),
163 None => error,
164 });
165 }
166 Ok(AppliedProxy {
167 rollback: Some(rollback),
168 })
169}
170
171fn restore_with_runner(rollback: &RollbackState, runner: &dyn CommandRunner) -> Result<(), String> {
172 for command in generate_revert_commands(rollback) {
173 let args: Vec<&str> = command.args.iter().map(String::as_str).collect();
174 runner
175 .run(&command.program, &args)
176 .map_err(|error| format!("failed to restore '{command}': {error}"))?;
177 }
178 Ok(())
179}
180
181impl RollbackState {
182 pub fn save(&self, path: &PathBuf) -> Result<(), String> {
184 let json = serde_json::to_string_pretty(self)
185 .map_err(|e| format!("failed to serialize rollback state: {e}"))?;
186 use std::io::Write;
187 let mut options = std::fs::OpenOptions::new();
188 options.write(true).create_new(true);
189 #[cfg(unix)]
190 {
191 use std::os::unix::fs::OpenOptionsExt;
192 options.mode(0o600);
193 }
194 options
195 .open(path)
196 .and_then(|mut file| file.write_all(json.as_bytes()))
197 .map_err(|e| format!("failed to write rollback file: {e}"))
198 }
199
200 pub fn load(path: &PathBuf) -> Result<Self, String> {
202 let json = std::fs::read_to_string(path)
203 .map_err(|e| format!("failed to read rollback file: {e}"))?;
204 serde_json::from_str(&json).map_err(|e| format!("failed to parse rollback file: {e}"))
205 }
206}
207
208pub fn plan_apply(
210 platform: &str,
211 service: Option<&str>,
212 http_proxy: Option<&str>,
213 https_proxy: Option<&str>,
214 socks_proxy: Option<&str>,
215 no_proxy: Option<&str>,
216 current_settings: Option<&SystemProxySettings>,
217) -> ApplyPlan {
218 let commands = match platform {
219 "macos" => {
220 let svc = service.unwrap_or("*Wi-Fi");
221 crate::backends::macos::generate_macos_apply_commands(
222 svc,
223 http_proxy,
224 https_proxy,
225 socks_proxy,
226 no_proxy,
227 )
228 }
229 "windows" => crate::backends::windows::generate_windows_apply_commands(
230 http_proxy,
231 https_proxy,
232 socks_proxy,
233 no_proxy,
234 ),
235 "linux" => crate::backends::linux::generate_gnome_apply_commands(
236 http_proxy,
237 https_proxy,
238 socks_proxy,
239 no_proxy,
240 ),
241 _ => Vec::new(),
242 };
243
244 ApplyPlan {
245 platform: platform.to_string(),
246 service: service.map(|s| s.to_string()),
247 http_proxy: http_proxy.map(|s| s.to_string()),
248 https_proxy: https_proxy.map(|s| s.to_string()),
249 socks_proxy: socks_proxy.map(|s| s.to_string()),
250 no_proxy: no_proxy.map(|s| s.to_string()),
251 commands,
252 previous_settings: current_settings.cloned(),
253 }
254}
255
256pub fn create_rollback(
258 platform: &str,
259 service: Option<&str>,
260 settings: &SystemProxySettings,
261) -> RollbackState {
262 let proxy_enabled = settings.raw.get("ProxyEnable").map(|v| v == "1");
263 RollbackState {
264 timestamp: chrono_timestamp(),
265 platform: platform.to_string(),
266 service: service.map(|s| s.to_string()),
267 http_proxy: settings.http_proxy.clone(),
268 https_proxy: settings.https_proxy.clone(),
269 socks_proxy: settings.socks_proxy.clone(),
270 no_proxy: settings.no_proxy.clone(),
271 proxy_enabled,
272 }
273}
274
275pub fn execute_apply(plan: &ApplyPlan, runner: &dyn CommandRunner) -> Result<Vec<Command>, String> {
277 let mut executed = Vec::new();
278 for cmd in &plan.commands {
279 let arg_refs: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
280 runner
281 .run(&cmd.program, &arg_refs)
282 .map_err(|e| format!("failed to execute '{cmd}': {e}"))?;
283 executed.push(cmd.clone());
284 }
285 Ok(executed)
286}
287
288pub fn generate_revert_commands(rollback: &RollbackState) -> Vec<Command> {
290 match rollback.platform.as_str() {
291 "macos" => {
292 let svc = rollback.service.as_deref().unwrap_or("*Wi-Fi");
293 let mut commands = crate::backends::macos::generate_macos_disable_commands(svc);
294 if let Some(ref http) = rollback.http_proxy {
295 if !http.is_empty() {
296 commands.push(Command::new(
297 "networksetup",
298 vec!["-setwebproxy".into(), svc.into(), "on".into()],
299 ));
300 commands.push(Command::new(
301 "networksetup",
302 vec!["-setwebproxyservers".into(), svc.into(), http.clone()],
303 ));
304 }
305 }
306 if let Some(ref https) = rollback.https_proxy {
307 if !https.is_empty() {
308 commands.push(Command::new(
309 "networksetup",
310 vec!["-setsecurewebproxy".into(), svc.into(), "on".into()],
311 ));
312 commands.push(Command::new(
313 "networksetup",
314 vec![
315 "-setsecurewebproxyservers".into(),
316 svc.into(),
317 https.clone(),
318 ],
319 ));
320 }
321 }
322 if let Some(ref socks) = rollback.socks_proxy {
323 if !socks.is_empty() {
324 commands.push(Command::new(
325 "networksetup",
326 vec!["-setsocksfirewallproxy".into(), svc.into(), "on".into()],
327 ));
328 commands.push(Command::new(
329 "networksetup",
330 vec![
331 "-setsocksfirewallproxyserver".into(),
332 svc.into(),
333 socks.clone(),
334 ],
335 ));
336 }
337 }
338 commands
339 }
340 "windows" => {
341 let mut commands = crate::backends::windows::generate_windows_disable_commands();
342 let mut parts = Vec::new();
343 if let Some(ref http) = rollback.http_proxy {
344 parts.push(format!("http={http}"));
345 }
346 if let Some(ref https) = rollback.https_proxy {
347 parts.push(format!("https={https}"));
348 }
349 if let Some(ref socks) = rollback.socks_proxy {
350 parts.push(format!("socks={socks}"));
351 }
352 if !parts.is_empty() {
353 let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
354 let proxy_value = parts.join(";");
355 commands.push(Command::new(
356 "reg",
357 vec![
358 "add".into(),
359 key.into(),
360 "/v".into(),
361 "ProxyServer".into(),
362 "/t".into(),
363 "REG_SZ".into(),
364 "/d".into(),
365 proxy_value,
366 "/f".into(),
367 ],
368 ));
369 if rollback.proxy_enabled.unwrap_or(true) {
370 commands.push(Command::new(
371 "reg",
372 vec![
373 "add".into(),
374 key.into(),
375 "/v".into(),
376 "ProxyEnable".into(),
377 "/t".into(),
378 "REG_DWORD".into(),
379 "/d".into(),
380 "1".into(),
381 "/f".into(),
382 ],
383 ));
384 }
385 }
386 commands
387 }
388 "linux" => {
389 let mut commands = crate::backends::linux::generate_gnome_disable_commands();
390 let has_any = rollback.http_proxy.is_some()
391 || rollback.https_proxy.is_some()
392 || rollback.socks_proxy.is_some();
393 if has_any {
394 commands.extend(crate::backends::linux::generate_gnome_apply_commands(
395 rollback.http_proxy.as_deref(),
396 rollback.https_proxy.as_deref(),
397 rollback.socks_proxy.as_deref(),
398 rollback.no_proxy.as_deref(),
399 ));
400 }
401 commands
402 }
403 _ => Vec::new(),
404 }
405}
406
407fn chrono_timestamp() -> String {
408 let now = std::time::SystemTime::now()
410 .duration_since(std::time::UNIX_EPOCH)
411 .unwrap_or_default()
412 .as_nanos();
413 format!("{now}")
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 #[cfg(unix)]
420 use crate::command_runner::MockCommandRunner;
421 use std::collections::HashMap;
422
423 #[test]
424 fn plan_apply_macos_produces_commands() {
425 let plan = plan_apply(
426 "macos",
427 Some("*Wi-Fi"),
428 Some("proxy:8080"),
429 Some("proxy:8443"),
430 None,
431 None,
432 None,
433 );
434 assert_eq!(plan.platform, "macos");
435 assert!(plan
436 .commands
437 .iter()
438 .any(|c| c.to_string().contains("networksetup")));
439 }
440
441 #[test]
442 fn plan_apply_windows_produces_commands() {
443 let plan = plan_apply("windows", None, Some("proxy:8080"), None, None, None, None);
444 assert_eq!(plan.platform, "windows");
445 assert!(plan
446 .commands
447 .iter()
448 .any(|c| c.to_string().contains("reg add")));
449 }
450
451 #[test]
452 fn plan_apply_linux_produces_commands() {
453 let plan = plan_apply("linux", None, Some("proxy:8080"), None, None, None, None);
454 assert_eq!(plan.platform, "linux");
455 assert!(plan
456 .commands
457 .iter()
458 .any(|c| c.to_string().contains("gsettings")));
459 }
460
461 #[cfg(unix)]
462 #[test]
463 fn execute_apply_preserves_spaces_in_args() {
464 use std::os::unix::process::ExitStatusExt;
465 let runner = MockCommandRunner::new().add_always(
466 "networksetup",
467 Ok(std::process::Output {
468 status: std::process::ExitStatus::from_raw(0),
469 stdout: Vec::new(),
470 stderr: Vec::new(),
471 }),
472 );
473
474 let plan = ApplyPlan {
475 platform: "macos".to_string(),
476 service: Some("USB 10/100/1000 LAN".to_string()),
477 http_proxy: Some("proxy:8080".to_string()),
478 https_proxy: None,
479 socks_proxy: None,
480 no_proxy: None,
481 commands: vec![Command::new(
482 "networksetup",
483 vec![
484 "-setwebproxy".into(),
485 "USB 10/100/1000 LAN".into(),
486 "on".into(),
487 ],
488 )],
489 previous_settings: None,
490 };
491
492 let _ = execute_apply(&plan, &runner).unwrap();
493 let calls = runner.calls();
494 assert_eq!(calls[0].0, "networksetup");
495 assert_eq!(
496 calls[0].1,
497 vec!["-setwebproxy", "USB 10/100/1000 LAN", "on"]
498 );
499 }
500
501 #[test]
502 fn rollback_state_save_and_load() {
503 let state = RollbackState {
504 timestamp: "12345".to_string(),
505 platform: "macos".to_string(),
506 service: Some("*Wi-Fi".to_string()),
507 http_proxy: Some("old-proxy:8080".to_string()),
508 https_proxy: None,
509 socks_proxy: None,
510 no_proxy: None,
511 proxy_enabled: None,
512 };
513
514 let dir = tempfile::tempdir().unwrap();
515 let path = dir.path().join("rollback.json");
516 state.save(&path).unwrap();
517
518 let loaded = RollbackState::load(&path).unwrap();
519 assert_eq!(loaded.platform, "macos");
520 assert_eq!(loaded.http_proxy.as_deref(), Some("old-proxy:8080"));
521 }
522
523 #[cfg(unix)]
524 #[test]
525 fn rollback_state_file_is_owner_only() {
526 use std::os::unix::fs::PermissionsExt;
527
528 let state = RollbackState {
529 timestamp: "12345".to_string(),
530 platform: "linux".to_string(),
531 service: None,
532 http_proxy: Some("http://user:secret@proxy:8080".to_string()),
533 https_proxy: None,
534 socks_proxy: None,
535 no_proxy: None,
536 proxy_enabled: None,
537 };
538 let dir = tempfile::tempdir().unwrap();
539 let path = dir.path().join("rollback.json");
540
541 state.save(&path).unwrap();
542
543 assert_eq!(
544 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
545 0o600
546 );
547 }
548
549 #[test]
550 fn create_rollback_from_settings() {
551 let settings = SystemProxySettings {
552 source: "test".to_string(),
553 http_proxy: Some("http://proxy:8080".to_string()),
554 https_proxy: None,
555 socks_proxy: None,
556 no_proxy: None,
557 raw: HashMap::new(),
558 };
559 let rollback = create_rollback("macos", Some("*Wi-Fi"), &settings);
560 assert_eq!(rollback.http_proxy.as_deref(), Some("http://proxy:8080"));
561 assert_eq!(rollback.platform, "macos");
562 }
563
564 #[cfg(unix)]
565 #[test]
566 fn execute_apply_runs_commands() {
567 use std::os::unix::process::ExitStatusExt;
568 let runner = MockCommandRunner::new().add_always(
569 "networksetup",
570 Ok(std::process::Output {
571 status: std::process::ExitStatus::from_raw(0),
572 stdout: Vec::new(),
573 stderr: Vec::new(),
574 }),
575 );
576
577 let plan = ApplyPlan {
578 platform: "macos".to_string(),
579 service: Some("*Wi-Fi".to_string()),
580 http_proxy: Some("proxy:8080".to_string()),
581 https_proxy: None,
582 socks_proxy: None,
583 no_proxy: None,
584 commands: vec![Command::new(
585 "networksetup",
586 vec!["-setwebproxy".into(), "*Wi-Fi".into(), "on".into()],
587 )],
588 previous_settings: None,
589 };
590
591 let executed = execute_apply(&plan, &runner).unwrap();
592 assert_eq!(executed.len(), 1);
593 let calls = runner.calls();
594 assert_eq!(calls[0].0, "networksetup");
595 assert_eq!(calls[0].1, vec!["-setwebproxy", "*Wi-Fi", "on"]);
596 }
597
598 #[test]
599 fn revert_commands_macos() {
600 let rollback = RollbackState {
601 timestamp: "12345".to_string(),
602 platform: "macos".to_string(),
603 service: Some("*Wi-Fi".to_string()),
604 http_proxy: Some("proxy:8080".to_string()),
605 https_proxy: None,
606 socks_proxy: None,
607 no_proxy: None,
608 proxy_enabled: None,
609 };
610 let commands = generate_revert_commands(&rollback);
611 assert!(commands.iter().any(|c| c.to_string().contains("off")));
612 assert!(commands
613 .iter()
614 .any(|c| c.to_string().contains("setwebproxy")));
615 }
616
617 #[cfg(unix)]
618 #[test]
619 fn applied_proxy_restore_is_idempotent() {
620 use std::os::unix::process::ExitStatusExt;
621 let runner = MockCommandRunner::new().add_always(
622 "networksetup",
623 Ok(std::process::Output {
624 status: std::process::ExitStatus::from_raw(0),
625 stdout: Vec::new(),
626 stderr: Vec::new(),
627 }),
628 );
629 let rollback = RollbackState {
630 timestamp: "12345".to_string(),
631 platform: "macos".to_string(),
632 service: Some("*Wi-Fi".to_string()),
633 http_proxy: None,
634 https_proxy: None,
635 socks_proxy: None,
636 no_proxy: None,
637 proxy_enabled: None,
638 };
639 let mut applied = AppliedProxy {
640 rollback: Some(rollback),
641 };
642 applied.restore_with_runner(&runner).unwrap();
643 applied.restore_with_runner(&runner).unwrap();
644 assert!(!runner.calls().is_empty());
645 }
646}