| 1 | //! Provisioning profile operations shared by both front ends. |
| 2 | |
| 3 | use serde::Serialize; |
| 4 | use smbcloud_ascapi_core::Client; |
| 5 | use smbcloud_ascapi_signing::prelude::*; |
| 6 | use smbcloud_ascapi_signing::profile::{Profile, ProfileType}; |
| 7 | |
| 8 | use crate::time::{is_expired, now_iso8601}; |
| 9 | |
| 10 | /// Map a CLI/MCP profile-type string onto the API enum. |
| 11 | /// |
| 12 | /// Accepts the App Store Connect spelling with either casing, so a caller |
| 13 | /// that copied `MAC_APP_STORE` out of an API response does not have to |
| 14 | /// translate it. |
| 15 | pub fn profile_type_from_str(value: &str) -> Result<ProfileType, String> { |
| 16 | match value.to_ascii_lowercase().as_str() { |
| 17 | "ios_app_development" => Ok(ProfileType::IosAppDevelopment), |
| 18 | "ios_app_store" => Ok(ProfileType::IosAppStore), |
| 19 | "ios_app_adhoc" => Ok(ProfileType::IosAppAdHoc), |
| 20 | "ios_app_inhouse" => Ok(ProfileType::IosAppInHouse), |
| 21 | "mac_app_development" => Ok(ProfileType::MacAppDevelopment), |
| 22 | "mac_app_store" => Ok(ProfileType::MacAppStore), |
| 23 | "mac_app_direct" => Ok(ProfileType::MacAppDirect), |
| 24 | "tvos_app_development" => Ok(ProfileType::TvOsAppDevelopment), |
| 25 | "tvos_app_store" => Ok(ProfileType::TvOsAppStore), |
| 26 | "tvos_app_adhoc" => Ok(ProfileType::TvOsAppAdHoc), |
| 27 | "tvos_app_inhouse" => Ok(ProfileType::TvOsAppInHouse), |
| 28 | "mac_catalyst_app_development" => Ok(ProfileType::MacCatalystAppDevelopment), |
| 29 | "mac_catalyst_app_store" => Ok(ProfileType::MacCatalystAppStore), |
| 30 | "mac_catalyst_app_direct" => Ok(ProfileType::MacCatalystAppDirect), |
| 31 | other => Err(format!( |
| 32 | "unknown profile type {other:?}; expected one of ios_app_development, \ |
| 33 | ios_app_store, ios_app_adhoc, ios_app_inhouse, mac_app_development, \ |
| 34 | mac_app_store, mac_app_direct, tvos_app_development, tvos_app_store, \ |
| 35 | tvos_app_adhoc, tvos_app_inhouse, mac_catalyst_app_development, \ |
| 36 | mac_catalyst_app_store, mac_catalyst_app_direct" |
| 37 | )), |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | /// A profile as reported to callers. |
| 42 | /// |
| 43 | /// Note what is missing: `profileContent`. It is the multi-kilobyte binary |
| 44 | /// profile, it is never needed to decide what to do next, and keeping it |
| 45 | /// out means a tool result can never carry one into a model's context. |
| 46 | /// `profiles download` writes it to a path instead, the same bargain |
| 47 | /// [`crate::certificates::IssuedCertificate`] makes for key material. |
| 48 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 49 | pub struct ProfileSummary { |
| 50 | pub id: String, |
| 51 | pub name: Option<String>, |
| 52 | pub profile_type: Option<String>, |
| 53 | pub profile_state: Option<String>, |
| 54 | pub platform: Option<String>, |
| 55 | /// The UUID inside the profile, which is what `codesign` and Xcode |
| 56 | /// diagnostics quote. Different from `id`. |
| 57 | pub uuid: Option<String>, |
| 58 | pub created_date: Option<String>, |
| 59 | pub expiration_date: Option<String>, |
| 60 | /// Computed locally: App Store Connect offers no filter for it, and a |
| 61 | /// profile can also be unusable while unexpired if its certificate was |
| 62 | /// revoked — check `profile_state` for that case. |
| 63 | pub expired: Option<bool>, |
| 64 | /// The bundle ID's resource id, set only when the caller asked for it |
| 65 | /// (`profiles list --with-bundle-id`), since it costs a request per |
| 66 | /// profile. |
| 67 | #[serde(skip_serializing_if = "Option::is_none")] |
| 68 | pub bundle_id: Option<String>, |
| 69 | /// The bundle identifier itself, e.g. `ai.splitfire.SplitfireAI`. Set |
| 70 | /// alongside `bundle_id`. |
| 71 | #[serde(skip_serializing_if = "Option::is_none")] |
| 72 | pub bundle_identifier: Option<String>, |
| 73 | } |
| 74 | |
| 75 | impl ProfileSummary { |
| 76 | pub fn from_resource(profile: &Profile, now: &str) -> Self { |
| 77 | let expiration_date = profile.attributes.expiration_date.clone(); |
| 78 | let expired = expiration_date |
| 79 | .as_ref() |
| 80 | .map(|at| is_expired(at.as_str(), now)); |
| 81 | |
| 82 | Self { |
| 83 | id: profile.id.clone(), |
| 84 | name: profile.attributes.name.clone(), |
| 85 | profile_type: profile |
| 86 | .attributes |
| 87 | .profile_type |
| 88 | .map(|t| t.as_api_str().to_string()), |
| 89 | profile_state: profile |
| 90 | .attributes |
| 91 | .profile_state |
| 92 | .map(|s| s.as_api_str().to_string()), |
| 93 | platform: profile.attributes.platform.clone(), |
| 94 | uuid: profile.attributes.uuid.clone(), |
| 95 | created_date: profile.attributes.created_date.clone(), |
| 96 | expiration_date, |
| 97 | expired, |
| 98 | bundle_id: None, |
| 99 | bundle_identifier: None, |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// The result of writing a profile to disk. |
| 105 | /// |
| 106 | /// Carries the path, never the profile bytes. |
| 107 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 108 | pub struct DownloadedProfile { |
| 109 | pub profile: ProfileSummary, |
| 110 | pub path: String, |
| 111 | pub bytes: usize, |
| 112 | } |
| 113 | |
| 114 | /// Fetch a profile's content and write it to `path`. |
| 115 | /// |
| 116 | /// Shared by the CLI and the MCP tool so the "ask for the field |
| 117 | /// explicitly" detail has one implementation: a plain resource fetch omits |
| 118 | /// `profileContent`, and the absence is indistinguishable from a profile |
| 119 | /// that genuinely has none. |
| 120 | pub async fn download_profile( |
| 121 | client: &Client, |
| 122 | id: &str, |
| 123 | path: &std::path::Path, |
| 124 | ) -> Result<DownloadedProfile, String> { |
| 125 | let profile = client |
| 126 | .get_profile_content(id) |
| 127 | .await |
| 128 | .map_err(|error| error.to_string())?; |
| 129 | |
| 130 | let content = profile |
| 131 | .attributes |
| 132 | .profile_content |
| 133 | .as_deref() |
| 134 | .ok_or_else(|| { |
| 135 | format!("profile {id} came back without profileContent, so there is nothing to write") |
| 136 | })?; |
| 137 | |
| 138 | let bytes = base64_decode(content)?; |
| 139 | |
| 140 | if let Some(parent) = path.parent() { |
| 141 | if !parent.as_os_str().is_empty() { |
| 142 | std::fs::create_dir_all(parent) |
| 143 | .map_err(|error| format!("creating {}: {error}", parent.display()))?; |
| 144 | } |
| 145 | } |
| 146 | std::fs::write(path, &bytes).map_err(|error| format!("writing {}: {error}", path.display()))?; |
| 147 | |
| 148 | Ok(DownloadedProfile { |
| 149 | profile: ProfileSummary::from_resource(&profile, &now_iso8601()), |
| 150 | path: path.display().to_string(), |
| 151 | bytes: bytes.len(), |
| 152 | }) |
| 153 | } |
| 154 | |
| 155 | fn base64_decode(input: &str) -> Result<Vec<u8>, String> { |
| 156 | use base64::Engine; |
| 157 | base64::engine::general_purpose::STANDARD |
| 158 | .decode(input.trim()) |
| 159 | .map_err(|error| format!("decoding the profile Apple returned: {error}")) |
| 160 | } |
| 161 | |
| 162 | #[cfg(test)] |
| 163 | mod tests { |
| 164 | use super::*; |
| 165 | |
| 166 | #[test] |
| 167 | fn profile_type_accepts_the_api_spelling_in_either_casing() { |
| 168 | assert_eq!( |
| 169 | profile_type_from_str("MAC_APP_STORE").unwrap(), |
| 170 | ProfileType::MacAppStore |
| 171 | ); |
| 172 | assert_eq!( |
| 173 | profile_type_from_str("mac_app_store").unwrap(), |
| 174 | ProfileType::MacAppStore |
| 175 | ); |
| 176 | } |
| 177 | |
| 178 | #[test] |
| 179 | fn errors_name_the_accepted_values() { |
| 180 | // A model that guessed wrong has to correct itself from the error |
| 181 | // alone, without another round trip. |
| 182 | let error = profile_type_from_str("mac_appstore").unwrap_err(); |
| 183 | assert!(error.contains("mac_app_store"), "unhelpful error: {error}"); |
| 184 | assert!(error.contains("ios_app_store"), "unhelpful error: {error}"); |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn summary_never_serializes_the_profile_body() { |
| 189 | let summary = ProfileSummary { |
| 190 | id: "ABC123".to_string(), |
| 191 | name: Some("smbCloud Browser MAS Distribution Profile".to_string()), |
| 192 | profile_type: Some("MAC_APP_STORE".to_string()), |
| 193 | profile_state: Some("ACTIVE".to_string()), |
| 194 | platform: Some("MAC_OS".to_string()), |
| 195 | uuid: Some("1a2b3c".to_string()), |
| 196 | created_date: Some("2026-08-26T20:03:07".to_string()), |
| 197 | expiration_date: Some("2027-08-06T19:49:55".to_string()), |
| 198 | expired: Some(false), |
| 199 | bundle_id: None, |
| 200 | bundle_identifier: None, |
| 201 | }; |
| 202 | let json = serde_json::to_string(&summary).expect("serializes"); |
| 203 | assert!(!json.contains("profileContent")); |
| 204 | assert!(!json.contains("profile_content")); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn downloaded_profile_reports_a_path_not_bytes() { |
| 209 | let downloaded = DownloadedProfile { |
| 210 | profile: ProfileSummary { |
| 211 | id: "ABC123".to_string(), |
| 212 | name: None, |
| 213 | profile_type: None, |
| 214 | profile_state: None, |
| 215 | platform: None, |
| 216 | uuid: None, |
| 217 | created_date: None, |
| 218 | expiration_date: None, |
| 219 | expired: None, |
| 220 | bundle_id: None, |
| 221 | bundle_identifier: None, |
| 222 | }, |
| 223 | path: "/tmp/out/app.provisionprofile".to_string(), |
| 224 | bytes: 12577, |
| 225 | }; |
| 226 | let json = serde_json::to_string(&downloaded).expect("serializes"); |
| 227 | assert!(json.contains("app.provisionprofile")); |
| 228 | assert!(!json.contains("profileContent")); |
| 229 | } |
| 230 | } |