From 78d9effb2ec5817e6640c899332bafd45283b39b Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:22:25 +0200 Subject: [PATCH] Add provisioning profiles to the signing crate and the CLI Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com> --- README.md | 2 +- crates/aso/src/bundle_id.rs | 60 +++++ crates/cli/src/main.rs | 225 ++++++++++++++++ crates/core/src/jsonapi.rs | 13 + crates/frontend/src/enums.rs | 4 + crates/frontend/src/lib.rs | 5 + crates/frontend/src/profiles.rs | 215 +++++++++++++++ crates/signing/src/lib.rs | 9 +- crates/signing/src/profile.rs | 455 ++++++++++++++++++++++++++++++++ 9 files changed, 984 insertions(+), 4 deletions(-) create mode 100644 crates/frontend/src/profiles.rs create mode 100644 crates/signing/src/profile.rs diff --git a/README.md b/README.md index 7952adf..726703a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ from the other: | --- | --- | | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types | | `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots | -| `smbcloud-ascapi-signing` | Code signing: certificates, plus local RSA key pair and CSR generation | +| `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation | | `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction | | `smbcloud-ascapi-mcp` | The MCP contract and stdio server | | `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` | diff --git a/crates/aso/src/bundle_id.rs b/crates/aso/src/bundle_id.rs index b096233..60ae5d8 100644 --- a/crates/aso/src/bundle_id.rs +++ b/crates/aso/src/bundle_id.rs @@ -22,6 +22,16 @@ pub enum BundleIdPlatform { MacOs, #[serde(rename = "UNIVERSAL")] Universal, + /// Not an app platform: the identifier kind Apple assigns to Services + /// IDs (Sign in with Apple, push-only identifiers, and similar). + /// + /// Present because `GET /v1/bundleIds` returns every identifier the + /// team owns, mixed together. Without this variant, one Services ID + /// anywhere in the account fails the deserialization of the whole + /// list, so listing breaks for reasons that have nothing to do with + /// the app being looked up. + #[serde(rename = "SERVICES")] + Services, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,6 +79,11 @@ impl BundleIdsApi for Client { if let Some(identifier) = filter_identifier { query.push(("filter[identifier]", identifier)); } + // Apple's default page size is 20. Without this an unfiltered list + // silently stops at the first 20 identifiers, which reads as "that + // bundle ID is not registered" for anything further down. + query.push(("limit", "200")); + let doc: ListDocument = self .request(Method::GET, "/v1/bundleIds", &query, None::<&()>) .await?; @@ -89,3 +104,48 @@ impl BundleIdsApi for Client { Ok(doc.data) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_services_identifier_does_not_break_the_list() { + // Regression: `GET /v1/bundleIds` returns every identifier a team + // owns, and a single Services ID used to fail the whole response's + // deserialization — so `bundle-ids list` reported nothing at all + // for accounts that have one. + let body = serde_json::json!({ + "data": [ + { + "id": "AAA", + "type": "bundleIds", + "attributes": { + "name": "An app", + "identifier": "xyz.example.app", + "platform": "UNIVERSAL", + "seedId": "TEAMID" + } + }, + { + "id": "BBB", + "type": "bundleIds", + "attributes": { + "name": "Sign in with Apple", + "identifier": "xyz.example.service", + "platform": "SERVICES", + "seedId": "TEAMID" + } + } + ] + }); + + let doc: ListDocument = + serde_json::from_value(body).expect("a Services identifier must not fail the list"); + assert_eq!(doc.data.len(), 2); + assert_eq!( + doc.data[1].attributes.platform, + Some(BundleIdPlatform::Services) + ); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 2a227c9..057b200 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,6 +17,9 @@ use smbcloud_ascapi_core::{ApiKey, Client}; use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType}; use smbcloud_ascapi_signing::csr::generate_certificate_request; use smbcloud_ascapi_signing::prelude::*; +use smbcloud_ascapi_signing::profile::{ + ProfileCreateAttributes, ProfileCreateRelationships, ProfileType, +}; use std::path::PathBuf; /// Add/update App Store Connect app metadata (apps, app infos, app store @@ -103,6 +106,131 @@ enum Command { #[command(subcommand)] command: CertificatesCommand, }, + /// Provisioning profiles: what a bundle ID is allowed to claim, and + /// which certificates may sign it. Create a new one after changing an + /// App ID's capabilities — existing profiles never pick the change up. + Profiles { + #[command(subcommand)] + command: ProfilesCommand, + }, +} + +#[derive(Subcommand)] +enum ProfilesCommand { + /// List the team's profiles, newest expiry last. + /// + /// `profileContent` is deliberately not shown; use `download` for the + /// file itself. + List { + /// Only show profiles with this exact name. Apple allows + /// duplicates, so this can still match several. + #[arg(long)] + name: Option, + /// Only show one type. + #[arg(long, value_enum)] + r#type: Option, + }, + /// Create a profile over a bundle ID and one or more certificates. + /// + /// This is the only way to pick up a capability added to the App ID + /// after an existing profile was made: profiles are snapshots, and + /// Apple keeps serving the stale one as ACTIVE until its certificate + /// dies. Apple does not treat an equivalent profile as a duplicate, so + /// repeated runs accumulate profiles rather than replacing one. + Create { + /// Label shown in the developer portal. + #[arg(long)] + name: String, + #[arg(long, value_enum)] + r#type: CliProfileType, + /// Resource id of the bundle ID (from `bundle-ids list`), not the + /// reverse-DNS identifier. + #[arg(long)] + bundle_id: String, + /// Certificate resource id (from `certificates list`). Repeat for + /// several. + #[arg(long = "certificate", required = true)] + certificates: Vec, + /// Device resource id. Only meaningful for development and ad hoc + /// types; ignored for store profiles, which Apple rejects if sent + /// devices. + #[arg(long = "device")] + devices: Vec, + }, + /// Write a profile's bytes to a file. + /// + /// The output is the binary `.provisionprofile`/`.mobileprovision` a + /// bundle embeds, not JSON. + Download { + id: String, + /// Where to write the profile. + #[arg(long)] + output: PathBuf, + }, + /// Delete a profile. + /// + /// Narrower than revoking a certificate: builds already signed with it + /// keep working, but new signing against it fails. + Delete { + id: String, + /// Required. Pass the profile id again to confirm. + #[arg(long)] + confirm: String, + }, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum CliProfileType { + /// iOS development — device-scoped. + IosAppDevelopment, + /// iOS App Store distribution. + IosAppStore, + /// iOS ad hoc — device-scoped. + IosAppAdhoc, + /// iOS in-house (Enterprise) distribution. + IosAppInhouse, + /// macOS development — device-scoped. + MacAppDevelopment, + /// Mac App Store distribution — the embedded.provisionprofile a MAS + /// submission carries. + MacAppStore, + /// Developer ID — distribution outside the App Store. + MacAppDirect, + /// tvOS development — device-scoped. + TvosAppDevelopment, + /// tvOS App Store distribution. + TvosAppStore, + /// tvOS ad hoc — device-scoped. + TvosAppAdhoc, + /// tvOS in-house (Enterprise) distribution. + TvosAppInhouse, + /// Mac Catalyst development — device-scoped. + MacCatalystAppDevelopment, + /// Mac Catalyst App Store distribution. + MacCatalystAppStore, + /// Mac Catalyst Developer ID distribution. + MacCatalystAppDirect, +} + +impl From for ProfileType { + fn from(value: CliProfileType) -> Self { + match value { + CliProfileType::IosAppDevelopment => ProfileType::IosAppDevelopment, + CliProfileType::IosAppStore => ProfileType::IosAppStore, + CliProfileType::IosAppAdhoc => ProfileType::IosAppAdHoc, + CliProfileType::IosAppInhouse => ProfileType::IosAppInHouse, + CliProfileType::MacAppDevelopment => ProfileType::MacAppDevelopment, + CliProfileType::MacAppStore => ProfileType::MacAppStore, + CliProfileType::MacAppDirect => ProfileType::MacAppDirect, + CliProfileType::TvosAppDevelopment => ProfileType::TvOsAppDevelopment, + CliProfileType::TvosAppStore => ProfileType::TvOsAppStore, + CliProfileType::TvosAppAdhoc => ProfileType::TvOsAppAdHoc, + CliProfileType::TvosAppInhouse => ProfileType::TvOsAppInHouse, + CliProfileType::MacCatalystAppDevelopment => ProfileType::MacCatalystAppDevelopment, + CliProfileType::MacCatalystAppStore => ProfileType::MacCatalystAppStore, + CliProfileType::MacCatalystAppDirect => ProfileType::MacCatalystAppDirect, + } + } } #[derive(Subcommand)] @@ -502,6 +630,7 @@ async fn main() -> Result<()> { run_app_screenshots(&client, command, cli.dry_run).await } Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await, + Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await, } } @@ -1007,6 +1136,102 @@ async fn run_certificates( } } +async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool) -> Result<()> { + use smbcloud_ascapi_frontend::profiles::{download_profile, ProfileSummary}; + use smbcloud_ascapi_frontend::time::now_iso8601; + + match command { + ProfilesCommand::List { name, r#type } => { + let mut profiles = client + .list_profiles(name.as_deref(), r#type.map(Into::into)) + .await?; + profiles.sort_by(|a, b| { + a.attributes + .expiration_date + .cmp(&b.attributes.expiration_date) + }); + let now = now_iso8601(); + let summaries: Vec<_> = profiles + .iter() + .map(|profile| ProfileSummary::from_resource(profile, &now)) + .collect(); + print_json(&summaries) + } + + ProfilesCommand::Create { + name, + r#type, + bundle_id, + certificates, + devices, + } => { + let profile_type: ProfileType = r#type.into(); + + // Say so rather than dropping them silently: a caller who + // passed devices to a store profile has misunderstood + // something, and the created profile will not be what they + // expect. + if !devices.is_empty() && !profile_type.takes_devices() { + eprintln!( + "note: {} does not take devices; ignoring the {} passed", + profile_type.as_api_str(), + devices.len() + ); + } + + let relationships = + ProfileCreateRelationships::new(profile_type, &bundle_id, &certificates, &devices); + let attributes = ProfileCreateAttributes { + name: name.clone(), + profile_type, + }; + + if dry_run { + print_json(&serde_json::json!({ + "data": { + "type": "profiles", + "attributes": attributes, + "relationships": relationships, + } + }))?; + return Ok(()); + } + + let profile = client.create_profile(attributes, relationships).await?; + print_json(&ProfileSummary::from_resource(&profile, &now_iso8601())) + } + + ProfilesCommand::Download { id, output } => { + if dry_run { + println!( + "# dry run — would write profile {id} to {}", + output.display() + ); + return Ok(()); + } + let downloaded = download_profile(client, &id, &output) + .await + .map_err(anyhow::Error::msg)?; + print_json(&downloaded) + } + + ProfilesCommand::Delete { id, confirm } => { + if confirm != id { + anyhow::bail!( + "refusing to delete: pass --confirm {id} to acknowledge that signing \ + against this profile stops working until a replacement exists" + ); + } + if dry_run { + println!("# dry run — would delete profile {id}"); + return Ok(()); + } + client.delete_profile(&id).await?; + print_json(&serde_json::json!({ "deleted": id })) + } + } +} + /// Write a private key with owner-only permissions, set at creation time /// rather than chmod'ed afterwards so the key is never briefly readable. fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> { diff --git a/crates/core/src/jsonapi.rs b/crates/core/src/jsonapi.rs index 6a5277a..5476765 100644 --- a/crates/core/src/jsonapi.rs +++ b/crates/core/src/jsonapi.rs @@ -48,6 +48,19 @@ pub struct ToOne { pub data: ResourceId, } +/// A to-many relationship payload, e.g. `relationships.certificates` on a +/// `POST /v1/profiles` body. +/// +/// Apple distinguishes an empty array from an absent relationship: sending +/// `{"data": []}` for `devices` means "no devices", which a development +/// profile rejects, while omitting the key entirely is what an App Store +/// profile wants. Callers that mean "absent" should pass `None` for the +/// whole field rather than an empty `ToMany`. +#[derive(Debug, Clone, Serialize)] +pub struct ToMany { + pub data: Vec, +} + #[derive(Debug, Clone, Serialize)] pub struct CreateBody { pub data: CreateData, diff --git a/crates/frontend/src/enums.rs b/crates/frontend/src/enums.rs index dfa8245..1f21529 100644 --- a/crates/frontend/src/enums.rs +++ b/crates/frontend/src/enums.rs @@ -29,6 +29,10 @@ pub fn bundle_id_platform_from_str(value: &str) -> Result Ok(BundleIdPlatform::Ios), "mac_os" | "macos" => Ok(BundleIdPlatform::MacOs), "universal" => Ok(BundleIdPlatform::Universal), + // Accepted so a round trip through this mapper does not lose a + // value the API itself returns. Registering one is a different + // flow than an app bundle ID, and Apple rejects it here. + "services" => Ok(BundleIdPlatform::Services), other => Err(format!( "unknown bundle ID platform {other:?}; expected one of ios, mac_os, universal" )), diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index d32de5e..345b944 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -16,10 +16,14 @@ //! refactor away from being bypassed. //! - **No certificate bodies either.** They are large, never needed to //! decide what to do next, and would otherwise land in a transcript. +//! [`profiles::ProfileSummary`] applies the same rule to +//! `profileContent`, and [`profiles::download_profile`] returns the path +//! it wrote instead. pub mod certificates; pub mod enums; pub mod env; +pub mod profiles; pub mod time; pub use certificates::{ @@ -27,4 +31,5 @@ pub use certificates::{ }; pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str}; pub use env::api_key_from_env; +pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary}; pub use time::{is_expired, now_iso8601}; diff --git a/crates/frontend/src/profiles.rs b/crates/frontend/src/profiles.rs new file mode 100644 index 0000000..2a4f2e9 --- /dev/null +++ b/crates/frontend/src/profiles.rs @@ -0,0 +1,215 @@ +//! Provisioning profile operations shared by both front ends. + +use serde::Serialize; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_signing::prelude::*; +use smbcloud_ascapi_signing::profile::{Profile, ProfileType}; + +use crate::time::{is_expired, now_iso8601}; + +/// Map a CLI/MCP profile-type string onto the API enum. +/// +/// Accepts the App Store Connect spelling with either casing, so a caller +/// that copied `MAC_APP_STORE` out of an API response does not have to +/// translate it. +pub fn profile_type_from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "ios_app_development" => Ok(ProfileType::IosAppDevelopment), + "ios_app_store" => Ok(ProfileType::IosAppStore), + "ios_app_adhoc" => Ok(ProfileType::IosAppAdHoc), + "ios_app_inhouse" => Ok(ProfileType::IosAppInHouse), + "mac_app_development" => Ok(ProfileType::MacAppDevelopment), + "mac_app_store" => Ok(ProfileType::MacAppStore), + "mac_app_direct" => Ok(ProfileType::MacAppDirect), + "tvos_app_development" => Ok(ProfileType::TvOsAppDevelopment), + "tvos_app_store" => Ok(ProfileType::TvOsAppStore), + "tvos_app_adhoc" => Ok(ProfileType::TvOsAppAdHoc), + "tvos_app_inhouse" => Ok(ProfileType::TvOsAppInHouse), + "mac_catalyst_app_development" => Ok(ProfileType::MacCatalystAppDevelopment), + "mac_catalyst_app_store" => Ok(ProfileType::MacCatalystAppStore), + "mac_catalyst_app_direct" => Ok(ProfileType::MacCatalystAppDirect), + other => Err(format!( + "unknown profile type {other:?}; expected one of ios_app_development, \ + ios_app_store, ios_app_adhoc, ios_app_inhouse, mac_app_development, \ + mac_app_store, mac_app_direct, tvos_app_development, tvos_app_store, \ + tvos_app_adhoc, tvos_app_inhouse, mac_catalyst_app_development, \ + mac_catalyst_app_store, mac_catalyst_app_direct" + )), + } +} + +/// A profile as reported to callers. +/// +/// Note what is missing: `profileContent`. It is the multi-kilobyte binary +/// profile, it is never needed to decide what to do next, and keeping it +/// out means a tool result can never carry one into a model's context. +/// `profiles download` writes it to a path instead, the same bargain +/// [`crate::certificates::IssuedCertificate`] makes for key material. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct ProfileSummary { + pub id: String, + pub name: Option, + pub profile_type: Option, + pub profile_state: Option, + pub platform: Option, + /// The UUID inside the profile, which is what `codesign` and Xcode + /// diagnostics quote. Different from `id`. + pub uuid: Option, + pub created_date: Option, + pub expiration_date: Option, + /// Computed locally: App Store Connect offers no filter for it, and a + /// profile can also be unusable while unexpired if its certificate was + /// revoked — check `profile_state` for that case. + pub expired: Option, +} + +impl ProfileSummary { + pub fn from_resource(profile: &Profile, now: &str) -> Self { + let expiration_date = profile.attributes.expiration_date.clone(); + let expired = expiration_date + .as_ref() + .map(|at| is_expired(at.as_str(), now)); + + Self { + id: profile.id.clone(), + name: profile.attributes.name.clone(), + profile_type: profile + .attributes + .profile_type + .map(|t| t.as_api_str().to_string()), + profile_state: profile + .attributes + .profile_state + .map(|s| s.as_api_str().to_string()), + platform: profile.attributes.platform.clone(), + uuid: profile.attributes.uuid.clone(), + created_date: profile.attributes.created_date.clone(), + expiration_date, + expired, + } + } +} + +/// The result of writing a profile to disk. +/// +/// Carries the path, never the profile bytes. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct DownloadedProfile { + pub profile: ProfileSummary, + pub path: String, + pub bytes: usize, +} + +/// Fetch a profile's content and write it to `path`. +/// +/// Shared by the CLI and the MCP tool so the "ask for the field +/// explicitly" detail has one implementation: a plain resource fetch omits +/// `profileContent`, and the absence is indistinguishable from a profile +/// that genuinely has none. +pub async fn download_profile( + client: &Client, + id: &str, + path: &std::path::Path, +) -> Result { + let profile = client + .get_profile_content(id) + .await + .map_err(|error| error.to_string())?; + + let content = profile + .attributes + .profile_content + .as_deref() + .ok_or_else(|| { + format!("profile {id} came back without profileContent, so there is nothing to write") + })?; + + let bytes = base64_decode(content)?; + + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("creating {}: {error}", parent.display()))?; + } + } + std::fs::write(path, &bytes).map_err(|error| format!("writing {}: {error}", path.display()))?; + + Ok(DownloadedProfile { + profile: ProfileSummary::from_resource(&profile, &now_iso8601()), + path: path.display().to_string(), + bytes: bytes.len(), + }) +} + +fn base64_decode(input: &str) -> Result, String> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(input.trim()) + .map_err(|error| format!("decoding the profile Apple returned: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_type_accepts_the_api_spelling_in_either_casing() { + assert_eq!( + profile_type_from_str("MAC_APP_STORE").unwrap(), + ProfileType::MacAppStore + ); + assert_eq!( + profile_type_from_str("mac_app_store").unwrap(), + ProfileType::MacAppStore + ); + } + + #[test] + fn errors_name_the_accepted_values() { + // A model that guessed wrong has to correct itself from the error + // alone, without another round trip. + let error = profile_type_from_str("mac_appstore").unwrap_err(); + assert!(error.contains("mac_app_store"), "unhelpful error: {error}"); + assert!(error.contains("ios_app_store"), "unhelpful error: {error}"); + } + + #[test] + fn summary_never_serializes_the_profile_body() { + let summary = ProfileSummary { + id: "ABC123".to_string(), + name: Some("smbCloud Browser MAS Distribution Profile".to_string()), + profile_type: Some("MAC_APP_STORE".to_string()), + profile_state: Some("ACTIVE".to_string()), + platform: Some("MAC_OS".to_string()), + uuid: Some("1a2b3c".to_string()), + created_date: Some("2026-08-26T20:03:07".to_string()), + expiration_date: Some("2027-08-06T19:49:55".to_string()), + expired: Some(false), + }; + let json = serde_json::to_string(&summary).expect("serializes"); + assert!(!json.contains("profileContent")); + assert!(!json.contains("profile_content")); + } + + #[test] + fn downloaded_profile_reports_a_path_not_bytes() { + let downloaded = DownloadedProfile { + profile: ProfileSummary { + id: "ABC123".to_string(), + name: None, + profile_type: None, + profile_state: None, + platform: None, + uuid: None, + created_date: None, + expiration_date: None, + expired: None, + }, + path: "/tmp/out/app.provisionprofile".to_string(), + bytes: 12577, + }; + let json = serde_json::to_string(&downloaded).expect("serializes"); + assert!(json.contains("app.provisionprofile")); + assert!(!json.contains("profileContent")); + } +} diff --git a/crates/signing/src/lib.rs b/crates/signing/src/lib.rs index 032f809..1c7dcc1 100644 --- a/crates/signing/src/lib.rs +++ b/crates/signing/src/lib.rs @@ -1,8 +1,9 @@ //! Apple code signing through App Store Connect. //! -//! Two halves that only make sense together: [`csr`] generates an RSA key -//! pair and a signing request locally, and [`certificate`] asks Apple to -//! certify it. +//! Three parts. [`csr`] generates an RSA key pair and a signing request +//! locally, [`certificate`] asks Apple to certify it, and [`profile`] binds +//! a certificate to an App ID so signing knows which entitlements are +//! allowed. //! //! The division of labour is the whole point. Apple never receives, and //! never returns, a private key. You keep it; they vouch for its public @@ -16,7 +17,9 @@ pub mod certificate; pub mod csr; +pub mod profile; pub mod prelude { pub use crate::certificate::CertificatesApi; + pub use crate::profile::ProfilesApi; } diff --git a/crates/signing/src/profile.rs b/crates/signing/src/profile.rs new file mode 100644 index 0000000..6c798ea --- /dev/null +++ b/crates/signing/src/profile.rs @@ -0,0 +1,455 @@ +//! Provisioning profiles: `/v1/profiles`. +//! +//! A profile is the document that ties three things together and says +//! Apple approves of the combination: *which app* (a bundle ID), *signed by +//! whom* (one or more certificates), and *allowed to run where* (a device +//! list, for the profile types that use one). Code signing consults it to +//! decide whether the entitlements a binary claims are ones the App ID is +//! actually authorised for. +//! +//! That last part is why this module exists alongside [`crate::certificate`] +//! rather than in the metadata crate. A certificate proves identity and +//! nothing else; it says nothing about entitlements. Enabling a capability +//! on an App ID does not reach an already-issued profile either — profiles +//! are snapshots, so a capability added today is invisible to a profile +//! created yesterday. The fix is always to create a new one, which is what +//! [`ProfilesApi::create_profile`] is for. +//! +//! Two operational notes that are easy to learn the expensive way: +//! +//! - **Apple will happily hand back a stale profile.** A profile stays +//! `ACTIVE` until its certificate expires or is revoked, so a capability +//! change leaves an outdated-but-valid profile in place. Nothing in the +//! API marks it as superseded. Recreate rather than re-download. +//! - **`profileContent` is the file.** It is base64 of the binary +//! `.mobileprovision`/`.provisionprofile` that goes into the bundle; +//! there is no separate download endpoint. +//! +//! App Groups are the common reason a profile needs regenerating and are +//! also the one thing this API cannot help with: there is no `appGroups` +//! resource, so assigning a group to an App ID is Developer Portal web UI +//! work. Once assigned, a new profile picks it up. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToMany, ToOne, +}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; + +pub const RESOURCE_TYPE: &str = "profiles"; + +/// The kind of profile, which fixes both the distribution channel and +/// whether a device list applies. +/// +/// The `*Development` and `*AdHoc` variants are device-scoped: a build +/// signed with one runs only on hardware in the profile's device list. +/// The `*AppStore` and `*InHouse` variants are not, and Apple rejects a +/// create request that sends devices for them. +/// +/// [`Self::MacAppStore`] is the one to use for a Mac App Store submission; +/// [`Self::MacAppDirect`] is Developer ID distribution outside the store +/// and is a different signing path entirely. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProfileType { + #[serde(rename = "IOS_APP_DEVELOPMENT")] + IosAppDevelopment, + #[serde(rename = "IOS_APP_STORE")] + IosAppStore, + #[serde(rename = "IOS_APP_ADHOC")] + IosAppAdHoc, + #[serde(rename = "IOS_APP_INHOUSE")] + IosAppInHouse, + #[serde(rename = "MAC_APP_DEVELOPMENT")] + MacAppDevelopment, + /// Mac App Store distribution — the profile a `productbuild` submission + /// embeds as `Contents/embedded.provisionprofile`. + #[serde(rename = "MAC_APP_STORE")] + MacAppStore, + #[serde(rename = "MAC_APP_DIRECT")] + MacAppDirect, + #[serde(rename = "TVOS_APP_DEVELOPMENT")] + TvOsAppDevelopment, + #[serde(rename = "TVOS_APP_STORE")] + TvOsAppStore, + #[serde(rename = "TVOS_APP_ADHOC")] + TvOsAppAdHoc, + #[serde(rename = "TVOS_APP_INHOUSE")] + TvOsAppInHouse, + #[serde(rename = "MAC_CATALYST_APP_DEVELOPMENT")] + MacCatalystAppDevelopment, + #[serde(rename = "MAC_CATALYST_APP_STORE")] + MacCatalystAppStore, + #[serde(rename = "MAC_CATALYST_APP_DIRECT")] + MacCatalystAppDirect, +} + +impl ProfileType { + /// The string App Store Connect uses, for building + /// `filter[profileType]` query values without going through serde. + pub fn as_api_str(self) -> &'static str { + match self { + Self::IosAppDevelopment => "IOS_APP_DEVELOPMENT", + Self::IosAppStore => "IOS_APP_STORE", + Self::IosAppAdHoc => "IOS_APP_ADHOC", + Self::IosAppInHouse => "IOS_APP_INHOUSE", + Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT", + Self::MacAppStore => "MAC_APP_STORE", + Self::MacAppDirect => "MAC_APP_DIRECT", + Self::TvOsAppDevelopment => "TVOS_APP_DEVELOPMENT", + Self::TvOsAppStore => "TVOS_APP_STORE", + Self::TvOsAppAdHoc => "TVOS_APP_ADHOC", + Self::TvOsAppInHouse => "TVOS_APP_INHOUSE", + Self::MacCatalystAppDevelopment => "MAC_CATALYST_APP_DEVELOPMENT", + Self::MacCatalystAppStore => "MAC_CATALYST_APP_STORE", + Self::MacCatalystAppDirect => "MAC_CATALYST_APP_DIRECT", + } + } + + /// Whether Apple expects a device list for this profile type. + /// + /// Sending devices for a store profile is an error rather than a + /// harmless extra, so callers building a create request need to know + /// this before they assemble relationships. + pub fn takes_devices(self) -> bool { + matches!( + self, + Self::IosAppDevelopment + | Self::IosAppAdHoc + | Self::MacAppDevelopment + | Self::TvOsAppDevelopment + | Self::TvOsAppAdHoc + | Self::MacCatalystAppDevelopment + ) + } +} + +/// Whether Apple still considers the profile usable. +/// +/// `Invalid` is not a separate deletion state: it is what a profile +/// becomes when something it depends on goes away, most often a revoked or +/// expired certificate. The profile stays listed so you can see why a +/// build that used to sign no longer does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProfileState { + #[serde(rename = "ACTIVE")] + Active, + #[serde(rename = "INVALID")] + Invalid, +} + +impl ProfileState { + pub fn as_api_str(self) -> &'static str { + match self { + Self::Active => "ACTIVE", + Self::Invalid => "INVALID", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileAttributes { + pub name: Option, + pub platform: Option, + pub profile_type: Option, + pub profile_state: Option, + /// The UUID embedded in the profile itself, which is what Xcode and + /// `codesign` diagnostics refer to. Distinct from the resource `id` + /// this API uses. + pub uuid: Option, + pub created_date: Option, + /// ISO-8601. Bounded by the signing certificate's own expiry, so a + /// profile never outlives the certificate it embeds. + pub expiration_date: Option, + /// Base64 of the binary profile file. Omitted from list responses + /// unless asked for via `fields[profiles]`; see + /// [`ProfilesApi::get_profile_content`]. + pub profile_content: Option, +} + +pub type Profile = Resource; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileCreateAttributes { + /// Free-form label shown in the developer portal. Apple permits + /// duplicates, so a team can accumulate several same-named profiles + /// that differ only by creation date — worth making distinctive. + pub name: String, + pub profile_type: ProfileType, +} + +/// The relationships a create request must carry. +/// +/// Built explicitly rather than derived from ids alone, because `devices` +/// has to be *absent* rather than empty for store profiles — see +/// [`ProfileType::takes_devices`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileCreateRelationships { + pub bundle_id: ToOne, + pub certificates: ToMany, + #[serde(skip_serializing_if = "Option::is_none")] + pub devices: Option, +} + +impl ProfileCreateRelationships { + /// Assemble relationships for `profile_type`, dropping `devices` when + /// the type does not take them. + pub fn new( + profile_type: ProfileType, + bundle_id: &str, + certificate_ids: &[String], + device_ids: &[String], + ) -> Self { + let devices = if profile_type.takes_devices() && !device_ids.is_empty() { + Some(ToMany { + data: device_ids + .iter() + .map(|id| ResourceId { + resource_type: "devices", + id: id.clone(), + }) + .collect(), + }) + } else { + None + }; + + Self { + bundle_id: ToOne { + data: ResourceId { + resource_type: "bundleIds", + id: bundle_id.to_string(), + }, + }, + certificates: ToMany { + data: certificate_ids + .iter() + .map(|id| ResourceId { + resource_type: "certificates", + id: id.clone(), + }) + .collect(), + }, + devices, + } + } +} + +/// Provisioning profiles. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait ProfilesApi { + /// `GET /v1/profiles`, optionally filtered by name and type. + /// + /// `profile_content` is left out of these results by Apple. That is + /// usually what you want — the content is a multi-kilobyte blob and + /// listing is for deciding *which* profile you need. Fetch the bytes + /// with [`ProfilesApi::get_profile_content`] once you know. + async fn list_profiles( + &self, + filter_name: Option<&str>, + filter_type: Option, + ) -> Result>; + + /// `GET /v1/profiles/{id}` asking explicitly for `profileContent`. + /// + /// Separate from listing because the field has to be requested: a + /// plain `GET` of the same resource returns metadata with + /// `profileContent` absent, which reads as "this profile has no + /// content" rather than "you did not ask for it". + async fn get_profile_content(&self, id: &str) -> Result; + + /// `POST /v1/profiles` — creates a profile over a bundle ID and + /// certificates. + /// + /// This is the only way to pick up a capability that was added to the + /// App ID after an existing profile was made. Apple does not reject + /// the request as a duplicate when an equivalent profile already + /// exists; you get a second one, and both stay active. + async fn create_profile( + &self, + attributes: ProfileCreateAttributes, + relationships: ProfileCreateRelationships, + ) -> Result; + + /// `DELETE /v1/profiles/{id}`. + /// + /// Narrower than revoking a certificate: it invalidates only this + /// profile, and any build already signed with it keeps working. New + /// signing that referenced it fails until a replacement exists. + async fn delete_profile(&self, id: &str) -> Result<()>; +} + +#[async_trait] +impl ProfilesApi for Client { + async fn list_profiles( + &self, + filter_name: Option<&str>, + filter_type: Option, + ) -> Result> { + let mut query = Vec::new(); + if let Some(name) = filter_name { + query.push(("filter[name]", name)); + } + if let Some(profile_type) = filter_type { + query.push(("filter[profileType]", profile_type.as_api_str())); + } + // Apple's default page size is 20, which a team carrying a profile + // per app per platform will exceed. + query.push(("limit", "200")); + + let doc: ListDocument = self + .request(Method::GET, "/v1/profiles", &query, None::<&()>) + .await?; + Ok(doc.data) + } + + async fn get_profile_content(&self, id: &str) -> Result { + let query = [( + "fields[profiles]", + "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent", + )]; + let doc: Document = self + .request( + Method::GET, + &format!("/v1/profiles/{id}"), + &query, + None::<&()>, + ) + .await?; + Ok(doc.data) + } + + async fn create_profile( + &self, + attributes: ProfileCreateAttributes, + relationships: ProfileCreateRelationships, + ) -> Result { + let body = CreateBody { + data: CreateData { + resource_type: RESOURCE_TYPE, + attributes, + relationships: Some(relationships), + }, + }; + let doc: Document = self + .request(Method::POST, "/v1/profiles", &[], Some(&body)) + .await?; + Ok(doc.data) + } + + async fn delete_profile(&self, id: &str) -> Result<()> { + self.request_no_content( + Method::DELETE, + &format!("/v1/profiles/{id}"), + &[], + None::<&()>, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_type_round_trips_through_the_api_wire_format() { + // The serde rename and as_api_str() are two separate spellings of + // the same contract, and a filter built from the wrong one fails + // silently by matching nothing. + for profile_type in [ + ProfileType::IosAppDevelopment, + ProfileType::IosAppStore, + ProfileType::IosAppAdHoc, + ProfileType::IosAppInHouse, + ProfileType::MacAppDevelopment, + ProfileType::MacAppStore, + ProfileType::MacAppDirect, + ProfileType::TvOsAppDevelopment, + ProfileType::TvOsAppStore, + ProfileType::TvOsAppAdHoc, + ProfileType::TvOsAppInHouse, + ProfileType::MacCatalystAppDevelopment, + ProfileType::MacCatalystAppStore, + ProfileType::MacCatalystAppDirect, + ] { + let json = serde_json::to_string(&profile_type).expect("serializes"); + let unquoted = json.trim_matches('"'); + assert_eq!( + unquoted, + profile_type.as_api_str(), + "serde and as_api_str disagree for {profile_type:?}" + ); + + let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(parsed, profile_type); + } + } + + #[test] + fn profile_state_round_trips_through_the_api_wire_format() { + for state in [ProfileState::Active, ProfileState::Invalid] { + let json = serde_json::to_string(&state).expect("serializes"); + assert_eq!(json.trim_matches('"'), state.as_api_str()); + let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(parsed, state); + } + } + + #[test] + fn store_profiles_omit_devices_entirely() { + // An empty `devices` array is not the same as no `devices` key: + // Apple rejects the former on a store profile. Serializing has to + // drop the field, not send `[]`. + let relationships = ProfileCreateRelationships::new( + ProfileType::MacAppStore, + "P59W5V5953", + &["CERT1".to_string()], + &["DEVICE1".to_string()], + ); + let json = serde_json::to_value(&relationships).expect("serializes"); + assert!( + json.get("devices").is_none(), + "a store profile must not carry devices: {json}" + ); + assert_eq!(json["bundleId"]["data"]["type"], "bundleIds"); + assert_eq!(json["certificates"]["data"][0]["type"], "certificates"); + } + + #[test] + fn development_profiles_keep_their_devices() { + let relationships = ProfileCreateRelationships::new( + ProfileType::IosAppDevelopment, + "P59W5V5953", + &["CERT1".to_string()], + &["DEVICE1".to_string(), "DEVICE2".to_string()], + ); + let json = serde_json::to_value(&relationships).expect("serializes"); + assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2); + assert_eq!(json["devices"]["data"][0]["type"], "devices"); + } + + #[test] + fn a_development_profile_with_no_devices_omits_the_key() { + // Rather than sending `{"data": []}`, which Apple reads as an + // explicit empty set and rejects. + let relationships = ProfileCreateRelationships::new( + ProfileType::IosAppDevelopment, + "P59W5V5953", + &["CERT1".to_string()], + &[], + ); + let json = serde_json::to_value(&relationships).expect("serializes"); + assert!(json.get("devices").is_none()); + } +}