smbcloud_ascapi_frontend/
profiles.rs1use serde::Serialize;
4use smbcloud_ascapi_core::Client;
5use smbcloud_ascapi_signing::prelude::*;
6use smbcloud_ascapi_signing::profile::{Profile, ProfileType};
7
8use crate::time::{is_expired, now_iso8601};
9
10pub 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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
49pub 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 pub uuid: Option<String>,
58 pub created_date: Option<String>,
59 pub expiration_date: Option<String>,
60 pub expired: Option<bool>,
64 #[serde(skip_serializing_if = "Option::is_none")]
68 pub bundle_id: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 pub bundle_identifier: Option<String>,
73}
74
75impl 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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
108pub struct DownloadedProfile {
109 pub profile: ProfileSummary,
110 pub path: String,
111 pub bytes: usize,
112}
113
114pub 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
155fn 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)]
163mod 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 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}