Skip to main content

ios_core/lockdown/
client.rs

1use serde::Serialize;
2use tokio::io::{AsyncRead, AsyncWrite};
3
4use crate::lockdown::pair_record::PairRecord;
5use crate::lockdown::protocol::*;
6use crate::lockdown::session::start_lockdown_session;
7use crate::lockdown::{LockdownError, ServiceInfo};
8
9/// High-level Lockdown client. Handles session management and service starting.
10pub struct LockdownClient {
11    reader: Box<dyn AsyncRead + Unpin + Send>,
12    writer: Box<dyn AsyncWrite + Unpin + Send>,
13    session_id: Option<String>,
14}
15
16impl LockdownClient {
17    /// Create a LockdownClient from an already-connected usbmux stream, performing TLS handshake.
18    pub async fn connect_with_stream<S>(
19        stream: S,
20        pair_record: &PairRecord,
21    ) -> Result<Self, LockdownError>
22    where
23        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
24    {
25        let (session_id, reader, writer) = start_lockdown_session(stream, pair_record).await?;
26        Ok(Self {
27            reader: Box::new(reader),
28            writer: Box::new(writer),
29            session_id: Some(session_id),
30        })
31    }
32
33    /// Get a value from lockdown.
34    pub async fn get_value(
35        &mut self,
36        domain: Option<&str>,
37        key: Option<&str>,
38    ) -> Result<plist::Value, LockdownError> {
39        send_lockdown(
40            &mut self.writer,
41            &GetValueRequest {
42                label: "ios-rs",
43                request: "GetValue",
44                domain,
45                key,
46            },
47        )
48        .await?;
49        let resp: plist::Value = recv_lockdown(&mut self.reader).await?;
50        extract_get_value(resp, domain, key)
51    }
52
53    /// Set a lockdown value.
54    pub async fn set_value<T>(
55        &mut self,
56        domain: Option<&str>,
57        key: Option<&str>,
58        value: T,
59    ) -> Result<(), LockdownError>
60    where
61        T: Serialize,
62    {
63        send_lockdown(
64            &mut self.writer,
65            &SetValueRequest {
66                label: "ios-rs",
67                request: "SetValue",
68                domain,
69                key,
70                value,
71            },
72        )
73        .await?;
74        let resp: ValueOperationResponse = recv_lockdown(&mut self.reader).await?;
75        if let Some(err) = resp.error {
76            return Err(LockdownError::Protocol(format!(
77                "SetValue failed for domain={domain:?} key={key:?}: {err}"
78            )));
79        }
80        Ok(())
81    }
82
83    /// Remove a lockdown value.
84    pub async fn remove_value(
85        &mut self,
86        domain: Option<&str>,
87        key: Option<&str>,
88    ) -> Result<(), LockdownError> {
89        send_lockdown(
90            &mut self.writer,
91            &RemoveValueRequest {
92                label: "ios-rs",
93                request: "RemoveValue",
94                domain,
95                key,
96            },
97        )
98        .await?;
99        let resp: ValueOperationResponse = recv_lockdown(&mut self.reader).await?;
100        if let Some(err) = resp.error {
101            return Err(LockdownError::Protocol(format!(
102                "RemoveValue failed for domain={domain:?} key={key:?}: {err}"
103            )));
104        }
105        Ok(())
106    }
107
108    /// Start a service and return its port information.
109    pub async fn start_service(&mut self, service: &str) -> Result<ServiceInfo, LockdownError> {
110        send_lockdown(
111            &mut self.writer,
112            &StartServiceRequest {
113                label: "ios-rs",
114                request: "StartService",
115                service: service.to_string(),
116            },
117        )
118        .await?;
119        let resp: StartServiceResponse = recv_lockdown(&mut self.reader).await?;
120        if let Some(err) = resp.error {
121            return Err(LockdownError::Protocol(format!(
122                "StartService '{service}' failed: {err}"
123            )));
124        }
125        let port = resp.port.ok_or_else(|| {
126            LockdownError::Protocol(format!("StartService '{service}': missing Port field"))
127        })?;
128        Ok(ServiceInfo {
129            port,
130            enable_service_ssl: resp.enable_service_ssl.unwrap_or(false),
131        })
132    }
133
134    /// Stop the current session.
135    pub async fn stop_session(&mut self) -> Result<(), LockdownError> {
136        if let Some(sid) = self.session_id.take() {
137            send_lockdown(
138                &mut self.writer,
139                &StopSessionRequest {
140                    label: "ios-rs",
141                    request: "StopSession",
142                    session_id: sid,
143                },
144            )
145            .await?;
146        }
147        Ok(())
148    }
149
150    /// Get the device product version string.
151    pub async fn product_version(&mut self) -> Result<semver::Version, LockdownError> {
152        let val = self.get_value(None, Some("ProductVersion")).await?;
153        let s = val
154            .as_string()
155            .ok_or_else(|| LockdownError::Protocol("ProductVersion is not a string".into()))?;
156        // iOS may return "15.5" (two-part); semver requires three parts
157        let normalized = match s.matches('.').count() {
158            0 => format!("{s}.0.0"),
159            1 => format!("{s}.0"),
160            _ => s.to_string(),
161        };
162        semver::Version::parse(&normalized)
163            .map_err(|e| LockdownError::Protocol(format!("invalid version '{s}': {e}")))
164    }
165}
166
167fn extract_get_value(
168    response: plist::Value,
169    domain: Option<&str>,
170    key: Option<&str>,
171) -> Result<plist::Value, LockdownError> {
172    if let plist::Value::Dictionary(mut values) = response {
173        if let Some(plist::Value::String(error)) = values.remove("Error") {
174            return Err(LockdownError::Protocol(format!(
175                "GetValue failed for domain={domain:?} key={key:?}: {error}"
176            )));
177        }
178
179        if let Some(value) = values.remove("Value") {
180            return Ok(value);
181        }
182
183        return Err(LockdownError::Protocol(format!(
184            "GetValue missing Value for domain={domain:?} key={key:?}: {:?}",
185            plist::Value::Dictionary(values)
186        )));
187    }
188
189    Err(LockdownError::Protocol(format!(
190        "GetValue returned non-dictionary response for domain={domain:?} key={key:?}: {response:?}"
191    )))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn missing_get_value_payload_reports_context() {
200        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
201            "Status".to_string(),
202            plist::Value::String("Success".into()),
203        )]));
204
205        let err = extract_get_value(
206            response,
207            Some("com.apple.mobile.wireless_lockdown"),
208            Some("EnableWifiConnections"),
209        )
210        .expect_err("missing value should error");
211
212        let rendered = err.to_string();
213        assert!(rendered.contains("EnableWifiConnections"));
214        assert!(rendered.contains("com.apple.mobile.wireless_lockdown"));
215        assert!(rendered.contains("Status"));
216    }
217}