Skip to main content

smbcloud_ascapi_frontend/
profiles.rs

1//! Provisioning profile operations shared by both front ends.
2
3use 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
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.
15pub 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)]
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    /// 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}
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/// The result of writing a profile to disk.
94///
95/// Carries the path, never the profile bytes.
96#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
97pub struct DownloadedProfile {
98    pub profile: ProfileSummary,
99    pub path: String,
100    pub bytes: usize,
101}
102
103/// Fetch a profile's content and write it to `path`.
104///
105/// Shared by the CLI and the MCP tool so the "ask for the field
106/// explicitly" detail has one implementation: a plain resource fetch omits
107/// `profileContent`, and the absence is indistinguishable from a profile
108/// that genuinely has none.
109pub 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        // A model that guessed wrong has to correct itself from the error
170        // alone, without another round trip.
171        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}