Skip to main content

rustferry_remote/
signing.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    error::Error,
4    fmt,
5};
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11
12use crate::secret::SecretReference;
13
14const MAX_LABEL_BYTES: usize = 128;
15const MAX_ENTITLEMENT_KEY_BYTES: usize = 255;
16const MAX_ENTITLEMENT_STRING_BYTES: usize = 16 * 1024;
17const MAX_ENTITLEMENT_COLLECTION_ITEMS: usize = 1_024;
18const MAX_ENTITLEMENT_DEPTH: usize = 16;
19const SIGNING_TARGET_GRAPH_SHA256_DOMAIN: &[u8] = b"rustferry.signing-target-graph.sha256.v1\0";
20
21/// Supported Apple signing mode.
22#[derive(
23    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
24)]
25#[serde(rename_all = "kebab-case")]
26pub enum SigningMode {
27    /// Compile and inspect an iPhoneOS artifact without making it installable.
28    UnsignedCompileOnly,
29    /// Normal Apple Development signing.
30    Development,
31    /// User-supplied development identity and profiles.
32    ManualDevelopment,
33    /// Persistent-Mac Personal Team flow configured interactively in Xcode.
34    PersonalTeam,
35    /// Future registered-device ad-hoc distribution.
36    AdHoc,
37    /// Future App Store distribution.
38    AppStore,
39}
40
41impl SigningMode {
42    /// Return whether this mode produces a signed artifact.
43    pub fn is_signed(self) -> bool {
44        self != Self::UnsignedCompileOnly
45    }
46
47    /// Return whether this mode requires one registered device.
48    pub fn requires_device(self) -> bool {
49        matches!(
50            self,
51            Self::Development | Self::ManualDevelopment | Self::PersonalTeam | Self::AdHoc
52        )
53    }
54
55    fn requires_identity_reference(self) -> bool {
56        matches!(
57            self,
58            Self::Development | Self::ManualDevelopment | Self::AdHoc | Self::AppStore
59        )
60    }
61}
62
63/// Fine-grained signing progress; never collapse this into one success boolean.
64#[derive(
65    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum SigningStatus {
69    /// No certificate or provisioning validation has completed.
70    Unsigned,
71    /// Certificate type, expiry, team, and private-key match validated.
72    CertificateValidated,
73    /// All required provisioning profiles validated.
74    ProfileValidated,
75    /// Frameworks, libraries, and extensions signed inside-out.
76    NestedCodeSigned,
77    /// Main application signed after nested code.
78    ApplicationSigned,
79    /// Development IPA exported.
80    IpaExported,
81    /// Independent artifact validation completed.
82    ArtifactValidated,
83    /// A validation or signing stage failed.
84    Invalid,
85}
86
87impl SigningStatus {
88    /// Advance one exact lifecycle step.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`SigningValidationError::InvalidStatusTransition`] when a stage
93    /// is skipped, repeated, or resumed after invalidation.
94    pub fn advance(self, next: Self) -> Result<Self, SigningValidationError> {
95        let valid = matches!(
96            (self, next),
97            (Self::Unsigned, Self::CertificateValidated)
98                | (Self::CertificateValidated, Self::ProfileValidated)
99                | (Self::ProfileValidated, Self::NestedCodeSigned)
100                | (Self::NestedCodeSigned, Self::ApplicationSigned)
101                | (Self::ApplicationSigned, Self::IpaExported)
102                | (Self::IpaExported, Self::ArtifactValidated)
103        );
104        if valid || next == Self::Invalid {
105            Ok(next)
106        } else {
107            Err(SigningValidationError::InvalidStatusTransition {
108                from: self,
109                to: next,
110            })
111        }
112    }
113}
114
115/// Serializable signing inputs containing public identity metadata and opaque
116/// references only, never secret bytes.
117#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
118#[serde(deny_unknown_fields)]
119pub struct SigningReference {
120    /// Expected public certificate paired with the opaque private-key reference.
121    pub identity: SigningIdentity,
122    /// Opaque reference to its password or passphrase, when required.
123    pub password: Option<SecretReference>,
124}
125
126/// Validated Apple application bundle identifier.
127#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
128#[schemars(transparent)]
129pub struct BundleIdentifier(String);
130
131impl BundleIdentifier {
132    /// Validate and construct an explicit bundle identifier.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`SigningValidationError::InvalidBundleIdentifier`] for a
137    /// wildcard, malformed, or oversized value.
138    pub fn new(value: impl Into<String>) -> Result<Self, SigningValidationError> {
139        let value = value.into();
140        if value.len() > 255
141            || value.split('.').count() < 2
142            || value.split('.').any(|segment| {
143                segment.is_empty()
144                    || !segment
145                        .bytes()
146                        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
147                    || !segment.as_bytes()[0].is_ascii_alphanumeric()
148                    || !segment.as_bytes()[segment.len() - 1].is_ascii_alphanumeric()
149            })
150        {
151            return Err(SigningValidationError::InvalidBundleIdentifier);
152        }
153        Ok(Self(value))
154    }
155
156    /// Return the validated identifier.
157    pub fn as_str(&self) -> &str {
158        &self.0
159    }
160}
161
162impl<'de> Deserialize<'de> for BundleIdentifier {
163    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
164    where
165        D: Deserializer<'de>,
166    {
167        let value = String::deserialize(deserializer)?;
168        Self::new(value).map_err(serde::de::Error::custom)
169    }
170}
171
172/// Apple development team selected for signing.
173#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
174pub struct DevelopmentTeam {
175    id: String,
176    display_name: Option<String>,
177}
178
179impl DevelopmentTeam {
180    /// Construct a validated ten-character Apple Team ID.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`SigningValidationError::InvalidTeamIdentifier`] for malformed
185    /// IDs or unsafe display names.
186    pub fn new(
187        id: impl Into<String>,
188        display_name: Option<String>,
189    ) -> Result<Self, SigningValidationError> {
190        let id = id.into();
191        if id.len() != 10
192            || !id
193                .bytes()
194                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
195        {
196            return Err(SigningValidationError::InvalidTeamIdentifier);
197        }
198        if display_name
199            .as_deref()
200            .is_some_and(|name| !is_safe_label(name))
201        {
202            return Err(SigningValidationError::InvalidTeamIdentifier);
203        }
204        Ok(Self { id, display_name })
205    }
206
207    /// Return the Apple Team ID.
208    pub fn id(&self) -> &str {
209        &self.id
210    }
211
212    /// Return the optional public display name.
213    pub fn display_name(&self) -> Option<&str> {
214        self.display_name.as_deref()
215    }
216}
217
218#[derive(Deserialize)]
219#[serde(deny_unknown_fields)]
220struct UncheckedDevelopmentTeam {
221    id: String,
222    display_name: Option<String>,
223}
224
225impl<'de> Deserialize<'de> for DevelopmentTeam {
226    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
227    where
228        D: Deserializer<'de>,
229    {
230        let unchecked = UncheckedDevelopmentTeam::deserialize(deserializer)?;
231        Self::new(unchecked.id, unchecked.display_name).map_err(serde::de::Error::custom)
232    }
233}
234
235/// Registered physical iPhone selected for a development artifact.
236#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
237pub struct DevicePlan {
238    udid_sha256: String,
239    display_name: Option<String>,
240}
241
242impl DevicePlan {
243    /// Validate and hash an Apple device identifier without retaining it.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`SigningValidationError::InvalidDeviceIdentifier`] for unsafe
248    /// identifiers or display names.
249    pub fn new(
250        udid: impl Into<String>,
251        display_name: Option<String>,
252    ) -> Result<Self, SigningValidationError> {
253        let udid = udid.into();
254        validate_raw_udid(&udid)?;
255        Self::from_sha256(sha256_lowercase(udid.as_bytes()), display_name)
256    }
257
258    /// Construct a device plan from an existing lowercase SHA-256 digest.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`SigningValidationError::InvalidDeviceIdentifier`] for a
263    /// malformed digest or unsafe display name.
264    pub fn from_sha256(
265        udid_sha256: impl Into<String>,
266        display_name: Option<String>,
267    ) -> Result<Self, SigningValidationError> {
268        let udid_sha256 = udid_sha256.into();
269        if !is_lowercase_sha256(&udid_sha256)
270            || display_name
271                .as_deref()
272                .is_some_and(|name| !is_safe_label(name))
273        {
274            return Err(SigningValidationError::InvalidDeviceIdentifier);
275        }
276        Ok(Self {
277            udid_sha256,
278            display_name,
279        })
280    }
281
282    /// Return the lowercase SHA-256 of the selected device identifier.
283    pub fn udid_sha256(&self) -> &str {
284        &self.udid_sha256
285    }
286
287    /// Validate and compare a raw device identifier without retaining it.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`SigningValidationError::InvalidDeviceIdentifier`] when the
292    /// candidate is malformed.
293    pub fn matches_udid(&self, udid: &str) -> Result<bool, SigningValidationError> {
294        validate_raw_udid(udid)?;
295        Ok(self.udid_sha256 == sha256_lowercase(udid.as_bytes()))
296    }
297
298    /// Return the optional public device name.
299    pub fn display_name(&self) -> Option<&str> {
300        self.display_name.as_deref()
301    }
302}
303
304#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct UncheckedDevicePlan {
307    udid_sha256: String,
308    display_name: Option<String>,
309}
310
311impl<'de> Deserialize<'de> for DevicePlan {
312    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
313    where
314        D: Deserializer<'de>,
315    {
316        let unchecked = UncheckedDevicePlan::deserialize(deserializer)?;
317        Self::from_sha256(unchecked.udid_sha256, unchecked.display_name)
318            .map_err(serde::de::Error::custom)
319    }
320}
321
322/// Development team expectation carried by a signing plan.
323#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
324#[serde(deny_unknown_fields)]
325pub struct DevelopmentTeamPlan {
326    /// Team that certificate, profile, application, and extensions must share.
327    pub expected: DevelopmentTeam,
328}
329
330/// Short protocol name for a development-team expectation.
331pub type TeamPlan = DevelopmentTeamPlan;
332
333/// Opaque reference to private-key material held outside project configuration.
334#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
335#[serde(deny_unknown_fields)]
336pub struct SigningPrivateKeyReference {
337    /// Validated credential-store, workflow-secret, environment, or worker handle.
338    pub reference: SecretReference,
339}
340
341/// Public certificate metadata used for validation and reporting.
342#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
343#[serde(deny_unknown_fields)]
344pub struct SigningCertificate {
345    /// Public certificate subject name.
346    pub common_name: String,
347    /// Uppercase SHA-256 fingerprint without separators.
348    pub sha256_fingerprint: String,
349    /// Team encoded by the signing certificate.
350    pub team: DevelopmentTeam,
351    /// Certificate expiration as a Unix timestamp.
352    pub expires_at_unix_seconds: u64,
353}
354
355impl SigningCertificate {
356    /// Validate public certificate metadata.
357    ///
358    /// # Errors
359    ///
360    /// Returns a typed validation error for an unsafe name or malformed
361    /// fingerprint.
362    pub fn validate(&self) -> Result<(), SigningValidationError> {
363        if !is_safe_label(&self.common_name) {
364            return Err(SigningValidationError::InvalidCertificateName);
365        }
366        validate_fingerprint(&self.sha256_fingerprint)
367    }
368}
369
370/// Certificate metadata paired with an opaque private-key reference.
371#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
372#[serde(deny_unknown_fields)]
373pub struct SigningIdentity {
374    /// Public certificate metadata.
375    pub certificate: SigningCertificate,
376    /// Reference only; private-key bytes are never serializable.
377    pub private_key: SigningPrivateKeyReference,
378}
379
380/// Provisioning profile category.
381#[derive(
382    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
383)]
384#[serde(rename_all = "kebab-case")]
385pub enum ProvisioningProfileType {
386    /// Registered-device development profile.
387    Development,
388    /// Registered-device ad-hoc distribution profile.
389    AdHoc,
390    /// App Store distribution profile.
391    AppStore,
392}
393
394/// Supported profile platform.
395#[derive(
396    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
397)]
398#[serde(rename_all = "snake_case")]
399pub enum ProvisioningPlatform {
400    /// Physical iPhone/iPad platform.
401    Ios,
402}
403
404/// Validated entitlement dictionary for one signing target.
405#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)]
406#[schemars(transparent)]
407pub struct EntitlementSet(BTreeMap<String, Value>);
408
409impl EntitlementSet {
410    /// Validate entitlement keys, collection bounds, depth, and plist-compatible values.
411    ///
412    /// # Errors
413    ///
414    /// Returns a typed entitlement validation error without echoing its value.
415    pub fn new(values: BTreeMap<String, Value>) -> Result<Self, SigningValidationError> {
416        if values.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
417            return Err(SigningValidationError::InvalidEntitlementValue {
418                key: None,
419                reason: EntitlementValueError::CollectionTooLarge,
420            });
421        }
422        for (key, value) in &values {
423            validate_entitlement_key(key)?;
424            validate_entitlement_value(Some(key), value, 0)?;
425        }
426        Ok(Self(values))
427    }
428
429    /// Return the validated entitlement mapping.
430    pub fn values(&self) -> &BTreeMap<String, Value> {
431        &self.0
432    }
433
434    /// Return one entitlement value.
435    pub fn get(&self, key: &str) -> Option<&Value> {
436        self.0.get(key)
437    }
438
439    /// Return whether no entitlements are requested.
440    pub fn is_empty(&self) -> bool {
441        self.0.is_empty()
442    }
443}
444
445impl<'de> Deserialize<'de> for EntitlementSet {
446    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
447    where
448        D: Deserializer<'de>,
449    {
450        let values = BTreeMap::<String, Value>::deserialize(deserializer)?;
451        Self::new(values).map_err(serde::de::Error::custom)
452    }
453}
454
455/// Public decoded provisioning-profile metadata; raw profile bytes are excluded.
456#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
457#[serde(deny_unknown_fields)]
458pub struct ProvisioningProfile {
459    /// Profile UUID.
460    pub uuid: String,
461    /// Public profile name.
462    pub name: String,
463    /// Development team embedded in the profile.
464    pub team: DevelopmentTeam,
465    /// Full `application-identifier` entitlement.
466    pub application_identifier: String,
467    /// Explicit or wildcard bundle pattern.
468    pub bundle_identifier_pattern: String,
469    /// Whether the profile uses a wildcard App ID.
470    pub wildcard: bool,
471    /// Creation time as a Unix timestamp.
472    pub created_at_unix_seconds: u64,
473    /// Expiration time as a Unix timestamp.
474    pub expires_at_unix_seconds: u64,
475    /// Lowercase SHA-256 digests of registered device identifiers, empty for
476    /// App Store profiles.
477    pub device_udid_sha256s: Vec<String>,
478    /// Profile entitlements.
479    pub entitlements: EntitlementSet,
480    /// Profile platform allowlist.
481    pub platforms: BTreeSet<ProvisioningPlatform>,
482    /// Profile category.
483    pub profile_type: ProvisioningProfileType,
484    /// Public certificate SHA-256 fingerprints.
485    pub certificate_fingerprints: Vec<String>,
486}
487
488impl ProvisioningProfile {
489    /// Validate public profile metadata without reading a profile or key.
490    ///
491    /// # Errors
492    ///
493    /// Returns the first typed metadata failure.
494    pub fn validate_metadata(&self) -> Result<(), SigningValidationError> {
495        if !is_profile_identifier(&self.uuid) {
496            return Err(SigningValidationError::InvalidProvisioningProfileIdentifier);
497        }
498        if !is_safe_label(&self.name) {
499            return Err(SigningValidationError::InvalidProvisioningProfileName);
500        }
501        if self.created_at_unix_seconds > self.expires_at_unix_seconds {
502            return Err(SigningValidationError::InvalidProvisioningProfileDates);
503        }
504        let bundle_pattern_valid = if self.wildcard {
505            self.bundle_identifier_pattern == "*"
506                || self
507                    .bundle_identifier_pattern
508                    .strip_suffix(".*")
509                    .is_some_and(|prefix| BundleIdentifier::new(prefix).is_ok())
510        } else {
511            BundleIdentifier::new(self.bundle_identifier_pattern.clone()).is_ok()
512        };
513        if !bundle_pattern_valid {
514            return Err(SigningValidationError::ProfileBundleIdentifierMismatch);
515        }
516        let expected_application_identifier =
517            format!("{}.{}", self.team.id(), self.bundle_identifier_pattern);
518        if self.application_identifier != expected_application_identifier {
519            return Err(SigningValidationError::ApplicationIdentifierMismatch);
520        }
521        if !self.platforms.contains(&ProvisioningPlatform::Ios) {
522            return Err(SigningValidationError::ProfilePlatformMismatch);
523        }
524        if self.certificate_fingerprints.is_empty() {
525            return Err(SigningValidationError::ProfileCertificateMismatch);
526        }
527        for fingerprint in &self.certificate_fingerprints {
528            validate_fingerprint(fingerprint)?;
529        }
530        let mut devices = BTreeSet::new();
531        for udid_sha256 in &self.device_udid_sha256s {
532            DevicePlan::from_sha256(udid_sha256.clone(), None)?;
533            if !devices.insert(udid_sha256) {
534                return Err(SigningValidationError::InvalidDeviceIdentifier);
535            }
536        }
537        match self.profile_type {
538            ProvisioningProfileType::Development | ProvisioningProfileType::AdHoc
539                if self.device_udid_sha256s.is_empty() =>
540            {
541                return Err(SigningValidationError::DeviceNotProvisioned);
542            }
543            ProvisioningProfileType::AppStore if !self.device_udid_sha256s.is_empty() => {
544                return Err(SigningValidationError::ProfileTypeMismatch);
545            }
546            ProvisioningProfileType::Development
547            | ProvisioningProfileType::AdHoc
548            | ProvisioningProfileType::AppStore => {}
549        }
550        Ok(())
551    }
552
553    /// Validate metadata and reject an expired profile at a caller-supplied time.
554    ///
555    /// # Errors
556    ///
557    /// Returns a typed time-validity error when the profile is not yet valid
558    /// or has expired, or another typed metadata error.
559    pub fn validate_metadata_at(
560        &self,
561        now_unix_seconds: u64,
562    ) -> Result<(), SigningValidationError> {
563        self.validate_metadata()?;
564        if self.created_at_unix_seconds > now_unix_seconds {
565            Err(SigningValidationError::ProfileNotYetValid)
566        } else if self.expires_at_unix_seconds <= now_unix_seconds {
567            Err(SigningValidationError::ProfileExpired)
568        } else {
569            Ok(())
570        }
571    }
572}
573
574fn validate_raw_udid(udid: &str) -> Result<(), SigningValidationError> {
575    let valid = (20..=64).contains(&udid.len())
576        && udid
577            .bytes()
578            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
579        && !udid.starts_with('-')
580        && !udid.ends_with('-');
581    if valid {
582        Ok(())
583    } else {
584        Err(SigningValidationError::InvalidDeviceIdentifier)
585    }
586}
587
588fn is_lowercase_sha256(value: &str) -> bool {
589    value.len() == 64
590        && value
591            .bytes()
592            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
593}
594
595fn sha256_lowercase(bytes: &[u8]) -> String {
596    hex::encode(Sha256::digest(bytes))
597}
598
599/// Kind of code object in the required inside-out signing order.
600#[derive(
601    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
602)]
603#[serde(rename_all = "snake_case")]
604pub enum SigningTargetKind {
605    /// Embedded dynamic library.
606    DynamicLibrary,
607    /// Embedded framework.
608    Framework,
609    /// Widget, Live Activity, or other application extension.
610    Extension,
611    /// Main application; always signed last.
612    Application,
613}
614
615/// One public signing target.
616#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
617#[serde(deny_unknown_fields)]
618pub struct SigningTarget {
619    /// Stable target name used by per-target plans.
620    pub name: String,
621    /// Bundle identifier encoded into its signature.
622    pub bundle_identifier: BundleIdentifier,
623    /// Code-object category determining inside-out order.
624    pub kind: SigningTargetKind,
625}
626
627/// SHA-256 of a canonical, order-independent full signing-target graph.
628///
629/// The hash input starts with a versioned domain separator and record count.
630/// Every target is sorted by UTF-8 target name, bundle identifier, then kind;
631/// kind, name, and bundle identifier are encoded as ASCII-length-prefixed byte
632/// fields. The resulting lowercase digest binds every target kind without
633/// relying on serialization formats or enum discriminants.
634pub fn canonical_signing_target_graph_sha256(targets: &[SigningTarget]) -> String {
635    let mut targets = targets.iter().collect::<Vec<_>>();
636    targets.sort_unstable_by(|left, right| {
637        left.name
638            .as_bytes()
639            .cmp(right.name.as_bytes())
640            .then_with(|| {
641                left.bundle_identifier
642                    .as_str()
643                    .as_bytes()
644                    .cmp(right.bundle_identifier.as_str().as_bytes())
645            })
646            .then_with(|| {
647                signing_target_kind_name(left.kind).cmp(signing_target_kind_name(right.kind))
648            })
649    });
650
651    let mut digest = Sha256::new();
652    digest.update(SIGNING_TARGET_GRAPH_SHA256_DOMAIN);
653    update_canonical_length(&mut digest, targets.len());
654    for target in targets {
655        update_canonical_field(
656            &mut digest,
657            signing_target_kind_name(target.kind).as_bytes(),
658        );
659        update_canonical_field(&mut digest, target.name.as_bytes());
660        update_canonical_field(&mut digest, target.bundle_identifier.as_str().as_bytes());
661    }
662    hex::encode(digest.finalize())
663}
664
665fn update_canonical_field(digest: &mut Sha256, value: &[u8]) {
666    update_canonical_length(digest, value.len());
667    digest.update(value);
668}
669
670fn update_canonical_length(digest: &mut Sha256, length: usize) {
671    digest.update(length.to_string().as_bytes());
672    digest.update(b":");
673}
674
675const fn signing_target_kind_name(kind: SigningTargetKind) -> &'static str {
676    match kind {
677        SigningTargetKind::DynamicLibrary => "dynamic_library",
678        SigningTargetKind::Framework => "framework",
679        SigningTargetKind::Extension => "extension",
680        SigningTargetKind::Application => "application",
681    }
682}
683
684/// Per-target provisioning-profile selection.
685#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
686#[serde(deny_unknown_fields)]
687pub struct ProvisioningPlan {
688    /// Target name from [`SigningPlan::targets`].
689    pub target: String,
690    /// Opaque profile reference; never raw `.mobileprovision` bytes.
691    pub profile: SecretReference,
692    /// Required profile category.
693    pub profile_type: ProvisioningProfileType,
694}
695
696/// Per-target entitlement expectation.
697#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
698#[serde(deny_unknown_fields)]
699pub struct EntitlementPlan {
700    /// Target name from [`SigningPlan::targets`].
701    pub target: String,
702    /// Entitlements that must remain enabled and match the profile.
703    pub required: EntitlementSet,
704}
705
706/// Complete declarative physical-iPhone signing plan.
707#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
708#[serde(deny_unknown_fields)]
709pub struct SigningPlan {
710    /// Requested signing mode.
711    pub mode: SigningMode,
712    /// Public identity metadata plus opaque key/password references.
713    pub signing: Option<SigningReference>,
714    /// Expected team for all code and profiles.
715    pub team: Option<DevelopmentTeamPlan>,
716    /// Required registered device for development and ad-hoc artifacts.
717    pub device: Option<DevicePlan>,
718    /// All signable code objects.
719    pub targets: Vec<SigningTarget>,
720    /// Separate profile for each application and extension target.
721    pub provisioning: Vec<ProvisioningPlan>,
722    /// Per-target entitlement requirements.
723    pub entitlements: Vec<EntitlementPlan>,
724    /// Explicit opt-in to official Xcode profile updates.
725    pub allow_provisioning_updates: bool,
726}
727
728impl SigningPlan {
729    /// Validate plan structure without accessing any secret or external service.
730    ///
731    /// # Errors
732    ///
733    /// Returns all independent typed structural failures.
734    pub fn validate(&self) -> Result<(), SigningValidationErrors> {
735        let mut errors = Vec::new();
736
737        if self.mode == SigningMode::UnsignedCompileOnly {
738            if self.signing.is_some() || !self.provisioning.is_empty() {
739                errors.push(SigningValidationError::SigningReferencesForbiddenForUnsigned);
740            }
741            if self.allow_provisioning_updates {
742                errors.push(SigningValidationError::ProvisioningUpdatesForbiddenForUnsigned);
743            }
744        } else {
745            if self.mode.requires_identity_reference() && self.signing.is_none() {
746                errors.push(SigningValidationError::MissingSigningReference);
747            }
748            if let Some(signing) = &self.signing {
749                if let Err(error) = signing.identity.certificate.validate() {
750                    errors.push(error);
751                }
752                if self.team.as_ref().is_some_and(|team| {
753                    team.expected.id() != signing.identity.certificate.team.id()
754                }) {
755                    errors.push(SigningValidationError::CertificateTeamMismatch);
756                }
757            }
758            if self.mode == SigningMode::ManualDevelopment
759                && self
760                    .signing
761                    .as_ref()
762                    .is_some_and(|signing| signing.password.is_none())
763            {
764                errors.push(SigningValidationError::MissingSigningPasswordReference);
765            }
766            if self.team.is_none() {
767                errors.push(SigningValidationError::MissingDevelopmentTeam);
768            }
769            if self.mode.requires_device() && self.device.is_none() {
770                errors.push(SigningValidationError::MissingDevice);
771            }
772        }
773
774        let mut target_names = BTreeSet::new();
775        let mut bundle_identifiers = BTreeSet::new();
776        let mut application_targets = 0;
777        for target in &self.targets {
778            if !is_safe_identifier(&target.name) {
779                errors.push(SigningValidationError::InvalidTargetName);
780            }
781            if !target_names.insert(target.name.as_str()) {
782                errors.push(SigningValidationError::DuplicateTarget {
783                    target: target.name.clone(),
784                });
785            }
786            if !bundle_identifiers.insert(target.bundle_identifier.as_str()) {
787                errors.push(SigningValidationError::DuplicateBundleIdentifier {
788                    bundle_identifier: target.bundle_identifier.as_str().to_owned(),
789                });
790            }
791            if target.kind == SigningTargetKind::Application {
792                application_targets += 1;
793            }
794        }
795        if application_targets == 0 {
796            errors.push(SigningValidationError::MissingApplicationTarget);
797        } else if application_targets > 1 {
798            errors.push(SigningValidationError::MultipleApplicationTargets);
799        }
800
801        validate_per_target_plans(self, &target_names, &mut errors);
802
803        if errors.is_empty() {
804            Ok(())
805        } else {
806            Err(SigningValidationErrors { errors })
807        }
808    }
809
810    /// Return targets in the required inside-out signing order.
811    pub fn targets_in_signing_order(&self) -> Vec<&SigningTarget> {
812        let mut targets: Vec<_> = self.targets.iter().collect();
813        targets.sort_by_key(|target| target.kind);
814        targets
815    }
816}
817
818fn validate_per_target_plans(
819    plan: &SigningPlan,
820    target_names: &BTreeSet<&str>,
821    errors: &mut Vec<SigningValidationError>,
822) {
823    let mut profile_targets = BTreeSet::new();
824    for provisioning in &plan.provisioning {
825        if !target_names.contains(provisioning.target.as_str()) {
826            errors.push(SigningValidationError::UnknownProvisioningTarget {
827                target: provisioning.target.clone(),
828            });
829        }
830        if !profile_targets.insert(provisioning.target.as_str()) {
831            errors.push(SigningValidationError::AmbiguousProvisioningProfile {
832                target: provisioning.target.clone(),
833            });
834        }
835        if expected_profile_type(plan.mode)
836            .is_some_and(|expected| provisioning.profile_type != expected)
837        {
838            errors.push(SigningValidationError::ProfileTypeMismatch);
839        }
840    }
841
842    let mut entitlement_targets = BTreeSet::new();
843    for entitlements in &plan.entitlements {
844        if !target_names.contains(entitlements.target.as_str()) {
845            errors.push(SigningValidationError::UnknownEntitlementTarget {
846                target: entitlements.target.clone(),
847            });
848        }
849        if !entitlement_targets.insert(entitlements.target.as_str()) {
850            errors.push(SigningValidationError::DuplicateEntitlementPlan {
851                target: entitlements.target.clone(),
852            });
853        }
854    }
855
856    for target in &plan.targets {
857        if !plan.mode.is_signed()
858            || !matches!(
859                target.kind,
860                SigningTargetKind::Application | SigningTargetKind::Extension
861            )
862        {
863            continue;
864        }
865        if !profile_targets.contains(target.name.as_str()) {
866            errors.push(SigningValidationError::MissingProvisioningProfile {
867                target: target.name.clone(),
868            });
869        }
870        if !entitlement_targets.contains(target.name.as_str()) {
871            errors.push(SigningValidationError::MissingEntitlementPlan {
872                target: target.name.clone(),
873            });
874        }
875    }
876}
877
878fn expected_profile_type(mode: SigningMode) -> Option<ProvisioningProfileType> {
879    match mode {
880        SigningMode::UnsignedCompileOnly => None,
881        SigningMode::Development | SigningMode::ManualDevelopment | SigningMode::PersonalTeam => {
882            Some(ProvisioningProfileType::Development)
883        }
884        SigningMode::AdHoc => Some(ProvisioningProfileType::AdHoc),
885        SigningMode::AppStore => Some(ProvisioningProfileType::AppStore),
886    }
887}
888
889/// Validation state for one independent signing concern.
890#[derive(
891    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
892)]
893#[serde(rename_all = "snake_case")]
894pub enum ValidationStatus {
895    /// Check has not run.
896    NotChecked,
897    /// Check completed and all invariants passed.
898    Validated,
899    /// Check completed and at least one invariant failed.
900    Invalid,
901    /// Check does not apply to this signing mode.
902    NotApplicable,
903}
904
905/// Component tracked independently in a validation report.
906#[derive(
907    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
908)]
909#[serde(rename_all = "snake_case")]
910pub enum ValidationComponent {
911    /// Public certificate metadata and private-key match.
912    Certificate,
913    /// Provisioning-profile selection and contents.
914    Provisioning,
915    /// Team compatibility across certificate, profile, and targets.
916    Team,
917    /// Registered-device compatibility.
918    Device,
919    /// Signed/profile/requested entitlement compatibility.
920    Entitlements,
921}
922
923/// Detailed validation state and typed failures for one signing operation.
924#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
925#[serde(deny_unknown_fields)]
926pub struct SigningValidationReport {
927    /// Signing mode whose evidence this report validates.
928    pub mode: SigningMode,
929    /// Overall signing lifecycle stage.
930    pub signing_status: SigningStatus,
931    /// Certificate validation state.
932    pub certificate: ValidationStatus,
933    /// Provisioning validation state.
934    pub provisioning: ValidationStatus,
935    /// Team validation state.
936    pub team: ValidationStatus,
937    /// Device validation state.
938    pub device: ValidationStatus,
939    /// Entitlement validation state.
940    pub entitlements: ValidationStatus,
941    /// Typed secret-free failures.
942    pub errors: Vec<SigningValidationError>,
943}
944
945impl SigningValidationReport {
946    /// Create an empty report with mode-appropriate component states.
947    pub fn new(mode: SigningMode) -> Self {
948        let initial = if mode.is_signed() {
949            ValidationStatus::NotChecked
950        } else {
951            ValidationStatus::NotApplicable
952        };
953        Self {
954            mode,
955            signing_status: SigningStatus::Unsigned,
956            certificate: initial,
957            provisioning: initial,
958            team: initial,
959            device: if mode.requires_device() {
960                initial
961            } else {
962                ValidationStatus::NotApplicable
963            },
964            entitlements: initial,
965            errors: Vec::new(),
966        }
967    }
968
969    /// Mark one component validated.
970    pub fn mark_validated(&mut self, component: ValidationComponent) {
971        *self.component_mut(component) = ValidationStatus::Validated;
972    }
973
974    /// Record a typed failure and invalidate the overall signing status.
975    pub fn record_error(&mut self, component: ValidationComponent, error: SigningValidationError) {
976        *self.component_mut(component) = ValidationStatus::Invalid;
977        self.signing_status = SigningStatus::Invalid;
978        self.errors.push(error);
979    }
980
981    /// Advance the overall lifecycle by one stage.
982    ///
983    /// # Errors
984    ///
985    /// Returns a typed transition error when a stage is skipped.
986    pub fn advance(&mut self, next: SigningStatus) -> Result<(), SigningValidationError> {
987        self.signing_status = self.signing_status.advance(next)?;
988        Ok(())
989    }
990
991    /// Return whether the report proves an independently validated artifact.
992    pub fn artifact_is_validated(&self) -> bool {
993        self.signing_status == SigningStatus::ArtifactValidated
994            && self.errors.is_empty()
995            && [
996                self.certificate,
997                self.provisioning,
998                self.team,
999                self.entitlements,
1000            ]
1001            .iter()
1002            .all(|status| *status == ValidationStatus::Validated)
1003            && if self.mode.requires_device() {
1004                self.device == ValidationStatus::Validated
1005            } else {
1006                self.device == ValidationStatus::NotApplicable
1007            }
1008    }
1009
1010    fn component_mut(&mut self, component: ValidationComponent) -> &mut ValidationStatus {
1011        match component {
1012            ValidationComponent::Certificate => &mut self.certificate,
1013            ValidationComponent::Provisioning => &mut self.provisioning,
1014            ValidationComponent::Team => &mut self.team,
1015            ValidationComponent::Device => &mut self.device,
1016            ValidationComponent::Entitlements => &mut self.entitlements,
1017        }
1018    }
1019}
1020
1021/// Collection of independent signing-plan validation failures.
1022#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1023pub struct SigningValidationErrors {
1024    errors: Vec<SigningValidationError>,
1025}
1026
1027impl SigningValidationErrors {
1028    /// Return all typed failures.
1029    pub fn errors(&self) -> &[SigningValidationError] {
1030        &self.errors
1031    }
1032
1033    /// Consume the collection.
1034    pub fn into_errors(self) -> Vec<SigningValidationError> {
1035        self.errors
1036    }
1037}
1038
1039impl fmt::Display for SigningValidationErrors {
1040    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1041        write!(
1042            formatter,
1043            "signing plan has {} validation error(s)",
1044            self.errors.len()
1045        )
1046    }
1047}
1048
1049impl Error for SigningValidationErrors {}
1050
1051/// Reason a signing plan, profile, entitlement, or validation stage failed.
1052#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1053#[serde(rename_all = "snake_case", tag = "code")]
1054pub enum SigningValidationError {
1055    /// Signed mode omitted the opaque identity reference.
1056    MissingSigningReference,
1057    /// Manual development mode omitted the encrypted identity password reference.
1058    MissingSigningPasswordReference,
1059    /// Signed mode omitted a development team.
1060    MissingDevelopmentTeam,
1061    /// Device-bound mode omitted a registered device.
1062    MissingDevice,
1063    /// Unsigned mode attempted to carry signing/profile references.
1064    SigningReferencesForbiddenForUnsigned,
1065    /// Unsigned mode enabled Xcode provisioning updates.
1066    ProvisioningUpdatesForbiddenForUnsigned,
1067    /// Apple Team ID or its public label was malformed.
1068    InvalidTeamIdentifier,
1069    /// Device identifier or its public label was malformed.
1070    InvalidDeviceIdentifier,
1071    /// Bundle identifier was malformed or contained a wildcard.
1072    InvalidBundleIdentifier,
1073    /// Target name was unsafe for protocol use.
1074    InvalidTargetName,
1075    /// Certificate public name was malformed.
1076    InvalidCertificateName,
1077    /// Certificate fingerprint was not a SHA-256 hex digest.
1078    InvalidCertificateFingerprint,
1079    /// Provisioning profile UUID was malformed.
1080    InvalidProvisioningProfileIdentifier,
1081    /// Provisioning profile public name was malformed.
1082    InvalidProvisioningProfileName,
1083    /// Provisioning profile creation/expiration ordering was invalid.
1084    InvalidProvisioningProfileDates,
1085    /// Entitlement key was empty, oversized, or malformed.
1086    InvalidEntitlementKey {
1087        /// Byte offset when available; rejected content is never echoed.
1088        index: Option<usize>,
1089    },
1090    /// Entitlement value cannot be represented safely as a bounded plist value.
1091    InvalidEntitlementValue {
1092        /// Public entitlement key, or none for the root collection.
1093        key: Option<String>,
1094        /// Structural reason; value content is never included.
1095        reason: EntitlementValueError,
1096    },
1097    /// No main application target was supplied.
1098    MissingApplicationTarget,
1099    /// More than one main application target was supplied.
1100    MultipleApplicationTargets,
1101    /// Target name appeared more than once.
1102    DuplicateTarget {
1103        /// Public target name.
1104        target: String,
1105    },
1106    /// Bundle identifier appeared on more than one target.
1107    DuplicateBundleIdentifier {
1108        /// Public bundle identifier.
1109        bundle_identifier: String,
1110    },
1111    /// Provisioning plan referred to an unknown target.
1112    UnknownProvisioningTarget {
1113        /// Public target name.
1114        target: String,
1115    },
1116    /// More than one profile matched or was assigned to a target.
1117    AmbiguousProvisioningProfile {
1118        /// Public target name.
1119        target: String,
1120    },
1121    /// Signed application or extension omitted a profile.
1122    MissingProvisioningProfile {
1123        /// Public target name.
1124        target: String,
1125    },
1126    /// Entitlement plan referred to an unknown target.
1127    UnknownEntitlementTarget {
1128        /// Public target name.
1129        target: String,
1130    },
1131    /// Target had multiple entitlement plans.
1132    DuplicateEntitlementPlan {
1133        /// Public target name.
1134        target: String,
1135    },
1136    /// Signed application or extension omitted entitlement expectations.
1137    MissingEntitlementPlan {
1138        /// Public target name.
1139        target: String,
1140    },
1141    /// Certificate has expired.
1142    CertificateExpired,
1143    /// Certificate type is not valid for the selected mode.
1144    CertificateTypeMismatch,
1145    /// Certificate does not match the referenced private key.
1146    CertificatePrivateKeyMismatch,
1147    /// Certificate and expected development team differ.
1148    CertificateTeamMismatch,
1149    /// Provisioning profile has expired.
1150    ProfileExpired,
1151    /// Provisioning profile creation time is in the future.
1152    ProfileNotYetValid,
1153    /// Provisioning profile is not an iOS-device profile.
1154    ProfilePlatformMismatch,
1155    /// Provisioning profile type does not match the mode.
1156    ProfileTypeMismatch,
1157    /// Provisioning profile and expected team differ.
1158    ProfileTeamMismatch,
1159    /// Provisioning profile and certificate are incompatible.
1160    ProfileCertificateMismatch,
1161    /// Provisioning profile and target bundle identifier differ.
1162    ProfileBundleIdentifierMismatch,
1163    /// Profile `application-identifier` does not match team and bundle.
1164    ApplicationIdentifierMismatch,
1165    /// Requested registered device is absent from the profile.
1166    DeviceNotProvisioned,
1167    /// Required extension has no separate compatible profile.
1168    ExtensionProfileRequired {
1169        /// Public target name.
1170        target: String,
1171    },
1172    /// Signed entitlements omitted one required key.
1173    MissingEntitlement {
1174        /// Public target name.
1175        target: String,
1176        /// Public entitlement key.
1177        key: String,
1178    },
1179    /// Signed/profile/requested values disagree for one entitlement.
1180    EntitlementMismatch {
1181        /// Public target name.
1182        target: String,
1183        /// Public entitlement key; values are never included.
1184        key: String,
1185    },
1186    /// Main application and extension application groups differ.
1187    AppGroupMismatch,
1188    /// `get-task-allow` does not match the signing mode.
1189    GetTaskAllowMismatch,
1190    /// Signing lifecycle attempted to skip, repeat, or resume a stage.
1191    InvalidStatusTransition {
1192        /// Current stage.
1193        from: SigningStatus,
1194        /// Requested next stage.
1195        to: SigningStatus,
1196    },
1197}
1198
1199impl fmt::Display for SigningValidationError {
1200    #[allow(clippy::too_many_lines)]
1201    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1202        match self {
1203            Self::MissingSigningReference => formatter.write_str("signing reference is required"),
1204            Self::MissingSigningPasswordReference => {
1205                formatter.write_str("signing password reference is required")
1206            }
1207            Self::MissingDevelopmentTeam => formatter.write_str("development team is required"),
1208            Self::MissingDevice => formatter.write_str("registered device is required"),
1209            Self::SigningReferencesForbiddenForUnsigned => {
1210                formatter.write_str("unsigned mode cannot carry signing references")
1211            }
1212            Self::ProvisioningUpdatesForbiddenForUnsigned => {
1213                formatter.write_str("unsigned mode cannot update provisioning")
1214            }
1215            Self::InvalidTeamIdentifier => formatter.write_str("development team is malformed"),
1216            Self::InvalidDeviceIdentifier => formatter.write_str("device identifier is malformed"),
1217            Self::InvalidBundleIdentifier => formatter.write_str("bundle identifier is malformed"),
1218            Self::InvalidTargetName => formatter.write_str("target name is malformed"),
1219            Self::InvalidCertificateName => formatter.write_str("certificate name is malformed"),
1220            Self::InvalidCertificateFingerprint => {
1221                formatter.write_str("certificate fingerprint is malformed")
1222            }
1223            Self::InvalidProvisioningProfileIdentifier => {
1224                formatter.write_str("provisioning profile identifier is malformed")
1225            }
1226            Self::InvalidProvisioningProfileName => {
1227                formatter.write_str("provisioning profile name is malformed")
1228            }
1229            Self::InvalidProvisioningProfileDates => {
1230                formatter.write_str("provisioning profile dates are inconsistent")
1231            }
1232            Self::InvalidEntitlementKey { .. } => {
1233                formatter.write_str("entitlement key is malformed")
1234            }
1235            Self::InvalidEntitlementValue { reason, .. } => {
1236                write!(formatter, "entitlement value is invalid: {reason}")
1237            }
1238            Self::MissingApplicationTarget => formatter.write_str("application target is missing"),
1239            Self::MultipleApplicationTargets => {
1240                formatter.write_str("multiple application targets were supplied")
1241            }
1242            Self::DuplicateTarget { target } => write!(formatter, "duplicate target `{target}`"),
1243            Self::DuplicateBundleIdentifier { bundle_identifier } => {
1244                write!(
1245                    formatter,
1246                    "duplicate bundle identifier `{bundle_identifier}`"
1247                )
1248            }
1249            Self::UnknownProvisioningTarget { target } => {
1250                write!(formatter, "unknown provisioning target `{target}`")
1251            }
1252            Self::AmbiguousProvisioningProfile { target } => {
1253                write!(formatter, "ambiguous provisioning profile for `{target}`")
1254            }
1255            Self::MissingProvisioningProfile { target } => {
1256                write!(formatter, "provisioning profile is missing for `{target}`")
1257            }
1258            Self::UnknownEntitlementTarget { target } => {
1259                write!(formatter, "unknown entitlement target `{target}`")
1260            }
1261            Self::DuplicateEntitlementPlan { target } => {
1262                write!(formatter, "duplicate entitlement plan for `{target}`")
1263            }
1264            Self::MissingEntitlementPlan { target } => {
1265                write!(formatter, "entitlement plan is missing for `{target}`")
1266            }
1267            Self::CertificateExpired => formatter.write_str("certificate has expired"),
1268            Self::CertificateTypeMismatch => {
1269                formatter.write_str("certificate type does not match signing mode")
1270            }
1271            Self::CertificatePrivateKeyMismatch => {
1272                formatter.write_str("certificate does not match private key")
1273            }
1274            Self::CertificateTeamMismatch => formatter.write_str("certificate team does not match"),
1275            Self::ProfileExpired => formatter.write_str("provisioning profile has expired"),
1276            Self::ProfileNotYetValid => {
1277                formatter.write_str("provisioning profile is not yet valid")
1278            }
1279            Self::ProfilePlatformMismatch => {
1280                formatter.write_str("provisioning profile is not for iOS devices")
1281            }
1282            Self::ProfileTypeMismatch => {
1283                formatter.write_str("provisioning profile type does not match")
1284            }
1285            Self::ProfileTeamMismatch => formatter.write_str("profile team does not match"),
1286            Self::ProfileCertificateMismatch => {
1287                formatter.write_str("profile certificate does not match")
1288            }
1289            Self::ProfileBundleIdentifierMismatch => {
1290                formatter.write_str("profile bundle identifier does not match")
1291            }
1292            Self::ApplicationIdentifierMismatch => {
1293                formatter.write_str("profile application identifier does not match")
1294            }
1295            Self::DeviceNotProvisioned => {
1296                formatter.write_str("device is absent from provisioning profile")
1297            }
1298            Self::ExtensionProfileRequired { target } => {
1299                write!(
1300                    formatter,
1301                    "extension `{target}` requires a separate profile"
1302                )
1303            }
1304            Self::MissingEntitlement { target, key } => {
1305                write!(
1306                    formatter,
1307                    "target `{target}` is missing entitlement `{key}`"
1308                )
1309            }
1310            Self::EntitlementMismatch { target, key } => {
1311                write!(
1312                    formatter,
1313                    "target `{target}` has incompatible entitlement `{key}`"
1314                )
1315            }
1316            Self::AppGroupMismatch => formatter.write_str("application groups do not match"),
1317            Self::GetTaskAllowMismatch => {
1318                formatter.write_str("get-task-allow does not match signing mode")
1319            }
1320            Self::InvalidStatusTransition { from, to } => {
1321                write!(
1322                    formatter,
1323                    "invalid signing transition from {from:?} to {to:?}"
1324                )
1325            }
1326        }
1327    }
1328}
1329
1330impl Error for SigningValidationError {}
1331
1332/// Structural reason an entitlement value was rejected.
1333#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1334#[serde(rename_all = "snake_case")]
1335pub enum EntitlementValueError {
1336    /// JSON null has no entitlement-plist representation.
1337    Null,
1338    /// Floating-point values are not accepted in entitlements.
1339    FloatingPoint,
1340    /// String exceeded the protocol limit.
1341    StringTooLong,
1342    /// Array or dictionary exceeded the item limit.
1343    CollectionTooLarge,
1344    /// Nested value exceeded the depth limit.
1345    TooDeep,
1346}
1347
1348impl fmt::Display for EntitlementValueError {
1349    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1350        match self {
1351            Self::Null => formatter.write_str("null is unsupported"),
1352            Self::FloatingPoint => formatter.write_str("floating point is unsupported"),
1353            Self::StringTooLong => formatter.write_str("string is too long"),
1354            Self::CollectionTooLarge => formatter.write_str("collection is too large"),
1355            Self::TooDeep => formatter.write_str("nesting is too deep"),
1356        }
1357    }
1358}
1359
1360fn validate_entitlement_key(key: &str) -> Result<(), SigningValidationError> {
1361    if key.is_empty() || key.len() > MAX_ENTITLEMENT_KEY_BYTES {
1362        return Err(SigningValidationError::InvalidEntitlementKey { index: None });
1363    }
1364    for (index, byte) in key.bytes().enumerate() {
1365        if !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) {
1366            return Err(SigningValidationError::InvalidEntitlementKey { index: Some(index) });
1367        }
1368    }
1369    Ok(())
1370}
1371
1372fn validate_entitlement_value(
1373    key: Option<&str>,
1374    value: &Value,
1375    depth: usize,
1376) -> Result<(), SigningValidationError> {
1377    if depth > MAX_ENTITLEMENT_DEPTH {
1378        return invalid_entitlement_value(key, EntitlementValueError::TooDeep);
1379    }
1380    match value {
1381        Value::Null => invalid_entitlement_value(key, EntitlementValueError::Null),
1382        Value::Number(number) if number.is_i64() || number.is_u64() => Ok(()),
1383        Value::Number(_) => invalid_entitlement_value(key, EntitlementValueError::FloatingPoint),
1384        Value::String(text) if text.len() > MAX_ENTITLEMENT_STRING_BYTES => {
1385            invalid_entitlement_value(key, EntitlementValueError::StringTooLong)
1386        }
1387        Value::Bool(_) | Value::String(_) => Ok(()),
1388        Value::Array(items) => {
1389            if items.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
1390                return invalid_entitlement_value(key, EntitlementValueError::CollectionTooLarge);
1391            }
1392            for item in items {
1393                validate_entitlement_value(key, item, depth + 1)?;
1394            }
1395            Ok(())
1396        }
1397        Value::Object(object) => {
1398            if object.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
1399                return invalid_entitlement_value(key, EntitlementValueError::CollectionTooLarge);
1400            }
1401            for (nested_key, nested_value) in object {
1402                validate_entitlement_key(nested_key)?;
1403                validate_entitlement_value(Some(nested_key), nested_value, depth + 1)?;
1404            }
1405            Ok(())
1406        }
1407    }
1408}
1409
1410fn invalid_entitlement_value(
1411    key: Option<&str>,
1412    reason: EntitlementValueError,
1413) -> Result<(), SigningValidationError> {
1414    Err(SigningValidationError::InvalidEntitlementValue {
1415        key: key.map(str::to_owned),
1416        reason,
1417    })
1418}
1419
1420fn validate_fingerprint(fingerprint: &str) -> Result<(), SigningValidationError> {
1421    if fingerprint.len() == 64 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1422        Ok(())
1423    } else {
1424        Err(SigningValidationError::InvalidCertificateFingerprint)
1425    }
1426}
1427
1428fn is_safe_label(value: &str) -> bool {
1429    !value.is_empty()
1430        && value.len() <= MAX_LABEL_BYTES
1431        && !value.chars().any(char::is_control)
1432        && value.trim() == value
1433}
1434
1435fn is_safe_identifier(value: &str) -> bool {
1436    !value.is_empty()
1437        && value.len() <= MAX_LABEL_BYTES
1438        && value
1439            .bytes()
1440            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
1441        && !value.contains("..")
1442}
1443
1444fn is_profile_identifier(value: &str) -> bool {
1445    (8..=64).contains(&value.len())
1446        && value
1447            .bytes()
1448            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1449        && !value.starts_with('-')
1450        && !value.ends_with('-')
1451}
1452
1453#[cfg(test)]
1454mod target_graph_digest_tests {
1455    use super::*;
1456
1457    fn target(name: &str, bundle_identifier: &str, kind: SigningTargetKind) -> SigningTarget {
1458        SigningTarget {
1459            name: name.to_owned(),
1460            bundle_identifier: BundleIdentifier::new(bundle_identifier).expect("bundle identifier"),
1461            kind,
1462        }
1463    }
1464
1465    #[test]
1466    fn app_only_target_graph_digest_matches_golden() {
1467        let targets = [target(
1468            "App",
1469            "com.example.app",
1470            SigningTargetKind::Application,
1471        )];
1472
1473        assert_eq!(
1474            canonical_signing_target_graph_sha256(&targets),
1475            "144f18f553557a96d6a39105b3aa0928c54bf075a5e32fef077a2eef59b37aff"
1476        );
1477    }
1478
1479    #[test]
1480    fn full_target_graph_digest_is_deterministic_and_binds_every_identity() {
1481        let targets = vec![
1482            target(
1483                "RuntimeBridge",
1484                "com.example.weather.runtime-bridge",
1485                SigningTargetKind::Framework,
1486            ),
1487            target(
1488                "WeatherWidget",
1489                "com.example.weather.widget",
1490                SigningTargetKind::Extension,
1491            ),
1492            target(
1493                "Weather",
1494                "com.example.weather",
1495                SigningTargetKind::Application,
1496            ),
1497        ];
1498        let digest = canonical_signing_target_graph_sha256(&targets);
1499        let reordered = targets.iter().rev().cloned().collect::<Vec<_>>();
1500
1501        assert_eq!(
1502            digest,
1503            "112e2935c240c978a4e356b5a0e7b3ffc0068ba249807666e8d1662bd366a691"
1504        );
1505        assert_eq!(canonical_signing_target_graph_sha256(&reordered), digest);
1506
1507        let mut app_drift = targets.clone();
1508        app_drift
1509            .iter_mut()
1510            .find(|target| target.kind == SigningTargetKind::Application)
1511            .expect("application target")
1512            .bundle_identifier =
1513            BundleIdentifier::new("com.example.weather.renamed").expect("app bundle drift");
1514        assert_ne!(canonical_signing_target_graph_sha256(&app_drift), digest);
1515
1516        let mut framework_drift = targets;
1517        framework_drift
1518            .iter_mut()
1519            .find(|target| target.kind == SigningTargetKind::Framework)
1520            .expect("framework target")
1521            .bundle_identifier =
1522            BundleIdentifier::new("com.example.weather.changed").expect("framework bundle drift");
1523        assert_ne!(
1524            canonical_signing_target_graph_sha256(&framework_drift),
1525            digest
1526        );
1527    }
1528}