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    let path = path.strip_prefix("Contents/").unwrap_or(&path).to_string();
927
928    path
929}
930
931/// Find the first rule matching a given path.
932///
933/// Internally, rules are sorted by decreasing priority, with exclusion
934/// rules having highest priority. So the first pattern that matches is
935/// rule we use.
936///
937/// Pattern matches are always against the normalized filename. (e.g.
938/// `Contents/` is stripped.)
939fn find_rule(rules: &[CodeResourcesRule], path: impl AsRef<Path>) -> Option<CodeResourcesRule> {
940    let path = normalized_resources_path(path);
941    rules.iter().find(|rule| rule.re.is_match(&path)).cloned()
942}
943
944/// Interface for constructing a `CodeResources` instance.
945///
946/// This type is used during bundle signing to construct a `CodeResources` instance.
947/// It contains logic for validating a file against registered processing rules and
948/// handling it accordingly.
949#[derive(Clone, Debug)]
950pub struct CodeResourcesBuilder {
951    rules: Vec<CodeResourcesRule>,
952    rules2: Vec<CodeResourcesRule>,
953    resources: CodeResources,
954    digests: Vec<DigestType>,
955}
956
957impl Default for CodeResourcesBuilder {
958    fn default() -> Self {
959        Self {
960            rules: vec![],
961            rules2: vec![],
962            resources: CodeResources::default(),
963            digests: vec![DigestType::Sha256],
964        }
965    }
966}
967
968impl CodeResourcesBuilder {
969    /// Obtain an instance with default rules for a bundle with a `Resources/` directory.
970    pub fn default_resources_rules() -> Result<Self, AppleCodesignError> {
971        let mut slf = Self::default();
972
973        slf.add_rule(CodeResourcesRule::new("^version.plist$")?);
974        slf.add_rule(CodeResourcesRule::new("^Resources/")?);
975        slf.add_rule(
976            CodeResourcesRule::new("^Resources/.*\\.lproj/")?
977                .optional()
978                .weight(1000),
979        );
980        slf.add_rule(CodeResourcesRule::new("^Resources/Base\\.lproj/")?.weight(1010));
981        slf.add_rule(
982            CodeResourcesRule::new("^Resources/.*\\.lproj/locversion.plist$")?
983                .omit()
984                .weight(1100),
985        );
986
987        slf.add_rule2(CodeResourcesRule::new("^.*")?);
988        slf.add_rule2(CodeResourcesRule::new("^[^/]+$")?.nested().weight(10));
989        slf.add_rule2(CodeResourcesRule::new("^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/")?
990                         .nested().weight(10));
991        slf.add_rule2(CodeResourcesRule::new(".*\\.dSYM($|/)")?.weight(11));
992        slf.add_rule2(
993            CodeResourcesRule::new("^(.*/)?\\.DS_Store$")?
994                .omit()
995                .weight(2000),
996        );
997        slf.add_rule2(CodeResourcesRule::new("^Info\\.plist$")?.omit().weight(20));
998        slf.add_rule2(CodeResourcesRule::new("^version\\.plist$")?.weight(20));
999        slf.add_rule2(CodeResourcesRule::new("^embedded\\.provisionprofile$")?.weight(20));
1000        slf.add_rule2(CodeResourcesRule::new("^PkgInfo$")?.omit().weight(20));
1001        slf.add_rule2(CodeResourcesRule::new("^Resources/")?.weight(20));
1002        slf.add_rule2(
1003            CodeResourcesRule::new("^Resources/.*\\.lproj/")?
1004                .optional()
1005                .weight(1000),
1006        );
1007        slf.add_rule2(CodeResourcesRule::new("^Resources/Base\\.lproj/")?.weight(1010));
1008        slf.add_rule2(
1009            CodeResourcesRule::new("^Resources/.*\\.lproj/locversion.plist$")?
1010                .omit()
1011                .weight(1100),
1012        );
1013
1014        Ok(slf)
1015    }
1016
1017    /// Obtain an instance with default rules for a bundle without a `Resources/` directory.
1018    pub fn default_no_resources_rules() -> Result<Self, AppleCodesignError> {
1019        let mut slf = Self::default();
1020
1021        slf.add_rule(CodeResourcesRule::new("^version.plist$")?);
1022        slf.add_rule(CodeResourcesRule::new("^.*")?);
1023        slf.add_rule(
1024            CodeResourcesRule::new("^.*\\.lproj/")?
1025                .optional()
1026                .weight(1000),
1027        );
1028        slf.add_rule(CodeResourcesRule::new("^Base\\.lproj/")?.weight(1010));
1029        slf.add_rule(
1030            CodeResourcesRule::new("^.*\\.lproj/locversion.plist$")?
1031                .omit()
1032                .weight(1100),
1033        );
1034        slf.add_rule2(CodeResourcesRule::new("^.*")?);
1035        slf.add_rule2(CodeResourcesRule::new(".*\\.dSYM($|/)")?.weight(11));
1036        slf.add_rule2(
1037            CodeResourcesRule::new("^(.*/)?\\.DS_Store$")?
1038                .omit()
1039                .weight(2000),
1040        );
1041        slf.add_rule2(CodeResourcesRule::new("^Info\\.plist$")?.omit().weight(20));
1042        slf.add_rule2(CodeResourcesRule::new("^version\\.plist$")?.weight(20));
1043        slf.add_rule2(CodeResourcesRule::new("^embedded\\.provisionprofile$")?.weight(20));
1044        slf.add_rule2(CodeResourcesRule::new("^PkgInfo$")?.omit().weight(20));
1045        slf.add_rule2(
1046            CodeResourcesRule::new("^.*\\.lproj/")?
1047                .optional()
1048                .weight(1000),
1049        );
1050        slf.add_rule2(CodeResourcesRule::new("^Base\\.lproj/")?.weight(1010));
1051        slf.add_rule2(
1052            CodeResourcesRule::new("^.*\\.lproj/locversion.plist$")?
1053                .omit()
1054                .weight(1100),
1055        );
1056
1057        Ok(slf)
1058    }
1059
1060    /// Set the digests to record in this instance.
1061    pub fn set_digests(&mut self, digests: impl Iterator<Item = DigestType>) {
1062        self.digests = digests.collect::<Vec<_>>();
1063    }
1064
1065    /// Add a rule to this instance in the `<rules>` section.
1066    pub fn add_rule(&mut self, rule: CodeResourcesRule) {
1067        self.rules.push(rule.clone());
1068        self.rules.sort();
1069        self.resources.add_rule(rule);
1070    }
1071
1072    /// Add a rule to this instance in the `<rules2>` section.
1073    pub fn add_rule2(&mut self, rule: CodeResourcesRule) {
1074        self.rules2.push(rule.clone());
1075        self.rules2.sort();
1076        self.resources.add_rule2(rule);
1077    }
1078
1079    /// Add an exclusion rule to the processing rules.
1080    ///
1081    /// Exclusion rules are not added to the [CodeResources] because they are
1082    /// implicit and used for filesystem traversal to influence which entities
1083    /// are skipped.
1084    pub fn add_exclusion_rule(&mut self, rule: CodeResourcesRule) {
1085        self.rules.push(rule.clone());
1086        self.rules.sort();
1087        self.rules2.push(rule);
1088        self.rules2.sort();
1089    }
1090
1091    /// Recursively seal a bundle directory.
1092    ///
1093    /// This function does the heavy lifting of walking a bundle directory
1094    /// and sealing the content inside.
1095    ///
1096    /// For each filesystem entry, it finds the most appropriate registered
1097    /// rule that applies to it. Then using that rule it takes actions.
1098    ///
1099    /// Typically, each file entity has its digest recorded/sealed.
1100    ///
1101    /// As a side-effect, files are copied/installed into the destination
1102    /// directory as part of sealing.
1103    pub fn walk_and_seal_directory(
1104        &mut self,
1105        root_bundle_path: &Path,
1106        bundle_root: &Path,
1107        context: &mut BundleSigningContext,
1108    ) -> Result<(), AppleCodesignError> {
1109        let mut skipping_rel_dirs = BTreeSet::new();
1110
1111        for entry in walkdir::WalkDir::new(bundle_root).sort_by_file_name() {
1112            let entry = entry?;
1113            let path = entry.path();
1114
1115            if path == bundle_root {
1116                continue;
1117            }
1118
1119            let rel_path = path
1120                .strip_prefix(bundle_root)
1121                .expect("stripping path prefix should always work");
1122            let root_rel_path_normalized = path
1123                .strip_prefix(root_bundle_path)
1124                .expect("stripping root prefix should always work")
1125                .to_string_lossy()
1126                .replace('\\', "/");
1127            let rel_path_normalized = normalized_resources_path(rel_path);
1128
1129            let file_name = rel_path
1130                .file_name()
1131                .expect("should have final path component")
1132                .to_string_lossy()
1133                .to_string();
1134
1135            // We're excluding a parent directory. Do nothing.
1136            if skipping_rel_dirs.iter().any(|p| rel_path.starts_with(p)) {
1137                debug!("{} ignored because marked as skipped", rel_path.display());
1138                continue;
1139            }
1140
1141            // Rules version 2.
1142            if let Some(rule) = find_rule(&self.rules2, rel_path) {
1143                debug!(
1144                    "{}:{} matches rules2 {:?}",
1145                    bundle_root.display(),
1146                    rel_path.display(),
1147                    rule
1148                );
1149
1150                if entry.file_type().is_dir() {
1151                    if rule.nested {
1152                        // Only treat as a nested bundle iff it has a dot in its name.
1153                        if file_name.contains('.') {
1154                            // We assume the bundle has already been signed because that's
1155                            // how our bundle walker works. So all we need to do here is
1156                            // seal the bundle. We can skip handling all files in this
1157                            // directory since they've already been processed.
1158                            self.seal_rules2_nested_bundle(
1159                                path,
1160                                rel_path,
1161                                &rel_path_normalized,
1162                                rule.optional,
1163                                &context.dest_dir,
1164                            )?;
1165
1166                            skipping_rel_dirs.insert(rel_path.to_path_buf());
1167                        }
1168                    } else if rule.exclude {
1169                        info!(
1170                            "{} marked as excluded in resource rules",
1171                            rel_path_normalized
1172                        );
1173                        skipping_rel_dirs.insert(rel_path.to_path_buf());
1174                    }
1175
1176                    // No need to do anything else since we'll walk into directory
1177                    // to handle files.
1178                } else if entry.file_type().is_file() {
1179                    if rule.exclude {
1180                        debug!("{} ignoring file due to exclude rule", rel_path_normalized);
1181                        continue;
1182                    }
1183
1184                    // Nested flag means the file should itself be signable.
1185                    if rule.nested {
1186                        if crate::reader::path_is_macho(path)? {
1187                            info!("sealing nested Mach-O binary: {}", rel_path.display());
1188
1189                            self.seal_rules2_nested_macho(
1190                                path,
1191                                rel_path,
1192                                &rel_path_normalized,
1193                                &root_rel_path_normalized,
1194                                context,
1195                                rule.optional,
1196                            )?;
1197                        } else {
1198                            // TODO implement this?
1199                            // The logical intent is to sign and seal the nested entity.
1200                            // But if we're not a directory bundle and not a Mach-O, I'm
1201                            // unsure how to convey that seal. Maybe other entities like
1202                            // DMG and pkg installers can have their signature digest
1203                            // encapsulated in a cdhash?
1204                            error!(
1205                                "encountered a non Mach-O file with a nested rule: {}",
1206                                rel_path.display()
1207                            );
1208                            error!("we do not know how to handle this scenario; either your bundle layout is invalid or you found a bug in this program");
1209                            error!("if the bundle signs and verifies with Apple's tooling, consider reporting this issue");
1210                        }
1211                    } else {
1212                        self.seal_rules2_file(
1213                            path,
1214                            rel_path,
1215                            &rel_path_normalized,
1216                            &root_rel_path_normalized,
1217                            rule.omit,
1218                            rule.optional,
1219                            context,
1220                        )?;
1221                    }
1222                } else if entry.file_type().is_symlink() {
1223                    if rule.exclude {
1224                        info!(
1225                            "{} ignoring symlink due to exclude rule",
1226                            rel_path_normalized
1227                        );
1228                        continue;
1229                    }
1230
1231                    self.seal_rules2_symlink(
1232                        path,
1233                        rel_path,
1234                        &rel_path_normalized,
1235                        rule.omit,
1236                        context,
1237                    )?;
1238                } else {
1239                    warn!(
1240                        "{} unexpected file type encountering during bundle signing",
1241                        rel_path_normalized
1242                    );
1243                }
1244            } else {
1245                debug!(
1246                    "{}:{} doesn't match any rules2 rule",
1247                    bundle_root.display(),
1248                    rel_path.display()
1249                );
1250            }
1251
1252            // Now rules version 1. Only regular files can be sealed. Version
1253            // 1 does not support nested signatures nor symlinks.
1254            if let Some(rule) = find_rule(&self.rules, rel_path) {
1255                debug!(
1256                    "{}:{} matches rules rule {:?}",
1257                    bundle_root.display(),
1258                    rel_path.display(),
1259                    rule
1260                );
1261
1262                if entry.file_type().is_file() {
1263                    if rule.exclude {
1264                        continue;
1265                    }
1266
1267                    self.seal_rules1_file(path, &rel_path_normalized, rule)?;
1268                }
1269            }
1270        }
1271
1272        Ok(())
1273    }
1274
1275    /// Seal a nested bundle for rules version 2.
1276    fn seal_rules2_nested_bundle(
1277        &mut self,
1278        full_path: &Path,
1279        rel_path: &Path,
1280        rel_path_normalized: &str,
1281        optional: bool,
1282        dest_dir: &Path,
1283    ) -> Result<(), AppleCodesignError> {
1284        info!(
1285            "sealing nested directory as a bundle: {}",
1286            rel_path.display()
1287        );
1288        let bundle = DirectoryBundle::new_from_path(full_path)?;
1289
1290        if let Some(nested_exe) = bundle
1291            .files(false)?
1292            .into_iter()
1293            .find(|f| matches!(f.is_main_executable(), Ok(true)))
1294        {
1295            let nested_exe = dest_dir.join(rel_path).join(nested_exe.relative_path());
1296
1297            info!("reading Mach-O signature from {}", nested_exe.display());
1298            let macho_data = std::fs::read(&nested_exe)?;
1299            let macho_info = SignedMachOInfo::parse_data(&macho_data)?;
1300
1301            self.resources
1302                .seal_macho(rel_path_normalized, &macho_info, optional)?;
1303        } else {
1304            warn!(
1305                "could not find main executable of presumed nested bundle: {}",
1306                rel_path.display()
1307            );
1308        }
1309
1310        Ok(())
1311    }
1312
1313    /// Seal a Mach-O binary matching a nested rule.
1314    fn seal_rules2_nested_macho(
1315        &mut self,
1316        full_path: &Path,
1317        rel_path: &Path,
1318        rel_path_normalized: &str,
1319        root_rel_path: &str,
1320        context: &mut BundleSigningContext,
1321        optional: bool,
1322    ) -> Result<(), AppleCodesignError> {
1323        let macho_info = if context
1324            .settings
1325            .path_exclusion_pattern_matches(root_rel_path)
1326        {
1327            warn!(
1328                "skipping signing of nested Mach-O binary because excluded by settings: {}",
1329                rel_path.display()
1330            );
1331            warn!("(an error will occur if this binary is not already signed)");
1332            warn!("(if you see an error, sign that Mach-O explicitly or remove it from the exclusion settings)");
1333
1334            let dest_path = context.install_file(full_path, rel_path)?;
1335            let data = std::fs::read(dest_path)?;
1336
1337            SignedMachOInfo::parse_data(&data)?
1338        } else {
1339            context.sign_and_install_macho(full_path, rel_path)?.1
1340        };
1341
1342        self.resources
1343            .seal_macho(rel_path_normalized, &macho_info, optional)
1344    }
1345
1346    /// Seal a file for version 2 rules.
1347    fn seal_rules2_file(
1348        &mut self,
1349        full_path: &Path,
1350        rel_path: &Path,
1351        rel_path_normalized: &str,
1352        root_rel_path: &str,
1353        omit: bool,
1354        optional: bool,
1355        context: &mut BundleSigningContext,
1356    ) -> Result<(), AppleCodesignError> {
1357        let mut need_install = !context.previously_installed_paths.contains(rel_path);
1358
1359        // Only seal if the omit flag is unset.
1360        if !omit {
1361            // Unlike Apple's tooling, we recognize Mach-O binaries when the nested
1362            // flag isn't set and we automatically sign.
1363            //
1364            // Unless the path is marked for exclusion or shallow signing mode is
1365            // active.
1366            //
1367            // The reason we exclude in shallow mode is that shallow mode is supposed
1368            // to behave like Apple's `codesign` and that tool only signs the bundle's
1369            // "main" Mach-O binary, not other binaries.
1370            let sign_macho = need_install
1371                && crate::reader::path_is_macho(full_path)?
1372                && !context
1373                    .settings
1374                    .path_exclusion_pattern_matches(root_rel_path)
1375                && !context.settings.shallow();
1376
1377            let read_path = if sign_macho {
1378                info!(
1379                    "non-nested file is a Mach-O binary; signing accordingly {}",
1380                    rel_path.display()
1381                );
1382                need_install = false;
1383                // We need to read the signed/installed version of the file since
1384                // signing will change its content.
1385                context.sign_and_install_macho(full_path, rel_path)?.0
1386            } else {
1387                info!("sealing regular file {}", rel_path_normalized);
1388
1389                // If we need to install the file, seal the source file. Else since the
1390                // file is already installed, seal the destination file.
1391                //
1392                // For regular files this distinction doesn't matter. But for Mach-O
1393                // binaries it ensures we pick up the final signature, not the source
1394                // file.
1395                if need_install {
1396                    full_path.to_path_buf()
1397                } else {
1398                    context.dest_dir.join(rel_path)
1399                }
1400            };
1401
1402            let digests = MultiDigest::from_path(read_path)?;
1403
1404            let flavor = if self.digests.contains(&DigestType::Sha1) {
1405                FilesFlavor::Rules2WithSha1
1406            } else {
1407                FilesFlavor::Rules2
1408            };
1409
1410            // When we seal the file, we treat it as a regular file since the
1411            // nested flag isn't set.
1412            self.resources
1413                .seal_regular_file(flavor, rel_path_normalized, digests, optional)?;
1414        }
1415
1416        if need_install {
1417            context.install_file(full_path, rel_path)?;
1418        }
1419
1420        Ok(())
1421    }
1422
1423    fn seal_rules2_symlink(
1424        &mut self,
1425        full_path: &Path,
1426        rel_path: &Path,
1427        rel_path_normalized: &str,
1428        omit: bool,
1429        context: &mut BundleSigningContext,
1430    ) -> Result<(), AppleCodesignError> {
1431        let link_target = std::fs::read_link(full_path)?
1432            .to_string_lossy()
1433            .replace('\\', "/");
1434
1435        if !omit {
1436            info!("sealing symlink {} -> {}", rel_path_normalized, link_target);
1437            self.resources
1438                .seal_symlink(rel_path_normalized, link_target);
1439        }
1440        context.install_file(full_path, rel_path)?;
1441
1442        Ok(())
1443    }
1444
1445    /// Perform sealing activity for an entry in rules v1.
1446    fn seal_rules1_file(
1447        &mut self,
1448        full_path: &Path,
1449        rel_path_normalized: &str,
1450        rule: CodeResourcesRule,
1451    ) -> Result<(), AppleCodesignError> {
1452        // Version 1 doesn't handle symlinks nor nested Mach-O binaries.
1453        // And version 2's handler installed files. So all we have to do here
1454        // is record SHA-1 digests in `<files>`.
1455
1456        let digests = MultiDigest::from_path(full_path)?;
1457
1458        self.resources.seal_regular_file(
1459            FilesFlavor::Rules,
1460            rel_path_normalized,
1461            digests,
1462            rule.optional,
1463        )?;
1464
1465        Ok(())
1466    }
1467
1468    /// Write CodeResources XML content to a writer.
1469    pub fn write_code_resources(&self, writer: impl Write) -> Result<(), AppleCodesignError> {
1470        self.resources.to_writer_xml(writer)
1471    }
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    const FIREFOX_SNIPPET: &str = r#"
1479        <?xml version="1.0" encoding="UTF-8"?>
1480        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1481        <plist version="1.0">
1482          <dict>
1483            <key>files</key>
1484            <dict>
1485              <key>Resources/XUL.sig</key>
1486              <data>Y0SEPxyC6hCQ+rl4LTRmXy7F9DQ=</data>
1487              <key>Resources/en.lproj/InfoPlist.strings</key>
1488              <dict>
1489                <key>hash</key>
1490                <data>U8LTYe+cVqPcBu9aLvcyyfp+dAg=</data>
1491                <key>optional</key>
1492                <true/>
1493              </dict>
1494              <key>Resources/firefox-bin.sig</key>
1495              <data>ZvZ3yDciAF4kB9F06Xr3gKi3DD4=</data>
1496            </dict>
1497            <key>files2</key>
1498            <dict>
1499              <key>Library/LaunchServices/org.mozilla.updater</key>
1500              <dict>
1501                <key>hash2</key>
1502                <data>iMnDHpWkKTI6xLi9Av93eNuIhxXhv3C18D4fljCfw2Y=</data>
1503              </dict>
1504              <key>TestOptional</key>
1505              <dict>
1506                <key>hash2</key>
1507                <data>iMnDHpWkKTI6xLi9Av93eNuIhxXhv3C18D4fljCfw2Y=</data>
1508                <key>optional</key>
1509                <true/>
1510              </dict>
1511              <key>MacOS/XUL</key>
1512              <dict>
1513                <key>cdhash</key>
1514                <data>NevNMzQBub9OjomMUAk2xBumyHM=</data>
1515                <key>requirement</key>
1516                <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>
1517              </dict>
1518              <key>MacOS/SafariForWebKitDevelopment</key>
1519              <dict>
1520                <key>symlink</key>
1521                <string>/Library/Application Support/Apple/Safari/SafariForWebKitDevelopment</string>
1522              </dict>
1523            </dict>
1524            <key>rules</key>
1525            <dict>
1526              <key>^Resources/</key>
1527              <true/>
1528              <key>^Resources/.*\.lproj/</key>
1529              <dict>
1530                <key>optional</key>
1531                <true/>
1532                <key>weight</key>
1533                <real>1000</real>
1534              </dict>
1535            </dict>
1536            <key>rules2</key>
1537            <dict>
1538              <key>.*\.dSYM($|/)</key>
1539              <dict>
1540                <key>weight</key>
1541                <real>11</real>
1542              </dict>
1543              <key>^(.*/)?\.DS_Store$</key>
1544              <dict>
1545                <key>omit</key>
1546                <true/>
1547                <key>weight</key>
1548                <real>2000</real>
1549              </dict>
1550              <key>^[^/]+$</key>
1551              <dict>
1552                <key>nested</key>
1553                <true/>
1554                <key>weight</key>
1555                <real>10</real>
1556              </dict>
1557              <key>optional</key>
1558              <dict>
1559                <key>optional</key>
1560                <true/>
1561              </dict>
1562            </dict>
1563          </dict>
1564        </plist>"#;
1565
1566    #[test]
1567    fn parse_firefox() {
1568        let resources = CodeResources::from_xml(FIREFOX_SNIPPET.as_bytes()).unwrap();
1569
1570        // Serialize back to XML.
1571        let mut buffer = Vec::<u8>::new();
1572        resources.to_writer_xml(&mut buffer).unwrap();
1573        let resources2 = CodeResources::from_xml(&buffer).unwrap();
1574
1575        assert_eq!(resources, resources2);
1576    }
1577}