Skip to main content

singbox_driver/
version.rs

1//! Native sing-box binary version probing.
2//!
3//! The probe reads the version the executable reports for itself. It never
4//! infers the running version from a download manifest or an executable path.
5
6use 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/// Reads the version reported by one sing-box executable.
14#[async_trait]
15pub trait SingBoxBinaryInspector: Send + Sync {
16    /// Reads the version the executable reports for itself.
17    async fn version(&self, binary: &Path) -> Result<CoreVersion, PortError>;
18}
19
20/// The production binary inspector that runs the executable.
21#[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
43/// Parses the first line of `sing-box -v` output into the canonical
44/// `v<major>.<minor>.<patch>` identity used by operation evidence locks.
45pub 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
63/// Rejects versions that cannot be used as an exact on-disk and identity
64/// selector. Accepts only `v<major>.<minor>.<patch>` with optional pre-release
65/// or build suffixes.
66pub 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}