singbox_driver/
version.rs1use async_trait::async_trait;
7use lurker_core_contract::error::{ErrorCode, Failure};
8use lurker_core_contract::identity::CoreVersion;
9use lurker_core_ports::error::PortError;
10use std::path::Path;
11use tokio::process::Command;
12
13#[async_trait]
15pub trait SingBoxBinaryInspector: Send + Sync {
16 async fn version(&self, binary: &Path) -> Result<CoreVersion, PortError>;
18}
19
20#[derive(Clone, Copy, Debug, Default)]
22pub struct ProcessSingBoxBinaryInspector;
23
24#[async_trait]
25impl SingBoxBinaryInspector for ProcessSingBoxBinaryInspector {
26 async fn version(&self, binary: &Path) -> Result<CoreVersion, PortError> {
27 let output = Command::new(binary)
28 .arg("-v")
29 .output()
30 .await
31 .map_err(|error| PortError::Failed(format!("run sing-box version probe: {error}")))?;
32 if !output.status.success() {
33 return Err(PortError::Failed(format!(
34 "sing-box version probe exited with {}",
35 output.status
36 )));
37 }
38 let stdout = String::from_utf8_lossy(&output.stdout);
39 parse_binary_version(&stdout)
40 }
41}
42
43pub fn parse_binary_version(output: &str) -> Result<CoreVersion, PortError> {
46 let first_line = output.lines().next().unwrap_or_default().trim();
47 let version = first_line
48 .strip_prefix("sing-box version ")
49 .or_else(|| first_line.strip_prefix("sing-box "))
50 .ok_or_else(|| {
51 PortError::Failed("sing-box version output has an unexpected engine identity".into())
52 })?;
53 let version = version.split_whitespace().next().unwrap_or_default();
54 let canonical = if version.starts_with('v') {
55 version.to_owned()
56 } else {
57 format!("v{version}")
58 };
59 validate_version(&canonical)?;
60 CoreVersion::new(canonical).map_err(|error| PortError::Failed(error.to_string()))
61}
62
63pub fn validate_version(version: &str) -> Result<(), PortError> {
67 let canonical = version.strip_prefix('v').ok_or_else(|| {
68 invalid_version("sing-box version must use the canonical v<version> form")
69 })?;
70 let numeric = canonical.split(['-', '+']).next().unwrap_or_default();
71 let components: Vec<&str> = numeric.split('.').collect();
72 if canonical.is_empty()
73 || components.len() != 3
74 || components.iter().any(|component| {
75 component.is_empty() || !component.bytes().all(|byte| byte.is_ascii_digit())
76 })
77 || !canonical
78 .bytes()
79 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'))
80 {
81 return Err(invalid_version(
82 "sing-box version contains invalid filesystem or identity characters",
83 ));
84 }
85 CoreVersion::new(version.to_owned())
86 .map(|_| ())
87 .map_err(|error| invalid_version(error.to_string()))
88}
89
90fn invalid_version(reason: impl Into<String>) -> PortError {
91 PortError::Rejected(Failure::new(ErrorCode::Configuration, reason, false))
92}
93
94#[cfg(test)]
95mod tests {
96 use super::parse_binary_version;
97
98 #[test]
99 fn probe_requires_sing_box_identity_and_canonical_version() {
100 let version =
101 parse_binary_version("sing-box version 1.14.3\nEnvironment: go1.25.0 linux/amd64\n")
102 .unwrap();
103 assert_eq!(version.raw(), "v1.14.3");
104 assert!(parse_binary_version("mihomo version v1.14.3").is_err());
105 assert!(parse_binary_version("sing-box version unknown").is_err());
106 assert!(parse_binary_version("sing-box version 1.14").is_err());
107 }
108}