Skip to main content

ios_core/proto/
nskeyedarchiver_encode.rs

1//! NSKeyedArchiver binary encoder.
2//!
3//! Encodes Rust values to NSKeyedArchiver binary plist format,
4//! which is required for DTX method invocation payloads and arguments.
5//!
6//! Reference: go-ios/ios/nskeyedarchiver/archiver.go
7
8use plist::{Dictionary, Uid, Value};
9use uuid::Uuid;
10
11#[derive(Debug, Clone)]
12pub struct NsUrl {
13    pub path: String,
14}
15
16#[derive(Debug, Clone)]
17pub struct XctCapabilities {
18    pub capabilities: Vec<(String, Value)>,
19}
20
21#[derive(Debug, Clone)]
22pub struct XcTestConfiguration {
23    pub session_identifier: Uuid,
24    pub test_bundle_url: NsUrl,
25    pub ide_capabilities: XctCapabilities,
26    pub automation_framework_path: String,
27    pub initialize_for_ui_testing: bool,
28    pub report_results_to_ide: bool,
29    pub tests_must_run_on_main_thread: bool,
30    pub test_timeouts_enabled: bool,
31    pub additional_fields: Vec<(String, Value)>,
32}
33
34/// Encode a string as NSKeyedArchiver binary plist (NSString).
35pub fn archive_string(s: &str) -> Vec<u8> {
36    archive_value(Value::String(s.to_string()))
37}
38
39/// Encode an integer as NSKeyedArchiver binary plist (NSNumber/int64).
40pub fn archive_int(n: i64) -> Vec<u8> {
41    archive_value(Value::Integer(n.into()))
42}
43
44/// Encode a float as NSKeyedArchiver binary plist (NSNumber/double).
45pub fn archive_float(f: f64) -> Vec<u8> {
46    archive_value(Value::Real(f))
47}
48
49/// Encode a bool as NSKeyedArchiver binary plist (NSNumber/BOOL).
50pub fn archive_bool(b: bool) -> Vec<u8> {
51    archive_value(Value::Boolean(b))
52}
53
54/// Encode an NSNull object.
55pub fn archive_null() -> Vec<u8> {
56    let mut objects = vec![Value::String("$null".to_string())];
57
58    let mut object = Dictionary::new();
59    object.insert("$class".to_string(), Value::Uid(Uid::new(2)));
60    objects.push(Value::Dictionary(object));
61    objects.push(class_descriptor("NSNull", &["NSNull", "NSObject"]));
62
63    let root_doc = build_keyed_archive(Value::Uid(Uid::new(1)), objects);
64    to_binary_plist(&root_doc)
65}
66
67/// Encode a byte array as NSKeyedArchiver binary plist (NSData).
68pub fn archive_data(data: &[u8]) -> Vec<u8> {
69    archive_value(Value::Data(data.to_vec()))
70}
71
72/// Encode an NSUUID object.
73pub fn archive_uuid(uuid: Uuid) -> Vec<u8> {
74    let mut objects = vec![Value::String("$null".to_string())];
75    let root_uid = archive_nsuuid_into(uuid, &mut objects);
76    let root_doc = build_keyed_archive(root_uid, objects);
77    to_binary_plist(&root_doc)
78}
79
80/// Encode an NSURL object with a file:// relative path.
81pub fn archive_nsurl(url: NsUrl) -> Vec<u8> {
82    let mut objects = vec![Value::String("$null".to_string())];
83    let root_uid = archive_nsurl_into(url, &mut objects);
84    let root_doc = build_keyed_archive(root_uid, objects);
85    to_binary_plist(&root_doc)
86}
87
88/// Encode an XCTCapabilities object with a capabilities-dictionary payload.
89pub fn archive_xct_capabilities(capabilities: XctCapabilities) -> Vec<u8> {
90    let mut objects = vec![Value::String("$null".to_string())];
91    let root_uid = archive_xct_capabilities_into(capabilities, &mut objects);
92    let root_doc = build_keyed_archive(root_uid, objects);
93    to_binary_plist(&root_doc)
94}
95
96/// Encode a minimal XCTestConfiguration object suitable for testmanager startup.
97pub fn archive_xctest_configuration(config: XcTestConfiguration) -> Vec<u8> {
98    let mut objects = vec![Value::String("$null".to_string())];
99    let root_uid = archive_xctest_configuration_into(config, &mut objects);
100    let root_doc = build_keyed_archive(root_uid, objects);
101    to_binary_plist(&root_doc)
102}
103
104/// Encode an array of pre-archived values as NSArray.
105///
106/// Each item must already be a plist-compatible `Value`.
107pub fn archive_array(items: Vec<Value>) -> Vec<u8> {
108    // Build $objects: [$null, NSArray_dict, item1, item2, ...]
109    let count = items.len();
110    let mut objects = vec![Value::String("$null".to_string())];
111
112    // NSArray object at index 1
113    let mut arr_obj = Dictionary::new();
114    arr_obj.insert("$class".to_string(), Value::Uid(Uid::new(2 + count as u64)));
115    let ns_objects: Vec<Value> = (0..count)
116        .map(|i| Value::Uid(Uid::new((2 + i) as u64)))
117        .collect();
118    arr_obj.insert("NS.objects".to_string(), Value::Array(ns_objects));
119    objects.push(Value::Dictionary(arr_obj));
120
121    // Item objects
122    for item in items {
123        objects.push(item);
124    }
125
126    // NSArray class descriptor
127    let mut class_obj = Dictionary::new();
128    class_obj.insert(
129        "$classname".to_string(),
130        Value::String("NSArray".to_string()),
131    );
132    class_obj.insert(
133        "$classes".to_string(),
134        Value::Array(vec![
135            Value::String("NSArray".to_string()),
136            Value::String("NSObject".to_string()),
137        ]),
138    );
139    objects.push(Value::Dictionary(class_obj));
140
141    let root_doc = build_keyed_archive(Value::Uid(Uid::new(1)), objects);
142    to_binary_plist(&root_doc)
143}
144
145/// Encode a dictionary as NSDictionary.
146pub fn archive_dict(pairs: Vec<(String, Value)>) -> Vec<u8> {
147    let mut objects: Vec<Value> = vec![Value::String("$null".to_string())];
148    let root_uid = archive_dict_into(&pairs, &mut objects);
149    let root_doc = build_keyed_archive(root_uid, objects);
150    to_binary_plist(&root_doc)
151}
152
153/// Recursively archive a plist Value into the objects array, returning its UID.
154fn archive_value_into(val: Value, objects: &mut Vec<Value>) -> Value {
155    match val {
156        // Primitives go directly into objects array
157        Value::String(_)
158        | Value::Integer(_)
159        | Value::Real(_)
160        | Value::Boolean(_)
161        | Value::Data(_) => {
162            let idx = objects.len();
163            objects.push(val);
164            Value::Uid(Uid::new(idx as u64))
165        }
166        Value::Array(items) => {
167            // NSArray: {$class, NS.objects: [UIDs]}
168            let item_uids: Vec<Value> = items
169                .into_iter()
170                .map(|v| archive_value_into(v, objects))
171                .collect();
172
173            let arr_idx = objects.len();
174            let class_idx = arr_idx + 1;
175
176            let mut arr_obj = Dictionary::new();
177            arr_obj.insert("$class".to_string(), Value::Uid(Uid::new(class_idx as u64)));
178            arr_obj.insert("NS.objects".to_string(), Value::Array(item_uids));
179            objects.push(Value::Dictionary(arr_obj));
180
181            let mut class_obj = Dictionary::new();
182            class_obj.insert(
183                "$classname".to_string(),
184                Value::String("NSArray".to_string()),
185            );
186            class_obj.insert(
187                "$classes".to_string(),
188                Value::Array(vec![
189                    Value::String("NSArray".to_string()),
190                    Value::String("NSObject".to_string()),
191                ]),
192            );
193            objects.push(Value::Dictionary(class_obj));
194
195            Value::Uid(Uid::new(arr_idx as u64))
196        }
197        Value::Dictionary(dict) => {
198            let pairs = dict.into_iter().collect::<Vec<_>>();
199            archive_dict_into(&pairs, objects)
200        }
201        other => {
202            let idx = objects.len();
203            objects.push(other);
204            Value::Uid(Uid::new(idx as u64))
205        }
206    }
207}
208
209fn archive_dict_into(pairs: &[(String, Value)], objects: &mut Vec<Value>) -> Value {
210    let dict_idx = objects.len();
211    // placeholder
212    objects.push(Value::Boolean(false));
213
214    let mut key_uids = Vec::new();
215    let mut val_uids = Vec::new();
216    for (k, v) in pairs {
217        let k_uid = archive_value_into(Value::String(k.clone()), objects);
218        let v_uid = archive_value_into(v.clone(), objects);
219        key_uids.push(k_uid);
220        val_uids.push(v_uid);
221    }
222
223    let class_idx = objects.len();
224    let mut class_obj = Dictionary::new();
225    class_obj.insert(
226        "$classname".to_string(),
227        Value::String("NSDictionary".to_string()),
228    );
229    class_obj.insert(
230        "$classes".to_string(),
231        Value::Array(vec![
232            Value::String("NSDictionary".to_string()),
233            Value::String("NSObject".to_string()),
234        ]),
235    );
236    objects.push(Value::Dictionary(class_obj));
237
238    let mut dict_obj = Dictionary::new();
239    dict_obj.insert("$class".to_string(), Value::Uid(Uid::new(class_idx as u64)));
240    dict_obj.insert("NS.keys".to_string(), Value::Array(key_uids));
241    dict_obj.insert("NS.objects".to_string(), Value::Array(val_uids));
242    objects[dict_idx] = Value::Dictionary(dict_obj);
243
244    Value::Uid(Uid::new(dict_idx as u64))
245}
246
247fn archive_nsuuid_into(uuid: Uuid, objects: &mut Vec<Value>) -> Value {
248    let object_idx = objects.len();
249    let class_idx = object_idx + 1;
250
251    let mut object = Dictionary::new();
252    object.insert("$class".to_string(), Value::Uid(Uid::new(class_idx as u64)));
253    object.insert(
254        "NS.uuidbytes".to_string(),
255        Value::Data(uuid.into_bytes().to_vec()),
256    );
257    objects.push(Value::Dictionary(object));
258
259    objects.push(class_descriptor("NSUUID", &["NSUUID", "NSObject"]));
260    Value::Uid(Uid::new(object_idx as u64))
261}
262
263fn archive_nsurl_into(url: NsUrl, objects: &mut Vec<Value>) -> Value {
264    let object_idx = objects.len();
265    let class_idx = object_idx + 1;
266    let relative_idx = object_idx + 2;
267
268    let mut object = Dictionary::new();
269    object.insert("$class".to_string(), Value::Uid(Uid::new(class_idx as u64)));
270    object.insert("NS.base".to_string(), Value::Uid(Uid::new(0)));
271    object.insert(
272        "NS.relative".to_string(),
273        Value::Uid(Uid::new(relative_idx as u64)),
274    );
275    objects.push(Value::Dictionary(object));
276    objects.push(class_descriptor("NSURL", &["NSURL", "NSObject"]));
277    objects.push(Value::String(format!("file://{}", url.path)));
278
279    Value::Uid(Uid::new(object_idx as u64))
280}
281
282fn archive_xct_capabilities_into(capabilities: XctCapabilities, objects: &mut Vec<Value>) -> Value {
283    let dict_uid = archive_dict_into(&capabilities.capabilities, objects);
284    let object_idx = objects.len();
285    let class_idx = object_idx + 1;
286
287    let mut object = Dictionary::new();
288    object.insert("$class".to_string(), Value::Uid(Uid::new(class_idx as u64)));
289    object.insert("capabilities-dictionary".to_string(), dict_uid);
290    objects.push(Value::Dictionary(object));
291    objects.push(class_descriptor(
292        "XCTCapabilities",
293        &["XCTCapabilities", "NSObject"],
294    ));
295
296    Value::Uid(Uid::new(object_idx as u64))
297}
298
299fn archive_xctest_configuration_into(
300    config: XcTestConfiguration,
301    objects: &mut Vec<Value>,
302) -> Value {
303    let session_uid = archive_nsuuid_into(config.session_identifier, objects);
304    let bundle_uid = archive_nsurl_into(config.test_bundle_url, objects);
305    let caps_uid = archive_xct_capabilities_into(config.ide_capabilities, objects);
306    let automation_uid =
307        archive_value_into(Value::String(config.automation_framework_path), objects);
308
309    let object_idx = objects.len();
310    let class_idx = object_idx + 1;
311
312    let mut object = Dictionary::new();
313    object.insert("$class".to_string(), Value::Uid(Uid::new(class_idx as u64)));
314    object.insert("sessionIdentifier".to_string(), session_uid);
315    object.insert("testBundleURL".to_string(), bundle_uid);
316    object.insert("IDECapabilities".to_string(), caps_uid);
317    object.insert("automationFrameworkPath".to_string(), automation_uid);
318    object.insert(
319        "initializeForUITesting".to_string(),
320        Value::Boolean(config.initialize_for_ui_testing),
321    );
322    object.insert(
323        "reportResultsToIDE".to_string(),
324        Value::Boolean(config.report_results_to_ide),
325    );
326    object.insert(
327        "testsMustRunOnMainThread".to_string(),
328        Value::Boolean(config.tests_must_run_on_main_thread),
329    );
330    object.insert(
331        "testTimeoutsEnabled".to_string(),
332        Value::Boolean(config.test_timeouts_enabled),
333    );
334    for (key, value) in config.additional_fields {
335        object.insert(key, value);
336    }
337    objects.push(Value::Dictionary(object));
338    objects.push(class_descriptor(
339        "XCTestConfiguration",
340        &["XCTestConfiguration", "NSObject"],
341    ));
342
343    Value::Uid(Uid::new(object_idx as u64))
344}
345
346fn class_descriptor(classname: &str, classes: &[&str]) -> Value {
347    let mut class_obj = Dictionary::new();
348    class_obj.insert(
349        "$classname".to_string(),
350        Value::String(classname.to_string()),
351    );
352    class_obj.insert(
353        "$classes".to_string(),
354        Value::Array(
355            classes
356                .iter()
357                .map(|name| Value::String((*name).to_string()))
358                .collect(),
359        ),
360    );
361    Value::Dictionary(class_obj)
362}
363
364// ── Internal helpers ──────────────────────────────────────────────────────────
365
366/// Encode a simple scalar value (String, Integer, Real, Boolean, Data).
367fn archive_value(val: Value) -> Vec<u8> {
368    let objects = vec![Value::String("$null".to_string()), val];
369    let root_doc = build_keyed_archive(Value::Uid(Uid::new(1)), objects);
370    to_binary_plist(&root_doc)
371}
372
373fn build_keyed_archive(root_uid: Value, objects: Vec<Value>) -> Value {
374    let mut top = Dictionary::new();
375    top.insert("root".to_string(), root_uid);
376
377    let mut doc = Dictionary::new();
378    doc.insert(
379        "$archiver".to_string(),
380        Value::String("NSKeyedArchiver".to_string()),
381    );
382    doc.insert("$version".to_string(), Value::Integer(100000.into()));
383    doc.insert("$top".to_string(), Value::Dictionary(top));
384    doc.insert("$objects".to_string(), Value::Array(objects));
385    Value::Dictionary(doc)
386}
387
388// Safety: plist::to_writer_binary into a Vec<u8> performs only in-memory writes,
389// which are infallible (the only failure mode is OOM, which triggers a panic via
390// the global allocator, not an Err). The unwrap is therefore safe.
391fn to_binary_plist(val: &Value) -> Vec<u8> {
392    let mut buf = Vec::new();
393    plist::to_writer_binary(&mut buf, val).unwrap();
394    buf
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn plist_doc(data: &[u8]) -> Value {
402        plist::from_bytes(data).unwrap()
403    }
404
405    fn objects(data: &[u8]) -> Vec<Value> {
406        let plist = plist_doc(data);
407        plist.as_dictionary().unwrap()["$objects"]
408            .as_array()
409            .unwrap()
410            .clone()
411    }
412
413    fn root_index(data: &[u8]) -> usize {
414        let plist = plist_doc(data);
415        let top = plist.as_dictionary().unwrap()["$top"]
416            .as_dictionary()
417            .unwrap();
418        match &top["root"] {
419            Value::Uid(uid) => uid.get() as usize,
420            other => panic!("unexpected root reference: {other:?}"),
421        }
422    }
423
424    fn root_object<'a>(data: &[u8], objects: &'a [Value]) -> &'a Dictionary {
425        objects[root_index(data)].as_dictionary().unwrap()
426    }
427
428    #[test]
429    fn test_archive_string_is_valid_plist() {
430        let data = archive_string("_requestChannelWithCode:identifier:");
431        // Should start with 'bplist00'
432        assert_eq!(&data[..6], b"bplist");
433        // Should be decodable
434        let _val: Value = plist::from_bytes(&data).unwrap();
435        // Root should be recoverable via unarchive
436        let recovered = crate::proto::nskeyedarchiver::unarchive(&data).unwrap();
437        assert_eq!(
438            recovered.as_str(),
439            Some("_requestChannelWithCode:identifier:")
440        );
441    }
442
443    #[test]
444    fn test_archive_int() {
445        let data = archive_int(42);
446        let recovered = crate::proto::nskeyedarchiver::unarchive(&data).unwrap();
447        assert_eq!(recovered.as_int(), Some(42));
448    }
449
450    #[test]
451    fn test_archive_null_stores_nsnull_class_descriptor() {
452        let data = archive_null();
453        let objects = objects(&data);
454        let root = root_object(&data, &objects);
455        let class_ref = match &root["$class"] {
456            Value::Uid(uid) => uid.get() as usize,
457            _ => panic!("expected uid"),
458        };
459        assert_eq!(
460            objects[class_ref].as_dictionary().unwrap()["$classname"].as_string(),
461            Some("NSNull")
462        );
463    }
464
465    #[test]
466    fn test_archive_null_roundtrips_to_null() {
467        let data = archive_null();
468        let recovered = crate::proto::nskeyedarchiver::unarchive(&data).unwrap();
469        assert!(matches!(
470            recovered,
471            crate::proto::nskeyedarchiver::ArchiveValue::Null
472        ));
473    }
474
475    #[test]
476    fn test_archive_roundtrip_nonempty() {
477        let s = archive_string("com.apple.instruments.server.services.sysmontap");
478        assert!(!s.is_empty());
479        assert!(s.len() > 8);
480    }
481
482    #[test]
483    fn test_archive_array_preserves_item_order() {
484        let data = archive_array(vec![
485            Value::Integer(12.into()),
486            Value::Integer(34.into()),
487            Value::Integer(56.into()),
488        ]);
489        let recovered = crate::proto::nskeyedarchiver::unarchive(&data).unwrap();
490        let values = recovered.as_array().unwrap();
491        assert_eq!(values[0].as_int(), Some(12));
492        assert_eq!(values[1].as_int(), Some(34));
493        assert_eq!(values[2].as_int(), Some(56));
494    }
495
496    #[test]
497    fn test_archive_dict_roundtrips_nested_dictionary_values() {
498        let nested = Dictionary::from_iter([
499            (
500                "inner-key".to_string(),
501                Value::String("inner-value".to_string()),
502            ),
503            ("inner-int".to_string(), Value::Integer(7.into())),
504        ]);
505        let data = archive_dict(vec![(
506            "outer".to_string(),
507            Value::Array(vec![Value::Dictionary(nested)]),
508        )]);
509
510        let recovered = crate::proto::nskeyedarchiver::unarchive(&data).unwrap();
511        let dict = recovered.as_dict().expect("root should be a dictionary");
512        let outer = dict.get("outer").expect("outer key should exist");
513        let outer_items = outer.as_array().expect("outer should be an array");
514        let first = outer_items.first().expect("outer should contain one item");
515        let nested = first
516            .as_dict()
517            .expect("nested dictionary should survive archiving");
518
519        assert_eq!(
520            nested.get("inner-key").and_then(|value| value.as_str()),
521            Some("inner-value")
522        );
523        assert_eq!(
524            nested.get("inner-int").and_then(|value| value.as_int()),
525            Some(7)
526        );
527    }
528
529    #[test]
530    fn test_archive_uuid_stores_nsuuid_class_and_bytes() {
531        let uuid = Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap();
532        let data = archive_uuid(uuid);
533        let objects = objects(&data);
534        let root = root_object(&data, &objects);
535        assert_eq!(
536            root["NS.uuidbytes"].as_data().unwrap(),
537            &uuid.into_bytes().to_vec()
538        );
539        let class_ref = match &root["$class"] {
540            Value::Uid(uid) => uid.get() as usize,
541            _ => panic!("expected uid"),
542        };
543        let class = objects[class_ref].as_dictionary().unwrap();
544        assert_eq!(class["$classname"].as_string(), Some("NSUUID"));
545    }
546
547    #[test]
548    fn test_archive_nsurl_stores_file_relative_path() {
549        let data = archive_nsurl(NsUrl {
550            path: "/private/tmp/TestBundle.xctest".to_string(),
551        });
552        let objects = objects(&data);
553        let root = root_object(&data, &objects);
554        let rel_ref = match &root["NS.relative"] {
555            Value::Uid(uid) => uid.get() as usize,
556            _ => panic!("expected uid"),
557        };
558        assert_eq!(
559            objects[rel_ref].as_string(),
560            Some("file:///private/tmp/TestBundle.xctest")
561        );
562    }
563
564    #[test]
565    fn test_archive_xct_capabilities_stores_capabilities_dictionary() {
566        let data = archive_xct_capabilities(XctCapabilities {
567            capabilities: vec![(
568                "expected failure test capability".to_string(),
569                Value::Boolean(true),
570            )],
571        });
572        let objects = objects(&data);
573        let root = root_object(&data, &objects);
574        let class_ref = match &root["$class"] {
575            Value::Uid(uid) => uid.get() as usize,
576            _ => panic!("expected uid"),
577        };
578        assert_eq!(
579            objects[class_ref].as_dictionary().unwrap()["$classname"].as_string(),
580            Some("XCTCapabilities")
581        );
582        let dict_ref = match &root["capabilities-dictionary"] {
583            Value::Uid(uid) => uid.get() as usize,
584            _ => panic!("expected uid"),
585        };
586        let dict = objects[dict_ref].as_dictionary().unwrap();
587        assert!(dict.contains_key("NS.keys"));
588        assert!(dict.contains_key("NS.objects"));
589    }
590
591    #[test]
592    fn test_archive_xctest_configuration_stores_nested_testmanager_objects() {
593        let data = archive_xctest_configuration(XcTestConfiguration {
594            session_identifier: Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
595            test_bundle_url: NsUrl {
596                path: "/private/tmp/WebDriverAgentRunner.xctest".to_string(),
597            },
598            ide_capabilities: XctCapabilities {
599                capabilities: vec![("XCTIssue capability".to_string(), Value::Boolean(true))],
600            },
601            automation_framework_path:
602                "/System/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework"
603                    .to_string(),
604            initialize_for_ui_testing: true,
605            report_results_to_ide: true,
606            tests_must_run_on_main_thread: true,
607            test_timeouts_enabled: false,
608            additional_fields: Vec::new(),
609        });
610
611        let objects = objects(&data);
612        let root = root_object(&data, &objects);
613        let class_ref = match &root["$class"] {
614            Value::Uid(uid) => uid.get() as usize,
615            _ => panic!("expected uid"),
616        };
617        assert_eq!(
618            objects[class_ref].as_dictionary().unwrap()["$classname"].as_string(),
619            Some("XCTestConfiguration")
620        );
621        assert!(matches!(root.get("sessionIdentifier"), Some(Value::Uid(_))));
622        assert!(matches!(root.get("testBundleURL"), Some(Value::Uid(_))));
623        assert!(matches!(root.get("IDECapabilities"), Some(Value::Uid(_))));
624        assert_eq!(root["reportResultsToIDE"].as_boolean(), Some(true));
625        assert_eq!(root["testsMustRunOnMainThread"].as_boolean(), Some(true));
626    }
627}