main
rs 230 lines 8.68 KB
Raw
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 }