Skip to main content

rustferry_remote/
profile.rs

1//! Bounded parsing and target-specific validation for decoded Apple provisioning profiles.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    error::Error,
6    fmt,
7    io::Cursor,
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11use crate::{
12    DevicePlan, EntitlementSet, ProvisioningPlatform, ProvisioningProfile, ProvisioningProfileType,
13    SigningCertificate, SigningTarget, SigningTargetKind,
14};
15use plist::{Dictionary, Value as PlistValue};
16use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};
17use sha2::{Digest, Sha256};
18
19/// Maximum size accepted by the decoded-plist byte entry point.
20pub const MAX_DECODED_PROFILE_BYTES: usize = 4 * 1024 * 1024;
21
22const MAX_PROFILE_DEPTH: usize = 16;
23const MAX_PROFILE_NODES: usize = 16_384;
24const MAX_COLLECTION_ITEMS: usize = 4_096;
25const MAX_STRING_BYTES: usize = 64 * 1024;
26const MAX_DATA_BYTES: usize = 2 * 1024 * 1024;
27const MAX_TOTAL_DATA_BYTES: usize = 3 * 1024 * 1024;
28const MAX_PROFILE_NAME_BYTES: usize = 128;
29const MAX_UUID_BYTES: usize = 64;
30const MAX_TEAM_NAME_BYTES: usize = 128;
31const MAX_APPLICATION_IDENTIFIER_BYTES: usize = 266;
32const MAX_DEVICE_COUNT: usize = 1_024;
33const MAX_DEVICE_IDENTIFIER_BYTES: usize = 64;
34const MAX_PLATFORM_COUNT: usize = 8;
35const MAX_CERTIFICATE_COUNT: usize = 32;
36const MAX_CERTIFICATE_BYTES: usize = 128 * 1024;
37const MAX_APP_GROUP_COUNT: usize = 1_024;
38const MAX_APP_GROUP_BYTES: usize = 255;
39
40const APPLICATION_IDENTIFIER: &str = "application-identifier";
41const TEAM_IDENTIFIER: &str = "com.apple.developer.team-identifier";
42const GET_TASK_ALLOW: &str = "get-task-allow";
43const APPLICATION_GROUPS: &str = "com.apple.security.application-groups";
44
45/// Fixed profile field identifiers used by secret-free parse diagnostics.
46#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
47pub enum ProfileField {
48    /// Entire decoded profile document.
49    Document,
50    /// UUID.
51    Uuid,
52    /// Name.
53    Name,
54    /// `TeamIdentifier`.
55    TeamIdentifier,
56    /// `TeamName`.
57    TeamName,
58    /// `CreationDate`.
59    CreationDate,
60    /// `ExpirationDate`.
61    ExpirationDate,
62    /// Platform.
63    Platform,
64    /// `ProvisionedDevices`.
65    ProvisionedDevices,
66    /// `ProvisionsAllDevices`.
67    ProvisionsAllDevices,
68    /// `DeveloperCertificates`.
69    DeveloperCertificates,
70    /// Entitlements.
71    Entitlements,
72    /// application-identifier entitlement.
73    ApplicationIdentifier,
74    /// get-task-allow entitlement.
75    GetTaskAllow,
76    /// com.apple.security.application-groups entitlement.
77    ApplicationGroups,
78}
79
80impl fmt::Display for ProfileField {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter.write_str(match self {
83            Self::Document => "document",
84            Self::Uuid => "UUID",
85            Self::Name => "Name",
86            Self::TeamIdentifier => "TeamIdentifier",
87            Self::TeamName => "TeamName",
88            Self::CreationDate => "CreationDate",
89            Self::ExpirationDate => "ExpirationDate",
90            Self::Platform => "Platform",
91            Self::ProvisionedDevices => "ProvisionedDevices",
92            Self::ProvisionsAllDevices => "ProvisionsAllDevices",
93            Self::DeveloperCertificates => "DeveloperCertificates",
94            Self::Entitlements => "Entitlements",
95            Self::ApplicationIdentifier => "application-identifier",
96            Self::GetTaskAllow => "get-task-allow",
97            Self::ApplicationGroups => "com.apple.security.application-groups",
98        })
99    }
100}
101
102/// Safe, typed failure while parsing a decoded provisioning-profile plist.
103///
104/// Errors identify only fixed schema fields and structural reasons. Plist
105/// values, certificates, device identifiers, and parser diagnostics are never
106/// included.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum ProvisioningProfileParseError {
109    /// Decoded input was empty or exceeded the byte limit.
110    InputSize,
111    /// Decoded bytes were not a supported plist.
112    MalformedPlist,
113    /// The complete plist exceeded a structural bound.
114    DocumentBounds,
115    /// A required field was absent.
116    MissingField {
117        /// Fixed schema field.
118        field: ProfileField,
119    },
120    /// A field had the wrong plist type.
121    InvalidFieldType {
122        /// Fixed schema field.
123        field: ProfileField,
124    },
125    /// A field value was structurally invalid or inconsistent.
126    InvalidField {
127        /// Fixed schema field.
128        field: ProfileField,
129    },
130    /// A field exceeded its field-specific bound.
131    FieldBounds {
132        /// Fixed schema field.
133        field: ProfileField,
134    },
135    /// The profile included a platform this worker cannot represent.
136    UnsupportedPlatform,
137    /// Enterprise or otherwise unsupported profile category.
138    UnsupportedProfileType,
139}
140
141impl fmt::Display for ProvisioningProfileParseError {
142    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::InputSize => formatter.write_str("decoded profile size is invalid"),
145            Self::MalformedPlist => formatter.write_str("decoded profile plist is malformed"),
146            Self::DocumentBounds => {
147                formatter.write_str("decoded profile exceeds structural limits")
148            }
149            Self::MissingField { field } => write!(formatter, "profile field {field} is missing"),
150            Self::InvalidFieldType { field } => {
151                write!(formatter, "profile field {field} has an invalid type")
152            }
153            Self::InvalidField { field } => {
154                write!(formatter, "profile field {field} is invalid")
155            }
156            Self::FieldBounds { field } => {
157                write!(formatter, "profile field {field} exceeds its limit")
158            }
159            Self::UnsupportedPlatform => {
160                formatter.write_str("provisioning profile platform is unsupported")
161            }
162            Self::UnsupportedProfileType => {
163                formatter.write_str("provisioning profile type is unsupported")
164            }
165        }
166    }
167}
168
169impl Error for ProvisioningProfileParseError {}
170
171/// Parse bounded XML or binary plist bytes obtained after CMS decoding.
172///
173/// This function deliberately does not decode CMS, invoke Security.framework,
174/// or run external commands.
175///
176/// # Errors
177///
178/// Returns a secret-free typed error for malformed, oversized, incomplete, or
179/// internally inconsistent profile metadata.
180pub fn parse_decoded_provisioning_profile(
181    decoded_plist: &[u8],
182) -> Result<ProvisioningProfile, ProvisioningProfileParseError> {
183    if decoded_plist.is_empty() || decoded_plist.len() > MAX_DECODED_PROFILE_BYTES {
184        return Err(ProvisioningProfileParseError::InputSize);
185    }
186
187    let value = PlistValue::from_reader(Cursor::new(decoded_plist))
188        .map_err(|_| ProvisioningProfileParseError::MalformedPlist)?;
189    parse_provisioning_profile_value(&value)
190}
191
192/// Parse an already-decoded and already-materialized plist value.
193///
194/// The entire value, including ignored root keys, is bounded before fields are
195/// extracted.
196///
197/// # Errors
198///
199/// Returns a secret-free typed error for malformed, oversized, incomplete, or
200/// internally inconsistent profile metadata.
201#[allow(clippy::too_many_lines)] // Parsing remains field-ordered to make every bounded input check explicit.
202pub fn parse_provisioning_profile_value(
203    value: &PlistValue,
204) -> Result<ProvisioningProfile, ProvisioningProfileParseError> {
205    validate_document_bounds(value)?;
206    let root = value
207        .as_dictionary()
208        .ok_or(ProvisioningProfileParseError::InvalidFieldType {
209            field: ProfileField::Document,
210        })?;
211
212    let uuid = required_bounded_string(root, "UUID", ProfileField::Uuid, MAX_UUID_BYTES)?;
213    let name = required_bounded_string(root, "Name", ProfileField::Name, MAX_PROFILE_NAME_BYTES)?;
214    let team_id = parse_team_identifier(root)?;
215    let team_name = optional_bounded_string(
216        root,
217        "TeamName",
218        ProfileField::TeamName,
219        MAX_TEAM_NAME_BYTES,
220    )?;
221    let team = crate::DevelopmentTeam::new(team_id.clone(), team_name).map_err(|_| {
222        ProvisioningProfileParseError::InvalidField {
223            field: ProfileField::TeamIdentifier,
224        }
225    })?;
226
227    let created_at_unix_seconds =
228        required_unix_date(root, "CreationDate", ProfileField::CreationDate)?;
229    let expires_at_unix_seconds =
230        required_unix_date(root, "ExpirationDate", ProfileField::ExpirationDate)?;
231    let platforms = parse_platforms(root)?;
232    let device_udid_sha256s = parse_device_identifier_sha256s(root)?;
233    let certificate_fingerprints = parse_certificate_fingerprints(root)?;
234    let entitlements = parse_entitlements(root)?;
235
236    let application_identifier = entitlements
237        .get(APPLICATION_IDENTIFIER)
238        .and_then(JsonValue::as_str)
239        .filter(|identifier| !identifier.is_empty())
240        .ok_or(ProvisioningProfileParseError::InvalidField {
241            field: ProfileField::ApplicationIdentifier,
242        })?
243        .to_owned();
244    if application_identifier.len() > MAX_APPLICATION_IDENTIFIER_BYTES {
245        return Err(ProvisioningProfileParseError::FieldBounds {
246            field: ProfileField::ApplicationIdentifier,
247        });
248    }
249
250    if entitlements
251        .get(TEAM_IDENTIFIER)
252        .is_some_and(|entitlement_team| entitlement_team.as_str() != Some(team.id()))
253    {
254        return Err(ProvisioningProfileParseError::InvalidField {
255            field: ProfileField::TeamIdentifier,
256        });
257    }
258
259    let team_prefix = format!("{}.", team.id());
260    let bundle_identifier_pattern = application_identifier
261        .strip_prefix(&team_prefix)
262        .filter(|pattern| !pattern.is_empty())
263        .ok_or(ProvisioningProfileParseError::InvalidField {
264            field: ProfileField::ApplicationIdentifier,
265        })?
266        .to_owned();
267    let wildcard = bundle_identifier_pattern == "*" || bundle_identifier_pattern.ends_with(".*");
268
269    let get_task_allow = entitlements
270        .get(GET_TASK_ALLOW)
271        .and_then(JsonValue::as_bool)
272        .ok_or(ProvisioningProfileParseError::InvalidField {
273            field: ProfileField::GetTaskAllow,
274        })?;
275    if entitlements
276        .get(APPLICATION_GROUPS)
277        .is_some_and(|groups| requested_application_groups(groups).is_none())
278    {
279        return Err(ProvisioningProfileParseError::InvalidField {
280            field: ProfileField::ApplicationGroups,
281        });
282    }
283    let provisions_all_devices = optional_boolean(
284        root,
285        "ProvisionsAllDevices",
286        ProfileField::ProvisionsAllDevices,
287    )?
288    .unwrap_or(false);
289    if provisions_all_devices {
290        return Err(ProvisioningProfileParseError::UnsupportedProfileType);
291    }
292    let profile_type = if get_task_allow {
293        ProvisioningProfileType::Development
294    } else if device_udid_sha256s.is_empty() {
295        ProvisioningProfileType::AppStore
296    } else {
297        ProvisioningProfileType::AdHoc
298    };
299
300    let profile = ProvisioningProfile {
301        uuid,
302        name,
303        team,
304        application_identifier,
305        bundle_identifier_pattern,
306        wildcard,
307        created_at_unix_seconds,
308        expires_at_unix_seconds,
309        device_udid_sha256s,
310        entitlements,
311        platforms,
312        profile_type,
313        certificate_fingerprints,
314    };
315    profile
316        .validate_metadata()
317        .map_err(|_| ProvisioningProfileParseError::InvalidField {
318            field: ProfileField::Document,
319        })?;
320    Ok(profile)
321}
322
323/// Target-specific validation request for one parsed provisioning profile.
324#[derive(Clone, Copy, Debug)]
325pub struct ProfileValidationRequest<'a> {
326    /// Application or extension target receiving this profile.
327    pub target: &'a SigningTarget,
328    /// Exact team selected for the signing job.
329    pub team: &'a crate::DevelopmentTeam,
330    /// Registered device required by development or ad-hoc signing.
331    pub device: Option<&'a DevicePlan>,
332    /// Exact public certificate selected for signing.
333    pub certificate: &'a SigningCertificate,
334    /// Required provisioning category.
335    pub profile_type: ProvisioningProfileType,
336    /// Entitlements requested for the signed target.
337    pub required_entitlements: &'a EntitlementSet,
338    /// Validation time supplied by the worker.
339    pub now_unix_seconds: u64,
340}
341
342/// One independently typed reason a profile cannot authorize a target.
343#[derive(Clone, Debug, Eq, PartialEq)]
344pub enum ProfileValidationIssue {
345    /// Parsed metadata failed its invariant checks.
346    InvalidProfileMetadata,
347    /// Profile is expired at the supplied time.
348    ProfileExpired,
349    /// Profile creation time is later than the supplied time.
350    ProfileNotYetValid,
351    /// Profile bundle pattern does not authorize the exact target.
352    TargetBundleIdentifierMismatch,
353    /// Signing target kind cannot receive a provisioning profile.
354    UnsupportedTargetKind,
355    /// Profile team differs from the selected team.
356    ProfileTeamMismatch,
357    /// Selected certificate metadata is malformed.
358    InvalidCertificate,
359    /// Selected certificate is expired.
360    CertificateExpired,
361    /// Selected certificate team differs from the selected team.
362    CertificateTeamMismatch,
363    /// Selected certificate is absent from the profile.
364    ProfileCertificateMismatch,
365    /// Required profile category differs.
366    ProfileTypeMismatch,
367    /// A device-bound category had no selected device.
368    MissingDevice,
369    /// Selected device is absent from the profile.
370    DeviceNotProvisioned,
371    /// Profile omitted a required entitlement.
372    MissingEntitlement {
373        /// Public entitlement key from the validated signing plan.
374        key: String,
375    },
376    /// Profile and requested entitlement values are incompatible.
377    EntitlementMismatch {
378        /// Public entitlement key from the validated signing plan.
379        key: String,
380    },
381    /// Profile application-identifier entitlement and public metadata differ.
382    ApplicationIdentifierMismatch,
383    /// Development debugging authorization differs from the profile category.
384    GetTaskAllowMismatch,
385    /// Required application groups are malformed or not all authorized.
386    ApplicationGroupMismatch,
387}
388
389impl fmt::Display for ProfileValidationIssue {
390    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
391        formatter.write_str(match self {
392            Self::InvalidProfileMetadata => "provisioning profile metadata is invalid",
393            Self::ProfileExpired => "provisioning profile is expired",
394            Self::ProfileNotYetValid => "provisioning profile is not yet valid",
395            Self::TargetBundleIdentifierMismatch => {
396                "provisioning profile does not authorize the target bundle identifier"
397            }
398            Self::UnsupportedTargetKind => {
399                "signing target kind cannot receive a provisioning profile"
400            }
401            Self::ProfileTeamMismatch => "provisioning profile team does not match",
402            Self::InvalidCertificate => "signing certificate metadata is invalid",
403            Self::CertificateExpired => "signing certificate is expired",
404            Self::CertificateTeamMismatch => "signing certificate team does not match",
405            Self::ProfileCertificateMismatch => {
406                "signing certificate is absent from the provisioning profile"
407            }
408            Self::ProfileTypeMismatch => "provisioning profile category does not match",
409            Self::MissingDevice => "a registered device is required",
410            Self::DeviceNotProvisioned => "selected device is absent from the provisioning profile",
411            Self::MissingEntitlement { .. } => "provisioning profile omits a required entitlement",
412            Self::EntitlementMismatch { .. } => "provisioning profile entitlement is incompatible",
413            Self::ApplicationIdentifierMismatch => {
414                "provisioning profile application identifier is inconsistent"
415            }
416            Self::GetTaskAllowMismatch => {
417                "get-task-allow does not match the provisioning profile category"
418            }
419            Self::ApplicationGroupMismatch => {
420                "provisioning profile does not authorize all required application groups"
421            }
422        })
423    }
424}
425
426/// Aggregate target-profile validation failure.
427#[derive(Clone, Debug, Eq, PartialEq)]
428pub struct ProfileValidationErrors {
429    issues: Vec<ProfileValidationIssue>,
430}
431
432impl ProfileValidationErrors {
433    /// Return all independent validation issues.
434    pub fn issues(&self) -> &[ProfileValidationIssue] {
435        &self.issues
436    }
437}
438
439impl fmt::Display for ProfileValidationErrors {
440    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
441        write!(
442            formatter,
443            "provisioning profile validation failed with {} issue(s)",
444            self.issues.len()
445        )
446    }
447}
448
449impl Error for ProfileValidationErrors {}
450
451/// Public evidence produced after all target-specific profile checks pass.
452#[derive(Clone, Debug, Eq, PartialEq)]
453pub struct ValidatedProvisioningProfile {
454    /// Public profile UUID.
455    pub profile_uuid: String,
456    /// Exact authorized target bundle identifier.
457    pub target_bundle_identifier: String,
458    /// Exact selected team identifier.
459    pub team_identifier: String,
460    /// Exact selected certificate fingerprint.
461    pub certificate_sha256_fingerprint: String,
462    /// Validated profile category.
463    pub profile_type: ProvisioningProfileType,
464    /// Required application groups authorized by the profile.
465    pub application_groups: BTreeSet<String>,
466    /// Profile expiration used by later evidence.
467    pub expires_at_unix_seconds: u64,
468}
469
470/// Validate one parsed profile against one exact signing target.
471///
472/// Required application groups are treated as an authorization subset: every
473/// group requested by the target must appear exactly in the profile, while a
474/// profile may authorize additional groups.
475///
476/// # Errors
477///
478/// Returns all independent typed mismatches. Error rendering never includes
479/// profile values, device identifiers, certificate fingerprints, or entitlement
480/// values.
481#[allow(clippy::too_many_lines)] // Accumulate all independent policy failures in one deterministic pass.
482pub fn validate_profile_for_target(
483    profile: &ProvisioningProfile,
484    request: ProfileValidationRequest<'_>,
485) -> Result<ValidatedProvisioningProfile, ProfileValidationErrors> {
486    let mut issues = Vec::new();
487
488    match profile.validate_metadata_at(request.now_unix_seconds) {
489        Ok(()) => {}
490        Err(crate::SigningValidationError::ProfileNotYetValid) => {
491            issues.push(ProfileValidationIssue::ProfileNotYetValid);
492        }
493        Err(crate::SigningValidationError::ProfileExpired) => {
494            issues.push(ProfileValidationIssue::ProfileExpired);
495        }
496        Err(_) => issues.push(ProfileValidationIssue::InvalidProfileMetadata),
497    }
498
499    if profile.team.id() != request.team.id() {
500        issues.push(ProfileValidationIssue::ProfileTeamMismatch);
501    }
502    if profile.profile_type != request.profile_type {
503        issues.push(ProfileValidationIssue::ProfileTypeMismatch);
504    }
505    if !matches!(
506        request.target.kind,
507        SigningTargetKind::Application | SigningTargetKind::Extension
508    ) {
509        issues.push(ProfileValidationIssue::UnsupportedTargetKind);
510    }
511    if !bundle_pattern_matches(
512        &profile.bundle_identifier_pattern,
513        request.target.bundle_identifier.as_str(),
514    ) {
515        issues.push(ProfileValidationIssue::TargetBundleIdentifierMismatch);
516    }
517
518    if request.certificate.validate().is_err() {
519        issues.push(ProfileValidationIssue::InvalidCertificate);
520    }
521    if request.certificate.expires_at_unix_seconds <= request.now_unix_seconds {
522        issues.push(ProfileValidationIssue::CertificateExpired);
523    }
524    if request.certificate.team.id() != request.team.id() {
525        issues.push(ProfileValidationIssue::CertificateTeamMismatch);
526    }
527    if !profile
528        .certificate_fingerprints
529        .iter()
530        .any(|fingerprint| fingerprint == &request.certificate.sha256_fingerprint)
531    {
532        issues.push(ProfileValidationIssue::ProfileCertificateMismatch);
533    }
534
535    if matches!(
536        request.profile_type,
537        ProvisioningProfileType::Development | ProvisioningProfileType::AdHoc
538    ) {
539        match request.device {
540            Some(device)
541                if !profile
542                    .device_udid_sha256s
543                    .iter()
544                    .any(|profile_udid_sha256| profile_udid_sha256 == device.udid_sha256()) =>
545            {
546                issues.push(ProfileValidationIssue::DeviceNotProvisioned);
547            }
548            Some(_) => {}
549            None => issues.push(ProfileValidationIssue::MissingDevice),
550        }
551    }
552
553    let expected_get_task_allow = request.profile_type == ProvisioningProfileType::Development;
554    if profile
555        .entitlements
556        .get(GET_TASK_ALLOW)
557        .and_then(JsonValue::as_bool)
558        != Some(expected_get_task_allow)
559    {
560        issues.push(ProfileValidationIssue::GetTaskAllowMismatch);
561    }
562
563    let expected_application_identifier = format!(
564        "{}.{}",
565        request.team.id(),
566        request.target.bundle_identifier.as_str()
567    );
568    if profile
569        .entitlements
570        .get(APPLICATION_IDENTIFIER)
571        .and_then(JsonValue::as_str)
572        != Some(profile.application_identifier.as_str())
573    {
574        issues.push(ProfileValidationIssue::ApplicationIdentifierMismatch);
575    }
576    if profile
577        .entitlements
578        .get(TEAM_IDENTIFIER)
579        .is_some_and(|value| value.as_str() != Some(request.team.id()))
580    {
581        issues.push(ProfileValidationIssue::EntitlementMismatch {
582            key: TEAM_IDENTIFIER.to_owned(),
583        });
584    }
585    if profile.entitlements.get(APPLICATION_GROUPS).is_some()
586        && profile_application_groups(profile).is_none()
587    {
588        issues.push(ProfileValidationIssue::ApplicationGroupMismatch);
589    }
590    let mut application_groups = BTreeSet::new();
591    for (key, required) in request.required_entitlements.values() {
592        match key.as_str() {
593            APPLICATION_IDENTIFIER => {
594                if required.as_str() != Some(expected_application_identifier.as_str()) {
595                    issues.push(ProfileValidationIssue::EntitlementMismatch { key: key.clone() });
596                }
597            }
598            GET_TASK_ALLOW => {
599                if required.as_bool() != Some(expected_get_task_allow) {
600                    push_unique(&mut issues, ProfileValidationIssue::GetTaskAllowMismatch);
601                }
602            }
603            APPLICATION_GROUPS => match requested_application_groups(required) {
604                Some(required_groups) => {
605                    application_groups.clone_from(&required_groups);
606                    if !profile_application_groups(profile)
607                        .is_some_and(|authorized| required_groups.is_subset(&authorized))
608                    {
609                        push_unique(
610                            &mut issues,
611                            ProfileValidationIssue::ApplicationGroupMismatch,
612                        );
613                    }
614                }
615                None => push_unique(
616                    &mut issues,
617                    ProfileValidationIssue::ApplicationGroupMismatch,
618                ),
619            },
620            _ => match profile.entitlements.get(key) {
621                None => {
622                    issues.push(ProfileValidationIssue::MissingEntitlement { key: key.clone() });
623                }
624                Some(actual) if actual != required => {
625                    issues.push(ProfileValidationIssue::EntitlementMismatch { key: key.clone() });
626                }
627                Some(_) => {}
628            },
629        }
630    }
631
632    if issues.is_empty() {
633        Ok(ValidatedProvisioningProfile {
634            profile_uuid: profile.uuid.clone(),
635            target_bundle_identifier: request.target.bundle_identifier.as_str().to_owned(),
636            team_identifier: request.team.id().to_owned(),
637            certificate_sha256_fingerprint: request.certificate.sha256_fingerprint.clone(),
638            profile_type: request.profile_type,
639            application_groups,
640            expires_at_unix_seconds: profile.expires_at_unix_seconds,
641        })
642    } else {
643        Err(ProfileValidationErrors { issues })
644    }
645}
646
647fn validate_document_bounds(value: &PlistValue) -> Result<(), ProvisioningProfileParseError> {
648    let mut bounds = DocumentBounds::default();
649    bounds.visit(value, 0)
650}
651
652#[derive(Default)]
653struct DocumentBounds {
654    nodes: usize,
655    data_bytes: usize,
656}
657
658impl DocumentBounds {
659    fn visit(
660        &mut self,
661        value: &PlistValue,
662        depth: usize,
663    ) -> Result<(), ProvisioningProfileParseError> {
664        if depth > MAX_PROFILE_DEPTH {
665            return Err(ProvisioningProfileParseError::DocumentBounds);
666        }
667        self.nodes = self
668            .nodes
669            .checked_add(1)
670            .ok_or(ProvisioningProfileParseError::DocumentBounds)?;
671        if self.nodes > MAX_PROFILE_NODES {
672            return Err(ProvisioningProfileParseError::DocumentBounds);
673        }
674
675        match value {
676            PlistValue::Array(items) => {
677                if items.len() > MAX_COLLECTION_ITEMS {
678                    return Err(ProvisioningProfileParseError::DocumentBounds);
679                }
680                for item in items {
681                    self.visit(item, depth + 1)?;
682                }
683            }
684            PlistValue::Dictionary(dictionary) => {
685                if dictionary.len() > MAX_COLLECTION_ITEMS {
686                    return Err(ProvisioningProfileParseError::DocumentBounds);
687                }
688                for (key, nested) in dictionary {
689                    if key.len() > MAX_STRING_BYTES {
690                        return Err(ProvisioningProfileParseError::DocumentBounds);
691                    }
692                    self.visit(nested, depth + 1)?;
693                }
694            }
695            PlistValue::Data(data) => {
696                if data.len() > MAX_DATA_BYTES {
697                    return Err(ProvisioningProfileParseError::DocumentBounds);
698                }
699                self.data_bytes = self
700                    .data_bytes
701                    .checked_add(data.len())
702                    .ok_or(ProvisioningProfileParseError::DocumentBounds)?;
703                if self.data_bytes > MAX_TOTAL_DATA_BYTES {
704                    return Err(ProvisioningProfileParseError::DocumentBounds);
705                }
706            }
707            PlistValue::String(string) if string.len() > MAX_STRING_BYTES => {
708                return Err(ProvisioningProfileParseError::DocumentBounds);
709            }
710            _ => {}
711        }
712        Ok(())
713    }
714}
715
716fn required_value<'a>(
717    dictionary: &'a Dictionary,
718    key: &str,
719    field: ProfileField,
720) -> Result<&'a PlistValue, ProvisioningProfileParseError> {
721    dictionary
722        .get(key)
723        .ok_or(ProvisioningProfileParseError::MissingField { field })
724}
725
726fn required_bounded_string(
727    dictionary: &Dictionary,
728    key: &str,
729    field: ProfileField,
730    max_bytes: usize,
731) -> Result<String, ProvisioningProfileParseError> {
732    let value = required_value(dictionary, key, field)?
733        .as_string()
734        .ok_or(ProvisioningProfileParseError::InvalidFieldType { field })?;
735    if value.is_empty() || value.len() > max_bytes {
736        return Err(ProvisioningProfileParseError::FieldBounds { field });
737    }
738    Ok(value.to_owned())
739}
740
741fn optional_bounded_string(
742    dictionary: &Dictionary,
743    key: &str,
744    field: ProfileField,
745    max_bytes: usize,
746) -> Result<Option<String>, ProvisioningProfileParseError> {
747    let Some(value) = dictionary.get(key) else {
748        return Ok(None);
749    };
750    let value = value
751        .as_string()
752        .ok_or(ProvisioningProfileParseError::InvalidFieldType { field })?;
753    if value.is_empty() || value.len() > max_bytes {
754        return Err(ProvisioningProfileParseError::FieldBounds { field });
755    }
756    Ok(Some(value.to_owned()))
757}
758
759fn optional_boolean(
760    dictionary: &Dictionary,
761    key: &str,
762    field: ProfileField,
763) -> Result<Option<bool>, ProvisioningProfileParseError> {
764    dictionary
765        .get(key)
766        .map(|value| {
767            value
768                .as_boolean()
769                .ok_or(ProvisioningProfileParseError::InvalidFieldType { field })
770        })
771        .transpose()
772}
773
774fn parse_team_identifier(root: &Dictionary) -> Result<String, ProvisioningProfileParseError> {
775    let values = required_value(root, "TeamIdentifier", ProfileField::TeamIdentifier)?
776        .as_array()
777        .ok_or(ProvisioningProfileParseError::InvalidFieldType {
778            field: ProfileField::TeamIdentifier,
779        })?;
780    if values.len() != 1 {
781        return Err(ProvisioningProfileParseError::InvalidField {
782            field: ProfileField::TeamIdentifier,
783        });
784    }
785    let team = values[0]
786        .as_string()
787        .ok_or(ProvisioningProfileParseError::InvalidFieldType {
788            field: ProfileField::TeamIdentifier,
789        })?;
790    if team.len() != 10 {
791        return Err(ProvisioningProfileParseError::InvalidField {
792            field: ProfileField::TeamIdentifier,
793        });
794    }
795    Ok(team.to_owned())
796}
797
798fn required_unix_date(
799    root: &Dictionary,
800    key: &str,
801    field: ProfileField,
802) -> Result<u64, ProvisioningProfileParseError> {
803    let date = required_value(root, key, field)?
804        .as_date()
805        .ok_or(ProvisioningProfileParseError::InvalidFieldType { field })?;
806    let system_time: SystemTime = date.into();
807    system_time
808        .duration_since(UNIX_EPOCH)
809        .map(|duration| duration.as_secs())
810        .map_err(|_| ProvisioningProfileParseError::InvalidField { field })
811}
812
813fn parse_platforms(
814    root: &Dictionary,
815) -> Result<BTreeSet<ProvisioningPlatform>, ProvisioningProfileParseError> {
816    let values = required_value(root, "Platform", ProfileField::Platform)?
817        .as_array()
818        .ok_or(ProvisioningProfileParseError::InvalidFieldType {
819            field: ProfileField::Platform,
820        })?;
821    if values.is_empty() || values.len() > MAX_PLATFORM_COUNT {
822        return Err(ProvisioningProfileParseError::FieldBounds {
823            field: ProfileField::Platform,
824        });
825    }
826
827    let mut platforms = BTreeSet::new();
828    for value in values {
829        match value.as_string() {
830            Some("iOS") => {
831                if !platforms.insert(ProvisioningPlatform::Ios) {
832                    return Err(ProvisioningProfileParseError::InvalidField {
833                        field: ProfileField::Platform,
834                    });
835                }
836            }
837            Some(_) => return Err(ProvisioningProfileParseError::UnsupportedPlatform),
838            None => {
839                return Err(ProvisioningProfileParseError::InvalidFieldType {
840                    field: ProfileField::Platform,
841                });
842            }
843        }
844    }
845    Ok(platforms)
846}
847
848fn parse_device_identifier_sha256s(
849    root: &Dictionary,
850) -> Result<Vec<String>, ProvisioningProfileParseError> {
851    let Some(value) = root.get("ProvisionedDevices") else {
852        return Ok(Vec::new());
853    };
854    let values = value
855        .as_array()
856        .ok_or(ProvisioningProfileParseError::InvalidFieldType {
857            field: ProfileField::ProvisionedDevices,
858        })?;
859    if values.len() > MAX_DEVICE_COUNT {
860        return Err(ProvisioningProfileParseError::FieldBounds {
861            field: ProfileField::ProvisionedDevices,
862        });
863    }
864
865    let mut seen = BTreeSet::new();
866    let mut device_udid_sha256s = Vec::with_capacity(values.len());
867    for value in values {
868        let device = value
869            .as_string()
870            .ok_or(ProvisioningProfileParseError::InvalidFieldType {
871                field: ProfileField::ProvisionedDevices,
872            })?;
873        if device.is_empty() || device.len() > MAX_DEVICE_IDENTIFIER_BYTES {
874            return Err(ProvisioningProfileParseError::InvalidField {
875                field: ProfileField::ProvisionedDevices,
876            });
877        }
878        let device = DevicePlan::new(device, None).map_err(|_| {
879            ProvisioningProfileParseError::InvalidField {
880                field: ProfileField::ProvisionedDevices,
881            }
882        })?;
883        if !seen.insert(device.udid_sha256().to_owned()) {
884            return Err(ProvisioningProfileParseError::InvalidField {
885                field: ProfileField::ProvisionedDevices,
886            });
887        }
888        device_udid_sha256s.push(device.udid_sha256().to_owned());
889    }
890    Ok(device_udid_sha256s)
891}
892
893fn parse_certificate_fingerprints(
894    root: &Dictionary,
895) -> Result<Vec<String>, ProvisioningProfileParseError> {
896    let values = required_value(
897        root,
898        "DeveloperCertificates",
899        ProfileField::DeveloperCertificates,
900    )?
901    .as_array()
902    .ok_or(ProvisioningProfileParseError::InvalidFieldType {
903        field: ProfileField::DeveloperCertificates,
904    })?;
905    if values.is_empty() || values.len() > MAX_CERTIFICATE_COUNT {
906        return Err(ProvisioningProfileParseError::FieldBounds {
907            field: ProfileField::DeveloperCertificates,
908        });
909    }
910
911    let mut seen = BTreeSet::new();
912    let mut fingerprints = Vec::with_capacity(values.len());
913    for value in values {
914        let certificate =
915            value
916                .as_data()
917                .ok_or(ProvisioningProfileParseError::InvalidFieldType {
918                    field: ProfileField::DeveloperCertificates,
919                })?;
920        if certificate.is_empty() || certificate.len() > MAX_CERTIFICATE_BYTES {
921            return Err(ProvisioningProfileParseError::FieldBounds {
922                field: ProfileField::DeveloperCertificates,
923            });
924        }
925        let fingerprint = uppercase_hex(&Sha256::digest(certificate));
926        if !seen.insert(fingerprint.clone()) {
927            return Err(ProvisioningProfileParseError::InvalidField {
928                field: ProfileField::DeveloperCertificates,
929            });
930        }
931        fingerprints.push(fingerprint);
932    }
933    Ok(fingerprints)
934}
935
936fn uppercase_hex(bytes: &[u8]) -> String {
937    const HEX: &[u8; 16] = b"0123456789ABCDEF";
938    let mut output = String::with_capacity(bytes.len() * 2);
939    for byte in bytes {
940        output.push(char::from(HEX[usize::from(byte >> 4)]));
941        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
942    }
943    output
944}
945
946fn parse_entitlements(root: &Dictionary) -> Result<EntitlementSet, ProvisioningProfileParseError> {
947    let dictionary = required_value(root, "Entitlements", ProfileField::Entitlements)?
948        .as_dictionary()
949        .ok_or(ProvisioningProfileParseError::InvalidFieldType {
950            field: ProfileField::Entitlements,
951        })?;
952    let mut values = BTreeMap::new();
953    for (key, value) in dictionary {
954        values.insert(key.clone(), plist_entitlement_to_json(value)?);
955    }
956    EntitlementSet::new(values).map_err(|_| ProvisioningProfileParseError::InvalidField {
957        field: ProfileField::Entitlements,
958    })
959}
960
961fn plist_entitlement_to_json(
962    value: &PlistValue,
963) -> Result<JsonValue, ProvisioningProfileParseError> {
964    match value {
965        PlistValue::Boolean(value) => Ok(JsonValue::Bool(*value)),
966        PlistValue::Integer(value) => {
967            if let Some(value) = value.as_signed() {
968                Ok(JsonValue::Number(JsonNumber::from(value)))
969            } else if let Some(value) = value.as_unsigned() {
970                Ok(JsonValue::Number(JsonNumber::from(value)))
971            } else {
972                Err(invalid_entitlement())
973            }
974        }
975        PlistValue::String(value) => Ok(JsonValue::String(value.clone())),
976        PlistValue::Array(values) => values
977            .iter()
978            .map(plist_entitlement_to_json)
979            .collect::<Result<Vec<_>, _>>()
980            .map(JsonValue::Array),
981        PlistValue::Dictionary(values) => {
982            let mut object = JsonMap::new();
983            for (key, value) in values {
984                object.insert(key.clone(), plist_entitlement_to_json(value)?);
985            }
986            Ok(JsonValue::Object(object))
987        }
988        _ => Err(invalid_entitlement()),
989    }
990}
991
992fn invalid_entitlement() -> ProvisioningProfileParseError {
993    ProvisioningProfileParseError::InvalidField {
994        field: ProfileField::Entitlements,
995    }
996}
997
998fn bundle_pattern_matches(pattern: &str, bundle_identifier: &str) -> bool {
999    if pattern == "*" {
1000        return true;
1001    }
1002    if let Some(prefix) = pattern.strip_suffix(".*") {
1003        return bundle_identifier
1004            .strip_prefix(prefix)
1005            .is_some_and(|suffix| suffix.starts_with('.') && suffix.len() > 1);
1006    }
1007    pattern == bundle_identifier
1008}
1009
1010fn requested_application_groups(required: &JsonValue) -> Option<BTreeSet<String>> {
1011    let values = required.as_array()?;
1012    if values.is_empty() || values.len() > MAX_APP_GROUP_COUNT {
1013        return None;
1014    }
1015    let mut groups = BTreeSet::new();
1016    for value in values {
1017        let group = value.as_str()?;
1018        if group.len() > MAX_APP_GROUP_BYTES
1019            || !group.starts_with("group.")
1020            || crate::BundleIdentifier::new(group).is_err()
1021            || !groups.insert(group.to_owned())
1022        {
1023            return None;
1024        }
1025    }
1026    Some(groups)
1027}
1028
1029fn profile_application_groups(profile: &ProvisioningProfile) -> Option<BTreeSet<String>> {
1030    profile
1031        .entitlements
1032        .get(APPLICATION_GROUPS)
1033        .and_then(requested_application_groups)
1034}
1035
1036fn push_unique(issues: &mut Vec<ProfileValidationIssue>, issue: ProfileValidationIssue) {
1037    if !issues.contains(&issue) {
1038        issues.push(issue);
1039    }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use std::{
1045        collections::{BTreeMap, BTreeSet},
1046        time::{Duration, UNIX_EPOCH},
1047    };
1048
1049    use crate::{
1050        BundleIdentifier, DevelopmentTeam, DevicePlan, EntitlementSet, ProvisioningProfileType,
1051        SigningCertificate, SigningTarget, SigningTargetKind,
1052    };
1053    use plist::{Dictionary, Value as PlistValue};
1054    use serde_json::json;
1055
1056    use super::{
1057        APPLICATION_GROUPS, APPLICATION_IDENTIFIER, GET_TASK_ALLOW, MAX_DECODED_PROFILE_BYTES,
1058        ProfileField, ProfileValidationIssue, ProfileValidationRequest,
1059        ProvisioningProfileParseError, parse_decoded_provisioning_profile,
1060        parse_provisioning_profile_value, validate_profile_for_target,
1061    };
1062
1063    const TEAM: &str = "ABC123XYZ9";
1064    const DEVICE: &str = "00008110-001234567890801E";
1065    const DEVICE_SHA256: &str = "4a5f50907ec074080957ea89dd35b48051f861d4e0db99a4ab391acd90fefc6d";
1066    const CERTIFICATE_FINGERPRINT: &str =
1067        "039058C6F2C0CB492C533B0A4D14EF77CC0F78ABCCCED5287D84A1A2011CFB81";
1068
1069    #[test]
1070    fn parses_all_public_profile_metadata_and_binary_certificate_fingerprint() {
1071        let profile = parse_provisioning_profile_value(&valid_profile_value())
1072            .expect("synthetic development profile should parse");
1073
1074        assert_eq!(profile.uuid, "12345678-1234-1234-1234-123456789ABC");
1075        assert_eq!(profile.name, "RustFerry Development");
1076        assert_eq!(profile.team.id(), TEAM);
1077        assert_eq!(profile.team.display_name(), Some("RustFerry Team"));
1078        assert_eq!(
1079            profile.application_identifier,
1080            "ABC123XYZ9.com.example.rustferry"
1081        );
1082        assert_eq!(profile.bundle_identifier_pattern, "com.example.rustferry");
1083        assert!(!profile.wildcard);
1084        assert_eq!(profile.created_at_unix_seconds, 100);
1085        assert_eq!(profile.expires_at_unix_seconds, 500);
1086        assert_eq!(profile.device_udid_sha256s, vec![DEVICE_SHA256.to_owned()]);
1087        assert!(
1088            !serde_json::to_string(&profile)
1089                .expect("profile metadata should serialize")
1090                .contains(DEVICE)
1091        );
1092        assert!(!format!("{profile:?}").contains(DEVICE));
1093        assert_eq!(
1094            profile.platforms,
1095            BTreeSet::from([crate::ProvisioningPlatform::Ios])
1096        );
1097        assert_eq!(profile.profile_type, ProvisioningProfileType::Development);
1098        assert_eq!(
1099            profile.certificate_fingerprints,
1100            vec![CERTIFICATE_FINGERPRINT.to_owned()]
1101        );
1102        assert_eq!(profile.entitlements.get(GET_TASK_ALLOW), Some(&json!(true)));
1103    }
1104
1105    #[test]
1106    fn decoded_bytes_entry_point_accepts_xml_and_rejects_unbounded_or_malformed_input() {
1107        let mut xml = Vec::new();
1108        valid_profile_value()
1109            .to_writer_xml(&mut xml)
1110            .expect("synthetic profile should serialize");
1111        let profile = parse_decoded_provisioning_profile(&xml)
1112            .expect("serialized synthetic profile should parse");
1113        assert_eq!(profile.team.id(), TEAM);
1114
1115        let mut binary = Vec::new();
1116        valid_profile_value()
1117            .to_writer_binary(&mut binary)
1118            .expect("synthetic profile should serialize as binary");
1119        let profile = parse_decoded_provisioning_profile(&binary)
1120            .expect("binary synthetic profile should parse");
1121        assert_eq!(profile.team.id(), TEAM);
1122
1123        assert_eq!(
1124            parse_decoded_provisioning_profile(&[]),
1125            Err(ProvisioningProfileParseError::InputSize)
1126        );
1127        assert_eq!(
1128            parse_decoded_provisioning_profile(&vec![0; MAX_DECODED_PROFILE_BYTES + 1]),
1129            Err(ProvisioningProfileParseError::InputSize)
1130        );
1131        assert_eq!(
1132            parse_decoded_provisioning_profile(b"not a plist"),
1133            Err(ProvisioningProfileParseError::MalformedPlist)
1134        );
1135    }
1136
1137    #[test]
1138    #[allow(clippy::too_many_lines)] // One mutation matrix makes every parser boundary auditable together.
1139    fn parse_mutation_matrix_rejects_missing_wrong_typed_and_inconsistent_fields() {
1140        let mut cases = Vec::new();
1141
1142        let mut missing_uuid = valid_profile_dictionary();
1143        missing_uuid.remove("UUID");
1144        cases.push((
1145            PlistValue::Dictionary(missing_uuid),
1146            ProvisioningProfileParseError::MissingField {
1147                field: ProfileField::Uuid,
1148            },
1149        ));
1150
1151        let mut wrong_team_type = valid_profile_dictionary();
1152        wrong_team_type.insert(
1153            "TeamIdentifier".to_owned(),
1154            PlistValue::String(TEAM.to_owned()),
1155        );
1156        cases.push((
1157            PlistValue::Dictionary(wrong_team_type),
1158            ProvisioningProfileParseError::InvalidFieldType {
1159                field: ProfileField::TeamIdentifier,
1160            },
1161        ));
1162
1163        let mut ambiguous_team = valid_profile_dictionary();
1164        ambiguous_team.insert(
1165            "TeamIdentifier".to_owned(),
1166            PlistValue::Array(vec![
1167                PlistValue::String(TEAM.to_owned()),
1168                PlistValue::String("ZZZ123XYZ9".to_owned()),
1169            ]),
1170        );
1171        cases.push((
1172            PlistValue::Dictionary(ambiguous_team),
1173            ProvisioningProfileParseError::InvalidField {
1174                field: ProfileField::TeamIdentifier,
1175            },
1176        ));
1177
1178        let mut wrong_date_type = valid_profile_dictionary();
1179        wrong_date_type.insert(
1180            "CreationDate".to_owned(),
1181            PlistValue::String("hidden date".to_owned()),
1182        );
1183        cases.push((
1184            PlistValue::Dictionary(wrong_date_type),
1185            ProvisioningProfileParseError::InvalidFieldType {
1186                field: ProfileField::CreationDate,
1187            },
1188        ));
1189
1190        let mut unsupported_platform = valid_profile_dictionary();
1191        unsupported_platform.insert(
1192            "Platform".to_owned(),
1193            PlistValue::Array(vec![PlistValue::String("macOS".to_owned())]),
1194        );
1195        cases.push((
1196            PlistValue::Dictionary(unsupported_platform),
1197            ProvisioningProfileParseError::UnsupportedPlatform,
1198        ));
1199
1200        let mut duplicate_platform = valid_profile_dictionary();
1201        duplicate_platform.insert(
1202            "Platform".to_owned(),
1203            PlistValue::Array(vec![
1204                PlistValue::String("iOS".to_owned()),
1205                PlistValue::String("iOS".to_owned()),
1206            ]),
1207        );
1208        cases.push((
1209            PlistValue::Dictionary(duplicate_platform),
1210            ProvisioningProfileParseError::InvalidField {
1211                field: ProfileField::Platform,
1212            },
1213        ));
1214
1215        let mut enterprise = valid_profile_dictionary();
1216        enterprise.insert("ProvisionsAllDevices".to_owned(), PlistValue::Boolean(true));
1217        cases.push((
1218            PlistValue::Dictionary(enterprise),
1219            ProvisioningProfileParseError::UnsupportedProfileType,
1220        ));
1221
1222        let mut wrong_certificate_type = valid_profile_dictionary();
1223        wrong_certificate_type.insert(
1224            "DeveloperCertificates".to_owned(),
1225            PlistValue::Array(vec![PlistValue::String("certificate".to_owned())]),
1226        );
1227        cases.push((
1228            PlistValue::Dictionary(wrong_certificate_type),
1229            ProvisioningProfileParseError::InvalidFieldType {
1230                field: ProfileField::DeveloperCertificates,
1231            },
1232        ));
1233
1234        let mut invalid_entitlement = valid_profile_dictionary();
1235        entitlements_mut(&mut invalid_entitlement)
1236            .insert("unsupported-data".to_owned(), PlistValue::Data(vec![7]));
1237        cases.push((
1238            PlistValue::Dictionary(invalid_entitlement),
1239            ProvisioningProfileParseError::InvalidField {
1240                field: ProfileField::Entitlements,
1241            },
1242        ));
1243
1244        let mut wrong_get_task_allow = valid_profile_dictionary();
1245        entitlements_mut(&mut wrong_get_task_allow).insert(
1246            GET_TASK_ALLOW.to_owned(),
1247            PlistValue::String("true".to_owned()),
1248        );
1249        cases.push((
1250            PlistValue::Dictionary(wrong_get_task_allow),
1251            ProvisioningProfileParseError::InvalidField {
1252                field: ProfileField::GetTaskAllow,
1253            },
1254        ));
1255
1256        let mut invalid_application_group = valid_profile_dictionary();
1257        entitlements_mut(&mut invalid_application_group).insert(
1258            APPLICATION_GROUPS.to_owned(),
1259            PlistValue::Array(vec![PlistValue::String("not-a-group".to_owned())]),
1260        );
1261        cases.push((
1262            PlistValue::Dictionary(invalid_application_group),
1263            ProvisioningProfileParseError::InvalidField {
1264                field: ProfileField::ApplicationGroups,
1265            },
1266        ));
1267
1268        for (value, expected) in cases {
1269            assert_eq!(parse_provisioning_profile_value(&value), Err(expected));
1270        }
1271    }
1272
1273    #[test]
1274    fn rejects_duplicate_devices_certificates_and_excessive_ignored_content() {
1275        let mut duplicate_device = valid_profile_dictionary();
1276        duplicate_device.insert(
1277            "ProvisionedDevices".to_owned(),
1278            PlistValue::Array(vec![
1279                PlistValue::String(DEVICE.to_owned()),
1280                PlistValue::String(DEVICE.to_owned()),
1281            ]),
1282        );
1283        assert_eq!(
1284            parse_provisioning_profile_value(&PlistValue::Dictionary(duplicate_device)),
1285            Err(ProvisioningProfileParseError::InvalidField {
1286                field: ProfileField::ProvisionedDevices,
1287            })
1288        );
1289
1290        let mut duplicate_certificate = valid_profile_dictionary();
1291        duplicate_certificate.insert(
1292            "DeveloperCertificates".to_owned(),
1293            PlistValue::Array(vec![
1294                PlistValue::Data(vec![1, 2, 3]),
1295                PlistValue::Data(vec![1, 2, 3]),
1296            ]),
1297        );
1298        assert_eq!(
1299            parse_provisioning_profile_value(&PlistValue::Dictionary(duplicate_certificate)),
1300            Err(ProvisioningProfileParseError::InvalidField {
1301                field: ProfileField::DeveloperCertificates,
1302            })
1303        );
1304
1305        let mut oversized_ignored = valid_profile_dictionary();
1306        oversized_ignored.insert(
1307            "Ignored".to_owned(),
1308            PlistValue::String("x".repeat(64 * 1024 + 1)),
1309        );
1310        assert_eq!(
1311            parse_provisioning_profile_value(&PlistValue::Dictionary(oversized_ignored)),
1312            Err(ProvisioningProfileParseError::DocumentBounds)
1313        );
1314    }
1315
1316    #[test]
1317    fn derives_ad_hoc_app_store_and_wildcard_profiles_without_guessing_enterprise() {
1318        let mut ad_hoc = valid_profile_dictionary();
1319        entitlements_mut(&mut ad_hoc).insert(GET_TASK_ALLOW.to_owned(), PlistValue::Boolean(false));
1320        assert_eq!(
1321            parse_provisioning_profile_value(&PlistValue::Dictionary(ad_hoc))
1322                .expect("ad-hoc profile should parse")
1323                .profile_type,
1324            ProvisioningProfileType::AdHoc
1325        );
1326
1327        let mut app_store = valid_profile_dictionary();
1328        app_store.remove("ProvisionedDevices");
1329        entitlements_mut(&mut app_store)
1330            .insert(GET_TASK_ALLOW.to_owned(), PlistValue::Boolean(false));
1331        assert_eq!(
1332            parse_provisioning_profile_value(&PlistValue::Dictionary(app_store))
1333                .expect("App Store profile should parse")
1334                .profile_type,
1335            ProvisioningProfileType::AppStore
1336        );
1337
1338        let mut wildcard = valid_profile_dictionary();
1339        entitlements_mut(&mut wildcard).insert(
1340            APPLICATION_IDENTIFIER.to_owned(),
1341            PlistValue::String(format!("{TEAM}.com.example.*")),
1342        );
1343        let parsed = parse_provisioning_profile_value(&PlistValue::Dictionary(wildcard))
1344            .expect("bounded wildcard profile should parse");
1345        assert!(parsed.wildcard);
1346        assert_eq!(parsed.bundle_identifier_pattern, "com.example.*");
1347    }
1348
1349    #[test]
1350    fn exact_target_validation_returns_public_evidence() {
1351        let profile = parsed_profile();
1352        let team = team();
1353        let device = device();
1354        let certificate = certificate();
1355        let target = target("com.example.rustferry");
1356        let required = required_entitlements();
1357
1358        let evidence = validate_profile_for_target(
1359            &profile,
1360            ProfileValidationRequest {
1361                target: &target,
1362                team: &team,
1363                device: Some(&device),
1364                certificate: &certificate,
1365                profile_type: ProvisioningProfileType::Development,
1366                required_entitlements: &required,
1367                now_unix_seconds: 200,
1368            },
1369        )
1370        .expect("all exact target requirements should validate");
1371
1372        assert_eq!(evidence.profile_uuid, profile.uuid);
1373        assert_eq!(evidence.target_bundle_identifier, "com.example.rustferry");
1374        assert_eq!(evidence.team_identifier, TEAM);
1375        assert_eq!(
1376            evidence.certificate_sha256_fingerprint,
1377            CERTIFICATE_FINGERPRINT
1378        );
1379        assert_eq!(
1380            evidence.application_groups,
1381            BTreeSet::from(["group.com.example.rustferry".to_owned()])
1382        );
1383    }
1384
1385    #[test]
1386    #[allow(clippy::too_many_lines)] // One mutation matrix makes every signing-policy boundary auditable together.
1387    fn validation_mutation_matrix_reports_each_security_boundary() {
1388        let base = parsed_profile();
1389        let base_team = team();
1390        let base_device = device();
1391        let base_certificate = certificate();
1392        let base_target = target("com.example.rustferry");
1393        let base_required = required_entitlements();
1394
1395        let wrong_target = target("com.other.app");
1396        assert_issue(
1397            &base,
1398            &wrong_target,
1399            &base_team,
1400            Some(&base_device),
1401            &base_certificate,
1402            ProvisioningProfileType::Development,
1403            &base_required,
1404            200,
1405            &ProfileValidationIssue::TargetBundleIdentifierMismatch,
1406        );
1407
1408        let mut framework_target = target("com.example.rustferry");
1409        framework_target.kind = SigningTargetKind::Framework;
1410        assert_issue(
1411            &base,
1412            &framework_target,
1413            &base_team,
1414            Some(&base_device),
1415            &base_certificate,
1416            ProvisioningProfileType::Development,
1417            &base_required,
1418            200,
1419            &ProfileValidationIssue::UnsupportedTargetKind,
1420        );
1421
1422        let wrong_team =
1423            DevelopmentTeam::new("ZZZ123XYZ9", None).expect("test team should be valid");
1424        assert_issue(
1425            &base,
1426            &base_target,
1427            &wrong_team,
1428            Some(&base_device),
1429            &base_certificate,
1430            ProvisioningProfileType::Development,
1431            &base_required,
1432            200,
1433            &ProfileValidationIssue::ProfileTeamMismatch,
1434        );
1435
1436        let wrong_device = DevicePlan::new("00008110-00AAAAAAAAAAAAAA", None)
1437            .expect("test device should be valid");
1438        assert_issue(
1439            &base,
1440            &base_target,
1441            &base_team,
1442            Some(&wrong_device),
1443            &base_certificate,
1444            ProvisioningProfileType::Development,
1445            &base_required,
1446            200,
1447            &ProfileValidationIssue::DeviceNotProvisioned,
1448        );
1449
1450        assert_issue(
1451            &base,
1452            &base_target,
1453            &base_team,
1454            None,
1455            &base_certificate,
1456            ProvisioningProfileType::Development,
1457            &base_required,
1458            200,
1459            &ProfileValidationIssue::MissingDevice,
1460        );
1461
1462        let mut wrong_certificate = certificate();
1463        wrong_certificate.sha256_fingerprint = "A".repeat(64);
1464        assert_issue(
1465            &base,
1466            &base_target,
1467            &base_team,
1468            Some(&base_device),
1469            &wrong_certificate,
1470            ProvisioningProfileType::Development,
1471            &base_required,
1472            200,
1473            &ProfileValidationIssue::ProfileCertificateMismatch,
1474        );
1475
1476        let mut invalid_certificate = certificate();
1477        invalid_certificate.sha256_fingerprint = "invalid".to_owned();
1478        assert_issue(
1479            &base,
1480            &base_target,
1481            &base_team,
1482            Some(&base_device),
1483            &invalid_certificate,
1484            ProvisioningProfileType::Development,
1485            &base_required,
1486            200,
1487            &ProfileValidationIssue::InvalidCertificate,
1488        );
1489
1490        let mut expired_certificate = certificate();
1491        expired_certificate.expires_at_unix_seconds = 200;
1492        assert_issue(
1493            &base,
1494            &base_target,
1495            &base_team,
1496            Some(&base_device),
1497            &expired_certificate,
1498            ProvisioningProfileType::Development,
1499            &base_required,
1500            200,
1501            &ProfileValidationIssue::CertificateExpired,
1502        );
1503
1504        let mut other_team_certificate = certificate();
1505        other_team_certificate.team =
1506            DevelopmentTeam::new("ZZZ123XYZ9", None).expect("test team should be valid");
1507        assert_issue(
1508            &base,
1509            &base_target,
1510            &base_team,
1511            Some(&base_device),
1512            &other_team_certificate,
1513            ProvisioningProfileType::Development,
1514            &base_required,
1515            200,
1516            &ProfileValidationIssue::CertificateTeamMismatch,
1517        );
1518
1519        assert_issue(
1520            &base,
1521            &base_target,
1522            &base_team,
1523            Some(&base_device),
1524            &base_certificate,
1525            ProvisioningProfileType::AdHoc,
1526            &base_required,
1527            200,
1528            &ProfileValidationIssue::ProfileTypeMismatch,
1529        );
1530
1531        assert_issue(
1532            &base,
1533            &base_target,
1534            &base_team,
1535            Some(&base_device),
1536            &base_certificate,
1537            ProvisioningProfileType::Development,
1538            &base_required,
1539            99,
1540            &ProfileValidationIssue::ProfileNotYetValid,
1541        );
1542
1543        assert_issue(
1544            &base,
1545            &base_target,
1546            &base_team,
1547            Some(&base_device),
1548            &base_certificate,
1549            ProvisioningProfileType::Development,
1550            &base_required,
1551            500,
1552            &ProfileValidationIssue::ProfileExpired,
1553        );
1554
1555        let mut inconsistent_identifier = base.clone();
1556        let mut inconsistent_entitlements = inconsistent_identifier.entitlements.values().clone();
1557        inconsistent_entitlements.insert(
1558            APPLICATION_IDENTIFIER.to_owned(),
1559            json!("ABC123XYZ9.com.other.app"),
1560        );
1561        inconsistent_identifier.entitlements = EntitlementSet::new(inconsistent_entitlements)
1562            .expect("test entitlement set should be valid");
1563        assert_issue(
1564            &inconsistent_identifier,
1565            &base_target,
1566            &base_team,
1567            Some(&base_device),
1568            &base_certificate,
1569            ProvisioningProfileType::Development,
1570            &base_required,
1571            200,
1572            &ProfileValidationIssue::ApplicationIdentifierMismatch,
1573        );
1574    }
1575
1576    #[test]
1577    fn entitlement_get_task_allow_and_application_group_mutations_are_typed() {
1578        let profile = parsed_profile();
1579        let team = team();
1580        let device = device();
1581        let certificate = certificate();
1582        let target = target("com.example.rustferry");
1583
1584        let wrong_environment = EntitlementSet::new(BTreeMap::from([
1585            (
1586                APPLICATION_IDENTIFIER.to_owned(),
1587                json!("ABC123XYZ9.com.example.rustferry"),
1588            ),
1589            (GET_TASK_ALLOW.to_owned(), json!(true)),
1590            ("aps-environment".to_owned(), json!("production")),
1591        ]))
1592        .expect("test entitlement set should be valid");
1593        assert_issue(
1594            &profile,
1595            &target,
1596            &team,
1597            Some(&device),
1598            &certificate,
1599            ProvisioningProfileType::Development,
1600            &wrong_environment,
1601            200,
1602            &ProfileValidationIssue::EntitlementMismatch {
1603                key: "aps-environment".to_owned(),
1604            },
1605        );
1606
1607        let wrong_debugging =
1608            EntitlementSet::new(BTreeMap::from([(GET_TASK_ALLOW.to_owned(), json!(false))]))
1609                .expect("test entitlement set should be valid");
1610        assert_issue(
1611            &profile,
1612            &target,
1613            &team,
1614            Some(&device),
1615            &certificate,
1616            ProvisioningProfileType::Development,
1617            &wrong_debugging,
1618            200,
1619            &ProfileValidationIssue::GetTaskAllowMismatch,
1620        );
1621
1622        let mut mismatched_profile_debugging = profile.clone();
1623        let mut profile_entitlements = mismatched_profile_debugging.entitlements.values().clone();
1624        profile_entitlements.insert(GET_TASK_ALLOW.to_owned(), json!(false));
1625        mismatched_profile_debugging.entitlements = EntitlementSet::new(profile_entitlements)
1626            .expect("test entitlement set should be valid");
1627        assert_issue(
1628            &mismatched_profile_debugging,
1629            &target,
1630            &team,
1631            Some(&device),
1632            &certificate,
1633            ProvisioningProfileType::Development,
1634            &required_entitlements(),
1635            200,
1636            &ProfileValidationIssue::GetTaskAllowMismatch,
1637        );
1638
1639        let wrong_group = EntitlementSet::new(BTreeMap::from([(
1640            APPLICATION_GROUPS.to_owned(),
1641            json!(["group.com.other"]),
1642        )]))
1643        .expect("test entitlement set should be valid");
1644        assert_issue(
1645            &profile,
1646            &target,
1647            &team,
1648            Some(&device),
1649            &certificate,
1650            ProvisioningProfileType::Development,
1651            &wrong_group,
1652            200,
1653            &ProfileValidationIssue::ApplicationGroupMismatch,
1654        );
1655
1656        let missing_entitlement = EntitlementSet::new(BTreeMap::from([(
1657            "com.apple.developer.healthkit".to_owned(),
1658            json!(true),
1659        )]))
1660        .expect("test entitlement set should be valid");
1661        assert_issue(
1662            &profile,
1663            &target,
1664            &team,
1665            Some(&device),
1666            &certificate,
1667            ProvisioningProfileType::Development,
1668            &missing_entitlement,
1669            200,
1670            &ProfileValidationIssue::MissingEntitlement {
1671                key: "com.apple.developer.healthkit".to_owned(),
1672            },
1673        );
1674    }
1675
1676    #[test]
1677    fn error_rendering_never_echoes_mutated_profile_values() {
1678        const MALFORMED_DEVICE: &str = "00008110-DO-NOT-ECHO/DEVICE";
1679        const WRONG_DEVICE: &str = "00008110-00AAAAAAAAAAAAAA";
1680
1681        let mut profile = valid_profile_dictionary();
1682        profile.insert(
1683            "TeamIdentifier".to_owned(),
1684            PlistValue::String("DO-NOT-ECHO-SECRET".to_owned()),
1685        );
1686        let error = parse_provisioning_profile_value(&PlistValue::Dictionary(profile))
1687            .expect_err("wrong team type should fail");
1688        let rendered = error.to_string();
1689        assert!(!rendered.contains("DO-NOT-ECHO-SECRET"));
1690
1691        let mut profile = valid_profile_dictionary();
1692        profile.insert(
1693            "ProvisionedDevices".to_owned(),
1694            PlistValue::Array(vec![PlistValue::String(MALFORMED_DEVICE.to_owned())]),
1695        );
1696        let error = parse_provisioning_profile_value(&PlistValue::Dictionary(profile))
1697            .expect_err("malformed device identifier should fail");
1698        assert!(!error.to_string().contains(MALFORMED_DEVICE));
1699
1700        let profile = parsed_profile();
1701        let team = team();
1702        let device = DevicePlan::new(WRONG_DEVICE, None).expect("test device should be valid");
1703        let certificate = certificate();
1704        let target = target("com.example.rustferry");
1705        let required = required_entitlements();
1706        let errors = validate_profile_for_target(
1707            &profile,
1708            ProfileValidationRequest {
1709                target: &target,
1710                team: &team,
1711                device: Some(&device),
1712                certificate: &certificate,
1713                profile_type: ProvisioningProfileType::Development,
1714                required_entitlements: &required,
1715                now_unix_seconds: 200,
1716            },
1717        )
1718        .expect_err("wrong device should fail");
1719        assert!(!errors.to_string().contains(WRONG_DEVICE));
1720        for issue in errors.issues() {
1721            assert!(!issue.to_string().contains(WRONG_DEVICE));
1722        }
1723    }
1724
1725    fn valid_profile_value() -> PlistValue {
1726        PlistValue::Dictionary(valid_profile_dictionary())
1727    }
1728
1729    fn valid_profile_dictionary() -> Dictionary {
1730        let mut entitlements = Dictionary::new();
1731        entitlements.insert(
1732            APPLICATION_IDENTIFIER.to_owned(),
1733            PlistValue::String(format!("{TEAM}.com.example.rustferry")),
1734        );
1735        entitlements.insert(
1736            "com.apple.developer.team-identifier".to_owned(),
1737            PlistValue::String(TEAM.to_owned()),
1738        );
1739        entitlements.insert(GET_TASK_ALLOW.to_owned(), PlistValue::Boolean(true));
1740        entitlements.insert(
1741            APPLICATION_GROUPS.to_owned(),
1742            PlistValue::Array(vec![
1743                PlistValue::String("group.com.example.rustferry".to_owned()),
1744                PlistValue::String("group.com.example.extra".to_owned()),
1745            ]),
1746        );
1747        entitlements.insert(
1748            "aps-environment".to_owned(),
1749            PlistValue::String("development".to_owned()),
1750        );
1751
1752        let mut profile = Dictionary::new();
1753        profile.insert(
1754            "UUID".to_owned(),
1755            PlistValue::String("12345678-1234-1234-1234-123456789ABC".to_owned()),
1756        );
1757        profile.insert(
1758            "Name".to_owned(),
1759            PlistValue::String("RustFerry Development".to_owned()),
1760        );
1761        profile.insert(
1762            "TeamIdentifier".to_owned(),
1763            PlistValue::Array(vec![PlistValue::String(TEAM.to_owned())]),
1764        );
1765        profile.insert(
1766            "TeamName".to_owned(),
1767            PlistValue::String("RustFerry Team".to_owned()),
1768        );
1769        profile.insert(
1770            "CreationDate".to_owned(),
1771            PlistValue::Date((UNIX_EPOCH + Duration::from_secs(100)).into()),
1772        );
1773        profile.insert(
1774            "ExpirationDate".to_owned(),
1775            PlistValue::Date((UNIX_EPOCH + Duration::from_secs(500)).into()),
1776        );
1777        profile.insert(
1778            "Platform".to_owned(),
1779            PlistValue::Array(vec![PlistValue::String("iOS".to_owned())]),
1780        );
1781        profile.insert(
1782            "ProvisionedDevices".to_owned(),
1783            PlistValue::Array(vec![PlistValue::String(DEVICE.to_owned())]),
1784        );
1785        profile.insert(
1786            "DeveloperCertificates".to_owned(),
1787            PlistValue::Array(vec![PlistValue::Data(vec![1, 2, 3])]),
1788        );
1789        profile.insert(
1790            "Entitlements".to_owned(),
1791            PlistValue::Dictionary(entitlements),
1792        );
1793        profile
1794    }
1795
1796    fn entitlements_mut(profile: &mut Dictionary) -> &mut Dictionary {
1797        profile
1798            .get_mut("Entitlements")
1799            .and_then(PlistValue::as_dictionary_mut)
1800            .expect("synthetic profile has entitlements")
1801    }
1802
1803    fn parsed_profile() -> crate::ProvisioningProfile {
1804        parse_provisioning_profile_value(&valid_profile_value())
1805            .expect("synthetic profile should parse")
1806    }
1807
1808    fn team() -> DevelopmentTeam {
1809        DevelopmentTeam::new(TEAM, Some("RustFerry Team".to_owned()))
1810            .expect("test team should be valid")
1811    }
1812
1813    fn device() -> DevicePlan {
1814        DevicePlan::new(DEVICE, Some("Acceptance iPhone".to_owned()))
1815            .expect("test device should be valid")
1816    }
1817
1818    fn certificate() -> SigningCertificate {
1819        SigningCertificate {
1820            common_name: "Apple Development: Test".to_owned(),
1821            sha256_fingerprint: CERTIFICATE_FINGERPRINT.to_owned(),
1822            team: team(),
1823            expires_at_unix_seconds: 500,
1824        }
1825    }
1826
1827    fn target(bundle_identifier: &str) -> SigningTarget {
1828        SigningTarget {
1829            name: "RustFerryApp".to_owned(),
1830            bundle_identifier: BundleIdentifier::new(bundle_identifier)
1831                .expect("test bundle identifier should be valid"),
1832            kind: SigningTargetKind::Application,
1833        }
1834    }
1835
1836    fn required_entitlements() -> EntitlementSet {
1837        EntitlementSet::new(BTreeMap::from([
1838            (
1839                APPLICATION_IDENTIFIER.to_owned(),
1840                json!("ABC123XYZ9.com.example.rustferry"),
1841            ),
1842            (GET_TASK_ALLOW.to_owned(), json!(true)),
1843            (
1844                APPLICATION_GROUPS.to_owned(),
1845                json!(["group.com.example.rustferry"]),
1846            ),
1847            ("aps-environment".to_owned(), json!("development")),
1848        ]))
1849        .expect("test entitlement set should be valid")
1850    }
1851
1852    #[allow(clippy::too_many_arguments)]
1853    fn assert_issue(
1854        profile: &crate::ProvisioningProfile,
1855        target: &SigningTarget,
1856        team: &DevelopmentTeam,
1857        device: Option<&DevicePlan>,
1858        certificate: &SigningCertificate,
1859        profile_type: ProvisioningProfileType,
1860        required_entitlements: &EntitlementSet,
1861        now_unix_seconds: u64,
1862        expected: &ProfileValidationIssue,
1863    ) {
1864        let errors = validate_profile_for_target(
1865            profile,
1866            ProfileValidationRequest {
1867                target,
1868                team,
1869                device,
1870                certificate,
1871                profile_type,
1872                required_entitlements,
1873                now_unix_seconds,
1874            },
1875        )
1876        .expect_err("mutated profile requirement should fail");
1877        assert!(
1878            errors.issues().contains(expected),
1879            "missing expected issue {expected:?}; actual: {:?}",
1880            errors.issues()
1881        );
1882    }
1883}