diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs index 4e0ccd0..be80af4 100644 --- a/isideload/src/sideload/application.rs +++ b/isideload/src/sideload/application.rs @@ -20,6 +20,33 @@ pub struct Application { //pub temp_path: PathBuf, } +#[cfg(test)] +mod musicbridge_tests { + use super::*; + #[test] + fn musicbridge_extensionkit_is_renamed_and_persisted() { + let root = std::env::temp_dir().join(format!("musicbridge-sign-test-{}", uuid::Uuid::new_v4())); + for (directory, id) in [("", "ru.yukinoki.musicbridge"), + ("PlugIns/Widget.appex", "ru.yukinoki.musicbridge.widgets"), + ("Extensions/Remote.appex", "ru.yukinoki.musicbridge.remote")] { + let path = root.join(directory); + std::fs::create_dir_all(&path).unwrap(); + let mut info = plist::Dictionary::new(); + info.insert("CFBundleIdentifier".into(), id.into()); + plist::to_file_xml(path.join("Info.plist"), &info).unwrap(); + } + let mut app = Application::new(root.clone()).unwrap(); + assert_eq!(app.bundle.app_extensions().len(), 2); + app.update_bundle_id("ru.yukinoki.musicbridge", "ru.yukinoki.musicbridge.TEAM").unwrap(); + app.bundle.write_info().unwrap(); + for extension in app.bundle.app_extensions_mut() { extension.write_info().unwrap(); } + let saved = Application::new(root).unwrap(); + for extension in saved.bundle.app_extensions() { + assert!(extension.bundle_identifier().unwrap().starts_with("ru.yukinoki.musicbridge.TEAM.")); + } + } +} + impl Application { pub fn new(path: PathBuf) -> Result { if !isideload_vfs::fs::metadata(&path).is_ok() { diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs index 67a947d..2cc2282 100644 --- a/isideload/src/sideload/bundle.rs +++ b/isideload/src/sideload/bundle.rs @@ -45,23 +45,19 @@ impl Bundle { "Failed to parse Info.plist".to_string(), ))?; - // Load app extensions from PlugIns directory - let plug_ins_dir = bundle_path.join("PlugIns"); - let app_extensions = if isideload_vfs::fs::metadata(&plug_ins_dir).is_ok() { - isideload_vfs::fs::read_dir(&plug_ins_dir) - .context(SideloadError::InvalidBundle( - "Failed to read PlugIns directory".to_string(), - ))? - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) - && isideload_vfs::fs::metadata(&entry.path().join("Info.plist")).is_ok() - }) - .filter_map(|entry| Bundle::new(entry.path()).ok()) - .collect() - } else { - Vec::new() - }; + // ExtensionKit uses Extensions; WidgetKit and older extensions use PlugIns. + // Invalid nested bundles must fail signing, never be silently skipped. + let mut app_extensions = Vec::new(); + for folder in ["PlugIns", "Extensions"] { + let directory = bundle_path.join(folder); + if !isideload_vfs::fs::metadata(&directory).is_ok() { continue; } + for entry in isideload_vfs::fs::read_dir(&directory)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + app_extensions.push(Bundle::new(entry.path())?); + } + } + } // Load frameworks from Frameworks directory let frameworks_dir = bundle_path.join("Frameworks"); diff --git a/isideload/src/sideload/sign.rs b/isideload/src/sideload/sign.rs index a3d9799..c7e6154 100644 --- a/isideload/src/sideload/sign.rs +++ b/isideload/src/sideload/sign.rs @@ -5,6 +5,7 @@ use apple_codesign::{ }; use plist::Dictionary; +use rootcause::option_ext::OptionExt; use rootcause::prelude::*; use crate::{ @@ -63,6 +64,10 @@ where ); } + let musicbridge = app.main_bundle_id()? == format!("ru.yukinoki.musicbridge.{}", team.team_id) + && app.bundle.app_info.get("MusicBridgeSharedKeychainGroup").and_then(plist::Value::as_string) + == Some("ru.yukinoki.musicbridge.remote-control"); + if musicbridge { entitlements = musicbridge_keychain(&entitlements)?; } let mut settings = BundleSigningSettings::new(&team.team_id, entitlements, Some(&signer)); settings.embedded_mobileprovision = Some(main_provisioning_profile.encoded_profile.as_ref()); @@ -75,9 +80,56 @@ where .map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone())) .collect(); + if musicbridge { + let remote_id = format!("{}.remote", app.main_bundle_id()?); + let remote = settings.entitlements_by_bundle_id.get(&remote_id) + .ok_or_report().context("MusicBridge remote extension has no provisioning profile")?; + let remote = musicbridge_keychain(remote)?; + settings.entitlements_by_bundle_id.insert(remote_id, remote); + } + if let Some(callback) = &progress_callback { callback(0.5).await; } Ok(sign_bundle(&app.bundle.bundle_dir, &settings)?) } + +// Request only a group explicitly permitted by Apple's actual profile. +fn musicbridge_keychain(profile: &Dictionary) -> Result { + let application = profile.get("application-identifier").and_then(plist::Value::as_string) + .ok_or_report().context("Profile has no application-identifier")?; + let prefix = application.split_once('.').ok_or_report().context("Invalid app identifier")?.0; + let shared = format!("{}.ru.yukinoki.musicbridge.remote-control", prefix); + let allowed = profile.get("keychain-access-groups").and_then(plist::Value::as_array) + .ok_or_report().context("Profile has no Keychain groups")?; + if !allowed.iter().filter_map(plist::Value::as_string).any(|group| + group == shared || group == format!("{}.*", prefix)) { + bail!("Apple's provisioning profile does not permit the MusicBridge shared Keychain group"); + } + let mut result = profile.clone(); + // A concrete per-app default group comes first, shared group second. + result.insert("keychain-access-groups".into(), plist::Value::Array(vec![ + plist::Value::String(application.into()), plist::Value::String(shared)])); + Ok(result) +} + +#[cfg(test)] +mod musicbridge_tests { + use super::*; + fn profile(group: &str) -> Dictionary { + let mut p = Dictionary::new(); + p.insert("application-identifier".into(), "TEAM.ru.yukinoki.musicbridge.TEAM".into()); + p.insert("keychain-access-groups".into(), plist::Value::Array(vec![group.into()])); + p + } + #[test] + fn musicbridge_sharing_respects_profile() { + let value = musicbridge_keychain(&profile("TEAM.*")).unwrap(); + let groups = value["keychain-access-groups"].as_array().unwrap(); + assert_eq!(groups[0].as_string(), Some("TEAM.ru.yukinoki.musicbridge.TEAM")); + assert_eq!(groups[1].as_string(), Some("TEAM.ru.yukinoki.musicbridge.remote-control")); + assert!(musicbridge_keychain(&profile("OTHER.*")).is_err()); + assert!(musicbridge_keychain(&profile("TEAM.unrelated")).is_err()); + } +}