Skip to main content

ios_core/services/testmanager/
mod.rs

1//! Minimal XCTestManager/testmanagerd startup helpers.
2//!
3//! This covers the DTX protocol shared by the iOS 17+ Remote Service Discovery
4//! path and older lockdown testmanager services. The CLI chooses the concrete
5//! transport/service name for each iOS generation.
6
7pub mod results;
8pub mod workflow;
9pub mod xctestrun;
10
11use crate::proto::nskeyedarchiver_encode::{
12    archive_uuid, archive_xct_capabilities, archive_xctest_configuration, XcTestConfiguration,
13    XctCapabilities,
14};
15use bytes::Bytes;
16use tokio::io::{AsyncRead, AsyncWrite};
17use uuid::Uuid;
18
19use results::TestExecutionEvent;
20
21use crate::services::dtx::{
22    archived_object, encode_dtx, DtxConnection, DtxError, DtxMessage, DtxPayload, NSObject, PrimArg,
23};
24
25pub const SERVICE_IOS17: &str = "com.apple.dt.testmanagerd.remote";
26pub const SERVICE_IOS14: &str = "com.apple.testmanagerd.lockdown.secure";
27pub const SERVICE_LEGACY: &str = "com.apple.testmanagerd.lockdown";
28pub const SERVICE_NAME: &str = SERVICE_IOS17;
29pub const DAEMON_CONNECTION_INTERFACE: &str =
30    "dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface";
31pub const DRIVER_INTERFACE: &str = "dtxproxy:XCTestDriverInterface:XCTestManager_IDEInterface";
32pub const START_EXECUTING_SELECTOR: &str = "_IDE_startExecutingTestPlanWithProtocolVersion:";
33pub const INITIATE_SESSION_SELECTOR: &str = "_IDE_initiateSessionWithIdentifier:capabilities:";
34pub const INITIATE_CONTROL_SESSION_SELECTOR: &str = "_IDE_initiateControlSessionWithCapabilities:";
35pub const AUTHORIZE_TEST_SESSION_SELECTOR: &str = "_IDE_authorizeTestSessionWithProcessID:";
36pub const TEST_RUNNER_READY_SELECTOR: &str = "_XCT_testRunnerReadyWithCapabilities:";
37pub const TEST_BUNDLE_READY_SELECTOR: &str =
38    "_XCT_testBundleReadyWithProtocolVersion:minimumVersion:";
39pub const REQUEST_CHANNEL_SELECTOR: &str = "_requestChannelWithCode:identifier:";
40pub const PROTOCOL_VERSION: u32 = 36;
41const MSG_RESPONSE: u32 = 3;
42
43#[derive(Debug, Clone)]
44pub enum StartupEvent {
45    TestRunnerReady {
46        message: DtxMessage,
47    },
48    TestBundleReady {
49        message: DtxMessage,
50        protocol_version: u64,
51        minimum_version: u64,
52    },
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct StartupSummary {
57    pub protocol_version: u64,
58    pub minimum_version: u64,
59}
60
61pub struct TestmanagerClient<S1, S2 = S1> {
62    session: DtxConnection<S1>,
63    session_channel: i32,
64    driver_channel: Option<i32>,
65    control: Option<(DtxConnection<S2>, i32)>,
66}
67
68impl<S1, S2> TestmanagerClient<S1, S2>
69where
70    S1: AsyncRead + AsyncWrite + Unpin + Send,
71    S2: AsyncRead + AsyncWrite + Unpin + Send,
72{
73    pub async fn connect(session_stream: S1, control_stream: S2) -> Result<Self, DtxError> {
74        let mut session = DtxConnection::new(session_stream);
75        let mut control = DtxConnection::new(control_stream);
76
77        let session_channel = session.request_channel(DAEMON_CONNECTION_INTERFACE).await?;
78        let control_channel = control.request_channel(DAEMON_CONNECTION_INTERFACE).await?;
79
80        Ok(Self {
81            session,
82            session_channel,
83            driver_channel: None,
84            control: Some((control, control_channel)),
85        })
86    }
87
88    pub async fn initiate_session(
89        &mut self,
90        session_identifier: Bytes,
91        capabilities: Bytes,
92    ) -> Result<DtxMessage, DtxError> {
93        self.session
94            .method_call(
95                self.session_channel,
96                INITIATE_SESSION_SELECTOR,
97                &[
98                    archived_object(session_identifier),
99                    archived_object(capabilities),
100                ],
101            )
102            .await
103    }
104
105    pub async fn initiate_control_session(
106        &mut self,
107        capabilities: Bytes,
108    ) -> Result<DtxMessage, DtxError> {
109        let (control, channel) = self.control_mut()?;
110        control
111            .method_call(
112                channel,
113                INITIATE_CONTROL_SESSION_SELECTOR,
114                &[archived_object(capabilities)],
115            )
116            .await
117    }
118
119    pub async fn initiate_session_with_capabilities(
120        &mut self,
121        session_identifier: Uuid,
122        capabilities: XctCapabilities,
123    ) -> Result<DtxMessage, DtxError> {
124        self.initiate_session(
125            Bytes::from(archive_uuid(session_identifier)),
126            Bytes::from(archive_xct_capabilities(capabilities)),
127        )
128        .await
129    }
130
131    pub async fn initiate_control_session_with_capabilities(
132        &mut self,
133        capabilities: XctCapabilities,
134    ) -> Result<DtxMessage, DtxError> {
135        self.initiate_control_session(Bytes::from(archive_xct_capabilities(capabilities)))
136            .await
137    }
138
139    pub async fn authorize_test_session_with_process_id(
140        &mut self,
141        pid: u64,
142    ) -> Result<bool, DtxError> {
143        let (control, channel) = self.control_mut()?;
144        let response = control
145            .method_call(
146                channel,
147                AUTHORIZE_TEST_SESSION_SELECTOR,
148                &[PrimArg::Int64(pid as i64)],
149            )
150            .await?;
151
152        match response.payload {
153            DtxPayload::Response(NSObject::Bool(authorized)) => Ok(authorized),
154            other => Err(DtxError::Protocol(format!(
155                "unexpected authorize test session response: {other:?}"
156            ))),
157        }
158    }
159
160    pub async fn request_driver_channel(&mut self) -> Result<i32, DtxError> {
161        let channel = self.session.request_channel(DRIVER_INTERFACE).await?;
162        self.driver_channel = Some(channel);
163        Ok(channel)
164    }
165
166    pub async fn await_driver_channel_request(&mut self) -> Result<i32, DtxError> {
167        loop {
168            let msg = self.session.recv().await?;
169            if let DtxPayload::MethodInvocation { selector, .. } = &msg.payload {
170                if selector == REQUEST_CHANNEL_SELECTOR {
171                    let (_requested_code, identifier) = decode_channel_request(&msg)?;
172                    if msg.expects_reply {
173                        self.session.send_ack(&msg).await?;
174                    }
175
176                    if identifier == DRIVER_INTERFACE {
177                        // testmanagerd often requests code `1` here but then sends traffic on
178                        // the default `-1` channel. Match go-ios' compatibility workaround.
179                        self.driver_channel = Some(-1);
180                        return Ok(-1);
181                    }
182                    continue;
183                }
184            }
185
186            if msg.expects_reply {
187                self.session.send_ack(&msg).await?;
188            }
189        }
190    }
191
192    pub async fn start_executing_test_plan(&mut self) -> Result<(), DtxError> {
193        let channel = self.driver_channel.unwrap_or(-1);
194        self.session
195            .method_call_async(
196                channel,
197                START_EXECUTING_SELECTOR,
198                &[PrimArg::Int64(PROTOCOL_VERSION as i64)],
199            )
200            .await
201    }
202
203    pub async fn recv_startup_event(&mut self) -> Result<StartupEvent, DtxError> {
204        loop {
205            let msg = self.session.recv().await?;
206            if let DtxPayload::MethodInvocation { selector, .. } = &msg.payload {
207                match selector.as_str() {
208                    TEST_RUNNER_READY_SELECTOR => {
209                        return Ok(StartupEvent::TestRunnerReady { message: msg });
210                    }
211                    TEST_BUNDLE_READY_SELECTOR => {
212                        let (protocol_version, minimum_version) =
213                            decode_test_bundle_ready_versions(&msg)?;
214                        return Ok(StartupEvent::TestBundleReady {
215                            message: msg,
216                            protocol_version,
217                            minimum_version,
218                        });
219                    }
220                    _ => {}
221                }
222            }
223
224            if msg.expects_reply {
225                self.session.send_ack(&msg).await?;
226            }
227        }
228    }
229
230    pub async fn recv_execution_event(&mut self) -> Result<TestExecutionEvent, DtxError> {
231        loop {
232            let msg = self.session.recv().await?;
233            let event = TestExecutionEvent::from_dtx_message(&msg);
234
235            if msg.expects_reply {
236                self.session.send_ack(&msg).await?;
237            }
238
239            if let Some(event) = event {
240                return Ok(event);
241            }
242        }
243    }
244
245    pub async fn respond_test_runner_ready(
246        &mut self,
247        msg: &DtxMessage,
248        configuration: Bytes,
249    ) -> Result<(), DtxError> {
250        let frame = encode_dtx(
251            msg.identifier,
252            msg.conversation_idx + 1,
253            msg.channel_code,
254            false,
255            MSG_RESPONSE,
256            &configuration,
257            &[],
258        );
259        self.session.send_raw(&frame).await
260    }
261
262    pub async fn respond_test_runner_ready_with_configuration(
263        &mut self,
264        msg: &DtxMessage,
265        configuration: XcTestConfiguration,
266    ) -> Result<(), DtxError> {
267        self.respond_test_runner_ready(
268            msg,
269            Bytes::from(archive_xctest_configuration(configuration)),
270        )
271        .await
272    }
273
274    pub async fn complete_startup_with_configuration(
275        &mut self,
276        configuration: XcTestConfiguration,
277    ) -> Result<StartupSummary, DtxError> {
278        let mut bundle_ready = None;
279        let mut pending_configuration = Some(configuration);
280
281        loop {
282            match self.recv_startup_event().await? {
283                StartupEvent::TestBundleReady {
284                    protocol_version,
285                    minimum_version,
286                    ..
287                } => {
288                    bundle_ready = Some(StartupSummary {
289                        protocol_version,
290                        minimum_version,
291                    });
292                }
293                StartupEvent::TestRunnerReady { message } => {
294                    let configuration = pending_configuration.take().ok_or_else(|| {
295                        DtxError::Protocol("test runner ready received more than once".into())
296                    })?;
297                    self.respond_test_runner_ready_with_configuration(&message, configuration)
298                        .await?;
299
300                    if let Some(summary) = bundle_ready {
301                        return Ok(summary);
302                    }
303                }
304            }
305        }
306    }
307
308    pub async fn authorize_and_start_test_plan_with_configuration(
309        &mut self,
310        pid: u64,
311        configuration: XcTestConfiguration,
312    ) -> Result<StartupSummary, DtxError> {
313        if !self.authorize_test_session_with_process_id(pid).await? {
314            return Err(DtxError::Protocol(
315                "testmanagerd rejected test session authorization".into(),
316            ));
317        }
318
319        let mut bundle_ready = None;
320        let mut pending_configuration = Some(configuration);
321        let mut driver_ready = self.driver_channel.is_some();
322
323        loop {
324            let msg = self.session.recv().await?;
325            if let DtxPayload::MethodInvocation { selector, .. } = &msg.payload {
326                match selector.as_str() {
327                    TEST_BUNDLE_READY_SELECTOR => {
328                        let (protocol_version, minimum_version) =
329                            decode_test_bundle_ready_versions(&msg)?;
330                        bundle_ready = Some(StartupSummary {
331                            protocol_version,
332                            minimum_version,
333                        });
334                    }
335                    TEST_RUNNER_READY_SELECTOR => {
336                        let configuration = pending_configuration.take().ok_or_else(|| {
337                            DtxError::Protocol(
338                                "test runner ready received more than once during startup".into(),
339                            )
340                        })?;
341                        self.respond_test_runner_ready_with_configuration(&msg, configuration)
342                            .await?;
343                    }
344                    REQUEST_CHANNEL_SELECTOR => {
345                        let (_requested_code, identifier) = decode_channel_request(&msg)?;
346                        if msg.expects_reply {
347                            self.session.send_ack(&msg).await?;
348                        }
349                        if identifier == DRIVER_INTERFACE {
350                            self.driver_channel = Some(-1);
351                            driver_ready = true;
352                        }
353                        if let Some(summary) = bundle_ready.filter(|_| driver_ready) {
354                            if pending_configuration.is_none() {
355                                // driver channel arrived last — all three conditions met, start now.
356                                self.start_executing_test_plan().await?;
357                                return Ok(summary);
358                            }
359                        }
360                        continue;
361                    }
362                    _ => {}
363                }
364            }
365
366            if msg.expects_reply
367                && !matches!(
368                    &msg.payload,
369                    DtxPayload::MethodInvocation {
370                        selector,
371                        ..
372                    } if selector == TEST_RUNNER_READY_SELECTOR
373                )
374            {
375                self.session.send_ack(&msg).await?;
376            }
377
378            // Check again for non-REQUEST_CHANNEL messages (e.g. bundle ready arriving after
379            // runner ready, when driver channel was already established beforehand).
380            if let Some(summary) = bundle_ready.filter(|_| driver_ready) {
381                if pending_configuration.is_none() {
382                    self.start_executing_test_plan().await?;
383                    return Ok(summary);
384                }
385            }
386        }
387    }
388
389    fn control_mut(&mut self) -> Result<(&mut DtxConnection<S2>, i32), DtxError> {
390        self.control
391            .as_mut()
392            .map(|(control, channel)| (control, *channel))
393            .ok_or_else(|| DtxError::Protocol("control connection is not configured".into()))
394    }
395}
396
397fn decode_test_bundle_ready_versions(msg: &DtxMessage) -> Result<(u64, u64), DtxError> {
398    let DtxPayload::MethodInvocation { args, .. } = &msg.payload else {
399        return Err(DtxError::Protocol(
400            "test bundle ready event did not contain a method invocation".into(),
401        ));
402    };
403
404    let protocol_version = args
405        .first()
406        .and_then(|value| value.as_int())
407        .ok_or_else(|| DtxError::Protocol("missing test bundle protocol version".into()))?;
408    let minimum_version = args
409        .get(1)
410        .and_then(|value| value.as_int())
411        .ok_or_else(|| DtxError::Protocol("missing test bundle minimum version".into()))?;
412
413    if protocol_version < 0 || minimum_version < 0 {
414        return Err(DtxError::Protocol(
415            "test bundle versions must be non-negative".into(),
416        ));
417    }
418
419    Ok((protocol_version as u64, minimum_version as u64))
420}
421
422fn decode_channel_request(msg: &DtxMessage) -> Result<(i32, String), DtxError> {
423    let DtxPayload::MethodInvocation { args, .. } = &msg.payload else {
424        return Err(DtxError::Protocol(
425            "channel request did not contain a method invocation".into(),
426        ));
427    };
428
429    let requested_code = args
430        .first()
431        .and_then(|value| value.as_int())
432        .ok_or_else(|| DtxError::Protocol("missing requested channel code".into()))?;
433    let identifier = args
434        .get(1)
435        .and_then(|value| value.as_str())
436        .ok_or_else(|| DtxError::Protocol("missing requested channel identifier".into()))?;
437
438    if requested_code < i32::MIN as i64 || requested_code > i32::MAX as i64 {
439        return Err(DtxError::Protocol(
440            "requested channel code out of range".into(),
441        ));
442    }
443
444    Ok((requested_code as i32, identifier.to_string()))
445}
446
447impl<S> TestmanagerClient<S, S>
448where
449    S: AsyncRead + AsyncWrite + Unpin + Send,
450{
451    /// Test-only constructor: single session connection, no control connection.
452    #[cfg(feature = "testmanager")]
453    pub fn from_session_connection_for_test(session_stream: S, session_channel: i32) -> Self {
454        Self {
455            session: DtxConnection::new(session_stream),
456            session_channel,
457            driver_channel: None,
458            control: None,
459        }
460    }
461
462    /// Test-only constructor: both session and control connections.
463    #[cfg(feature = "testmanager")]
464    pub fn from_connections_for_test(
465        session_stream: S,
466        session_channel: i32,
467        control_stream: S,
468        control_channel: i32,
469    ) -> Self {
470        Self {
471            session: DtxConnection::new(session_stream),
472            session_channel,
473            driver_channel: None,
474            control: Some((DtxConnection::new(control_stream), control_channel)),
475        }
476    }
477}