1use 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
19pub 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
47pub enum ProfileField {
48 Document,
50 Uuid,
52 Name,
54 TeamIdentifier,
56 TeamName,
58 CreationDate,
60 ExpirationDate,
62 Platform,
64 ProvisionedDevices,
66 ProvisionsAllDevices,
68 DeveloperCertificates,
70 Entitlements,
72 ApplicationIdentifier,
74 GetTaskAllow,
76 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#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum ProvisioningProfileParseError {
109 InputSize,
111 MalformedPlist,
113 DocumentBounds,
115 MissingField {
117 field: ProfileField,
119 },
120 InvalidFieldType {
122 field: ProfileField,
124 },
125 InvalidField {
127 field: ProfileField,
129 },
130 FieldBounds {
132 field: ProfileField,
134 },
135 UnsupportedPlatform,
137 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
171pub 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#[allow(clippy::too_many_lines)] pub 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#[derive(Clone, Copy, Debug)]
325pub struct ProfileValidationRequest<'a> {
326 pub target: &'a SigningTarget,
328 pub team: &'a crate::DevelopmentTeam,
330 pub device: Option<&'a DevicePlan>,
332 pub certificate: &'a SigningCertificate,
334 pub profile_type: ProvisioningProfileType,
336 pub required_entitlements: &'a EntitlementSet,
338 pub now_unix_seconds: u64,
340}
341
342#[derive(Clone, Debug, Eq, PartialEq)]
344pub enum ProfileValidationIssue {
345 InvalidProfileMetadata,
347 ProfileExpired,
349 ProfileNotYetValid,
351 TargetBundleIdentifierMismatch,
353 UnsupportedTargetKind,
355 ProfileTeamMismatch,
357 InvalidCertificate,
359 CertificateExpired,
361 CertificateTeamMismatch,
363 ProfileCertificateMismatch,
365 ProfileTypeMismatch,
367 MissingDevice,
369 DeviceNotProvisioned,
371 MissingEntitlement {
373 key: String,
375 },
376 EntitlementMismatch {
378 key: String,
380 },
381 ApplicationIdentifierMismatch,
383 GetTaskAllowMismatch,
385 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#[derive(Clone, Debug, Eq, PartialEq)]
428pub struct ProfileValidationErrors {
429 issues: Vec<ProfileValidationIssue>,
430}
431
432impl ProfileValidationErrors {
433 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#[derive(Clone, Debug, Eq, PartialEq)]
453pub struct ValidatedProvisioningProfile {
454 pub profile_uuid: String,
456 pub target_bundle_identifier: String,
458 pub team_identifier: String,
460 pub certificate_sha256_fingerprint: String,
462 pub profile_type: ProvisioningProfileType,
464 pub application_groups: BTreeSet<String>,
466 pub expires_at_unix_seconds: u64,
468}
469
470#[allow(clippy::too_many_lines)] pub 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)] 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)] 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}