Skip to main content

isideload_apple_codesign/
code_resources.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Functionality related to "code resources," external resources captured in signatures.
6//!
7//! Bundles can contain a `_CodeSignature/CodeResources` XML plist file
8//! denoting signatures for resources not in the binary. The signature data
9//! in the binary can record the digest of this file so integrity is transitively
10//! verified.
11//!
12//! We've implemented our own (de)serialization code in this module because
13//! the default derived Deserialize provided by the `plist` crate doesn't
14//! handle enums correctly. We attempted to implement our own `Deserialize`
15//! and `Visitor` traits to get things to parse, but we couldn't make it work.
16//! We gave up and decided to just coerce the [plist::Value] instances instead.
17
18use {
19    crate::{
20        bundle_signing::{BundleSigningContext, SignedMachOInfo},
21        cryptography::{DigestType, MultiDigest},
22        error::AppleCodesignError,
23    },
24    apple_bundles::DirectoryBundle,
25    log::{debug, error, info, warn},
26    plist::{Dictionary, Value},
27    std::{
28        cmp::Ordering,
29        collections::{BTreeMap, BTreeSet},
30        io::Write,
31        path::Path,
32    },
33};
34
35#[derive(Clone, PartialEq)]
36enum FilesValue {
37    Required(Vec<u8>),
38    Optional(Vec<u8>),
39}
40
41impl std::fmt::Debug for FilesValue {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Required(digest) => f
45                .debug_struct("FilesValue")
46                .field("required", &true)
47                .field("digest", &hex::encode(digest))
48                .finish(),
49            Self::Optional(digest) => f
50                .debug_struct("FilesValue")
51                .field("required", &false)
52                .field("digest", &hex::encode(digest))
53                .finish(),
54        }
55    }
56}
57
58impl std::fmt::Display for FilesValue {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Required(digest) => {
62                f.write_fmt(format_args!("{} (required)", hex::encode(digest)))
63            }
64            Self::Optional(digest) => {
65                f.write_fmt(format_args!("{} (optional)", hex::encode(digest)))
66            }
67        }
68    }
69}
70
71impl TryFrom<&Value> for FilesValue {
72    type Error = AppleCodesignError;
73
74    fn try_from(v: &Value) -> Result<Self, Self::Error> {
75        match v {
76            Value::Data(digest) => Ok(Self::Required(digest.to_vec())),
77            Value::Dictionary(dict) => {
78                let mut digest = None;
79                let mut optional = None;
80
81                for (key, value) in dict.iter() {
82                    match key.as_str() {
83                        "hash" => {
84                            let data = value.as_data().ok_or_else(|| {
85                                AppleCodesignError::ResourcesPlistParse(format!(
86                                    "expected <data> for files <dict> entry, got {value:?}"
87                                ))
88                            })?;
89
90                            digest = Some(data.to_vec());
91                        }
92                        "optional" => {
93                            let v = value.as_boolean().ok_or_else(|| {
94                                AppleCodesignError::ResourcesPlistParse(format!(
95                                    "expected boolean for optional key, got {value:?}"
96                                ))
97                            })?;
98
99                            optional = Some(v);
100                        }
101                        key => {
102                            return Err(AppleCodesignError::ResourcesPlistParse(format!(
103                                "unexpected key in files dict: {key}"
104                            )));
105                        }
106                    }
107                }
108
109                match (digest, optional) {
110                    (Some(digest), Some(true)) => Ok(Self::Optional(digest)),
111                    (Some(digest), Some(false)) => Ok(Self::Required(digest)),
112                    _ => Err(AppleCodesignError::ResourcesPlistParse(
113                        "missing hash or optional key".to_string(),
114                    )),
115                }
116            }
117            _ => Err(AppleCodesignError::ResourcesPlistParse(format!(
118                "bad value in files <dict>; expected <data> or <dict>, got {v:?}"
119            ))),
120        }
121    }
122}
123
124impl From<&FilesValue> for Value {
125    fn from(v: &FilesValue) -> Self {
126        match v {
127            FilesValue::Required(digest) => Self::Data(digest.to_vec()),
128            FilesValue::Optional(digest) => {
129                let mut dict = Dictionary::new();
130                dict.insert("hash".to_string(), Value::Data(digest.to_vec()));
131                dict.insert("optional".to_string(), Value::Boolean(true));
132
133                Self::Dictionary(dict)
134            }
135        }
136    }
137}
138
139#[derive(Clone, PartialEq)]
140struct Files2Value {
141    cdhash: Option<Vec<u8>>,
142    hash: Option<Vec<u8>>,
143    hash2: Option<Vec<u8>>,
144    optional: Option<bool>,
145    requirement: Option<String>,
146    symlink: Option<String>,
147}
148
149impl std::fmt::Debug for Files2Value {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("Files2Value")
152            .field(
153                "cdhash",
154                &format_args!("{:?}", self.cdhash.as_ref().map(hex::encode)),
155            )
156            .field(
157                "hash",
158                &format_args!("{:?}", self.hash.as_ref().map(hex::encode)),
159            )
160            .field(
161                "hash2",
162                &format_args!("{:?}", self.hash2.as_ref().map(hex::encode)),
163            )
164            .field("optional", &format_args!("{:?}", self.optional))
165            .field("requirement", &format_args!("{:?}", self.requirement))
166            .field("symlink", &format_args!("{:?}", self.symlink))
167            .finish()
168    }
169}
170
171impl TryFrom<&Value> for Files2Value {
172    type Error = AppleCodesignError;
173
174    fn try_from(v: &Value) -> Result<Self, Self::Error> {
175        let dict = v.as_dictionary().ok_or_else(|| {
176            AppleCodesignError::ResourcesPlistParse("files2 value should be a dict".to_string())
177        })?;
178
179        let mut hash = None;
180        let mut hash2 = None;
181        let mut cdhash = None;
182        let mut optional = None;
183        let mut requirement = None;
184        let mut symlink = None;
185
186        for (key, value) in dict.iter() {
187            match key.as_str() {
188                "cdhash" => {
189                    let data = value.as_data().ok_or_else(|| {
190                        AppleCodesignError::ResourcesPlistParse(format!(
191                            "expected <data> for files2 cdhash entry, got {value:?}"
192                        ))
193                    })?;
194
195                    cdhash = Some(data.to_vec());
196                }
197                "hash" => {
198                    let data = value.as_data().ok_or_else(|| {
199                        AppleCodesignError::ResourcesPlistParse(format!(
200                            "expected <data> for files2 hash entry, got {value:?}"
201                        ))
202                    })?;
203
204                    hash = Some(data.to_vec());
205                }
206                "hash2" => {
207                    let data = value.as_data().ok_or_else(|| {
208                        AppleCodesignError::ResourcesPlistParse(format!(
209                            "expected <data> for files2 hash2 entry, got {value:?}"
210                        ))
211                    })?;
212
213                    hash2 = Some(data.to_vec());
214                }
215                "optional" => {
216                    let v = value.as_boolean().ok_or_else(|| {
217                        AppleCodesignError::ResourcesPlistParse(format!(
218                            "expected bool for optional key, got {value:?}"
219                        ))
220                    })?;
221
222                    optional = Some(v);
223                }
224                "requirement" => {
225                    let v = value.as_string().ok_or_else(|| {
226                        AppleCodesignError::ResourcesPlistParse(format!(
227                            "expected string for requirement key, got {value:?}"
228                        ))
229                    })?;
230
231                    requirement = Some(v.to_string());
232                }
233                "symlink" => {
234                    symlink = Some(
235                        value
236                            .as_string()
237                            .ok_or_else(|| {
238                                AppleCodesignError::ResourcesPlistParse(format!(
239                                    "expected string for symlink key, got {value:?}"
240                                ))
241                            })?
242                            .to_string(),
243                    );
244                }
245                key => {
246                    return Err(AppleCodesignError::ResourcesPlistParse(format!(
247                        "unexpected key in files2 dict entry: {key}"
248                    )));
249                }
250            }
251        }
252
253        Ok(Self {
254            cdhash,
255            hash,
256            hash2,
257            optional,
258            requirement,
259            symlink,
260        })
261    }
262}
263
264impl From<&Files2Value> for Value {
265    fn from(v: &Files2Value) -> Self {
266        let mut dict = Dictionary::new();
267
268        if let Some(cdhash) = &v.cdhash {
269            dict.insert("cdhash".to_string(), Value::Data(cdhash.to_vec()));
270        }
271
272        if let Some(hash) = &v.hash {
273            dict.insert("hash".to_string(), Value::Data(hash.to_vec()));
274        }
275
276        if let Some(hash2) = &v.hash2 {
277            dict.insert("hash2".to_string(), Value::Data(hash2.to_vec()));
278        }
279
280        if let Some(optional) = &v.optional {
281            dict.insert("optional".to_string(), Value::Boolean(*optional));
282        }
283
284        if let Some(requirement) = &v.requirement {
285            dict.insert(
286                "requirement".to_string(),
287                Value::String(requirement.to_string()),
288            );
289        }
290
291        if let Some(symlink) = &v.symlink {
292            dict.insert("symlink".to_string(), Value::String(symlink.to_string()));
293        }
294
295        Value::Dictionary(dict)
296    }
297}
298
299#[derive(Clone, Debug, PartialEq)]
300struct RulesValue {
301    omit: bool,
302    required: bool,
303    weight: Option<f64>,
304}
305
306impl TryFrom<&Value> for RulesValue {
307    type Error = AppleCodesignError;
308
309    fn try_from(v: &Value) -> Result<Self, Self::Error> {
310        match v {
311            Value::Boolean(true) => Ok(Self {
312                omit: false,
313                required: true,
314                weight: None,
315            }),
316            Value::Dictionary(dict) => {
317                let mut omit = None;
318                let mut optional = None;
319                let mut weight = None;
320
321                for (key, value) in dict {
322                    match key.as_str() {
323                        "omit" => {
324                            omit = Some(value.as_boolean().ok_or_else(|| {
325                                AppleCodesignError::ResourcesPlistParse(format!(
326                                    "rules omit key value not a boolean; got {value:?}"
327                                ))
328                            })?);
329                        }
330                        "optional" => {
331                            optional = Some(value.as_boolean().ok_or_else(|| {
332                                AppleCodesignError::ResourcesPlistParse(format!(
333                                    "rules optional key value not a boolean, got {value:?}"
334                                ))
335                            })?);
336                        }
337                        "weight" => {
338                            weight = Some(value.as_real().ok_or_else(|| {
339                                AppleCodesignError::ResourcesPlistParse(format!(
340                                    "rules weight key value not a real, got {value:?}"
341                                ))
342                            })?);
343                        }
344                        key => {
345                            return Err(AppleCodesignError::ResourcesPlistParse(format!(
346                                "extra key in rules dict: {key}"
347                            )));
348                        }
349                    }
350                }
351
352                Ok(Self {
353                    omit: omit.unwrap_or(false),
354                    required: !optional.unwrap_or(false),
355                    weight,
356                })
357            }
358            _ => Err(AppleCodesignError::ResourcesPlistParse(
359                "invalid value for rules entry".to_string(),
360            )),
361        }
362    }
363}
364
365impl From<&RulesValue> for Value {
366    fn from(v: &RulesValue) -> Self {
367        if v.required && !v.omit && v.weight.is_none() {
368            Value::Boolean(true)
369        } else {
370            let mut dict = Dictionary::new();
371
372            if v.omit {
373                dict.insert("omit".to_string(), Value::Boolean(true));
374            }
375            if !v.required {
376                dict.insert("optional".to_string(), Value::Boolean(true));
377            }
378
379            if let Some(weight) = v.weight {
380                dict.insert("weight".to_string(), Value::Real(weight));
381            }
382
383            Value::Dictionary(dict)
384        }
385    }
386}
387
388#[derive(Clone, Debug, PartialEq)]
389struct Rules2Value {
390    nested: Option<bool>,
391    omit: Option<bool>,
392    optional: Option<bool>,
393    weight: Option<f64>,
394}
395
396impl TryFrom<&Value> for Rules2Value {
397    type Error = AppleCodesignError;
398
399    fn try_from(v: &Value) -> Result<Self, Self::Error> {
400        let dict = v.as_dictionary().ok_or_else(|| {
401            AppleCodesignError::ResourcesPlistParse("rules2 value should be a dict".to_string())
402        })?;
403
404        let mut nested = None;
405        let mut omit = None;
406        let mut optional = None;
407        let mut weight = None;
408
409        for (key, value) in dict.iter() {
410            match key.as_str() {
411                "nested" => {
412                    nested = Some(value.as_boolean().ok_or_else(|| {
413                        AppleCodesignError::ResourcesPlistParse(format!(
414                            "expected bool for rules2 nested key, got {value:?}"
415                        ))
416                    })?);
417                }
418                "omit" => {
419                    omit = Some(value.as_boolean().ok_or_else(|| {
420                        AppleCodesignError::ResourcesPlistParse(format!(
421                            "expected bool for rules2 omit key, got {value:?}"
422                        ))
423                    })?);
424                }
425                "optional" => {
426                    optional = Some(value.as_boolean().ok_or_else(|| {
427                        AppleCodesignError::ResourcesPlistParse(format!(
428                            "expected bool for rules2 optional key, got {value:?}"
429                        ))
430                    })?);
431                }
432                "weight" => {
433                    weight = Some(value.as_real().ok_or_else(|| {
434                        AppleCodesignError::ResourcesPlistParse(format!(
435                            "expected real for rules2 weight key, got {value:?}"
436                        ))
437                    })?);
438                }
439                key => {
440                    return Err(AppleCodesignError::ResourcesPlistParse(format!(
441                        "unexpected key in rules dict entry: {key}"
442                    )));
443                }
444            }
445        }
446
447        Ok(Self {
448            nested,
449            omit,
450            optional,
451            weight,
452        })
453    }
454}
455
456impl From<&Rules2Value> for Value {
457    fn from(v: &Rules2Value) -> Self {
458        let mut dict = Dictionary::new();
459
460        if let Some(true) = v.nested {
461            dict.insert("nested".to_string(), Value::Boolean(true));
462        }
463
464        if let Some(true) = v.omit {
465            dict.insert("omit".to_string(), Value::Boolean(true));
466        }
467
468        if let Some(true) = v.optional {
469            dict.insert("optional".to_string(), Value::Boolean(true));
470        }
471
472        if let Some(weight) = v.weight {
473            dict.insert("weight".to_string(), Value::Real(weight));
474        }
475
476        if dict.is_empty() {
477            Value::Boolean(true)
478        } else {
479            Value::Dictionary(dict)
480        }
481    }
482}
483
484/// Represents an abstract rule in a `CodeResources` XML plist.
485///
486/// This type represents both `<rules>` and `<rules2>` entries. It contains a
487/// superset of all fields for these entries.
488#[derive(Clone, Debug)]
489pub struct CodeResourcesRule {
490    /// The rule pattern.
491    ///
492    /// The `<key>` in the `<rules>` or `<rules2>` dict.
493    pub pattern: String,
494
495    /// Matched paths are excluded from processing completely.
496    ///
497    /// If any rule with this flag matches a path, the path is excluded.
498    pub exclude: bool,
499
500    /// The matched path is a signable entity.
501    ///
502    /// The path should be signed before sealing. And its seal may be
503    /// stored specially.
504    pub nested: bool,
505
506    /// Whether to omit the path from sealing.
507    ///
508    /// Paths matching this rule can exist in a bundle. But their content
509    /// isn't captured in the `CodeResources` file.
510    pub omit: bool,
511
512    /// Unknown. Best guess is whether the file's presence is optional.
513    pub optional: bool,
514
515    /// Weighting to apply to the rule.
516    pub weight: Option<u32>,
517
518    re: regex::Regex,
519}
520
521impl PartialEq for CodeResourcesRule {
522    fn eq(&self, other: &Self) -> bool {
523        self.pattern == other.pattern
524            && self.exclude == other.exclude
525            && self.nested == other.nested
526            && self.omit == other.omit
527            && self.optional == other.optional
528            && self.weight == other.weight
529    }
530}
531
532impl Eq for CodeResourcesRule {}
533
534impl PartialOrd for CodeResourcesRule {
535    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
536        Some(self.cmp(other))
537    }
538}
539
540impl Ord for CodeResourcesRule {
541    fn cmp(&self, other: &Self) -> Ordering {
542        // Default weight is 1 if not specified.
543        let our_weight = self.weight.unwrap_or(1);
544        let their_weight = other.weight.unwrap_or(1);
545
546        // Exclusion rules always take priority over inclusion rules.
547        // The smaller the weight, the less important it is.
548        match (self.exclude, other.exclude) {
549            (true, false) => Ordering::Less,
550            (false, true) => Ordering::Greater,
551            _ => their_weight.cmp(&our_weight),
552        }
553    }
554}
555
556impl CodeResourcesRule {
557    pub fn new(pattern: impl ToString) -> Result<Self, AppleCodesignError> {
558        Ok(Self {
559            pattern: pattern.to_string(),
560            exclude: false,
561            nested: false,
562            omit: false,
563            optional: false,
564            weight: None,
565            re: regex::Regex::new(&pattern.to_string())
566                .map_err(|e| AppleCodesignError::ResourcesBadRegex(pattern.to_string(), e))?,
567        })
568    }
569
570    /// Mark this as an exclusion rule.
571    ///
572    /// Exclusion rules are internal to the builder and not materialized in the
573    /// `CodeResources` file.
574    #[must_use]
575    pub fn exclude(mut self) -> Self {
576        self.exclude = true;
577        self
578    }
579
580    /// Mark the rule as nested.
581    #[must_use]
582    pub fn nested(mut self) -> Self {
583        self.nested = true;
584        self
585    }
586
587    /// Set the omit field.
588    #[must_use]
589    pub fn omit(mut self) -> Self {
590        self.omit = true;
591        self
592    }
593
594    /// Mark the files matched by this rule are optional.
595    #[must_use]
596    pub fn optional(mut self) -> Self {
597        self.optional = true;
598        self
599    }
600
601    /// Set the weight of this rule.
602    #[must_use]
603    pub fn weight(mut self, v: u32) -> Self {
604        self.weight = Some(v);
605        self
606    }
607}
608
609/// Which files section we are operating on and how to digest.
610#[derive(Clone, Copy, Debug)]
611pub enum FilesFlavor {
612    /// `<rules>`.
613    Rules,
614    /// `<rules2>`.
615    Rules2,
616    /// `<rules2>` and also include the SHA-1 digest.
617    Rules2WithSha1,
618}
619
620/// Represents a `_CodeSignature/CodeResources` XML plist.
621///
622/// This file/type represents a collection of file-based resources whose
623/// content is digested and captured in this file.
624#[derive(Clone, Debug, Default, PartialEq)]
625pub struct CodeResources {
626    files: BTreeMap<String, FilesValue>,
627    files2: BTreeMap<String, Files2Value>,
628    rules: BTreeMap<String, RulesValue>,
629    rules2: BTreeMap<String, Rules2Value>,
630}
631
632impl CodeResources {
633    /// Construct an instance by parsing an XML plist.
634    pub fn from_xml(xml: &[u8]) -> Result<Self, AppleCodesignError> {
635        let plist = Value::from_reader_xml(xml).map_err(AppleCodesignError::ResourcesPlist)?;
636
637        let dict = plist.into_dictionary().ok_or_else(|| {
638            AppleCodesignError::ResourcesPlistParse(
639                "plist root element should be a <dict>".to_string(),
640            )
641        })?;
642
643        let mut files = BTreeMap::new();
644        let mut files2 = BTreeMap::new();
645        let mut rules = BTreeMap::new();
646        let mut rules2 = BTreeMap::new();
647
648        for (key, value) in dict.iter() {
649            match key.as_ref() {
650                "files" => {
651                    let dict = value.as_dictionary().ok_or_else(|| {
652                        AppleCodesignError::ResourcesPlistParse(format!(
653                            "expecting files to be a dict, got {value:?}"
654                        ))
655                    })?;
656
657                    for (key, value) in dict {
658                        files.insert(key.to_string(), FilesValue::try_from(value)?);
659                    }
660                }
661                "files2" => {
662                    let dict = value.as_dictionary().ok_or_else(|| {
663                        AppleCodesignError::ResourcesPlistParse(format!(
664                            "expecting files2 to be a dict, got {value:?}"
665                        ))
666                    })?;
667
668                    for (key, value) in dict {
669                        files2.insert(key.to_string(), Files2Value::try_from(value)?);
670                    }
671                }
672                "rules" => {
673                    let dict = value.as_dictionary().ok_or_else(|| {
674                        AppleCodesignError::ResourcesPlistParse(format!(
675                            "expecting rules to be a dict, got {value:?}"
676                        ))
677                    })?;
678
679                    for (key, value) in dict {
680                        rules.insert(key.to_string(), RulesValue::try_from(value)?);
681                    }
682                }
683                "rules2" => {
684                    let dict = value.as_dictionary().ok_or_else(|| {
685                        AppleCodesignError::ResourcesPlistParse(format!(
686                            "expecting rules2 to be a dict, got {value:?}"
687                        ))
688                    })?;
689
690                    for (key, value) in dict {
691                        rules2.insert(key.to_string(), Rules2Value::try_from(value)?);
692                    }
693                }
694                key => {
695                    return Err(AppleCodesignError::ResourcesPlistParse(format!(
696                        "unexpected key in root dict: {key}"
697                    )));
698                }
699            }
700        }
701
702        Ok(Self {
703            files,
704            files2,
705            rules,
706            rules2,
707        })
708    }
709
710    /// Serialize an instance to XML.
711    pub fn to_writer_xml(&self, mut writer: impl Write) -> Result<(), AppleCodesignError> {
712        let value = Value::from(self);
713
714        // Ideally we'd write direct to the output. However, Apple's XML writer doesn't
715        // emit a space for empty elements. e.g. we do `<true />` and Apple does `<true/>`.
716        // In addition, our writer doesn't emit a trailing newline. To make it easier to
717        // diff generated files with the canonical output, we normalize to Apple's format.
718        let mut data = Vec::<u8>::new();
719        value
720            .to_writer_xml(&mut data)
721            .map_err(AppleCodesignError::ResourcesPlist)?;
722
723        let data = String::from_utf8(data).expect("XML should be valid UTF-8");
724        let data = data.replace("<dict />", "<dict/>");
725        let data = data.replace("<true />", "<true/>");
726        let data = data.replace("&quot;", "\"");
727
728        writer.write_all(data.as_bytes())?;
729        writer.write_all(b"\n")?;
730
731        Ok(())
732    }
733
734    /// Add a rule to this instance in the `<rules>` section.
735    pub fn add_rule(&mut self, rule: CodeResourcesRule) {
736        self.rules.insert(
737            rule.pattern,
738            RulesValue {
739                omit: rule.omit,
740                required: !rule.optional,
741                weight: rule.weight.map(|x| x as f64),
742            },
743        );
744    }
745
746    /// Add a rule to this instance in the `<rules2>` section.
747    pub fn add_rule2(&mut self, rule: CodeResourcesRule) {
748        self.rules2.insert(
749            rule.pattern,
750            Rules2Value {
751                nested: if rule.nested { Some(true) } else { None },
752                omit: if rule.omit { Some(true) } else { None },
753                optional: if rule.optional { Some(true) } else { None },
754                weight: rule.weight.map(|x| x as f64),
755            },
756        );
757    }
758
759    /// Seal a regular file.
760    ///
761    /// This will digest the content specified and record that digest in the files or
762    /// files2 list.
763    ///
764    /// To seal a symlink, call [CodeResources::seal_symlink] instead. If the file
765    /// is a Mach-O file, call [CodeResources::seal_macho] instead.
766    pub fn seal_regular_file(
767        &mut self,
768        files_flavor: FilesFlavor,
769        path: impl ToString,
770        digests: MultiDigest,
771        optional: bool,
772    ) -> Result<(), AppleCodesignError> {
773        match files_flavor {
774            FilesFlavor::Rules => {
775                self.files.insert(
776                    path.to_string(),
777                    if optional {
778                        FilesValue::Optional(digests.sha1.to_vec())
779                    } else {
780                        FilesValue::Required(digests.sha1.to_vec())
781                    },
782                );
783
784                Ok(())
785            }
786            FilesFlavor::Rules2 => {
787                let hash2 = Some(digests.sha256.to_vec());
788
789                self.files2.insert(
790                    path.to_string(),
791                    Files2Value {
792                        cdhash: None,
793                        hash: None,
794                        hash2,
795                        optional: if optional { Some(true) } else { None },
796                        requirement: None,
797                        symlink: None,
798                    },
799                );
800
801                Ok(())
802            }
803            FilesFlavor::Rules2WithSha1 => {
804                let hash = Some(digests.sha1.to_vec());
805                let hash2 = Some(digests.sha256.to_vec());
806
807                self.files2.insert(
808                    path.to_string(),
809                    Files2Value {
810                        cdhash: None,
811                        hash,
812                        hash2,
813                        optional: if optional { Some(true) } else { None },
814                        requirement: None,
815                        symlink: None,
816                    },
817                );
818
819                Ok(())
820            }
821        }
822    }
823
824    /// Seal a symlink file.
825    ///
826    /// `path` is the path of the symlink and `target` is the path it points to.
827    pub fn seal_symlink(&mut self, path: impl ToString, target: impl ToString) {
828        // Version 1 doesn't support sealing symlinks.
829        self.files2.insert(
830            path.to_string(),
831            Files2Value {
832                cdhash: None,
833                hash: None,
834                hash2: None,
835                optional: None,
836                requirement: None,
837                symlink: Some(target.to_string()),
838            },
839        );
840    }
841
842    /// Record metadata of a previously signed Mach-O binary.
843    ///
844    /// If sealing a fat/universal binary, pass in metadata for the first Mach-O within in.
845    pub fn seal_macho(
846        &mut self,
847        path: impl ToString,
848        info: &SignedMachOInfo,
849        optional: bool,
850    ) -> Result<(), AppleCodesignError> {
851        self.files2.insert(
852            path.to_string(),
853            Files2Value {
854                cdhash: Some(DigestType::Sha256Truncated.digest_data(&info.code_directory_blob)?),
855                hash: None,
856                hash2: None,
857                optional: if optional { Some(true) } else { None },
858                requirement: info.designated_code_requirement.clone(),
859                symlink: None,
860            },
861        );
862
863        Ok(())
864    }
865}
866
867impl From<&CodeResources> for Value {
868    fn from(cr: &CodeResources) -> Self {
869        let mut dict = Dictionary::new();
870
871        dict.insert(
872            "files".to_string(),
873            Value::Dictionary(
874                cr.files
875                    .iter()
876                    .map(|(key, value)| (key.to_string(), Value::from(value)))
877                    .collect::<Dictionary>(),
878            ),
879        );
880
881        dict.insert(
882            "files2".to_string(),
883            Value::Dictionary(
884                cr.files2
885                    .iter()
886                    .map(|(key, value)| (key.to_string(), Value::from(value)))
887                    .collect::<Dictionary>(),
888            ),
889        );
890
891        if !cr.rules.is_empty() {
892            dict.insert(
893                "rules".to_string(),
894                Value::Dictionary(
895                    cr.rules
896                        .iter()
897                        .map(|(key, value)| (key.to_string(), Value::from(value)))
898                        .collect::<Dictionary>(),
899                ),
900            );
901        }
902
903        if !cr.rules2.is_empty() {
904            dict.insert(
905                "rules2".to_string(),
906                Value::Dictionary(
907                    cr.rules2
908                        .iter()
909                        .map(|(key, value)| (key.to_string(), Value::from(value)))
910                        .collect::<Dictionary>(),
911                ),
912            );
913        }
914
915        Value::Dictionary(dict)
916    }
917}
918
919/// Convert a relative filesystem path to its `CodeResources` normalized form.
920pub fn normalized_resources_path(path: impl AsRef<Path>) -> String {
921    // Always use UNIX style directory separators.
922    let path = path.as_ref().to_string_lossy().replace('\\', "/");
923
924    // The Contents/ prefix is also removed for pattern matching and references in the
925    // resources file.
926
927    path.strip_prefix("Contents/").unwrap_or(&path).to_string()
928}
929
930/// Find the first rule matching a given path.
931///
932/// Internally, rules are sorted by decreasing priority, with exclusion
933/// rules having highest priority. So the first pattern that matches is
934/// rule we use.
935///
936/// Pattern matches are always against the normalized filename. (e.g.
937/// `Contents/` is stripped.)
938fn find_rule(rules: &[CodeResourcesRule], path: impl AsRef<Path>) -> Option<CodeResourcesRule> {
939    let path = normalized_resources_path(path);
940    rules.iter().find(|rule| rule.re.is_match(&path)).cloned()
941}
942
943/// Interface for constructing a `CodeResources` instance.
944///
945/// This type is used during bundle signing to construct a `CodeResources` instance.
946/// It contains logic for validating a file against registered processing rules and
947/// handling it accordingly.
948#[derive(Clone, Debug)]
949pub struct CodeResourcesBuilder {
950    rules: Vec<CodeResourcesRule>,
951    rules2: Vec<CodeResourcesRule>,
952    resources: CodeResources,
953    digests: Vec<DigestType>,
954}
955
956impl Default for CodeResourcesBuilder {
957    fn default() -> Self {
958        Self {
959            rules: vec![],
960            rules2: vec![],
961            resources: CodeResources::default(),
962            digests: vec![DigestType::Sha256],
963        }
964    }
965}
966
967impl CodeResourcesBuilder {
968    /// Obtain an instance with default rules for a bundle with a `Resources/` directory.
969    pub fn default_resources_rules() -> Result<Self, AppleCodesignError> {
970        let mut slf = Self::default();
971
972        slf.add_rule(CodeResourcesRule::new("^version.plist$")?);
973        slf.add_rule(CodeResourcesRule::new("^Resources/")?);
974        slf.add_rule(
975            CodeResourcesRule::new("^Resources/.*\\.lproj/")?
976                .optional()
977                .weight(1000),
978        );
979        slf.add_rule(CodeResourcesRule::new("^Resources/Base\\.lproj/")?.weight(1010));
980        slf.add_rule(
981            CodeResourcesRule::new("^Resources/.*\\.lproj/locversion.plist$")?
982                .omit()
983                .weight(1100),
984        );
985
986        slf.add_rule2(CodeResourcesRule::new("^.*")?);
987        slf.add_rule2(CodeResourcesRule::new("^[^/]+$")?.nested().weight(10));
988        slf.add_rule2(CodeResourcesRule::new("^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/")?
989                         .nested().weight(10));
990        slf.add_rule2(CodeResourcesRule::new(".*\\.dSYM($|/)")?.weight(11));
991        slf.add_rule2(
992            CodeResourcesRule::new("^(.*/)?\\.DS_Store$")?
993                .omit()
994                .weight(2000),
995        );
996        slf.add_rule2(CodeResourcesRule::new("^Info\\.plist$")?.omit().weight(20));
997        slf.add_rule2(CodeResourcesRule::new("^version\\.plist$")?.weight(20));
998        slf.add_rule2(CodeResourcesRule::new("^embedded\\.provisionprofile$")?.weight(20));
999        slf.add_rule2(CodeResourcesRule::new("^PkgInfo$")?.omit().weight(20));
1000        slf.add_rule2(CodeResourcesRule::new("^Resources/")?.weight(20));
1001        slf.add_rule2(
1002            CodeResourcesRule::new("^Resources/.*\\.lproj/")?
1003                .optional()
1004                .weight(1000),
1005        );
1006        slf.add_rule2(CodeResourcesRule::new("^Resources/Base\\.lproj/")?.weight(1010));
1007        slf.add_rule2(
1008            CodeResourcesRule::new("^Resources/.*\\.lproj/locversion.plist$")?
1009                .omit()
1010                .weight(1100),
1011        );
1012
1013        Ok(slf)
1014    }
1015
1016    /// Obtain an instance with default rules for a bundle without a `Resources/` directory.
1017    pub fn default_no_resources_rules() -> Result<Self, AppleCodesignError> {
1018        let mut slf = Self::default();
1019
1020        slf.add_rule(CodeResourcesRule::new("^version.plist$")?);
1021        slf.add_rule(CodeResourcesRule::new("^.*")?);
1022        slf.add_rule(
1023            CodeResourcesRule::new("^.*\\.lproj/")?
1024                .optional()
1025                .weight(1000),
1026        );
1027        slf.add_rule(CodeResourcesRule::new("^Base\\.lproj/")?.weight(1010));
1028        slf.add_rule(
1029            CodeResourcesRule::new("^.*\\.lproj/locversion.plist$")?
1030                .omit()
1031                .weight(1100),
1032        );
1033        slf.add_rule2(CodeResourcesRule::new("^.*")?);
1034        slf.add_rule2(CodeResourcesRule::new(".*\\.dSYM($|/)")?.weight(11));
1035        slf.add_rule2(
1036            CodeResourcesRule::new("^(.*/)?\\.DS_Store$")?
1037                .omit()
1038                .weight(2000),
1039        );
1040        slf.add_rule2(CodeResourcesRule::new("^Info\\.plist$")?.omit().weight(20));
1041        slf.add_rule2(CodeResourcesRule::new("^version\\.plist$")?.weight(20));
1042        slf.add_rule2(CodeResourcesRule::new("^embedded\\.provisionprofile$")?.weight(20));
1043        slf.add_rule2(CodeResourcesRule::new("^PkgInfo$")?.omit().weight(20));
1044        slf.add_rule2(
1045            CodeResourcesRule::new("^.*\\.lproj/")?
1046                .optional()
1047                .weight(1000),
1048        );
1049        slf.add_rule2(CodeResourcesRule::new("^Base\\.lproj/")?.weight(1010));
1050        slf.add_rule2(
1051            CodeResourcesRule::new("^.*\\.lproj/locversion.plist$")?
1052                .omit()
1053                .weight(1100),
1054        );
1055
1056        Ok(slf)
1057    }
1058
1059    /// Set the digests to record in this instance.
1060    pub fn set_digests(&mut self, digests: impl Iterator<Item = DigestType>) {
1061        self.digests = digests.collect::<Vec<_>>();
1062    }
1063
1064    /// Add a rule to this instance in the `<rules>` section.
1065    pub fn add_rule(&mut self, rule: CodeResourcesRule) {
1066        self.rules.push(rule.clone());
1067        self.rules.sort();
1068        self.resources.add_rule(rule);
1069    }
1070
1071    /// Add a rule to this instance in the `<rules2>` section.
1072    pub fn add_rule2(&mut self, rule: CodeResourcesRule) {
1073        self.rules2.push(rule.clone());
1074        self.rules2.sort();
1075        self.resources.add_rule2(rule);
1076    }
1077
1078    /// Add an exclusion rule to the processing rules.
1079    ///
1080    /// Exclusion rules are not added to the [CodeResources] because they are
1081    /// implicit and used for filesystem traversal to influence which entities
1082    /// are skipped.
1083    pub fn add_exclusion_rule(&mut self, rule: CodeResourcesRule) {
1084        self.rules.push(rule.clone());
1085        self.rules.sort();
1086        self.rules2.push(rule);
1087        self.rules2.sort();
1088    }
1089
1090    /// Recursively seal a bundle directory.
1091    ///
1092    /// This function does the heavy lifting of walking a bundle directory
1093    /// and sealing the content inside.
1094    ///
1095    /// For each filesystem entry, it finds the most appropriate registered
1096    /// rule that applies to it. Then using that rule it takes actions.
1097    ///
1098    /// Typically, each file entity has its digest recorded/sealed.
1099    ///
1100    /// As a side-effect, files are copied/installed into the destination
1101    /// directory as part of sealing.
1102    pub fn walk_and_seal_directory(
1103        &mut self,
1104        root_bundle_path: &Path,
1105        bundle_root: &Path,
1106        context: &mut BundleSigningContext,
1107    ) -> Result<(), AppleCodesignError> {
1108        let mut skipping_rel_dirs = BTreeSet::new();
1109
1110        for entry in walkdir::WalkDir::new(bundle_root).sort_by_file_name() {
1111            let entry = entry?;
1112            let path = entry.path();
1113
1114            if path == bundle_root {
1115                continue;
1116            }
1117
1118            let rel_path = path
1119                .strip_prefix(bundle_root)
1120                .expect("stripping path prefix should always work");
1121            let root_rel_path_normalized = path
1122                .strip_prefix(root_bundle_path)
1123                .expect("stripping root prefix should always work")
1124                .to_string_lossy()
1125                .replace('\\', "/");
1126            let rel_path_normalized = normalized_resources_path(rel_path);
1127
1128            let file_name = rel_path
1129                .file_name()
1130                .expect("should have final path component")
1131                .to_string_lossy()
1132                .to_string();
1133
1134            // We're excluding a parent directory. Do nothing.
1135            if skipping_rel_dirs.iter().any(|p| rel_path.starts_with(p)) {
1136                debug!("{} ignored because marked as skipped", rel_path.display());
1137                continue;
1138            }
1139
1140            // Rules version 2.
1141            if let Some(rule) = find_rule(&self.rules2, rel_path) {
1142                debug!(
1143                    "{}:{} matches rules2 {:?}",
1144                    bundle_root.display(),
1145                    rel_path.display(),
1146                    rule
1147                );
1148
1149                if entry.file_type().is_dir() {
1150                    if rule.nested {
1151                        // Only treat as a nested bundle iff it has a dot in its name.
1152                        if file_name.contains('.') {
1153                            // We assume the bundle has already been signed because that's
1154                            // how our bundle walker works. So all we need to do here is
1155                            // seal the bundle. We can skip handling all files in this
1156                            // directory since they've already been processed.
1157                            self.seal_rules2_nested_bundle(
1158                                path,
1159                                rel_path,
1160                                &rel_path_normalized,
1161                                rule.optional,
1162                                &context.dest_dir,
1163                            )?;
1164
1165                            skipping_rel_dirs.insert(rel_path.to_path_buf());
1166                        }
1167                    } else if rule.exclude {
1168                        info!(
1169                            "{} marked as excluded in resource rules",
1170                            rel_path_normalized
1171                        );
1172                        skipping_rel_dirs.insert(rel_path.to_path_buf());
1173                    }
1174
1175                    // No need to do anything else since we'll walk into directory
1176                    // to handle files.
1177                } else if entry.file_type().is_file() {
1178                    if rule.exclude {
1179                        debug!("{} ignoring file due to exclude rule", rel_path_normalized);
1180                        continue;
1181                    }
1182
1183                    // Nested flag means the file should itself be signable.
1184                    if rule.nested {
1185                        if crate::reader::path_is_macho(path)? {
1186                            info!("sealing nested Mach-O binary: {}", rel_path.display());
1187
1188                            self.seal_rules2_nested_macho(
1189                                path,
1190                                rel_path,
1191                                &rel_path_normalized,
1192                                &root_rel_path_normalized,
1193                                context,
1194                                rule.optional,
1195                            )?;
1196                        } else {
1197                            // TODO implement this?
1198                            // The logical intent is to sign and seal the nested entity.
1199                            // But if we're not a directory bundle and not a Mach-O, I'm
1200                            // unsure how to convey that seal. Maybe other entities like
1201                            // DMG and pkg installers can have their signature digest
1202                            // encapsulated in a cdhash?
1203                            error!(
1204                                "encountered a non Mach-O file with a nested rule: {}",
1205                                rel_path.display()
1206                            );
1207                            error!(
1208                                "we do not know how to handle this scenario; either your bundle layout is invalid or you found a bug in this program"
1209                            );
1210                            error!(
1211                                "if the bundle signs and verifies with Apple's tooling, consider reporting this issue"
1212                            );
1213                        }
1214                    } else {
1215                        self.seal_rules2_file(
1216                            path,
1217                            rel_path,
1218                            &rel_path_normalized,
1219                            &root_rel_path_normalized,
1220                            rule.omit,
1221                            rule.optional,
1222                            context,
1223                        )?;
1224                    }
1225                } else if entry.file_type().is_symlink() {
1226                    if rule.exclude {
1227                        info!(
1228                            "{} ignoring symlink due to exclude rule",
1229                            rel_path_normalized
1230                        );
1231                        continue;
1232                    }
1233
1234                    self.seal_rules2_symlink(
1235                        path,
1236                        rel_path,
1237                        &rel_path_normalized,
1238                        rule.omit,
1239                        context,
1240                    )?;
1241                } else {
1242                    warn!(
1243                        "{} unexpected file type encountering during bundle signing",
1244                        rel_path_normalized
1245                    );
1246                }
1247            } else {
1248                debug!(
1249                    "{}:{} doesn't match any rules2 rule",
1250                    bundle_root.display(),
1251                    rel_path.display()
1252                );
1253            }
1254
1255            // Now rules version 1. Only regular files can be sealed. Version
1256            // 1 does not support nested signatures nor symlinks.
1257            if let Some(rule) = find_rule(&self.rules, rel_path) {
1258                debug!(
1259                    "{}:{} matches rules rule {:?}",
1260                    bundle_root.display(),
1261                    rel_path.display(),
1262                    rule
1263                );
1264
1265                if entry.file_type().is_file() {
1266                    if rule.exclude {
1267                        continue;
1268                    }
1269
1270                    self.seal_rules1_file(path, &rel_path_normalized, rule)?;
1271                }
1272            }
1273        }
1274
1275        Ok(())
1276    }
1277
1278    /// Seal a nested bundle for rules version 2.
1279    fn seal_rules2_nested_bundle(
1280        &mut self,
1281        full_path: &Path,
1282        rel_path: &Path,
1283        rel_path_normalized: &str,
1284        optional: bool,
1285        dest_dir: &Path,
1286    ) -> Result<(), AppleCodesignError> {
1287        info!(
1288            "sealing nested directory as a bundle: {}",
1289            rel_path.display()
1290        );
1291        let bundle = DirectoryBundle::new_from_path(full_path)?;
1292
1293        if let Some(nested_exe) = bundle
1294            .files(false)?
1295            .into_iter()
1296            .find(|f| matches!(f.is_main_executable(), Ok(true)))
1297        {
1298            let nested_exe = dest_dir.join(rel_path).join(nested_exe.relative_path());
1299
1300            info!("reading Mach-O signature from {}", nested_exe.display());
1301            let macho_data = isideload_vfs::fs::read(&nested_exe)?;
1302            let macho_info = SignedMachOInfo::parse_data(&macho_data)?;
1303
1304            self.resources
1305                .seal_macho(rel_path_normalized, &macho_info, optional)?;
1306        } else {
1307            warn!(
1308                "could not find main executable of presumed nested bundle: {}",
1309                rel_path.display()
1310            );
1311        }
1312
1313        Ok(())
1314    }
1315
1316    /// Seal a Mach-O binary matching a nested rule.
1317    fn seal_rules2_nested_macho(
1318        &mut self,
1319        full_path: &Path,
1320        rel_path: &Path,
1321        rel_path_normalized: &str,
1322        root_rel_path: &str,
1323        context: &mut BundleSigningContext,
1324        optional: bool,
1325    ) -> Result<(), AppleCodesignError> {
1326        let macho_info = if context
1327            .settings
1328            .path_exclusion_pattern_matches(root_rel_path)
1329        {
1330            warn!(
1331                "skipping signing of nested Mach-O binary because excluded by settings: {}",
1332                rel_path.display()
1333            );
1334            warn!("(an error will occur if this binary is not already signed)");
1335            warn!(
1336                "(if you see an error, sign that Mach-O explicitly or remove it from the exclusion settings)"
1337            );
1338
1339            let dest_path = context.install_file(full_path, rel_path)?;
1340            let data = isideload_vfs::fs::read(dest_path)?;
1341
1342            SignedMachOInfo::parse_data(&data)?
1343        } else {
1344            context.sign_and_install_macho(full_path, rel_path)?.1
1345        };
1346
1347        self.resources
1348            .seal_macho(rel_path_normalized, &macho_info, optional)
1349    }
1350
1351    /// Seal a file for version 2 rules.
1352    fn seal_rules2_file(
1353        &mut self,
1354        full_path: &Path,
1355        rel_path: &Path,
1356        rel_path_normalized: &str,
1357        root_rel_path: &str,
1358        omit: bool,
1359        optional: bool,
1360        context: &mut BundleSigningContext,
1361    ) -> Result<(), AppleCodesignError> {
1362        let mut need_install = !context.previously_installed_paths.contains(rel_path);
1363        // Only seal if the omit flag is unset.
1364        if !omit {
1365            // Unlike Apple's tooling, we recognize Mach-O binaries when the nested
1366            // flag isn't set and we automatically sign.
1367            //
1368            // Unless the path is marked for exclusion or shallow signing mode is
1369            // active.
1370            //
1371            // The reason we exclude in shallow mode is that shallow mode is supposed
1372            // to behave like Apple's `codesign` and that tool only signs the bundle's
1373            // "main" Mach-O binary, not other binaries.
1374            let sign_macho = need_install
1375                && crate::reader::path_is_macho(full_path)?
1376                && !context
1377                    .settings
1378                    .path_exclusion_pattern_matches(root_rel_path)
1379                && !context.settings.shallow();
1380
1381            let read_path = if sign_macho {
1382                info!(
1383                    "non-nested file is a Mach-O binary; signing accordingly {}",
1384                    rel_path.display()
1385                );
1386                need_install = false;
1387                // We need to read the signed/installed version of the file since
1388                // signing will change its content.
1389                context.sign_and_install_macho(full_path, rel_path)?.0
1390            } else {
1391                info!("sealing regular file {}", rel_path_normalized);
1392
1393                // If we need to install the file, seal the source file. Else since the
1394                // file is already installed, seal the destination file.
1395                //
1396                // For regular files this distinction doesn't matter. But for Mach-O
1397                // binaries it ensures we pick up the final signature, not the source
1398                // file.
1399                if need_install {
1400                    full_path.to_path_buf()
1401                } else {
1402                    context.dest_dir.join(rel_path)
1403                }
1404            };
1405
1406            let digests = MultiDigest::from_path(read_path)?;
1407
1408            let flavor = if self.digests.contains(&DigestType::Sha1) {
1409                FilesFlavor::Rules2WithSha1
1410            } else {
1411                FilesFlavor::Rules2
1412            };
1413
1414            // When we seal the file, we treat it as a regular file since the
1415            // nested flag isn't set.
1416            self.resources
1417                .seal_regular_file(flavor, rel_path_normalized, digests, optional)?;
1418        }
1419
1420        if need_install {
1421            context.install_file(full_path, rel_path)?;
1422        }
1423
1424        Ok(())
1425    }
1426
1427    fn seal_rules2_symlink(
1428        &mut self,
1429        full_path: &Path,
1430        rel_path: &Path,
1431        rel_path_normalized: &str,
1432        omit: bool,
1433        context: &mut BundleSigningContext,
1434    ) -> Result<(), AppleCodesignError> {
1435        let link_target = isideload_vfs::fs::read_link(full_path)?
1436            .to_string_lossy()
1437            .replace('\\', "/");
1438
1439        if !omit {
1440            info!("sealing symlink {} -> {}", rel_path_normalized, link_target);
1441            self.resources
1442                .seal_symlink(rel_path_normalized, link_target);
1443        }
1444        context.install_file(full_path, rel_path)?;
1445
1446        Ok(())
1447    }
1448
1449    /// Perform sealing activity for an entry in rules v1.
1450    fn seal_rules1_file(
1451        &mut self,
1452        full_path: &Path,
1453        rel_path_normalized: &str,
1454        rule: CodeResourcesRule,
1455    ) -> Result<(), AppleCodesignError> {
1456        // Version 1 doesn't handle symlinks nor nested Mach-O binaries.
1457        // And version 2's handler installed files. So all we have to do here
1458        // is record SHA-1 digests in `<files>`.
1459
1460        let digests = MultiDigest::from_path(full_path)?;
1461
1462        self.resources.seal_regular_file(
1463            FilesFlavor::Rules,
1464            rel_path_normalized,
1465            digests,
1466            rule.optional,
1467        )?;
1468
1469        Ok(())
1470    }
1471
1472    /// Write CodeResources XML content to a writer.
1473    pub fn write_code_resources(&self, writer: impl Write) -> Result<(), AppleCodesignError> {
1474        self.resources.to_writer_xml(writer)
1475    }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481
1482    const FIREFOX_SNIPPET: &str = r#"
1483        <?xml version="1.0" encoding="UTF-8"?>
1484        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1485        <plist version="1.0">
1486          <dict>
1487            <key>files</key>
1488            <dict>
1489              <key>Resources/XUL.sig</key>
1490              <data>Y0SEPxyC6hCQ+rl4LTRmXy7F9DQ=</data>
1491              <key>Resources/en.lproj/InfoPlist.strings</key>
1492              <dict>
1493                <key>hash</key>
1494                <data>U8LTYe+cVqPcBu9aLvcyyfp+dAg=</data>
1495                <key>optional</key>
1496                <true/>
1497              </dict>
1498              <key>Resources/firefox-bin.sig</key>
1499              <data>ZvZ3yDciAF4kB9F06Xr3gKi3DD4=</data>
1500            </dict>
1501            <key>files2</key>
1502            <dict>
1503              <key>Library/LaunchServices/org.mozilla.updater</key>
1504              <dict>
1505                <key>hash2</key>
1506                <data>iMnDHpWkKTI6xLi9Av93eNuIhxXhv3C18D4fljCfw2Y=</data>
1507              </dict>
1508              <key>TestOptional</key>
1509              <dict>
1510                <key>hash2</key>
1511                <data>iMnDHpWkKTI6xLi9Av93eNuIhxXhv3C18D4fljCfw2Y=</data>
1512                <key>optional</key>
1513                <true/>
1514              </dict>
1515              <key>MacOS/XUL</key>
1516              <dict>
1517                <key>cdhash</key>
1518                <data>NevNMzQBub9OjomMUAk2xBumyHM=</data>
1519                <key>requirement</key>
1520                <string>anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "43AQ936H96"</string>
1521              </dict>
1522              <key>MacOS/SafariForWebKitDevelopment</key>
1523              <dict>
1524                <key>symlink</key>
1525                <string>/Library/Application Support/Apple/Safari/SafariForWebKitDevelopment</string>
1526              </dict>
1527            </dict>
1528            <key>rules</key>
1529            <dict>
1530              <key>^Resources/</key>
1531              <true/>
1532              <key>^Resources/.*\.lproj/</key>
1533              <dict>
1534                <key>optional</key>
1535                <true/>
1536                <key>weight</key>
1537                <real>1000</real>
1538              </dict>
1539            </dict>
1540            <key>rules2</key>
1541            <dict>
1542              <key>.*\.dSYM($|/)</key>
1543              <dict>
1544                <key>weight</key>
1545                <real>11</real>
1546              </dict>
1547              <key>^(.*/)?\.DS_Store$</key>
1548              <dict>
1549                <key>omit</key>
1550                <true/>
1551                <key>weight</key>
1552                <real>2000</real>
1553              </dict>
1554              <key>^[^/]+$</key>
1555              <dict>
1556                <key>nested</key>
1557                <true/>
1558                <key>weight</key>
1559                <real>10</real>
1560              </dict>
1561              <key>optional</key>
1562              <dict>
1563                <key>optional</key>
1564                <true/>
1565              </dict>
1566            </dict>
1567          </dict>
1568        </plist>"#;
1569
1570    #[test]
1571    fn parse_firefox() {
1572        let resources = CodeResources::from_xml(FIREFOX_SNIPPET.as_bytes()).unwrap();
1573
1574        // Serialize back to XML.
1575        let mut buffer = Vec::<u8>::new();
1576        resources.to_writer_xml(&mut buffer).unwrap();
1577        let resources2 = CodeResources::from_xml(&buffer).unwrap();
1578
1579        assert_eq!(resources, resources2);
1580    }
1581}