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    /// 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
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/// The result of writing a profile to disk.
105///
106/// Carries the path, never the profile bytes.
107#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
108pub 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.
120pub 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        // 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}