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}
65
66impl ProfileSummary {
67 pub fn from_resource(profile: &Profile, now: &str) -> Self {
68 let expiration_date = profile.attributes.expiration_date.clone();
69 let expired = expiration_date
70 .as_ref()
71 .map(|at| is_expired(at.as_str(), now));
72
73 Self {
74 id: profile.id.clone(),
75 name: profile.attributes.name.clone(),
76 profile_type: profile
77 .attributes
78 .profile_type
79 .map(|t| t.as_api_str().to_string()),
80 profile_state: profile
81 .attributes
82 .profile_state
83 .map(|s| s.as_api_str().to_string()),
84 platform: profile.attributes.platform.clone(),
85 uuid: profile.attributes.uuid.clone(),
86 created_date: profile.attributes.created_date.clone(),
87 expiration_date,
88 expired,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
97pub struct DownloadedProfile {
98 pub profile: ProfileSummary,
99 pub path: String,
100 pub bytes: usize,
101}
102
103pub async fn download_profile(
110 client: &Client,
111 id: &str,
112 path: &std::path::Path,
113) -> Result<DownloadedProfile, String> {
114 let profile = client
115 .get_profile_content(id)
116 .await
117 .map_err(|error| error.to_string())?;
118
119 let content = profile
120 .attributes
121 .profile_content
122 .as_deref()
123 .ok_or_else(|| {
124 format!("profile {id} came back without profileContent, so there is nothing to write")
125 })?;
126
127 let bytes = base64_decode(content)?;
128
129 if let Some(parent) = path.parent() {
130 if !parent.as_os_str().is_empty() {
131 std::fs::create_dir_all(parent)
132 .map_err(|error| format!("creating {}: {error}", parent.display()))?;
133 }
134 }
135 std::fs::write(path, &bytes).map_err(|error| format!("writing {}: {error}", path.display()))?;
136
137 Ok(DownloadedProfile {
138 profile: ProfileSummary::from_resource(&profile, &now_iso8601()),
139 path: path.display().to_string(),
140 bytes: bytes.len(),
141 })
142}
143
144fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
145 use base64::Engine;
146 base64::engine::general_purpose::STANDARD
147 .decode(input.trim())
148 .map_err(|error| format!("decoding the profile Apple returned: {error}"))
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn profile_type_accepts_the_api_spelling_in_either_casing() {
157 assert_eq!(
158 profile_type_from_str("MAC_APP_STORE").unwrap(),
159 ProfileType::MacAppStore
160 );
161 assert_eq!(
162 profile_type_from_str("mac_app_store").unwrap(),
163 ProfileType::MacAppStore
164 );
165 }
166
167 #[test]
168 fn errors_name_the_accepted_values() {
169 let error = profile_type_from_str("mac_appstore").unwrap_err();
172 assert!(error.contains("mac_app_store"), "unhelpful error: {error}");
173 assert!(error.contains("ios_app_store"), "unhelpful error: {error}");
174 }
175
176 #[test]
177 fn summary_never_serializes_the_profile_body() {
178 let summary = ProfileSummary {
179 id: "ABC123".to_string(),
180 name: Some("smbCloud Browser MAS Distribution Profile".to_string()),
181 profile_type: Some("MAC_APP_STORE".to_string()),
182 profile_state: Some("ACTIVE".to_string()),
183 platform: Some("MAC_OS".to_string()),
184 uuid: Some("1a2b3c".to_string()),
185 created_date: Some("2026-08-26T20:03:07".to_string()),
186 expiration_date: Some("2027-08-06T19:49:55".to_string()),
187 expired: Some(false),
188 };
189 let json = serde_json::to_string(&summary).expect("serializes");
190 assert!(!json.contains("profileContent"));
191 assert!(!json.contains("profile_content"));
192 }
193
194 #[test]
195 fn downloaded_profile_reports_a_path_not_bytes() {
196 let downloaded = DownloadedProfile {
197 profile: ProfileSummary {
198 id: "ABC123".to_string(),
199 name: None,
200 profile_type: None,
201 profile_state: None,
202 platform: None,
203 uuid: None,
204 created_date: None,
205 expiration_date: None,
206 expired: None,
207 },
208 path: "/tmp/out/app.provisionprofile".to_string(),
209 bytes: 12577,
210 };
211 let json = serde_json::to_string(&downloaded).expect("serializes");
212 assert!(json.contains("app.provisionprofile"));
213 assert!(!json.contains("profileContent"));
214 }
215}